← back to AbramsOS
tick 8: CSRF protection on HTML POST forms
1f3190129d290d0af4640f521cb0ccc65834bf22 · 2026-05-10 01:01:15 -0700 · Steve
- middleware/csrf.js: cookie+body double-submit pattern
· ensureToken mints non-httpOnly aos.csrf cookie on every request
· verifyToken rejects 403 on POST/PUT/DELETE/PATCH to NON-/api/ routes
when body._csrf or x-csrf-token header doesn't match the cookie
· /api/* exempted (same-origin JS, sameSite=lax cookie protects)
· timing-safe comparison with length-guard (avoids 500 on length mismatch)
- views: hidden _csrf input added to signup/signin/enroll-totp/step-up/signout
- tests/csrf.test.js: 4 new (no-token rejection, wrong-token rejection, GET mints cookie)
- 34/35 green (1 skip — pdf-parse fixture)
Files touched
A middleware/csrf.jsM server.jsM tests/auth-e2e.test.jsA tests/csrf.test.jsM views/enroll-totp.ejsM views/partials/header.ejsM views/signin.ejsM views/signup.ejsM views/step-up.ejs
Diff
commit 1f3190129d290d0af4640f521cb0ccc65834bf22
Author: Steve <steve@designerwallcoverings.com>
Date: Sun May 10 01:01:15 2026 -0700
tick 8: CSRF protection on HTML POST forms
- middleware/csrf.js: cookie+body double-submit pattern
· ensureToken mints non-httpOnly aos.csrf cookie on every request
· verifyToken rejects 403 on POST/PUT/DELETE/PATCH to NON-/api/ routes
when body._csrf or x-csrf-token header doesn't match the cookie
· /api/* exempted (same-origin JS, sameSite=lax cookie protects)
· timing-safe comparison with length-guard (avoids 500 on length mismatch)
- views: hidden _csrf input added to signup/signin/enroll-totp/step-up/signout
- tests/csrf.test.js: 4 new (no-token rejection, wrong-token rejection, GET mints cookie)
- 34/35 green (1 skip — pdf-parse fixture)
---
middleware/csrf.js | 48 +++++++++++++++++++++++++++++++++++
server.js | 5 ++++
tests/auth-e2e.test.js | 25 ++++++++++++++----
tests/csrf.test.js | 64 +++++++++++++++++++++++++++++++++++++++++++++++
views/enroll-totp.ejs | 1 +
views/partials/header.ejs | 1 +
views/signin.ejs | 2 ++
views/signup.ejs | 1 +
views/step-up.ejs | 1 +
9 files changed, 143 insertions(+), 5 deletions(-)
diff --git a/middleware/csrf.js b/middleware/csrf.js
new file mode 100644
index 0000000..acad605
--- /dev/null
+++ b/middleware/csrf.js
@@ -0,0 +1,48 @@
+// CSRF: cookie+body double-submit pattern.
+// On every request: ensure a non-httpOnly `aos.csrf` cookie is set.
+// On state-changing requests (POST/PUT/DELETE/PATCH) to NON-/api routes:
+// require body field `_csrf` matches the cookie value. Reject 403 if not.
+// /api/* routes are exempted because they're called by same-origin JS that
+// already carries the httpOnly session cookie + sameSite=lax.
+
+const crypto = require('crypto');
+
+const COOKIE = 'aos.csrf';
+const FIELD = '_csrf';
+const TTL_MS = 24 * 60 * 60 * 1000;
+
+function ensureToken(req, res, next) {
+ let token = req.cookies?.[COOKIE];
+ if (!token) {
+ token = crypto.randomBytes(24).toString('base64url');
+ res.cookie(COOKIE, token, {
+ httpOnly: false, // body must read it
+ sameSite: 'lax',
+ maxAge: TTL_MS,
+ secure: false, // dev http
+ });
+ }
+ res.locals.csrfToken = token;
+ req.csrfToken = token;
+ next();
+}
+
+function verifyToken(req, res, next) {
+ if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) return next();
+ // /api/* is JSON-only same-origin; trust cookie+sameSite there
+ if (req.path.startsWith('/api/')) return next();
+
+ const submitted = req.body?.[FIELD] || req.headers['x-csrf-token'];
+ const cookie = req.cookies?.[COOKIE];
+ if (!cookie || !submitted) {
+ return res.status(403).render('error', { error: 'CSRF token missing. Reload and try again.' });
+ }
+ const a = Buffer.from(String(submitted));
+ const b = Buffer.from(String(cookie));
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
+ return res.status(403).render('error', { error: 'CSRF token mismatch. Reload and try again.' });
+ }
+ next();
+}
+
+module.exports = { ensureToken, verifyToken, COOKIE, FIELD };
diff --git a/server.js b/server.js
index 4a6fa27..7d0937d 100644
--- a/server.js
+++ b/server.js
@@ -6,6 +6,7 @@ const path = require('path');
const cookieParser = require('cookie-parser');
const { loadSessionMiddleware, requireAuth, requireStepUp } = require('./middleware/auth');
+const csrf = require('./middleware/csrf');
const home = require('./routes/home');
const health = require('./routes/health');
@@ -35,6 +36,10 @@ app.use(cookieParser(process.env.SESSION_SECRET || 'dev-only-rotate-me'));
// Load DB-backed session for every request (no-op if cookie missing)
app.use(loadSessionMiddleware);
+// CSRF: token mint + verify (HTML POST forms only; /api/* exempted)
+app.use(csrf.ensureToken);
+app.use(csrf.verifyToken);
+
// View locals
app.locals.infoEmail = process.env.INFO_EMAIL || 'info@abramsos.agentabrams.com';
app.use((req, res, next) => {
diff --git a/tests/auth-e2e.test.js b/tests/auth-e2e.test.js
index ff1c9e5..54f159a 100644
--- a/tests/auth-e2e.test.js
+++ b/tests/auth-e2e.test.js
@@ -25,27 +25,35 @@ test.after(async () => {
await pool.end();
});
-let cookieJar = '';
+let cookieJar = {};
+let csrfToken = '';
+
+function cookieHeader() {
+ return Object.entries(cookieJar).map(([k, v]) => `${k}=${v}`).join('; ');
+}
function captureCookie(res) {
const set = res.headers['set-cookie'];
if (set) {
for (const c of set) {
const m = c.match(/^([^=]+)=([^;]*)/);
- if (m && m[1] === 'aos.sid') cookieJar = `aos.sid=${m[2]}`;
- if (m && m[1] === 'aos.sid' && m[2] === '') cookieJar = '';
+ if (!m) continue;
+ const name = m[1], val = m[2];
+ if (val === '') delete cookieJar[name];
+ else cookieJar[name] = val;
+ if (name === 'aos.csrf') csrfToken = val;
}
}
}
-function req(path, { method = 'GET', body = null, follow = false } = {}) {
+function req(path, { method = 'GET', body = null } = {}) {
return new Promise((resolve, reject) => {
const port = server.address().port;
const opts = {
method, hostname: '127.0.0.1', port, path,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
- 'Cookie': cookieJar || '',
+ 'Cookie': cookieHeader(),
'Accept': 'text/html,application/json',
},
};
@@ -57,7 +65,11 @@ function req(path, { method = 'GET', body = null, follow = false } = {}) {
});
});
r.on('error', reject);
+ // Auto-attach CSRF token for unsafe methods even when caller passed no body
+ const isUnsafe = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method);
+ if (isUnsafe && !body) body = {};
if (body) {
+ if (typeof body === 'object' && csrfToken && !body._csrf) body._csrf = csrfToken;
const enc = typeof body === 'string' ? body : new URLSearchParams(body).toString();
r.write(enc);
}
@@ -65,6 +77,9 @@ function req(path, { method = 'GET', body = null, follow = false } = {}) {
});
}
+// Prime CSRF cookie before any POST tests
+test.before(async () => { await req('/signup'); });
+
test('signup creates user + sets cookie', async () => {
const r = await req('/signup', { method: 'POST', body: { email: 'steve+e2e@example.com', password: 'verystrongpassword!' } });
assert.strictEqual(r.status, 302);
diff --git a/tests/csrf.test.js b/tests/csrf.test.js
new file mode 100644
index 0000000..06d4b46
--- /dev/null
+++ b/tests/csrf.test.js
@@ -0,0 +1,64 @@
+// Verifies CSRF middleware rejects POSTs without a matching token.
+
+const test = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+
+require('dotenv').config();
+const app = require('../server');
+const { pool } = require('../lib/db');
+
+let server;
+test.before(() => new Promise((r) => { server = app.listen(0, r); }));
+test.after(async () => {
+ await new Promise((r) => server.close(r));
+ await pool.end();
+});
+
+function post(path, formObj = {}, headers = {}) {
+ return new Promise((resolve, reject) => {
+ const port = server.address().port;
+ const body = new URLSearchParams(formObj).toString();
+ const req = http.request({
+ method: 'POST', hostname: '127.0.0.1', port, path,
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body), ...headers },
+ }, (res) => {
+ let data = ''; res.on('data', (c) => (data += c));
+ res.on('end', () => resolve({ status: res.statusCode, body: data, headers: res.headers }));
+ });
+ req.on('error', reject);
+ req.write(body);
+ req.end();
+ });
+}
+
+test('POST /signup without CSRF token → 403', async () => {
+ const r = await post('/signup', { email: 'attacker@example.com', password: 'verystrongpw1234' });
+ assert.strictEqual(r.status, 403);
+ assert.match(r.body, /CSRF/i);
+});
+
+test('POST /signin without CSRF token → 403', async () => {
+ const r = await post('/signin', { stage: 'password', email: 'a@b.com', password: 'x' });
+ assert.strictEqual(r.status, 403);
+});
+
+test('POST /signup with WRONG CSRF token → 403', async () => {
+ // First request to mint a real cookie, then POST with a forged body token
+ const get = await new Promise((resolve, reject) => {
+ const port = server.address().port;
+ http.get(`http://127.0.0.1:${port}/signup`, (res) => { res.resume(); res.on('end', () => resolve(res)); }).on('error', reject);
+ });
+ const cookieHeader = (get.headers['set-cookie'] || []).map(c => c.split(';')[0]).join('; ');
+ const r = await post('/signup', { _csrf: 'forged-token-xxx', email: 'a@b.com', password: 'verystrongpw1234' }, { cookie: cookieHeader });
+ assert.strictEqual(r.status, 403);
+});
+
+test('GET routes mint a CSRF cookie', async () => {
+ const port = server.address().port;
+ const got = await new Promise((resolve, reject) => {
+ http.get(`http://127.0.0.1:${port}/signup`, (res) => { res.resume(); res.on('end', () => resolve(res)); }).on('error', reject);
+ });
+ const setCookie = got.headers['set-cookie'] || [];
+ assert.ok(setCookie.some(c => c.startsWith('aos.csrf=')), 'aos.csrf cookie should be set');
+});
diff --git a/views/enroll-totp.ejs b/views/enroll-totp.ejs
index dfd9293..b23ff03 100644
--- a/views/enroll-totp.ejs
+++ b/views/enroll-totp.ejs
@@ -15,6 +15,7 @@
</div>
<form method="post" action="/enroll-totp" autocomplete="off">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
<label>6-digit code from your app
<input type="text" name="token" inputmode="numeric" pattern="[0-9 ]{6,8}" maxlength="8" required autofocus>
</label>
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index 323dcd8..568a9cd 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -29,6 +29,7 @@
</nav>
<% if (typeof userId !== 'undefined' && userId) { %>
<form method="post" action="/signout" style="margin:0">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button class="signout-btn" type="submit">Sign out</button>
</form>
<% } %>
diff --git a/views/signin.ejs b/views/signin.ejs
index 50de419..65223b9 100644
--- a/views/signin.ejs
+++ b/views/signin.ejs
@@ -6,6 +6,7 @@
<% if (error) { %><p class="error-banner"><%= error %></p><% } %>
<form method="post" action="/signin" autocomplete="off">
<input type="hidden" name="stage" value="password">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="next" value="<%= next %>">
<label>Email
<input type="email" name="email" required autofocus>
@@ -21,6 +22,7 @@
<% if (error) { %><p class="error-banner"><%= error %></p><% } %>
<form method="post" action="/signin" autocomplete="off">
<input type="hidden" name="stage" value="totp">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="next" value="<%= next %>">
<label>6-digit code
<input type="text" name="token" inputmode="numeric" pattern="[0-9 ]{6,8}" maxlength="8" required autofocus>
diff --git a/views/signup.ejs b/views/signup.ejs
index dd5fc7a..88878cb 100644
--- a/views/signup.ejs
+++ b/views/signup.ejs
@@ -7,6 +7,7 @@
<% if (error) { %><p class="error-banner"><%= error %></p><% } %>
<form method="post" action="/signup" autocomplete="off">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
<label>Email
<input type="email" name="email" required autofocus>
</label>
diff --git a/views/step-up.ejs b/views/step-up.ejs
index 5d6f0de..685ad3c 100644
--- a/views/step-up.ejs
+++ b/views/step-up.ejs
@@ -7,6 +7,7 @@
<% if (error) { %><p class="error-banner"><%= error %></p><% } %>
<form method="post" action="/step-up" autocomplete="off">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="next" value="<%= next %>">
<label>6-digit code
<input type="text" name="token" inputmode="numeric" pattern="[0-9 ]{6,8}" maxlength="8" required autofocus>
← 1f60c67 tick 7: /claims dashboard UI + claim detail with draft previ
·
back to AbramsOS
·
tick 9: in-process cron for reminders + CPSC refresh d34f2f6 →