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/*.jsonmerged byconfig/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
| Boundary | Where | What crosses |
|---|---|---|
| Browser → Express | server.js:71-75 | HTTP body, query, cookies |
| Session middleware | server.js:78-102 | req.session.userId (set on login / signup) |
| Auth middleware | app/routes/session.js:36-42 | Rejects non-authenticated GETs by redirect |
| Admin middleware | app/routes/session.js:25-34 | Exists but not mounted on the admin routes it should protect |
| App → MongoDB | app/data/*-dao.js | Queries; some interpolated into $where JS strings |
| App → external HTTP | app/routes/research.js:16 | Server-side fetch of user-supplied URL |
Input surfaces (HTTP)
| Method + path | Handler | Auth | Notable inputs |
|---|---|---|---|
POST /login | handleLoginRequest | — | userName, password |
POST /signup | handleSignup | — | userName / firstName / lastName / password / verify / email |
POST /profile | handleProfileUpdate | ✔ | firstName, lastName, ssn, dob, address, bankAcc, bankRouting |
POST /contributions | handleContributionsUpdate | ✔ | preTax, afterTax, roth (body) |
GET /benefits · POST /benefits | displayBenefits · updateBenefits | ✔ (no admin check) | userId, benefitStartDate (body) |
GET /allocations/:userId | displayAllocations | ✔ | req.params.userId, req.query.threshold |
POST /memos | addMemos | ✔ | req.body.memo |
GET /learn | inline redirect | ✔ | req.query.url |
GET /research | displayResearch | ✔ | req.query.url, req.query.symbol |
Auth model
- Password storage: plaintext.
app/data/user-dao.js:25writes the raw password field on the user document; thebcrypt.hashSync(...)fix is present commented at lines 27-30. - Password comparison: non-constant-time
===.user-dao.js:61. Thebcrypt.compareSync(...)fix is commented at line 65. - Session id: assigned on successful login by
session.js:116without callingreq.session.regenerate(...). The correct pattern is used at signup (session.js:234) — login is a genuine gap. - Admin gate:
isAdminUserMiddlewareexists atsession.js:25-34but is never mounted./benefitsuses onlyisLoggedIn(index.js:55-56).
Template engine + escaping
swigviaconsolidate(server.js:116-118).autoescapedisabled globally (server.js:135-137). Any{{ variable }}renders unescaped unless the template pipes through an explicit filter.marked.setOptions({ sanitize: true })is set atserver.js:126-128, butsanitizewas deprecated / removed in latermarkedversions.
Deployment surface
http.createServer(app).listen(port)atserver.js:145— HTTP only; HTTPS block commented at lines 20-27 / 149-155.helmetanddont-sniff-mimetypeimported-then-commented atserver.js:10-14/40-64; no CSP, HSTS, X-Frame-Options, MIME-sniff protection.csurflikewise imported-then-commented atserver.js:7/104-113; every state-changing POST accepts requests with no CSRF token.
Risk hotspots (highest hunting value)
app/routes/contributions.js:32-34— three literaleval(req.body.*)calls. SSJS injection with no role restriction.app/data/allocations-dao.js:77-79— MongoDB$whereclause built with template-string interpolation ofreq.query.threshold.app/routes/allocations.js:16-18—userIdfromreq.paramsinstead ofreq.session.app/routes/index.js:55-56—/benefitsmount without theisAdminmiddleware.app/routes/research.js:14-16—needle.get(req.query.url + req.query.symbol).app/routes/index.js:70-73—res.redirect(req.query.url).app/routes/profile.js:59— regex/([0-9]+)+\#/on user input; textbook ReDoS.app/data/user-dao.js:25+:61— plaintext password storage and non-constant-time compare at the same site.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
| Severity | Count |
|---|---|
| Critical | 2 |
| High | 5 |
| Medium | 2 |
| Low | 0 |
| Informational | 0 |
Findings
eval() in POST /contributionsapp/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.
$where in GET /allocations/:userIdapp/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.
GET /allocations/:userIdapp/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.
/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.
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.
GET /researchapp/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.
GET /learnapp/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.
POST /profileapp/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.
POST /loginapp/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.
What NodeGoat does well (in this run)
app/data/user-dao.js:91-93uses 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 wrapsreq.session.userId = user._idinreq.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.
eval() on POST /contributionsTrace
{preTax, afterTax, roth} from any logged-in user.const preTax = eval(req.body.preTax); — no sanitizer.const afterTax = eval(req.body.afterTax);const 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.
$where on GET /allocations/:userIdTrace
req.query.threshold as an unchecked string.allocationsDAO.getByUserIdAndThreshold.{$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.
GET /allocations/:userIdTrace
const { userId } = req.params; — trusts the URL segment.{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 requiresverdict, 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.