DEV Community

Cover image for The Express.js Vulnerability Is the Middleware You Forgot — 14 ESLint Rules That Catch What Isn't There
Ofri Peretz
Ofri Peretz

Posted on • Edited on • Originally published at ofriperetz.dev

The Express.js Vulnerability Is the Middleware You Forgot — 14 ESLint Rules That Catch What Isn't There

This Express app is vulnerable four separate ways. There is not one line in it you could point at in review.

const app = express(); // no helmet()       → CWE-693
app.use(cors({ origin: true, credentials: true })); // reflected origin  → CWE-942
app.use(express.json()); // no size limit     → CWE-400
app.post("/transfer", (req, res) => transfer(req.body)); // no CSRF guard     → CWE-352
Enter fullscreen mode Exit fullscreen mode

Every one of those is an absence. A diff shows you what changed; it cannot show you the middleware nobody wrote. That is why this class of bug survives review, survives the test suite (the test sends a well-formed request from a trusted origin), and then survives all the way to production — unless you run static analysis that knows what an Express app is supposed to have.

eslint-plugin-express-security reads that same file and fires on the gaps. Below: what each absence costs, which rule catches it, and the two steps that put all 14 rules on your next push. Setup, from npm install to a red pipeline, takes about a minute.

The Hardened Stack series · eslint-plugin-node-securityeslint-plugin-express-security (you are here)eslint-plugin-nestjs-securityeslint-plugin-jwt. Express is the HTTP layer; node-security is the standard-library floor underneath it.


Finding 1: Missing security headers (require-helmet, CWE-693)

The vulnerable code:

const app = express();
app.use(express.json());
app.post("/transfer", (req, res) => transfer(req.body));
app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Why it survived review: A reviewer reads four clean lines, sees a working POST endpoint, and approves. Nothing on screen is wrong. The dangerous part is the lines that aren't there — no helmet() means the response ships without X-Frame-Options (clickjacking), X-Content-Type-Options (MIME-sniffing), Strict-Transport-Security (HTTPS enforcement), or a CSP. You can't review code that was never written, and a test for "did we forget Helmet?" is one nobody writes. The rule files it under CWE-693, Protection Mechanism Failure — the weakness class for a defense that was available and simply not switched on.

The fix:

import helmet from "helmet";

const app = express();
app.use(helmet()); // X-Frame-Options, HSTS, X-Content-Type-Options, CSP — in one line
app.use(express.json());
app.post("/transfer", (req, res) => transfer(req.body));
app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Finding 2: CORS that reflects the caller's origin (no-cors-credentials-wildcard + no-permissive-cors, CWE-942)

The vulnerable code:

app.use(cors({ origin: "*", credentials: true })); // fails loudly
app.use(cors({ origin: true, credentials: true })); // fails silently — this is the dangerous one
Enter fullscreen mode Exit fullscreen mode

Why it survived review: The first line is the one everybody expects to be wrong, and browsers do reject it — the spec forbids Access-Control-Allow-Origin: * alongside credentials, so the requests simply break. What happens next is the actual vulnerability. Someone debugs the broken feature, finds that the cors package accepts origin: true, sets it, and the feature works again. origin: true means reflect whatever origin asked — so every site on the internet is now an allowed origin, with the victim's cookies attached. The diff reads - "*" / + true. It looks like a fix. It is the hole.

The fix:

// Option 1: Public API — no cookies, no credentials, wildcard is honest.
// The rule flags this anyway (see below), so make the exception explicit:
// eslint-disable-next-line express-security/no-permissive-cors -- public read-only API, no credentials
app.use(cors({ origin: "*" }));

// Option 2: Credentialed requests — explicit allowlist, never reflection
const ALLOWED_ORIGINS = [
  "https://app.yourdomain.com",
  "https://admin.yourdomain.com",
];
app.use(
  cors({
    origin: (origin, cb) => cb(null, ALLOWED_ORIGINS.includes(origin ?? "")),
    credentials: true,
  }),
);
Enter fullscreen mode Exit fullscreen mode

no-cors-credentials-wildcard catches the wildcard-plus-credentials form; no-permissive-cors catches the reflected form and a bare origin: "*", credentials or not. That second part is why Option 1 carries a disable comment: the browser only rejects * when credentials ride along, but the rule rejects * full stop. It is enforcing policy rather than the spec — "public, no cookies" is how most APIs describe themselves right up until someone adds a session, and at that moment the wildcard becomes the credential leak with nobody having touched the CORS line. If your API really is public, the disable comment is how you say so: one reviewable line naming the assumption, instead of a config that quietly stops being true. Two rules, because the two mistakes arrive at different times — one at design, one during the debugging that follows.


Finding 3: State-changing route with no CSRF guard and no body cap (require-csrf-protection + require-express-body-parser-limits, CWE-352 / CWE-400)

