← back to Letsbegin
HMAC-sign the local letsbegin_session cookie (close forgeable-session bypass)
db94bc1a69d89399456f5ea3def89aac351ef8ca · 2026-09-22 11:46:44 -0700 · Steve Abrams
Same class as TK-11776 #4/#1: the local session was an UNSIGNED base64(JSON) cookie that
middleware.ts and /api/auth/session decoded and trusted on `authenticated` alone, so
btoa('{"authenticated":true,"loginTime":<now>}') was a valid session on every gated route.
New lib/session-token.ts signs the JSON payload with HMAC-SHA256 under DW_SESSION_SECRET
(envelope <payloadB64>.<sigB64>, constant-time crypto.subtle.verify, fails closed on unset
secret). login mints a signed token; middleware + /api/auth/session now VERIFY the signature
before trusting `authenticated` (+ the existing 30-day age check). Kept separate from the
lockstep dw-central-session.ts (that is the DW Central username:timestamp/24h contract; this
is the local letsbegin_session JSON/30-day session). +7-assertion negative test.
NOTE: existing logged-in users are bounced to /login once (old cookies now rejected), and
DW_SESSION_SECRET must be set in Letsbegin's env or login 500s / middleware fails closed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwQJGSbGmK5rDzRsCqvVEc
Files touched
A __tests__/session-token.test.mjsM app/api/auth/login/route.tsM app/api/auth/session/route.tsA lib/session-token.tsM middleware.ts
Diff
commit db94bc1a69d89399456f5ea3def89aac351ef8ca
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 22 11:46:44 2026 -0700
HMAC-sign the local letsbegin_session cookie (close forgeable-session bypass)
Same class as TK-11776 #4/#1: the local session was an UNSIGNED base64(JSON) cookie that
middleware.ts and /api/auth/session decoded and trusted on `authenticated` alone, so
btoa('{"authenticated":true,"loginTime":<now>}') was a valid session on every gated route.
New lib/session-token.ts signs the JSON payload with HMAC-SHA256 under DW_SESSION_SECRET
(envelope <payloadB64>.<sigB64>, constant-time crypto.subtle.verify, fails closed on unset
secret). login mints a signed token; middleware + /api/auth/session now VERIFY the signature
before trusting `authenticated` (+ the existing 30-day age check). Kept separate from the
lockstep dw-central-session.ts (that is the DW Central username:timestamp/24h contract; this
is the local letsbegin_session JSON/30-day session). +7-assertion negative test.
NOTE: existing logged-in users are bounced to /login once (old cookies now rejected), and
DW_SESSION_SECRET must be set in Letsbegin's env or login 500s / middleware fails closed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwQJGSbGmK5rDzRsCqvVEc
---
__tests__/session-token.test.mjs | 68 ++++++++++++++++++++++++
app/api/auth/login/route.ts | 6 ++-
app/api/auth/session/route.ts | 26 +++++-----
lib/session-token.ts | 109 +++++++++++++++++++++++++++++++++++++++
middleware.ts | 39 +++++++-------
5 files changed, 215 insertions(+), 33 deletions(-)
diff --git a/__tests__/session-token.test.mjs b/__tests__/session-token.test.mjs
new file mode 100644
index 0000000..2ace2a0
--- /dev/null
+++ b/__tests__/session-token.test.mjs
@@ -0,0 +1,68 @@
+/**
+ * session-token.test.mjs — PROOF for the TK-11776 follow-up (Letsbegin local session).
+ *
+ * The local `letsbegin_session` cookie used to be an UNSIGNED base64(JSON) blob that
+ * middleware.ts + /api/auth/session trusted on its `authenticated` flag alone, so
+ * `btoa('{"authenticated":true,"loginTime":<now>}')` was a valid session. This proves the
+ * new HMAC signer REJECTS that forgery, ACCEPTS a validly-signed session, and fails closed
+ * on wrong-secret / tampered / unset-secret. Runnable with plain
+ * `node __tests__/session-token.test.mjs` (Node 23.6+/26 imports the .ts lib directly).
+ */
+import assert from 'node:assert/strict';
+import { signSession, verifySession } from '../lib/session-token.ts';
+
+const FIXTURE = 'hmac-test-fixture';
+// the exact live forgery: an unsigned base64(JSON) cookie claiming authenticated:true
+const forge = (extra = {}) =>
+ Buffer.from(JSON.stringify({ authenticated: true, loginTime: Date.now(), ...extra })).toString('base64');
+
+// the vulnerable OLD logic, verbatim — used only to prove this test is a real detector
+function oldVerify(token) {
+ try {
+ const d = JSON.parse(Buffer.from(token, 'base64').toString());
+ return d.authenticated === true;
+ } catch {
+ return false;
+ }
+}
+
+let pass = 0;
+const check = async (name, fn) => { await fn(); pass++; console.log(` ✓ ${name}`); };
+
+console.log('TK-11776 follow-up — forgeable letsbegin_session is closed');
+
+await check('detector sanity: OLD logic ACCEPTS the forged authenticated cookie', async () => {
+ assert.equal(oldVerify(forge()), true);
+});
+await check('REJECTS the forged unsigned base64(JSON) cookie (the live exploit) -> null', async () => {
+ assert.equal(await verifySession(forge(), FIXTURE), null);
+});
+await check('ACCEPTS a validly-signed session and returns its fields', async () => {
+ const token = await signSession({ authenticated: true, username: 'steve', loginTime: Date.now() }, FIXTURE);
+ const s = await verifySession(token, FIXTURE);
+ assert.ok(s && s.authenticated === true, 'authenticated should be true');
+ assert.equal(s.username, 'steve');
+ assert.equal(typeof s.loginTime, 'number');
+});
+await check('REJECTS a valid token under the WRONG secret -> null', async () => {
+ const token = await signSession({ authenticated: true, loginTime: Date.now() }, FIXTURE);
+ assert.equal(await verifySession(token, 'a-different-secret'), null);
+});
+await check('REJECTS a tampered payload with a stolen signature -> null', async () => {
+ const good = await signSession({ authenticated: false, loginTime: Date.now() }, FIXTURE);
+ const sig = good.slice(good.lastIndexOf('.'));
+ const tampered = `${Buffer.from(JSON.stringify({ authenticated: true, loginTime: Date.now() })).toString('base64')}${sig}`;
+ assert.equal(await verifySession(tampered, FIXTURE), null);
+});
+await check('FAILS CLOSED when the secret is unset -> verify null, sign throws', async () => {
+ const token = await signSession({ authenticated: true, loginTime: Date.now() }, FIXTURE);
+ assert.equal(await verifySession(token, undefined), null);
+ await assert.rejects(() => signSession({ authenticated: true }, undefined));
+});
+await check('REJECTS junk / signatureless / empty tokens -> null', async () => {
+ for (const t of ['', 'x', 'not.base64.parts', forge(), null, undefined]) {
+ assert.equal(await verifySession(t, FIXTURE), null);
+ }
+});
+
+console.log(`\n${pass} passed`);
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
index 41e4d3e..47e8264 100644
--- a/app/api/auth/login/route.ts
+++ b/app/api/auth/login/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { validateCredentials, AUTH_CONFIG } from '@/lib/auth'
import { cookies } from 'next/headers'
+import { signSession } from '@/lib/session-token'
export async function POST(request: NextRequest) {
try {
@@ -51,7 +52,10 @@ export async function POST(request: NextRequest) {
username,
loginTime: Date.now()
}
- const sessionToken = Buffer.from(JSON.stringify(sessionData)).toString('base64')
+ // HMAC-sign the session token (TK-11776 follow-up): middleware + /api/auth/session now
+ // VERIFY the signature, so an unsigned base64(JSON) cookie is no longer accepted. Throws if
+ // DW_SESSION_SECRET is unset → the outer catch returns 500 (never mint an unsigned session).
+ const sessionToken = await signSession(sessionData, process.env.DW_SESSION_SECRET)
// Set cookie
const cookieStore = await cookies()
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
index 3083c5d..0f40e86 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { AUTH_CONFIG, isWhitelistedIP } from '@/lib/auth'
import { cookies } from 'next/headers'
+import { verifySession } from '@/lib/session-token'
export async function GET(request: NextRequest) {
try {
@@ -28,18 +29,19 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ authenticated: false }, { status: 401 })
}
- try {
- const sessionData = JSON.parse(Buffer.from(sessionCookie.value, 'base64').toString())
-
- if (sessionData.authenticated) {
- return NextResponse.json({
- authenticated: true,
- username: sessionData.username,
- loginTime: sessionData.loginTime
- })
- }
- } catch {
- // Invalid session format
+ // HMAC-VERIFY the session token (TK-11776 follow-up), not JSON-decode-and-trust — otherwise
+ // this endpoint would report a forged cookie as authenticated.
+ const sessionData = await verifySession<{ authenticated?: boolean; username?: string; loginTime?: number }>(
+ sessionCookie.value,
+ process.env.DW_SESSION_SECRET,
+ )
+
+ if (sessionData?.authenticated) {
+ return NextResponse.json({
+ authenticated: true,
+ username: sessionData.username,
+ loginTime: sessionData.loginTime
+ })
}
return NextResponse.json({ authenticated: false }, { status: 401 })
diff --git a/lib/session-token.ts b/lib/session-token.ts
new file mode 100644
index 0000000..0a5cec0
--- /dev/null
+++ b/lib/session-token.ts
@@ -0,0 +1,109 @@
+/**
+ * session-token.ts — the HMAC-signed local ("letsbegin_session") session cookie.
+ *
+ * SECURITY (TK-11776 follow-up): the previous local session was an UNSIGNED
+ * base64(JSON.stringify({ authenticated: true, ... })) cookie that middleware.ts and
+ * /api/auth/session decoded and TRUSTED on the `authenticated` flag alone — so
+ * `btoa('{"authenticated":true,"loginTime":<now>}')` was a valid session on every gated
+ * route, with no signature (the same forgeable class TK-11776 #4 closed for the DW Central
+ * cookie). This signs the payload with HMAC-SHA256 under DW_SESSION_SECRET so only a
+ * secret-holder can mint one.
+ *
+ * Envelope: token = <payloadB64> + "." + <sigB64>
+ * payload = utf8(JSON.stringify(state)) // the session object, unchanged
+ * sig = HMAC-SHA256( ascii(payloadB64), DW_SESSION_SECRET )
+ * Standard base64 has no ".", so lastIndexOf('.') splits cleanly and every legacy unsigned
+ * base64(JSON) cookie (which has no ".") is REJECTED.
+ *
+ * Runtime-agnostic (Web Crypto + btoa/atob): works in the Edge middleware runtime, in
+ * Node 18+ (the route handlers), and in jest's node env. crypto.subtle.verify is itself
+ * constant-time. Fails CLOSED: sign() throws on a missing secret (never mint an unsigned
+ * token); verify() returns null on a missing secret, a missing/bad signature, or any decode
+ * error. This intentionally does NOT reuse dw-central-session.ts — that module is the
+ * lockstep-copied DW Central `username:timestamp` / 24h contract; this is the local
+ * `letsbegin_session` JSON / 30-day session, a separate concern.
+ */
+
+function bytesToBase64(bytes: Uint8Array): string {
+ let bin = '';
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
+ return btoa(bin);
+}
+function base64ToBytes(b64: string): Uint8Array {
+ const bin = atob(b64);
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out;
+}
+function utf8ToBase64(str: string): string {
+ return bytesToBase64(new TextEncoder().encode(str));
+}
+function base64ToUtf8(b64: string): string {
+ return new TextDecoder().decode(base64ToBytes(b64));
+}
+// Uint8Array -> BufferSource: a runtime no-op cast to satisfy crypto.subtle's typing across
+// tsconfigs (same shim as dw-central-session.ts).
+function bs(u: Uint8Array): BufferSource {
+ return u as unknown as BufferSource;
+}
+
+async function hmacKey(secret: string): Promise<CryptoKey> {
+ return crypto.subtle.importKey(
+ 'raw',
+ bs(new TextEncoder().encode(secret)),
+ { name: 'HMAC', hash: 'SHA-256' },
+ false,
+ ['sign', 'verify'],
+ );
+}
+
+/**
+ * Sign a session state object into a tamper-evident token. Throws if the secret is unset —
+ * an unsigned session token must never be minted.
+ */
+export async function signSession(
+ state: Record<string, unknown>,
+ secret: string | undefined,
+): Promise<string> {
+ if (!secret) throw new Error('DW_SESSION_SECRET is required to sign a session');
+ const payloadB64 = utf8ToBase64(JSON.stringify(state));
+ const key = await hmacKey(secret);
+ const sig = new Uint8Array(
+ await crypto.subtle.sign('HMAC', key, bs(new TextEncoder().encode(payloadB64))),
+ );
+ return `${payloadB64}.${bytesToBase64(sig)}`;
+}
+
+/**
+ * Verify a session token and return its decoded state, or null when the signature is
+ * invalid / secret unset / token malformed. Callers still enforce their own semantics
+ * (e.g. `authenticated === true`, age window) on the returned object.
+ */
+export async function verifySession<T = Record<string, unknown>>(
+ token: string | undefined | null,
+ secret: string | undefined,
+): Promise<T | null> {
+ try {
+ if (!secret) return null; // fail closed — never accept-all
+ if (!token || typeof token !== 'string') return null;
+
+ const dot = token.lastIndexOf('.');
+ if (dot <= 0 || dot >= token.length - 1) return null; // rejects legacy unsigned base64(JSON) cookies
+ const payloadB64 = token.slice(0, dot);
+ const sigB64 = token.slice(dot + 1);
+ if (!payloadB64 || !sigB64) return null;
+
+ const key = await hmacKey(secret);
+ const ok = await crypto.subtle.verify( // constant-time HMAC verification
+ 'HMAC',
+ key,
+ bs(base64ToBytes(sigB64)),
+ bs(new TextEncoder().encode(payloadB64)),
+ );
+ if (!ok) return null;
+
+ return JSON.parse(base64ToUtf8(payloadB64)) as T;
+ } catch {
+ return null;
+ }
+}
diff --git a/middleware.ts b/middleware.ts
index fd06335..6d17cc5 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { AUTH_CONFIG } from '@/lib/auth'
import { verifyDWCentralSession } from '@/lib/dw-central-session'
+import { verifySession } from '@/lib/session-token'
// Routes that don't require authentication
const publicPaths = [
@@ -53,31 +54,29 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(loginUrl)
}
- // Validate session token
- try {
- const sessionData = JSON.parse(Buffer.from(sessionCookie.value, 'base64').toString())
+ // Validate session token — HMAC-VERIFIED (TK-11776 follow-up), not JSON-decode-and-trust.
+ // Previously any base64(JSON) with authenticated:true passed; verifySession rejects every
+ // unsigned/forged cookie (returns null), so only a token minted by /api/auth/login under
+ // DW_SESSION_SECRET is honored.
+ const sessionData = await verifySession<{ authenticated?: boolean; loginTime?: number }>(
+ sessionCookie.value,
+ process.env.DW_SESSION_SECRET,
+ )
- if (!sessionData.authenticated) {
- // Invalid session - redirect to login
- const loginUrl = new URL('/login', request.url)
- return NextResponse.redirect(loginUrl)
- }
-
- // Check session age (30 days max)
- const sessionAge = Date.now() - sessionData.loginTime
- if (sessionAge > AUTH_CONFIG.cookieMaxAge) {
- // Session expired - redirect to login
- const loginUrl = new URL('/login', request.url)
- return NextResponse.redirect(loginUrl)
- }
+ if (!sessionData || !sessionData.authenticated) {
+ const loginUrl = new URL('/login', request.url)
+ loginUrl.searchParams.set('redirect', pathname)
+ return NextResponse.redirect(loginUrl)
+ }
- // Valid session - proceed
- return NextResponse.next()
- } catch (error) {
- // Invalid session data - redirect to login
+ // Check session age (30 days max) — a non-numeric/absent loginTime is treated as expired.
+ if (typeof sessionData.loginTime !== 'number' || Date.now() - sessionData.loginTime > AUTH_CONFIG.cookieMaxAge) {
const loginUrl = new URL('/login', request.url)
return NextResponse.redirect(loginUrl)
}
+
+ // Valid, signed, in-window session - proceed
+ return NextResponse.next()
}
export const config = {
← 9761423 security(TK-11776 #4): extend HMAC DW Central session fix to
·
back to Letsbegin
·
(newest)