Express / Node
csurf is deprecated and archived, csrf-csrf as the replacement, and the body-parser settings that reopen the text/plain vector.
The rule
Express ships no CSRF protection at all, and the package everyone reaches for — csurf — was deprecated and archived in 2022.
This is the framework where you are most on your own. Django, Rails, Spring, Laravel, and ASP.NET Core all default to protected; Express defaults to nothing. Whatever protection exists was added deliberately by someone, which means it can be absent, partial, or misconfigured, and nothing in the framework will tell you.
Three things to get right:
- Use a maintained library.
csrf-csrfimplements the signed double-submit pattern OWASP recommends.csurfis unmaintained and had a known bypass in its default configuration. - Do not configure a catch-all body parser.
express.json({ type: '*/*' })reopens the text/plain JSON vector on an API that would otherwise be unreachable from a form. - Add a Fetch Metadata check. It is a dozen lines, it protects every route at once, and it costs nothing.
Why not csurf
// csurf was deprecated in September 2022 and the repository is archived.
// It receives no security updates. It is still the top search result and
// still in a great many package.json files.
const csurf = require('csurf')
// The specific problem with the cookie mode: it implements NAIVE
// double-submit. The cookie value and the submitted value are compared to
// each other, with nothing binding either to the session.
//
// An attacker who can write a cookie into the domain -- from any subdomain,
// which is same-site and therefore unaffected by SameSite -- sets both
// halves to a value they choose and the check passes. See the cookie-tossing
// guide.
app.use(csurf({ cookie: true }))
// The session mode is sounder, because the expected value lives in the
// session. It is still unmaintained code in the security path.
app.use(csurf({ cookie: false }))
// Check for it:
// npm ls csurf
// grep -rn "require('csurf')\|from 'csurf'" .The replacement
const express = require('express')
const cookieParser = require('cookie-parser')
const { doubleCsrf } = require('csrf-csrf')
const app = express()
app.use(cookieParser())
// Only parse the content types this application actually accepts. NOT
// { type: '*/*' } -- that is the whole text/plain vector.
app.use(express.json())
app.use(express.urlencoded({ extended: false }))
const { doubleCsrfProtection, generateCsrfToken } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET, // 32+ random bytes
// Binds the token to the session. This is what makes it SIGNED
// double-submit rather than naive: an attacker who overwrites the cookie
// still cannot produce a valid signature for the victim's session.
getSessionIdentifier: (req) => req.session.id,
// __Host- forbids a Domain attribute, so a sibling subdomain cannot
// overwrite the cookie in the first place. Defence in depth alongside the
// signature.
cookieName: '__Host-psifi.x-csrf-token',
cookieOptions: { sameSite: 'lax', secure: true, path: '/' },
// Matches the framework convention for safe methods.
ignoredMethods: ['GET', 'HEAD', 'OPTIONS'],
getCsrfTokenFromRequest: (req) => req.headers['x-csrf-token'],
})
// Hand the token to the frontend.
app.get('/csrf-token', (req, res) => {
res.json({ token: generateCsrfToken(req, res) })
})
// Then protect everything after this point.
app.use(doubleCsrfProtection)
app.post('/account/email', (req, res) => {
updateEmail(req.session.userId, req.body.email)
res.json({ ok: true })
})The cheap layer worth adding
// A Resource Isolation Policy. Roughly a dozen lines, no dependency, and it
// covers every route at once -- including any route someone adds later and
// forgets to protect.
//
// Mount it BEFORE the CSRF middleware so a cross-site request is rejected
// before any token work happens.
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
const ALLOWED_SITES = new Set(['same-origin', 'same-site', 'none'])
function resourceIsolation(req, res, next) {
const site = req.get('Sec-Fetch-Site')
// No header: an old browser or a non-browser client. Allowing it keeps
// curl and server-to-server callers working, and is why this sits
// alongside the token rather than replacing it.
if (!site) return next()
if (ALLOWED_SITES.has(site)) return next()
if (SAFE_METHODS.has(req.method)) return next()
return res.status(403).json({ error: 'cross-site request rejected' })
}
// 'same-site' means a subdomain. Drop it from ALLOWED_SITES if any
// subdomain hosts user content or could be taken over.
module.exports = resourceIsolation
// Also enforce the content type on state-changing routes, so a form-encoded
// or text/plain body cannot reach a JSON handler:
//
// app.use((req, res, next) => {
// if (SAFE_METHODS.has(req.method)) return next()
// if (!req.is('application/json')) {
// return res.status(415).json({ error: 'Content-Type must be application/json' })
// }
// next()
// })Checklist
npm ls csurfreturns nothing. If it is present, replace it withcsrf-csrf.- The CSRF library binds the token to the session (
getSessionIdentifier), so it is signed double-submit rather than naive. express.json()has notype: '*/*', and only the parsers the app needs are mounted.- Content type enforced with a
415on state-changing routes. - A
Sec-Fetch-Sitecheck mounted before the CSRF middleware. express-sessioncookie:sameSite: 'lax',secure: true,httpOnly: true,__Host-prefixed name, nodomain.req.session.regenerate()called on login and on privilege change.- No
app.all()on state-changing routes, andmethod-overrideeither absent or reading from a header only. See Method and Content-Type Switching. - The CSRF middleware is mounted before the routes it protects — ordering is positional in Express and a route registered above
app.use(protection)is unprotected. CSRF_SECRETandSESSION_SECRETcome from the environment, are at least 32 random bytes, and are not in the repository.
Related
Naive double-submit and why OWASP now discourages it, then the HMAC-signed and encrypted-token variants that fix it.
Smuggling a JSON document through enctype="text/plain", content-type confusion in body parsers, and GraphQL over GET and form encodings.
Overwriting the CSRF cookie from a sibling subdomain, why SameSite offers zero protection here, and why __Host- is the fix.
Sec-Fetch-Site, Mode and Dest, Origin checks done correctly, the missing-header decision, and Resource Isolation Policy.
GET and POST routing, _method and X-HTTP-Method-Override, and body parsers that ignore the declared content type.
Forcing the victim into the attacker's session, the OAuth state parameter, callback CSRF, and account-linking takeover.