CodeVetter · Repo Unpack · Risk map
expressjs/express
The security-sensitive surface concentrates in four places: proxy trust (which decides whether client-supplied `X-Forwarded-*` headers are believed), JSONP callback sanitisation, file transfer via `res.sendFile`/`res.download`, and dynamic `require()` inside the view system. Blast radius is dominated by `res.send` — `json`, `jsonp`, `sendStatus` and the default `render` callback all funnel through it, so any change there touches nearly every response. Coupling risks come from prototype mutation: `app.handle` re-prototypes live Node objects and mounting re-parents settings chains, so behaviour can change at a distance. Supply-chain posture is strong, but `.npmrc` disables lockfiles, so local installs are not reproducible.
- SECURITY — proxy trust: `req.ip`, `req.ips`, `req.protocol`, `req.secure` and `req.host` all believe client-supplied headers whenever `trust proxy fn` returns true; setting `trust proxy` to `true` makes `compileTrust` return a constant-true function, so `X-Forwarded-For`/`-Proto`/`-Host` become fully spoofable.
- SECURITY — dynamic require: the `View` constructor derives a module name from the view's file extension and calls `require(mod)`. An application that renders a view name built from user input can drive module resolution, and the thrown errors leak which module was attempted.
- SECURITY — JSONP: the callback is taken from `req.query` and filtered with `replace(/[^\[\]\w$.]/g, '')`; correctness of this allowlist plus the `/**/ typeof` wrapper and forced `X-Content-Type-Options: nosniff` are the only things standing between `res.jsonp` and reflected script injection.
- SECURITY — prototype keys: the `extended` query parser calls `qs.parse` with `allowPrototypes: true`, which is a deliberate compatibility choice that permits keys like `constructor` in parsed query objects.
- SECURITY — file IO outside repo root: `res.sendFile` only enforces "absolute path or `root` option" and `encodeURI`; every actual containment check is delegated to the `send` package, so a `send` regression is an Express path-traversal regression.
- SECURITY — information disclosure by default: `x-powered-by` is enabled in `defaultConfiguration` and `app.handle` sets the `X-Powered-By: Express` header on every response unless explicitly disabled.
- SECURITY — cookie signing depends on a foreign property: `res.cookie` reads `this.req.secret`, which Express never sets; it throws `cookieParser("secret") required for signed cookies` if `cookie-parser` was not installed upstream, making signing silently middleware-order dependent.
- BLAST RADIUS — `res.send` is the shared exit for `res.json`, `res.jsonp`, `res.sendStatus` and the default `res.render` callback; the HEAD-vs-body, 204/304/205 header stripping, ETag and Content-Length rules all live in that one function.
- FRAGILE COUPLING — mutation of live Node objects: `app.handle` calls `Object.setPrototypeOf` on the actual `IncomingMessage`/`ServerResponse`, and `mounted_app` must manually restore the parent prototypes after a sub-app completes; forgetting that restore silently leaks the child's prototype into sibling middleware.
- FRAGILE COUPLING — settings inheritance is by prototype chain (`Object.setPrototypeOf(this.settings, parent.settings)`), guarded by a string symbol `'@@symbol:trust_proxy_default'` that is deleted/redefined in two separate places to preserve back-compat.
- PERFORMANCE — synchronous file IO on the request path: `View#resolve` calls `fs.statSync` up to twice per lookup, and `view cache` is only auto-enabled when `NODE_ENV === 'production'`, so non-production deployments stat the filesystem on every render.
- PERFORMANCE — `req.query`, `req.host`, `req.protocol` and `req.ip` are non-memoised getters; each access re-parses. Middleware that reads `req.query` repeatedly pays the `qs`/`querystring` cost every time. (inferred)
- LEGACY — `depd` deprecation paths remain live in `res.redirect` for missing URLs, non-string URLs and non-number statuses, meaning three invalid-argument shapes still produce a response instead of throwing.
- LEGACY — `legacy.yml` still runs the full suite on Node 16 and 17 even though `package.json` declares `engines.node >= 18`; the two signals disagree about the supported floor.
- REPRODUCIBILITY — `.npmrc` sets `package-lock=false`, and no lockfile is committed, so CI and local installs resolve dependency versions independently at install time; `min-release-age=7` is the mitigation.
- SUBTLE API — `res.download` rebinds options with `opts = Object.create(opts)` so caller options live on the prototype rather than as own properties; any downstream code doing `Object.keys(opts)` or a spread will not see them.
- BREAKING-CHANGE HAZARD — `res.status` now throws `TypeError` for non-integers and `RangeError` outside 100–999, so any code path that computed a status dynamically can convert a soft bug into a thrown exception mid-response.