- Prevented admin challenge solves from creating score records

- Added operator solves list to the Admin panel profile
- Allowed deletion of specific operator solves from the Admin panel
- Enhanced operator solves list with alphabetical sorting, difficulty colors, and point values
- Added rank medal icons to operator solves in the Admin panel
This commit is contained in:
m0rph3us1987
2026-03-07 02:18:47 +01:00
parent e04547301b
commit 800192c87f
6 changed files with 117 additions and 8 deletions

View File

@@ -1,13 +1,14 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { X, Edit3, Trash2, Shield, ShieldCheck, ShieldAlert, Skull, Newspaper, Download, Upload, Database, Save, History, Plus, Globe, User, ShieldX, UserMinus, UserCheck } from 'lucide-react';
import { X, Edit3, Trash2, Shield, ShieldCheck, ShieldAlert, Skull, Newspaper, Download, Upload, Database, Save, History, Plus, Globe, User, ShieldX, UserMinus, UserCheck, Medal, CheckCircle2 } from 'lucide-react';
import { useCTF } from './CTFContext';
import { Challenge, Team, BlogPost, Difficulty, ChallengeFile } from './types';
import { Button, Countdown, CategoryIcon } from './UIComponents';
import { CATEGORIES, DIFFICULTIES } from './constants';
import { calculateChallengeValue, getFirstBloodBonusFactor } from './services/scoring';
export const Admin: React.FC = () => {
const { state, toggleCtf, resetScores, upsertChallenge, deleteChallenge, deleteAllChallenges, updateTeam, deleteTeam, createBlogPost, updateBlogPost, deleteBlogPost, updateConfig, exportChallenges, importChallenges, backupDatabase, restoreDatabase, refreshState } = useCTF();
const { state, toggleCtf, resetScores, upsertChallenge, deleteChallenge, deleteAllChallenges, updateTeam, deleteTeam, deleteSolve, createBlogPost, updateBlogPost, deleteBlogPost, updateConfig, exportChallenges, importChallenges, backupDatabase, restoreDatabase, refreshState } = useCTF();
const [editingChallenge, setEditingChallenge] = useState<Partial<Challenge> | null>(null);
const [editingTeam, setEditingTeam] = useState<Partial<Team> & { newPassword?: string } | null>(null);
@@ -460,6 +461,60 @@ export const Admin: React.FC = () => {
</div>
<Button type="submit" className="w-full py-4 uppercase">Update Identity</Button>
</form>
<div className="mt-8 border-t-2 border-[#333] pt-6">
<h4 className="text-xl font-black italic text-[#bf00ff] mb-4 uppercase">SOLVES</h4>
<div className="space-y-2 max-h-48 overflow-y-auto pr-2">
{state.solves.filter(s => s.teamId === editingTeam.id).length === 0 ? (
<p className="text-slate-500 italic text-sm font-black">NO_SOLVES_FOUND</p>
) : (
state.solves
.filter(s => s.teamId === editingTeam.id)
.map(solve => ({
solve,
challenge: state.challenges.find(c => c.id === solve.challengeId)
}))
.sort((a, b) => (a.challenge?.title || '').localeCompare(b.challenge?.title || ''))
.map(({ solve, challenge }) => {
if (!challenge) return null;
const diffColor = challenge.difficulty === 'Low' ? 'text-[#00ff00]' : challenge.difficulty === 'Medium' ? 'text-[#ffaa00]' : 'text-[#ff0000]';
const challengeSolves = state.solves
.filter(s => s.challengeId === challenge.id)
.sort((a, b) => a.timestamp - b.timestamp);
const rank = challengeSolves.findIndex(s => s.teamId === editingTeam.id);
const baseValue = calculateChallengeValue(
challenge.initialPoints,
challenge.minimumPoints || 0,
challenge.decaySolves || 1,
(challenge.solves || []).length
);
const bonus = Math.floor(challenge.initialPoints * getFirstBloodBonusFactor(rank));
const totalPoints = baseValue + bonus;
return (
<div key={solve.challengeId} className="flex justify-between items-center bg-white/5 border border-[#333] p-3 text-sm font-bold">
<div className="flex items-center gap-2 truncate flex-1">
{rank === 0 ? (
<Medal size={16} className="text-[#ffaa00]" />
) : rank === 1 ? (
<Medal size={16} className="text-slate-400" />
) : rank === 2 ? (
<Medal size={16} className="text-[#cd7f32]" />
) : (
<CheckCircle2 size={14} className="text-[#00ff00]" />
)}
<span className={`truncate ${diffColor}`}>{challenge.title}</span>
</div>
<span className="text-white text-xs mr-4">{totalPoints} PTS</span>
<button type="button" onClick={() => deleteSolve(editingTeam.id as string, solve.challengeId)} className="text-red-500 hover:text-red-400 transition-colors" title="Delete Solve"><Trash2 size={16}/></button>
</div>
);
})
)}
</div>
</div>
</div>
</div>
)}

