Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
- [ ] Flag added in `prisma/seed.ts` with format `OSS{...}`
- [ ] Three progressive hints added in `prisma/seed.ts`
- [ ] Vulnerable code path is exploitable and demonstrable
- [ ] Markdown documentation added under `content/vulnerabilities/`
- [ ] Reference doc added under `content/vulnerabilities/` (concept + fix only — no exploit steps, payloads, or flag value)
- [ ] Regression tests added (unit, API, and/or E2E)
- [ ] No real-world secrets introduced
- [ ] If a walkthrough was also added under `docs/src/data/blog/`, `walkthroughSlug` is set on the flag in `prisma/seed.ts`
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,4 @@ logs/*

# other
docs/update-deps.sh
.claude/
12 changes: 6 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ app/
├── hall-of-fame/ # Hall of Fame page
└── [pages]/ # Next.js pages

content/vulnerabilities/ # Markdown files for each vulnerability
content/vulnerabilities/ # In-app reference docs (concept + fix, no exploit details)
lib/ # Utilities (api, auth, prisma, types)
prisma/ # Schema and seed.ts with CTF flags
uploads/ # User-uploaded files (served via /api/uploads/)
docs/ # Astro documentation site (separate npm project)
docs/ # Astro walkthrough site (step-by-step exploits, screenshots)
hall-of-fame/data.json # Hall of Fame entries (community-driven via PRs)
tests/ # Jest unit and API exploitation tests
├── unit/ # Unit tests (MD5, JWT, input filters)
Expand Down Expand Up @@ -119,16 +119,16 @@ npm run docs:build # Build Astro site

### Adding New Vulnerabilities

1. Add flag to `prisma/seed.ts` in `OSS{...}` format
1. Add flag to `prisma/seed.ts` in `OSS{...}` format (set `walkthroughSlug` if a writeup exists)
2. Add 3 hints in the `flagHints` map in `prisma/seed.ts` (keyed by slug, levels 1→3 from vague to near-solution)
3. Create documentation in `content/vulnerabilities/`
4. Document: overview, vulnerable code, exploitation, mitigation
3. Create the in-app reference doc in `content/vulnerabilities/` — overview, why dangerous, vulnerable code, secure implementation, references. **No exploitation steps, payloads, or flag value** (those go in the walkthrough).
4. Optional: add a step-by-step walkthrough in `docs/src/data/blog/` (Astro site) — this is where exploit details, payloads, and screenshots belong.
5. Test exploitability

### CTF Flag System

- Format: `OSS{...}`
- Model: `Flag` with `flag`, `slug`, `category`, `difficulty`, `markdownFile`
- Model: `Flag` with `flag`, `slug`, `category`, `difficulty`, `markdownFile`, `walkthroughSlug` (optional)
- Categories: INJECTION, AUTHENTICATION, AUTHORIZATION, XSS, CSRF, etc.
- Difficulty: EASY, MEDIUM, HARD
- Each flag has 3 progressive hints (stored in `Hint` model, tracked by `RevealedHint`)
Expand Down
17 changes: 12 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,25 +36,32 @@ New here? Check [good first issues](https://github.com/users/kOaDT/projects/3/vi
### Adding a vulnerability

1. **Add the flag in `prisma/seed.ts`**
Create a `Flag` record with format `OSS{...}`. Set `slug`, `category`, `difficulty`, and `markdownFile` to match.
Create a `Flag` record with format `OSS{...}`. Set `slug`, `category`, `difficulty`, and `markdownFile` to match. Set `walkthroughSlug` if a walkthrough exists on the docs site (see step 6).

2. **Add hints in `prisma/seed.ts`**
Add three progressive hints in the `flagHints` map, keyed by slug. Level 1 is vague, level 2 more specific, level 3 near-solution.

3. **Implement the vulnerability**
Write the vulnerable code path (API route, page, feature) that lets an attacker get the flag. It needs to be actually exploitable.

4. **Document it**
Add a markdown file under `content/vulnerabilities/` (e.g. `your-vulnerability.md`) with an overview, vulnerable code examples, exploitation steps, and how to fix it.
4. **Document it (reference doc)**
Add a markdown file under `content/vulnerabilities/` (e.g. `your-vulnerability.md`). This is the **in-app reference** rendered at `/vulnerabilities/<slug>` after a player finds the flag. It should focus on:
- Overview — what the vulnerability is
- Why it is dangerous
- Vulnerable code (the snippet from the codebase)
- Secure implementation (how to fix it)
- References (OWASP, CWE, etc.)

Do **not** include step-by-step exploitation, payloads, screenshots, or the flag value here. Those belong in the walkthrough (step 6). The in-app doc is meant to explain the concept and the fix, not to re-teach the exploit the player just executed.

5. **Add regression tests**
Tests keep the vulnerability exploitable so nobody accidentally patches it:
- Unit tests in `tests/unit/` for helpers (hashing, filters, etc.)
- API tests in `tests/api/` for exploitation scenarios
- E2E tests in `cypress/e2e/` for full exploitation flows through the UI

6. **Optional: write a walkthrough**
See [Writing walkthroughs](#writing-walkthroughs) below.
6. **Optional: write a walkthrough (the exploit playbook)**
The walkthrough lives on the docs site (`docs/src/data/blog/`) and is where the step-by-step exploitation belongs: payloads, request examples, screenshots, narrative voice. See [Writing walkthroughs](#writing-walkthroughs) below. If you add one, set `walkthroughSlug` on the flag in `prisma/seed.ts` so the in-app reference page links to it.

### Writing walkthroughs

Expand Down
147 changes: 103 additions & 44 deletions app/flags/FlagsClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,39 @@

import { useState, useMemo } from "react";
import Link from "next/link";
import type { Flag, FlagDifficulty } from "@/lib/types";
import type { Flag, FlagCategory, FlagDifficulty } from "@/lib/types";
import { formatSlug, CATEGORY_LABELS } from "@/lib/format";

interface FlagsClientProps {
flags: Flag[];
foundFlagIds: string[];
}

function LockIcon() {
return (
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75M6.75 21.75h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</svg>
);
}

function LockedFlag() {
return (
<span className="inline-flex items-center gap-1.5 rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-500 dark:bg-slate-700/50 dark:text-slate-400">
<LockIcon />
Locked — solve to reveal
</span>
);
}

const DIFFICULTY_CONFIG: Record<
Expand All @@ -32,25 +61,6 @@ const DIFFICULTY_CONFIG: Record<
},
};

const CATEGORY_LABELS: Record<string, string> = {
INJECTION: "Injection",
AUTHENTICATION: "Authentication",
AUTHORIZATION: "Authorization",
REQUEST_FORGERY: "Request Forgery",
INFORMATION_DISCLOSURE: "Information Disclosure",
INPUT_VALIDATION: "Input Validation",
CRYPTOGRAPHIC: "Cryptographic",
REMOTE_CODE_EXECUTION: "Remote Code Execution",
OTHER: "Other",
};

function formatSlug(slug: string): string {
return slug
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}

function DifficultyBadge({ difficulty }: { difficulty: FlagDifficulty }) {
const config = DIFFICULTY_CONFIG[difficulty];
return (
Expand All @@ -63,10 +73,10 @@ function DifficultyBadge({ difficulty }: { difficulty: FlagDifficulty }) {
);
}

function CategoryBadge({ category }: { category: string }) {
function CategoryBadge({ category }: { category: FlagCategory }) {
return (
<span className="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 dark:bg-slate-700 dark:text-slate-300">
{CATEGORY_LABELS[category] || category}
{CATEGORY_LABELS[category]}
</span>
);
}
Expand Down Expand Up @@ -125,28 +135,58 @@ function ListIcon({ active }: { active: boolean }) {
);
}

function FlagCardGrid({ flag }: { flag: Flag }) {
function FoundBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
Found
</span>
);
}

function FlagCardGrid({ flag, found }: { flag: Flag; found: boolean }) {
return (
<Link
href={`/vulnerabilities/${flag.slug}`}
className="group flex flex-col rounded-xl border border-slate-200 bg-white p-5 transition-all duration-200 hover:border-primary-300 hover:shadow-xl hover:shadow-primary-500/10 dark:border-slate-700 dark:bg-slate-800/50 dark:hover:border-primary-600"
>
<div className="mb-3 flex items-start justify-between gap-2">
<DifficultyBadge difficulty={flag.difficulty} />
{flag.cve && (
<span className="rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-semibold text-red-700 dark:bg-red-900/30 dark:text-red-400">
{flag.cve}
</span>
)}
<div className="flex items-center gap-2">
{found && <FoundBadge />}
{flag.cve && (
<span className="rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-semibold text-red-700 dark:bg-red-900/30 dark:text-red-400">
{flag.cve}
</span>
)}
</div>
</div>

<h3 className="mb-2 font-mono text-sm font-bold text-slate-900 transition-colors group-hover:text-primary-600 dark:text-slate-100 dark:group-hover:text-primary-400">
{flag.flag}
<h3 className="mb-2 text-base font-bold text-slate-900 transition-colors group-hover:text-primary-600 dark:text-slate-100 dark:group-hover:text-primary-400">
{formatSlug(flag.slug)}
</h3>

<p className="mb-4 text-sm font-medium text-slate-600 dark:text-slate-400">
{formatSlug(flag.slug)}
</p>
<div className="mb-4">
{found ? (
<p className="break-all font-mono text-sm text-slate-600 dark:text-slate-400">
{flag.flag}
</p>
) : (
<LockedFlag />
)}
</div>

<div className="mt-auto">
<CategoryBadge category={flag.category} />
Expand All @@ -155,7 +195,7 @@ function FlagCardGrid({ flag }: { flag: Flag }) {
);
}

function FlagCardList({ flag }: { flag: Flag }) {
function FlagCardList({ flag, found }: { flag: Flag; found: boolean }) {
return (
<Link
href={`/vulnerabilities/${flag.slug}`}
Expand All @@ -169,15 +209,22 @@ function FlagCardList({ flag }: { flag: Flag }) {
<div className="mb-1 flex items-center gap-2 sm:hidden">
<DifficultyBadge difficulty={flag.difficulty} />
</div>
<h3 className="truncate font-mono text-sm font-bold text-slate-900 transition-colors group-hover:text-primary-600 dark:text-slate-100 dark:group-hover:text-primary-400 sm:text-base">
{flag.flag}
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">
<h3 className="truncate text-sm font-bold text-slate-900 transition-colors group-hover:text-primary-600 dark:text-slate-100 dark:group-hover:text-primary-400 sm:text-base">
{formatSlug(flag.slug)}
</p>
</h3>
{found ? (
<p className="truncate font-mono text-sm text-slate-600 dark:text-slate-400">
{flag.flag}
</p>
) : (
<div className="mt-1">
<LockedFlag />
</div>
)}
</div>

<div className="hidden items-center gap-3 md:flex">
{found && <FoundBadge />}
<CategoryBadge category={flag.category} />
{flag.cve && (
<span className="rounded-full bg-red-100 px-2.5 py-1 text-xs font-semibold text-red-700 dark:bg-red-900/30 dark:text-red-400">
Expand All @@ -203,18 +250,22 @@ function FlagCardList({ flag }: { flag: Flag }) {
);
}

export default function FlagsClient({ flags }: FlagsClientProps) {
export default function FlagsClient({ flags, foundFlagIds }: FlagsClientProps) {
const [searchQuery, setSearchQuery] = useState("");
const [difficultyFilter, setDifficultyFilter] = useState<
FlagDifficulty | "ALL"
>("ALL");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");

const foundSet = useMemo(() => new Set(foundFlagIds), [foundFlagIds]);

const filteredFlags = useMemo(() => {
return flags.filter((flag) => {
const isFound = foundSet.has(flag.id);
const matchesSearch =
searchQuery === "" ||
flag.flag.toLowerCase().includes(searchQuery.toLowerCase()) ||
(isFound &&
flag.flag.toLowerCase().includes(searchQuery.toLowerCase())) ||
flag.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
(flag.cve?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false);

Expand All @@ -223,7 +274,7 @@ export default function FlagsClient({ flags }: FlagsClientProps) {

return matchesSearch && matchesDifficulty;
});
}, [flags, searchQuery, difficultyFilter]);
}, [flags, searchQuery, difficultyFilter, foundSet]);

const stats = useMemo(() => {
const byDifficulty = {
Expand Down Expand Up @@ -345,13 +396,21 @@ export default function FlagsClient({ flags }: FlagsClientProps) {
) : viewMode === "grid" ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filteredFlags.map((flag) => (
<FlagCardGrid key={flag.id} flag={flag} />
<FlagCardGrid
key={flag.id}
flag={flag}
found={foundSet.has(flag.id)}
/>
))}
</div>
) : (
<div className="space-y-3">
{filteredFlags.map((flag) => (
<FlagCardList key={flag.id} flag={flag} />
<FlagCardList
key={flag.id}
flag={flag}
found={foundSet.has(flag.id)}
/>
))}
</div>
)}
Expand Down
Loading
Loading