[object Object]

← 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

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 →