The vulnerable code:

app.post("/transfer", async (req, res) => {
  await db.transfers.create(req.body); // req.body is attacker-controlled
  res.json({ ok: true });
});
Enter fullscreen mode Exit fullscreen mode

Why it survived review: The line reads like a feature: "accept the transfer payload and persist it." The danger is invisible — no CSRF token check means any site can forge this POST from a victim's browser, using the victim's cookies, and your server will execute it. It also misses a body-size cap, so a 2 GB JSON body is a free denial-of-service. Both look like omissions, not bugs. Both survive a diff. require-csrf-protection carries a declared CVSS of 8.8 — the highest in the plugin.

The fix:

import { doubleCsrf } from "csrf-csrf";

const { doubleCsrfProtection } = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET!,
  getSessionIdentifier: (req) => req.session.id,
});

app.use(express.json({ limit: "100kb" })); // bounded body
app.use(doubleCsrfProtection); // forged-request guard

app.post("/transfer", async (req, res) => {
  // token already verified by the middleware above
  await db.transfers.create(req.body);
  res.json({ ok: true });
});
Enter fullscreen mode Exit fullscreen mode

One note before you copy that: the old standard here was csurf, and csurf was deprecated in 2022. csrf-csrf is the maintained double-submit-cookie replacement, and the rule accepts it — it matches any middleware identifier containing csrf, so doubleCsrfProtection satisfies it, as does lusca.csrf() or your own csrfMiddleware. The rule's own fix hint, however, still tells you to install csurf. I wrote that hint. It is on the list.


Finding 4: Open redirect via req.query (no-user-controlled-redirect, CWE-601)

The vulnerable code:

app.get("/login", (req, res) => {
  // ...authenticate...
  res.redirect(req.query.returnUrl); // whatever the caller sent, we go there
});
Enter fullscreen mode Exit fullscreen mode

Why it survived review: returnUrl is not a bug-shaped word. It is a convenience feature — "send the user back where they came from after login" — and it appears in the ticket, the design doc, and the acceptance criteria. What it actually is: a link on your domain, wearing your TLS certificate, that lands the user on the attacker's page. That is precisely what open redirects get used for in phishing — the link survives a domain check because the domain really is yours. It is also the kind of omission a coding assistant reproduces without hesitating: across 80 AI-written functions, 65–75% shipped carrying at least one vulnerability, and "reflect the parameter the caller sent" is a shape that satisfies every test you would think to write.

The fix:

// Resolve against an allowlist — never reflect raw input back into a Location header
const ALLOWED = new Set(["/dashboard", "/settings", "/profile"]);

app.get("/login", (req, res) => {
  const target = String(req.query.returnUrl ?? "");
  res.redirect(ALLOWED.has(target) ? target : "/dashboard");
});
Enter fullscreen mode Exit fullscreen mode

The neighbour this plugin does not catch. The sibling bug on that route is path traversal — res.sendFile(path.join(__dirname, "uploads", req.params.name)), which looks safe right up until name is ../../etc/passwd. The fix is path.basename() before the join. But that is CWE-22, a filesystem weakness rather than an HTTP one, so it belongs to eslint-plugin-node-security (detect-non-literal-fs-filename, no-arbitrary-file-access), not here. A plugin that half-owns a weakness class is worse than one that clearly doesn't.


Get it running (about a minute)

The six rules above — plus eight more — run before the code reaches review. One install, one config line, 14 CWE-mapped rules on every push.

Step 1 — install:

# npm
npm install --save-dev eslint-plugin-express-security
# yarn
yarn add --dev eslint-plugin-express-security
# pnpm
pnpm add --save-dev eslint-plugin-express-security
# bun
bun add --dev eslint-plugin-express-security
Enter fullscreen mode Exit fullscreen mode

Step 2 — flat config (eslint.config.js), pick one preset:

// `configs` is a NAMED export; the default export is the plugin object.
import { configs } from "eslint-plugin-express-security";

export default [
  configs.recommended, // all 14 — criticals at error, the noisier ones at warn
  // configs.strict,   // all 14, every rule at error
  // configs.api,      // 5-rule REST hardening set (helmet, CORS, CSRF, cookies, rate limit)
  // configs.graphql,  // introspection-in-production only
];
Enter fullscreen mode Exit fullscreen mode

Step 3 — run it. Every finding carries the CWE, the OWASP Top 10 category, the CVSS score, and the fix — on the line, not in a portal:

src/routes/transfer.ts
  9:1  error  🔒 CWE-352 OWASP:A01-Broken CVSS:8.8 | Route handler for POST request lacks CSRF protection.
             Fix: Add CSRF middleware: app.use(csrf()) or use csurf package. Include csrfToken in forms.
