白盒代码安全审计 — Cloudflare security-audit-skill 六阶段产出

Target: OWASP NodeGoat · Node.js + Express 4 + MongoDB · run-1
Critical2 High5 Medium2 Low0 Info0
Phase 5 · findings.json PASS validate-findings.cjs · 9/9 valid · zero-dep Node.js

Phase 1 · architecture.md

Recon output. Application shape, trust boundaries, complete HTTP input surface, and the risk-ranked hotspots that seeded the Phase 2 hunters.

What this is

OWASP NodeGoat — an intentionally-vulnerable retirement-plan web app maintained as a teaching artifact for the OWASP Top 10. Node 12 + Express 4, MongoDB persistence, swig templates via consolidate, express-session cookies, needle for outbound HTTP.

  • Entry: server.js — boots Mongo, wires middleware, mounts routes.
  • Routes: app/routes/*.js (session, profile, contributions, allocations, benefits, memos, research, index, tutorial, error).
  • Data access: app/data/*-dao.js.
  • Views: app/views/*.html (swig templates).
  • Config: config/env/*.json merged by config/config.js.

Comparable class: prior-generation intentionally-vulnerable web apps (DVWA / WebGoat / Juice Shop). Findings are calibrated as real vulnerabilities against the running behavior, not as a checklist of deviations.

Trust boundaries

BoundaryWhereWhat crosses
Browser → Expressserver.js:71-75HTTP body, query, cookies
Session middlewareserver.js:78-102req.session.userId (set on login / signup)
Auth middlewareapp/routes/session.js:36-42Rejects non-authenticated GETs by redirect
Admin middlewareapp/routes/session.js:25-34Exists but not mounted on the admin routes it should protect
App → MongoDBapp/data/*-dao.jsQueries; some interpolated into $where JS strings
App → external HTTPapp/routes/research.js:16Server-side fetch of user-supplied URL

Input surfaces (HTTP)

Method + pathHandlerAuthNotable inputs
POST /loginhandleLoginRequestuserName, password
POST /signuphandleSignupuserName / firstName / lastName / password / verify / email
POST /profilehandleProfileUpdatefirstName, lastName, ssn, dob, address, bankAcc, bankRouting
POST /contributionshandleContributionsUpdatepreTax, afterTax, roth (body)
GET /benefits · POST /benefitsdisplayBenefits · updateBenefits(no admin check)userId, benefitStartDate (body)
GET /allocations/:userIddisplayAllocationsreq.params.userId, req.query.threshold
POST /memosaddMemosreq.body.memo
GET /learninline redirectreq.query.url
GET /researchdisplayResearchreq.query.url, req.query.symbol

Auth model

  • Password storage: plaintext. app/data/user-dao.js:25 writes the raw password field on the user document; the bcrypt.hashSync(...) fix is present commented at lines 27-30.
  • Password comparison: non-constant-time ===. user-dao.js:61. The bcrypt.compareSync(...) fix is commented at line 65.
  • Session id: assigned on successful login by session.js:116 without calling req.session.regenerate(...). The correct pattern is used at signup (session.js:234) — login is a genuine gap.
  • Admin gate: isAdminUserMiddleware exists at session.js:25-34 but is never mounted. /benefits uses only isLoggedIn (index.js:55-56).

Template engine + escaping

  • swig via consolidate (server.js:116-118).
  • autoescape disabled globally (server.js:135-137). Any {{ variable }} renders unescaped unless the template pipes through an explicit filter.
  • marked.setOptions({ sanitize: true }) is set at server.js:126-128, but sanitize was deprecated / removed in later marked versions.

Deployment surface

  • http.createServer(app).listen(port) at server.js:145 — HTTP only; HTTPS block commented at lines 20-27 / 149-155.
  • helmet and dont-sniff-mimetype imported-then-commented at server.js:10-14 / 40-64; no CSP, HSTS, X-Frame-Options, MIME-sniff protection.
  • csurf likewise imported-then-commented at server.js:7 / 104-113; every state-changing POST accepts requests with no CSRF token.

Risk hotspots (highest hunting value)

  1. app/routes/contributions.js:32-34 — three literal eval(req.body.*) calls. SSJS injection with no role restriction.
  2. app/data/allocations-dao.js:77-79 — MongoDB $where clause built with template-string interpolation of req.query.threshold.
  3. app/routes/allocations.js:16-18userId from req.params instead of req.session.
  4. app/routes/index.js:55-56/benefits mount without the isAdmin middleware.
  5. app/routes/research.js:14-16needle.get(req.query.url + req.query.symbol).
  6. app/routes/index.js:70-73res.redirect(req.query.url).
  7. app/routes/profile.js:59 — regex /([0-9]+)+\#/ on user input; textbook ReDoS.
  8. app/data/user-dao.js:25 + :61 — plaintext password storage and non-constant-time compare at the same site.
  9. app/routes/session.js:116 — session id not regenerated at login.

Baseline / calibration

This code is intentionally vulnerable. The auditor's job here is NOT to enumerate every intentional gap (that reproduces the OWASP tutorial and produces a useless report), but to (a) confirm each intentional gap is actually exploitable in the current source and (b) rank findings by concrete attacker impact so the report reads like a real audit and not a study guide. Findings without a demonstrable payload against the current source were dropped from the report.

Phase 4 · REPORT.md

Human-readable audit report. Severity distribution, ranked findings with two-line summaries, plus one call-out for what the codebase does well.

Severity summary

SeverityCount
Critical2
High5
Medium2
Low0
Informational0

Findings

CRITICAL-1 Critical Server-Side JavaScript Injection via eval() in POST /contributions

app/routes/contributions.js:32-34 runs eval() on three raw request-body fields (preTax, afterTax, roth) before any sanitization. The subsequent parseInt / isNaN validation only affects the response — the side effect (arbitrary Node.js code execution) has already occurred. Any logged-in user can execute code inside the Node worker: read filesystem, spawn a child process, dump MongoDB, reverse-shell.

See FINDINGS-DETAIL.md §1 for the full data flow.
CRITICAL-2 Critical NoSQL / server-side JS injection via $where in GET /allocations/:userId

app/data/allocations-dao.js:77-79 interpolates the untrusted query-string threshold into a MongoDB $where clause built as a template literal. $where hands the string to MongoDB's JavaScript engine, so ?threshold=0'; while(true){}// triggers a per-document infinite loop (DoS on the collection scan); ?threshold=0' || '1'=='1 bypasses the intended filter and returns every allocation row.

See FINDINGS-DETAIL.md §2.
HIGH-3 High Insecure Direct Object Reference on GET /allocations/:userId

app/routes/allocations.js:16-18 derives userId from the URL segment (req.params.userId) rather than req.session.userId. Any logged-in user retrieves any other user's allocations by walking the sequential id space. The safe pattern is right above the vulnerable code, commented out at lines 12-14.

See FINDINGS-DETAIL.md §3.
HIGH-4 High Missing authorization on /benefits (privilege escalation to admin)

app/routes/index.js:55-56 mounts GET /benefits and POST /benefits with only isLoggedIn; the isAdmin middleware at session.js:25-34 is available but never applied. Any non-admin user can POST userId=<victim>&benefitStartDate=<value> and mutate another user's benefit record — an explicit RBAC boundary defeated with real state change.

See FINDINGS-DETAIL.md §4.
HIGH-5 High Plaintext password storage + non-constant-time compare

app/data/user-dao.js:25 writes password verbatim on the user document; a DB dump discloses every credential. user-dao.js:61 compares with ===, which short-circuits on the first differing byte — a network-latency-observable timing side channel. Both bcrypt fixes are present commented in the same functions.

See FINDINGS-DETAIL.md §5.
HIGH-6 High Server-Side Request Forgery via GET /research

app/routes/research.js:14-16 concatenates req.query.url + req.query.symbol and passes it straight to needle.get(url, ...), then streams the response body back at line 25. With no scheme / host allow-list the app fetches any URL reachable from the server — cloud metadata (169.254.169.254), internal admin panels, MongoDB HTTP ports — and reflects the response verbatim.

See FINDINGS-DETAIL.md §6.
HIGH-7 High Open redirect on GET /learn

app/routes/index.js:70-73 does res.redirect(req.query.url) with no validation. Classic "login-then-redirect-to-attacker" building block: the browser sees the redirect start from a domain the victim trusts. Express's res.redirect also accepts protocol-relative URLs (//attacker.example), which many naive prefix checks miss.

See FINDINGS-DETAIL.md §7.
MEDIUM-8 Medium Regex Denial of Service in POST /profile

app/routes/profile.js:59 compiles /([0-9]+)+\#/ and applies it to req.body.bankRouting at line 61. The nested +)+ quantifier is a textbook catastrophic-backtracking pattern: an input of 40+ digits without # pushes the regex engine into exponential work and blocks the event loop until timeout. Because Node.js is single-threaded, a single request can DoS the whole worker.

See FINDINGS-DETAIL.md §8.
MEDIUM-9 Medium Session fixation on POST /login

app/routes/session.js:116 writes req.session.userId = user._id without calling req.session.regenerate(...). The correct pattern is used at signup (session.js:234), so login is a genuine gap. Combined with default cookie flags (server.js:78-102 — no httpOnly, no secure, no sameSite), an attacker who plants a session id on a victim's browser keeps that id valid after the victim logs in.

See FINDINGS-DETAIL.md §9.

What NodeGoat does well (in this run)

  • app/data/user-dao.js:91-93 uses a typed field lookup (findOne({userName: userName})) rather than string-concatenated queries, so login is not NoSQL-injectable even though the rest of the codebase is.
  • Signup (session.js:234) correctly wraps req.session.userId = user._id in req.session.regenerate(...). It is only login that omits regeneration.

Coverage note

This is run-1 against this target. Cloudflare's harness data suggests a single run of this skill finds roughly half of what is present across multiple runs. Recommend a run-2 focused on: prototype pollution in body-parsed objects, tutorial-router path traversal (app/routes/tutorial.js), the memos template rendering path (stored XSS via swig autoescape=false), and business-logic edge cases in contributions.js:47-57 after the eval issue is fixed.

Phase 4 · FINDINGS-DETAIL.md

MEDIUM+ findings with full data-flow trace (entrypoint → propagation → sink), reproducer payload, expected result, and remediation. Excerpt of the top three findings shown; the file on disk contains all nine.

§1 Critical SSJS Injection via eval() on POST /contributions

Trace

entrypointapp/routes/contributions.js:28 · handleContributionsUpdateExpress route accepts POST body {preTax, afterTax, roth} from any logged-in user.
propagationapp/routes/contributions.js:32const preTax = eval(req.body.preTax); — no sanitizer.
propagationapp/routes/contributions.js:33const afterTax = eval(req.body.afterTax);
sinkapp/routes/contributions.js:34const roth = eval(req.body.roth); — third eval; enough to trigger any side effect.

Payload

POST /contributions HTTP/1.1
Cookie: connect.sid=<any authenticated session>
Content-Type: application/x-www-form-urlencoded

preTax=require('child_process').execSync('id').toString()&afterTax=0&roth=0

Result: child_process.execSync('id') runs inside the Node worker; the string return value is coerced to NaN by the subsequent isNaN check and the response ends in the usual "Invalid contribution percentages" render, but the side effect (command execution) has already happened. More destructive payloads (process.exit(1), filesystem write, reverse-shell) land the same way.

Remediation: replace lines 32-34 with parseInt(req.body.preTax, 10) etc. The fix is already present as a commented block at lines 36-41.

§2 Critical NoSQL injection via $where on GET /allocations/:userId

Trace

entrypointapp/routes/allocations.js:19-21Handler reads req.query.threshold as an unchecked string.
propagationapp/routes/allocations.js:23Threshold passed to allocationsDAO.getByUserIdAndThreshold.
propagationapp/data/allocations-dao.js:62Truthiness guard on threshold; no parse.
sinkapp/data/allocations-dao.js:78Query built as {$where: `this.userId == ${parsedUserId} && this.stocks > '${threshold}'`} and executed by allocationsCol.find(...) at line 86.

Payloads

GET /allocations/1?threshold=0'%20%7C%7C%20'1'%3D%3D'1
    -> decoded: 0' || '1'=='1
    -> effective $where: this.userId == 1 && this.stocks > '0' || '1'=='1'
    -> matches every document; exfiltrates all allocations.

GET /allocations/1?threshold=0'%3B%20while(true)%7B%7D%2F%2F
    -> decoded: 0'; while(true){}//
    -> hangs the MongoDB collection scan indefinitely (DoS).

Result: full data exfiltration or per-connection DoS depending on payload. Because $where runs the string in Mongo's own JS engine, this is a full server-side JavaScript injection sink, not just a filter bypass.

Remediation: parseInt(threshold, 10) with range validation and numeric interpolation, or better, replace $where with a native operator ({stocks: {$gt: parsedThreshold}}). The fix is present commented at allocations-dao.js:63-76.

§3 High IDOR on GET /allocations/:userId

Trace

entrypointapp/routes/allocations.js:16-18const { userId } = req.params; — trusts the URL segment.
propagationapp/routes/allocations.js:23Passed to DAO as the authoritative user id.
sinkapp/data/allocations-dao.js:82Query {userId: parsedUserId} returns rows for the URL-supplied user, not the session user.

Payload

GET /allocations/2   ← as logged-in user 1
Cookie: connect.sid=<any authenticated session>

Result: the response renders user 2's allocations (stocks, funds, bonds, plus first/last name via userDAO.getUserById). User ids are sequential integers minted by getNextSequence in user-dao.js:109-120, so an attacker enumerates the whole user base.

Remediation: const { userId } = req.session; (as documented at allocations.js:12-14) and drop the :userId path segment from the route mount at index.js:63.

Findings §4-9 (missing admin authorization, plaintext passwords, SSRF, open redirect, ReDoS, session fixation) follow the same shape — each has an entrypoint → propagation → sink trace, at least one concrete payload, expected result, and a code-change remediation. See the full FINDINGS-DETAIL.md on disk.

Phase 5 · findings.json (schema-validated)

Machine-readable output conforming to the upstream report-schema.json. Every finding has a trace array whose first step is kind: entrypoint, last is kind: sink, intermediate steps are kind: propagation — enforced semantically by validate-findings.cjs.

Validator
node validate-findings.cjs findings.json — zero dependencies
Result
PASS   9/9 findings valid; exit code 0
Severity mix
2 critical · 5 high · 2 medium
Schema
oneOf: [confirmed, rejected]; every confirmed finding requires verdict, title, description, root_cause, intended_behavior, trace, conditions, execution, remediation, severity, confidence.

Excerpt — finding [0] (Critical: SSJS Injection)

{
  "verdict": "confirmed",
  "title": "Server-Side JavaScript Injection via eval() in POST /contributions",
  "description": "The POST /contributions handler runs eval() on three raw request-body fields (preTax, afterTax, roth) before any sanitization. ...",
  "root_cause": "handleContributionsUpdate in app/routes/contributions.js does not parse the request body as numeric input, allowing eval to execute arbitrary JavaScript with the Node process's privileges.",
  "intended_behavior": "The handler is meant to accept three percentage integers ... The commented block at lines 36-41 shows the maintainers know the safe form is parseInt(req.body.<field>, 10).",
  "trace": [
    { "kind": "entrypoint",  "file": "app/routes/contributions.js", "line": 28, "scope": "handleContributionsUpdate", "description": "Express route registered at index.js:52 invokes this handler; req.body is populated by bodyParser." },
    { "kind": "propagation", "file": "app/routes/contributions.js", "line": 32, "scope": "handleContributionsUpdate", "description": "const preTax = eval(req.body.preTax); the raw string from the body is passed to eval." },
    { "kind": "propagation", "file": "app/routes/contributions.js", "line": 33, "scope": "handleContributionsUpdate", "description": "const afterTax = eval(req.body.afterTax); same pattern on the second field." },
    { "kind": "sink",        "file": "app/routes/contributions.js", "line": 34, "scope": "handleContributionsUpdate", "description": "const roth = eval(req.body.roth); third eval; any of the three eval calls executes attacker-supplied JavaScript." }
  ],
  "conditions": [
    { "kind": "authentication_level", "description": "Requires any authenticated session (isLoggedIn middleware only); no admin role or CSRF token required." }
  ],
  "execution": {
    "attacker_perspective": "Any signed-in user of the NodeGoat instance.",
    "payloads": [
      "preTax=require('child_process').execSync('id').toString()&afterTax=0&roth=0",
      "preTax=process.exit(1)&afterTax=0&roth=0"
    ],
    "instructions": [
      "Register or log in and capture the connect.sid cookie.",
      "Submit POST /contributions with body preTax=<payload>&afterTax=0&roth=0.",
      "Observe the eval side effect on the server."
    ],
    "expected_result": "The eval'd JavaScript expression executes with the privileges of the Node.js process; the HTTP response ends in the standard 'Invalid contribution percentages' render because isNaN of the return value is true, but the side effect has already occurred."
  },
  "remediation": {
    "strategy": "Replace all three eval(req.body.<field>) calls with parseInt(req.body.<field>, 10). The commented block at lines 36-41 already contains the fix verbatim.",
    "code_changes": [
      { "file_name": "app/routes/contributions.js",
        "fixed_code": "const preTax = parseInt(req.body.preTax, 10);\nconst afterTax = parseInt(req.body.afterTax, 10);\nconst roth = parseInt(req.body.roth, 10);" }
    ]
  },
  "severity": {
    "likelihood": { "score": "high", "reason": "Requires only a valid session; the attack is a single POST with a trivial payload." },
    "impact":     { "score": "critical", "reason": "Server-side arbitrary code execution as the Node process." },
    "overall_severity": "critical"
  },
  "confidence": {
    "score": "high",
    "reason": "eval on req.body is present in three literal calls at lines 32-34; the route mount at app/routes/index.js:52 is direct; verified by re-reading both files."
  }
}

Validator run

$ node validate-findings.cjs findings.json
Checking [0] Server-Side JavaScript Injection via eval() in POST /contributions
Checking [1] NoSQL injection via $where operator in GET /allocations/:userId
Checking [2] Insecure Direct Object Reference on GET /allocations/:userId
Checking [3] Missing admin authorization on /benefits allows non-admin benefit mutation
Checking [4] Plaintext password storage with non-constant-time compare
Checking [5] Server-Side Request Forgery in GET /research
Checking [6] Open redirect on GET /learn via res.redirect(req.query.url)
Checking [7] Regex Denial of Service on POST /profile bankRouting field
Checking [8] Session fixation on POST /login (no req.session.regenerate)

PASS: 9 findings valid

Findings [1] through [8] follow the same shape — each carries a full trace, execution, remediation, severity, and confidence block. The complete findings.json on disk is the machine-readable source of truth downstream tools consume.