Back to Blog
high SEVERITY6 min read

How Cross-Site Request Forgery (CSRF) happens in Express.js and how to fix it

A semgrep audit flagged `devboard/server/index.js` for lacking any CSRF middleware, meaning every state-changing route (`POST`, `PUT`, `DELETE` under `/api/*`) could be triggered by a forged cross-origin request riding on a victim's session cookie. The fix wires in `cookie-parser` and `csurf` right after body parsing, so every mutating request now requires a valid, per-session CSRF token before it reaches route handlers.

O
By Orbis AppSec
Published August 31, 2026Reviewed August 31, 2026

Answer Summary

This is a Cross-Site Request Forgery (CSRF) vulnerability, CWE-352, found in an Express.js/Node.js application at `devboard/server/index.js`. The app processed state-changing requests (`POST`/`PUT`/`DELETE` to `/api/*`) without any CSRF token validation middleware. The fix adds `cookie-parser` and `csurf({ cookie: true })` immediately after `express.json()`, forcing every mutating request to carry a valid CSRF token tied to the user's session before it reaches routes like `/api/auth`, `/api/tasks`, and `/api/data`.

Vulnerability at a Glance

cweCWE-352
fixAdded `cookie-parser` + `csurf({ cookie: true })` middleware before route mounting to enforce per-session CSRF tokens
riskAttackers can forge authenticated state-changing requests from a malicious page, using the victim's browser session
languageJavaScript (Node.js / Express)
root causeExpress app registered routes without any CSRF token validation middleware
vulnerabilityMissing CSRF Protection (Cross-Site Request Forgery)

Introduction

The devboard/server/index.js file bootstraps the Devboard Express application — it wires up helmet for security headers, cors for cross-origin policy, express-rate-limit for abuse protection, and mounts the app's core routes: /api/auth, /api/tasks, and others. It does a lot of defensive setup. But a semgrep audit rule, javascript.express.security.audit.express-check-csurf-middleware-usage.express-check-csurf-middleware-usage, flagged line 23 for a gap none of that other middleware covers: there was no CSRF protection at all.

That means every POST, PUT, and DELETE route in the application — think /api/data, /api/data/:id, task creation, task updates — trusted any request that arrived with a valid session cookie, regardless of where the request actually came from. A malicious webpage the victim happens to have open in another tab could fire off a fetch or auto-submitting form to Devboard's API, and the browser would happily attach the victim's cookies. If the developer working on this file assumed that helmet() and cors() were "enough" security, this vulnerability is a reminder that neither of those tools does anything to stop CSRF.

The Vulnerability Explained

Before the fix, the middleware stack in index.js looked like this:

app.use(express.json());
app.use(limiter);

// Routes
app.use("/api/auth", require("./routes/auth"));
app.use("/api/tasks", require("./routes/tasks"));

There's a rate limiter, JSON body parsing, and then the routes go straight into business logic. Nothing here checks who the request is really coming from — only that a session cookie exists. That's the essence of CSRF: the browser automatically attaches cookies to requests, even ones initiated by a third-party site, so the server can't tell a legitimate click from a forged one unless it explicitly checks for a token that a foreign origin couldn't have obtained.

Attack scenario: Imagine a Devboard user is logged in and, in another tab, visits https://malicious-site.com, which hosts this hidden auto-submitting form:

<form action="https://devboard-app.example/api/data/1" method="POST">
  <input type="hidden" name="update" value="unauthorized" />
</form>
<script>document.forms[0].submit();</script>

Because the victim's browser automatically includes the Devboard session cookie with the request, the Express server in index.js sees what looks like a perfectly valid, authenticated POST /api/data/1 — and processes it. There was no middleware standing between express.json() and the route handlers to reject requests lacking a legitimate, session-bound CSRF token. The regression test included in this PR simulates exactly this: cross-origin POST/PUT/DELETE requests with an Origin header of https://malicious-site.com or https://attacker.com, expecting the server to reject them with a 401/403 or a CSRF-related error.

The Fix

The PR adds two dependencies — cookie-parser and csurf — and wires them into the middleware chain in devboard/server/index.js:

Before:

app.use(express.json());
app.use(limiter);

// Routes
app.use("/api/auth", require("./routes/auth"));

After:

const cookieParser = require("cookie-parser");
const csrf = require("csurf");

app.use(cookieParser());
app.use(express.json());
app.use(limiter);

