Imported from XaocZenon/curl-trainer (
AGENTS.md). Install upstream withnpx skills add XaocZenon/curl-trainer. Copyright stays with the author.
AGENTS.md
Guide for AI agents and contributors working on this repo. Read this before making changes.
What this is
A training lab for web pentesters and bug bounty hunters to practice curl. It has two parts:
- An Express API (
server/) that runs deliberately vulnerable endpoints. When the "right" request hits one (e.g. a path traversal, SQL injection, forged cookie), it returns aflag{...}. - A React + Vite SPA (
client/) that teaches curl/pipes/grep/jq on the landing page and lists the challenges, so users know what to attack.
There is no auth, no database, no user accounts. Solved state lives in the browser's localStorage. Challenge docs are served by the API (the client fetches them); the cheatsheet/landing content is static JSX in the client.
Layout
package.json npm workspaces root; `npm run dev` runs both apps
server/
index.js express app: /lab/* routes, flag factory, verify endpoint
challenges.js THE 9 vulnerable endpoints + all challenge metadata
data/notes.txt filesystem content used by the traversal challenge
secret_flag.txt "secret" file the traversal challenge must reach
client/
vite.config.js dev proxy: /lab -> http://localhost:4000
src/
main.jsx entry, HashRouter
App.jsx nav + routes + lab-status ping (/lab/health every 10s)
api.js fetch wrappers for /lab/challenges, /lab/verify
index.css all styling (plain CSS, dark cyber theme)
utils/solved.js localStorage helpers for solved challenge ids
components/ CodeBlock, Terminal, Tabs, Badge, FlagCheck
pages/ Home (landing/cheatsheet), Challenges, Challenge
Running it
npm install
npm run dev # lab on http://localhost:4000, web app on http://localhost:5173
npm run dev -w server→node --watch index.js(Express on :4000)npm run dev -w client→vite(Vite on :5173, proxies/lab→ :4000)npm run build -w client→ production build check- Users run
curl http://localhost:4000/...directly from their terminal; the web app is documentation + flag verification only.
How the server works
Challenge registration pattern
challenges.js exports an array of challenge objects. index.js iterates them:
for (const c of challenges) c.setup(app, flagFor, BASE_URL)
Each object must have:
| field | description |
|---|---|
id |
unique slug; used in URLs, flags, and verify |
title |
display name |
difficulty |
easy | medium | hard (drives badge color) |
category |
bug class, shown as a tag |
summary |
one-line hook, shown on cards |
description |
2–3 sentence brief for the detail page |
hint |
nudge, shown behind a "show hint" toggle |
tip |
example curl, shown under the hint |
solution |
the canonical solving curl command, returned ONLY on verify |
target(base) |
returns the starter URL to attack |
setup(app, flag, base) |
registers the vulnerable route(s) |
Flags
// server/index.js
function flagFor(id) {
const digest = crypto.createHmac('sha256', SECRET).update(id).digest('hex').slice(0, 12)
return `flag{${id}_${digest}}`
}
SECRET = process.env.FLAG_SECRET || randomBytes(16) — a fresh random secret per
boot, so flags change every server start. This means reading the source gives the
bug but not the flag. The secret is printed at startup for debugging.
Never return the flag on a benign request — only when the vuln is triggered.
/lab/verify compares submitted flags with crypto.timingSafeEqual.
Response shape gotcha
/lab/challenges returns metadata without description/hint/tip/solution
(cards only need the short fields). /lab/challenges/:id returns the full detail.
/lab/verify returns { ok, solution } and is the only endpoint that ever
reveals a solution.
Server gotchas (read before editing)
- Express 5 — check v5 behavior before assuming. In particular, keep
app.set('query parser', 'extended'); the HPP challenge depends on repeated query params arriving as an array. - No
corspackage. It was removed on purpose: Vite's proxy makes the SPA same-origin, andcorsauto-answeredOPTIONSwith a 204 (and anAllowlist), which destroyed the verb-tampering challenge. If you add it back, the/lab/admin/usersOPTIONS hint breaks. - OPTIONS auto-handling: Express 5 answers
OPTIONSwith a 204+Allowunless a route explicitly registers it. The verb-tampering challenge registersapp.options('/lab/admin/users', deny)explicitly so it can returnAllow: PATCH+ 405. If you rely on auto-OPTIONS, it will not run your handler. - No session/auth: everything is stateless and public by design.
- Don't move
server/data/notes.txtorsecret_flag.txt— the traversal challenge computespath.resolve(path.join(DATA_DIR, file))and checksstartsWith(DATA_DIR); escaping that check is what triggers the flag.
How the client works
- Routing:
HashRouter(/#/,/#/challenges,/#/challenges/:id). Hash routing so the built SPA works from any static host without rewrite rules. - Data flow: pages call
api.js→fetch('/lab/...')→ Vite proxy → Express. - Solved state:
utils/solved.jsstores an array of solved challenge ids inlocalStorageundercurl-trainer:solved.isSolved/markSolvedare the only API. TheChallengepage callsmarkSolvedonly after/lab/verifyreturnsok— never optimistically. FlagCheckcomponent holds the whole verify flow: input → POST → onok, shows the returnedsolutionand calls the parent'sonSolved.- Lab status dot in the nav pings
/lab/healthevery 10s (seeLabStatusinApp.jsx). - Home page content (cheatsheet rows, "why curl" cards, terminal demo) is static
data in
pages/Home.jsx. To add a cheatsheet row, append to the matching*Rowsarray — each row is{ cmd, desc }.
Client gotchas (read before editing)
- JSX text cannot contain a bare
{.The server hands you a <code>flag{…}</code>crashes the build. Use{'flag{…}'}. Same for any literal braces in text. - JSX attributes are not escaped —
cmd="... \"..."is a syntax error. Use an expression:cmd={'curl -H "Host: x" ...'}. - The proxy lives in
client/vite.config.js;vitemust be launched from theclient/directory or the proxy andindex.htmldon't apply. - Vite binds
[::1](IPv6 localhost) by default on some systems;curl localhostmay needcurl "http://[::1]:5173".
Conventions
- No code comments. Write self-explanatory code. (These docs live here instead.)
- Plain CSS with CSS variables in
client/src/index.css; dark cyber theme, monospace for code. Match existing class names — do not introduce a CSS framework. - No new runtime deps unless required. Express 5, React 19, Vite 7, react-router-dom 6.
- Difficulty values are
easy/medium/hard;Badgemaps them to colors. - Challenge ids are kebab-case slugs and must not change once shipped (flags and localStorage keys embed them).
Adding a new challenge (recipe)
- In
server/challenges.js, push a new object to the array. Copy an existing one as a template so all required fields are present. - Give it a unique
id, a distinct target path (e.g./lab/<id>/...), and a bug whose trigger is a specific curl request. - Register the route in
setup. Guard the flag so it only appears when the vuln is actually exercised — e.g. check for the tampered value, the forged header, the escaping../, etc. - Set
solutionto the exact curl command that wins, andtarget(base)to the benign starter URL. - Test it exactly like a user would:
npm run dev
# solve path:
curl <exploit-request> # must return flag{...}
# benign request must NOT return a flag
# then verify:
curl -s -X POST http://localhost:4000/lab/verify \
-H 'Content-Type: application/json' \
-d '{"id":"<id>","flag":"flag{...}"}' # => {"ok":true}
- Run
npm run build -w clientto confirm the client still builds.
Useful commands
npm run dev # both apps, watch mode
npm run build -w client # production build check
curl http://localhost:4000/lab/challenges # list
curl http://localhost:4000/lab/verify -X POST -d '{"id":"...","flag":"..."}' -H 'Content-Type: application/json'