View File

@@ -21,6 +21,7 @@ interface CTFContextType {
updateTeam: (id: string, data: any) => Promise<void>;
updateProfile: (password?: string) => Promise<void>;
deleteTeam: (id: string) => Promise<void>;
deleteSolve: (teamId: string, challengeId: string) => Promise<void>;
createBlogPost: (data: { title: string, content: string }) => Promise<void>;
updateBlogPost: (id: string, data: { title: string, content: string }) => Promise<void>;
deleteBlogPost: (id: string) => Promise<void>;
@@ -98,6 +99,7 @@ export const CTFProvider: React.FC<{ children: React.ReactNode }> = ({ children
const updateTeam = async (id: string, d: any) => { await api.updateTeam(id, d); await refreshState(); };
const updateProfile = async (p?: string) => { await api.updateProfile({ password: p }); await refreshState(); };
const deleteTeam = async (id: string) => { if (window.confirm("EXPEL_OPERATOR?")) { await api.deleteTeam(id); await refreshState(); } };
const deleteSolve = async (teamId: string, challengeId: string) => { if (window.confirm("DELETE_SOLVE?")) { await api.deleteSolve(teamId, challengeId); await refreshState(); } };
const createBlogPost = async (d: any) => { await api.createBlogPost(d); await refreshState(); };
const updateBlogPost = async (id: string, d: any) => { await api.updateBlogPost(id, d); await refreshState(); };
const deleteBlogPost = async (id: string) => { await api.deleteBlogPost(id); await refreshState(); };
@@ -108,7 +110,7 @@ export const CTFProvider: React.FC<{ children: React.ReactNode }> = ({ children
state, currentUser, login, register, logout, submitFlag, toggleCtf, resetScores,
upsertChallenge, deleteChallenge, deleteAllChallenges, exportChallenges,
importChallenges, backupDatabase, restoreDatabase, updateTeam, updateProfile,
deleteTeam, createBlogPost, updateBlogPost, deleteBlogPost, updateConfig,
deleteTeam, deleteSolve, createBlogPost, updateBlogPost, deleteBlogPost, updateConfig,
refreshState, loading, loadError
}}>
{children}

37
changelog.txt Normal file
View File

@@ -0,0 +1,37 @@
2026-03-07
- Prevented admin challenge solves from creating score records
- Added operator solves list to the Admin panel profile
- Allowed deletion of specific operator solves from the Admin panel
- Enhanced operator solves list with alphabetical sorting, difficulty colors, and point values
- Added rank medal icons to operator solves in the Admin panel
2026-02-28
- Removed the UTC time display from the countdown, leaving only the CET time
- Added logic to display the event start time in Central European Time (CET) on the Challenges list page
- Replaced mock authentication tokens with secure JWT-like signed tokens
- Added robust error handling and type checking for password hashing and validation functions
- Implemented logic to disable the default admin account (admin-0) once another admin is created
- Applied a database schema whitelist to prevent SQL injection during database restores
- Filtered out admin and disabled teams from the public scoreboard state for non-admin users
- Added strict /admin middleware to protect administrative API endpoints by verifying user permissions
- Updated page title to HIP7CTF in index.html
- Enhanced the state endpoint to completely hide challenges if the current time is before the configured event start time
2026-02-22
- Modified the /state endpoint to return an empty challenges list if the event has not started and the user is not an admin
2026-02-05
- Included authorization headers in the frontend getState API requests
- Added security check in the /state endpoint to filter out the flag from challenge data for non-admin users
- Added a dbGet utility function to the server
2026-01-21
- Removed the README.md file
- Added a fix-permissions service to docker-compose.yml to automatically set correct ownership for data and uploads directories
- Modularized the frontend by splitting the monolithic App.tsx into dedicated components (Admin.tsx, Auth.tsx, Blog.tsx, CTFContext.tsx, Challenges.tsx, Home.tsx, Scoreboard.tsx, UIComponents.tsx)
- Restructured API, server, and scoring logic to fit the new modular frontend architecture
- Re-added README.md file temporarily
2026-01-07
- Removed the README.md file
- Initial project setup including React frontend, Express backend, Docker configuration, and baseline scoring services

1
data/secret.key Normal file
View File

@@ -0,0 +1 @@
3ca2310b67c3e5a157860fa2a5a3d381f34925ed8ec7988b44ddd7d39506aaf8

View File

@@ -429,11 +429,15 @@ apiRouter.post('/challenges/submit', (req, res) => {
configRows.forEach(row => { config[row.key] = row.value; });
const now = Date.now(), start = parseInt(config.eventStartTime || 0), end = parseInt(config.eventEndTime || Date.now() + 86400000);
if (config.isStarted !== 'true' || now < start || now > end) return res.status(403).json({ success: false, message: 'COMPETITION_NOT_ACTIVE' });
db.get("SELECT isDisabled FROM teams WHERE id = ?", [teamId], (err, team) => {
db.get("SELECT isDisabled, isAdmin FROM teams WHERE id = ?", [teamId], (err, team) => {
if (team?.isDisabled) return res.status(403).json({ success: false, message: 'Account disabled' });
db.get("SELECT * FROM challenges WHERE id = ?", [challengeId], (err, challenge) => {
if (challenge && challenge.flag === flag) {
db.run("INSERT OR IGNORE INTO solves (teamId, challengeId, timestamp) VALUES (?, ?, ?)", [teamId, challengeId, Date.now()], () => res.json({ success: true }));
if (team?.isAdmin) {
res.json({ success: true });
} else {
db.run("INSERT OR IGNORE INTO solves (teamId, challengeId, timestamp) VALUES (?, ?, ?)", [teamId, challengeId, Date.now()], () => res.json({ success: true }));
}
} else res.json({ success: false });
});
});
@@ -478,6 +482,10 @@ apiRouter.delete('/admin/teams/:id', (req, res) => {
db.run("DELETE FROM teams WHERE id = ?", [req.params.id], () => db.run("DELETE FROM solves WHERE teamId = ?", [req.params.id], () => res.json({ success: true })));
});
apiRouter.delete('/admin/solves/:teamId/:challengeId', (req, res) => {
db.run("DELETE FROM solves WHERE teamId = ? AND challengeId = ?", [req.params.teamId, req.params.challengeId], () => res.json({ success: true }));
});
apiRouter.post('/admin/blogs', (req, res) => {
const id = 'blog-' + Math.random().toString(36).substr(2, 9);
db.run("INSERT INTO blogs (id, title, content, timestamp) VALUES (?, ?, ?, ?)", [id, req.body.title, req.body.content, Date.now()], () => res.json({ success: true, id }));

View File

@@ -134,11 +134,17 @@ class ApiService {
}
async deleteTeam(id: string): Promise<void> {
const res = await fetch(`${API_BASE}/admin/teams/${id}`, {
await fetch(`${API_BASE}/admin/teams/${id}`, {
method: 'DELETE',
headers: this.getHeaders(),
headers: { ...this.getHeaders() }
});
}
async deleteSolve(teamId: string, challengeId: string): Promise<void> {
await fetch(`${API_BASE}/admin/solves/${teamId}/${challengeId}`, {
method: 'DELETE',
headers: { ...this.getHeaders() }
});
if (!res.ok) throw new Error('Failed to delete team');
}
async submitFlag(challengeId: string, flag: string): Promise<{ success: boolean }> {