// CSRF protection via csurf middleware (cookie-based)
app.use(csrf({ cookie: true }));

// Routes
app.use("/api/auth", require("./routes/auth"));

Here's why each change was necessary:

  • cookie-parser: csurf needs to read and write a secret stored in a cookie in order to validate tokens across requests. Without a cookie parser, csurf({ cookie: true }) has nothing to attach the CSRF secret to.
  • csurf({ cookie: true }): This middleware generates a per-session CSRF secret (stored in a cookie) and requires that state-changing requests include a matching token (typically submitted via a form field or an X-CSRF-Token header). Any request without a valid, matching token — like the forged form submission from malicious-site.com — is rejected before it ever reaches /api/data or /api/tasks route handlers.
  • Placement matters: The middleware is registered after cookieParser() and express.json() (so it can read cookies and parsed bodies) but before the route handlers, ensuring every route benefits from the check without each individual route needing its own logic.
  • package.json update: Adding cookie-parser and csurf as explicit dependencies makes the security control part of the reproducible build, not an implicit assumption.

The regression test bundled with the PR confirms the fix: cross-origin POST, PUT, and DELETE requests to /api/data and /api/data/1 without a valid CSRF token now return a 401/403 (or a CSRF-token error), instead of silently succeeding.

Prevention & Best Practices

  • Always pair session cookies with a CSRF defense. If your app uses cookie-based sessions for any state-changing endpoint, you need CSRF protection — SameSite=Lax/Strict cookies help but are not a complete substitute for token validation, especially for GET-triggered side effects or older browsers.
  • Register CSRF middleware globally, early, and consistently. Bolting CSRF checks onto only some routes is a common source of regressions; middleware applied once in index.js, as done here, is far harder to accidentally bypass.
  • Use semgrep or similar SAST tooling in CI. The rule express-check-csurf-middleware-usage that caught this issue is designed to run automatically against any Express codebase — wiring it into CI would catch this class of gap before merge, not after a security audit.
  • Test CSRF behavior explicitly. The regression test in this PR — sending cross-origin requests with forged Origin/Referer headers and asserting a 401/403/404 — is a good template for verifying CSRF protection stays enforced as routes evolve.
  • Follow OWASP's CSRF Prevention Cheat Sheet. It covers synchronizer tokens (what csurf implements), double-submit cookies, and SameSite cookie strategies for cases where a token-based approach isn't practical.

Key Takeaways

  • The devboard/server/index.js middleware stack had helmet, cors, and rate limiting — but none of those substitute for CSRF token validation on state-changing routes.
  • csurf({ cookie: true }) now sits between body parsing and route mounting, protecting /api/auth, /api/tasks, and all other mutating routes at once.
  • cookie-parser is a required companion dependency for csurf's cookie-based token storage — forgetting it silently breaks the protection.
  • The semgrep rule express-check-csurf-middleware-usage is a reliable, low-noise signal for this exact gap in Express apps and should be part of any Node.js CI pipeline.
  • The included regression test — forging Origin/Referer headers on POST/PUT/DELETE — is a reusable pattern for verifying CSRF protection doesn't regress as new routes are added.

How Orbis AppSec Detected This

  • Source: Any cross-origin HTTP request (POST, PUT, DELETE) sent to a Devboard API endpoint while the victim's browser holds a valid session cookie.
  • Sink: The route handlers mounted in devboard/server/index.js at line 23 and beyond (e.g., /api/data, /api/data/:id, /api/tasks), which processed requests immediately after JSON body parsing.
  • Missing control: No CSRF token validation middleware (csurf/csrf) existed anywhere in the middleware chain, so authenticity of the request's origin was never checked — only the presence of a session cookie.
  • CWE: CWE-352 (Cross-Site Request Forgery).
  • Fix: Added cookie-parser and csurf({ cookie: true }) middleware, registered right after body parsing and before route mounting, so every state-changing request must now present a valid, session-bound CSRF token.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

CSRF is easy to overlook precisely because everything looks fine — the app is authenticated, the requests are well-formed JSON, and the session cookie checks out. But without a dedicated token-validation step, an Express app like Devboard has no way to distinguish a legitimate user action from a forged request fired by a hostile page the victim never intended to trust. Adding cookie-parser and csurf({ cookie: true }) to devboard/server/index.js closes that gap for every current and future route mounted after it, and the accompanying regression test ensures it stays closed. If your Express app handles session cookies and mutating routes, treat CSRF middleware as a non-negotiable baseline, not an afterthought.