Enter fullscreen mode Exit fullscreen mode

That is the whole setup. No Express install, no running server, no build step: the plugin is AST-based, so it reads your app.use(...) chain and route definitions straight out of the source file. It recognises the standard middleware — helmet, express-rate-limit, csurf/csrf-csrf/lusca, cors — on app or on a router, and it fires the moment the pattern is absent.


TL;DR

  • 14 rules, each carrying a declared CWE and CVSS, covering headers, CORS, CSRF, cookies, body limits, ReDoS routes, debug endpoints, GraphQL introspection, and open redirects.
  • 4 presets: recommended (all 14 — criticals at error; the rules with the widest false-positive surface, rate-limiting and CSRF, default to warn) · strict (all 14 at error) · api (5-rule REST hardening set) · graphql (introspection-in-production only).
  • Flat config, CommonJS, ESLint 8 || 9 || 10, Node >= 18. AST-based — it lints source; no runtime peers, no Express install needed.

Rule set and preset severities below are v1.3.2, verified against the source on 2026-07-28.

Rule Catches CWE recommended
require-helmet App missing helmet() security headers CWE-693 error
require-rate-limiting No rate limiter → brute force / DoS CWE-770 warn
require-csrf-protection State-changing route, no CSRF CWE-352 warn
require-express-body-parser-limits Body parser with no size limit CWE-400 warn
no-express-unsafe-regex-route ReDoS in a route pattern CWE-1333 error
no-permissive-cors origin: '*' / reflected origin CWE-942 error
no-cors-credentials-wildcard Wildcard origin + credentials CWE-942 error
no-insecure-cookie-options Missing Secure/HttpOnly/SameSite CWE-614 error
no-exposed-debug-endpoints Debug routes reachable in prod CWE-489 error
no-graphql-introspection-production Introspection enabled in prod CWE-200 warn
no-user-controlled-redirect res.redirect() of raw user input CWE-601 error
no-missing-cors-check Origin trusted without validation CWE-346 warn
no-missing-csrf-protection State change with no CSRF guard CWE-352 warn
no-missing-security-headers Response missing security headers CWE-693 warn

Compatibility

Surface Support
Package managers npm, yarn, pnpm, bun — plain dev dependency
Node >= 18.0.0
ESLint `^8.0.0 \
Express Detects Express 4/5 {% raw %}app.use(...) chains, route definitions, and cors/helmet/csrf/express-rate-limit usage
Module system CommonJS — loads from both eslint.config.js and eslint.config.mjs
Runtime peers None — it lints source AST
Oxlint Loads under Oxlint's JS-plugin runner via the interlace-express-security port

What it sees — and doesn't

  • Presence, not correctness. require-helmet proves you called helmet(); it can't prove your contentSecurityPolicy is tight, or that your rate-limit max is sane. It removes the "we forgot entirely" failure mode, not the "our config is weak" one. That is a deliberate trade: "forgot entirely" has by far the higher base rate in real Express codebases, and it is the one a checkable rule can decide without guessing.
  • Structural checks, not taint tracking. These rules match AST shapes — a missing app.use, an origin: true, a res.redirect() whose argument is a req.query / req.body access. They do not follow a value through an assignment at all: const url = req.query.url; res.redirect(url) is not detected — same function, one line apart. The upside is precision: a structural check either matches or it doesn't, so it rarely invents a finding. The cost is recall — every indirection is invisible, from a local variable to a helper three files away.
  • It reads the obvious wiring. A bespoke homegrown CSRF layer it doesn't recognise may need an inline disable with a comment explaining why. That is the honest failure mode, and it is a one-line comment rather than a wrong result.

Your turn

Point configs.recommended at your oldest Express service — the one nobody has touched in a year — and look only at the first finding it reports. My guess is require-helmet, because that one gets decided at project setup and then never revisited. Drop the rule name and roughly how old the service is in the comments; I want to know which absence ages worst.


Context: where this fits the bigger picture

The "framework hands you the guard, you ship without it" failure shows up at every layer of a Node stack, and each layer has its own rule set:

For how this slot compares to everything else competing for it, I benchmarked the field against a hand-labelled ground-truth corpus in Benchmark: 17 ESLint Security Plugins Compared.

The Hardened Stack · eslint-plugin-node-security | eslint-plugin-express-security (current) | eslint-plugin-nestjs-security | eslint-plugin-jwt. If your Express app also touches the filesystem or child_process, read the node-security floor next — it owns the CWE-22 half of Finding 4.


Links

📦 npm install --save-dev eslint-plugin-express-security — 14 rules, one config line, running on your next push.


eslint-plugin-express-security is part of the Interlace ESLint ecosystem. Source on GitHub · Follow: Dev.to/ofri-peretz

Top comments (0)