References

  • CWE-352: Cross-Site Request Forgery — https://cwe.mitre.org/data/definitions/352.html
  • OWASP CSRF Prevention Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
  • csurf middleware documentation — https://github.com/expressjs/csurf
  • cookie-parser documentation — https://github.com/expressjs/cookie-parser
  • Semgrep rule reference — https://semgrep.dev/r?q=express-check-csurf-middleware-usage
  • harden: add CSRF protection in index.js...

Frequently Asked Questions

What is Cross-Site Request Forgery (CSRF)?

CSRF is an attack where a malicious site tricks a logged-in victim's browser into sending an authenticated request (e.g., a form submit or fetch call) to a target application, causing unwanted state changes like data updates or deletions.

How do you prevent CSRF in Express.js?

Use a CSRF middleware such as `csurf` or `csrf` with cookie- or session-based tokens, validate the token on every state-changing request, and pair it with `SameSite` cookie attributes and origin checks for defense in depth.

What CWE is CSRF?

CSRF corresponds to CWE-352 (Cross-Site Request Forgery).

Is CORS configuration enough to prevent CSRF?

No. CORS controls which origins can read a response via JavaScript, but it does not stop a browser from sending a request with cookies attached — simple HTML forms and non-CORS requests can still trigger state changes without a CSRF token.

Can static analysis detect missing CSRF protection?

Yes. Rules like semgrep's `express-check-csurf-middleware-usage` scan Express apps for the absence of recognized CSRF middleware on routes that handle state-changing verbs, flagging the gap before it ships to production.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #353

Related Articles

critical

How missing authorization and code injection happen in Mindustry JavaScript mods and how to fix it

A `TapEvent` handler in `scripts/CommandBlock.js` exposed a full administrative command palette — including a `run-javascript` command that piped player-supplied text straight into `new Function(text)()` — behind nothing more than a team-membership check. Any player who happened to share a team with the block could execute arbitrary JavaScript (and, through Rhino's Java bridge, arbitrary host code) inside the game runtime. The fix adds an explicit `if (!e.player.admin) return;` guard at the top

critical

How Unauthenticated API Exposure Happens in Node.js Koa Routers and How to Fix It

The `/api/adapters` and `/api/list` endpoints in the OneBots framework were registered before authentication middleware, making them publicly accessible to unauthenticated attackers. This critical vulnerability allowed anyone to enumerate all configured adapters, accounts, and sensitive metadata with a simple GET request. The fix ensures these endpoints are protected by the existing auth middleware by correcting route registration order.

high

How Unauthorized SSH Command Execution Happens in Go and How to Fix It

A high-severity vulnerability in `golang.org/x/crypto/ssh` (CVE-2026-39828) allowed attackers to execute unauthorized commands by exploiting discarded SSH permissions. The fix involved upgrading `golang.org/x/crypto` from v0.51.0 to v0.52.0 in `go.mod`, closing an authentication bypass that could be triggered remotely in any Go service using the SSH package.

critical

How Unauthenticated HTTP Endpoints happen in Node.js ECP Servers and how to fix it

The ECP (External Control Protocol) server in `src/server/ecp.js` exposed device control endpoints—like launching apps and sending keypresses—over the local network with zero authentication. Any attacker sharing the same Wi-Fi or LAN could send unauthenticated HTTP requests to take full control of the simulator. The fix introduces local-only binding controls and access restrictions to close this attack surface.

high

How Authorization Bypass and Balance Corruption happen in Node.js and how to fix it

A high-severity authorization bypass in `commands/profile/transfer.js` allowed any user to transfer coins directly to owner/admin accounts, bypassing privilege checks entirely. Compounding the issue, the absence of a numeric guard on `targetDb.coin` could corrupt balances with `NaN` when the field was uninitialized. Three targeted lines of code closed both attack surfaces without changing any valid transfer behavior.

high

How Dependabot Missing Cooldown Periods Enable Supply Chain Attacks and How to Fix It

A critical security vulnerability in `.github/dependabot.yml` was exposing a Node.js library to supply chain attacks by automatically updating to newly published packages without a safety delay. By adding a 7-day cooldown period to each package ecosystem configuration, the project now protects against malicious or unstable package versions that could affect downstream consumers.