Three layers of login hardening pulled together — addresses the
"don't let middleman / robot easily log in by mimicking headers"
follow-up.
1. AES-256-GCM session cookie (apps/web/src/lib/auth-cookie.ts)
The old format was base64-encoded JSON + HMAC-SHA256 signature, so
anyone with the cookie could read userId/role straight off the
bytes. Switched to AES-GCM authenticated encryption: the payload
is encrypted with a 256-bit key derived from AUTH_SECRET via
SHA-256, a fresh 12-byte nonce is drawn per encryption (never
reused — locked in by test), and tampering with either the IV or
ciphertext fails the GCM auth tag → decrypt throws → null.
Cookie format: <base64url(iv)>.<base64url(ciphertext+tag)>
Existing cookies become invalid on deploy because the IV portion
doesn't decode to 12 bytes — middleware bounces them to /login.
No env bump needed; users just sign in once with the new secret.
2. Three-layer rate limit on loginAction
Old: per-IP only. An attacker with a residential-proxy pool or
spoofed X-Forwarded-For could hop IPs and brute one account.
New: Promise.all of three checkRateLimit calls
- per-IP login:<ip> 10 / 5 min
- per-username login-user:<lower> 5 / 15 min
- global login-global 100 / min (backstop)
First-hit wins; logger captures which limit tripped (ip / username
/ global) without telling the attacker which one.
3. Action-level Origin/Host check
serverActions.allowedOrigins already does this at the framework
layer; running it inside loginAction lets us log the mismatch and
reject before bcrypt + DB. Missing Origin treated as same-origin
(RFC: same-origin POSTs may omit it). Malformed Origin → reject.
Tests:
- auth-cookie.test.ts updated to AES-GCM (15 tests, +4 vs HMAC):
fresh IV per encryption, ciphertext doesn't leak userId/role,
IV-swap rejected, ciphertext-tamper rejected, wrong-length IV
rejected, malformed b64 doesn't throw.
- auth.test.ts adds 7 new cases: three-layer key shape, per-username
limit alone trips, global limit alone trips, cross-origin rejected,
same-origin accepted, missing-Origin treated as same-origin,
malformed-Origin rejected.
Web suite 453 → 463 tests, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
136 lines
5.4 KiB
TypeScript
136 lines
5.4 KiB
TypeScript
import { describe, it, expect, beforeAll } from "vitest";
|
|
import {
|
|
signSession,
|
|
verifySession,
|
|
COOKIE_NAME,
|
|
DEFAULT_TTL_SECONDS,
|
|
type SessionPayload,
|
|
} from "./auth-cookie";
|
|
|
|
const SECRET = "test-secret-not-used-anywhere-real";
|
|
const NOW = 1_700_000_000; // 2023-11-14 — fixed clock for determinism
|
|
|
|
beforeAll(() => {
|
|
process.env.AUTH_SECRET = SECRET;
|
|
process.env.OPERATOR_TOKEN_VERSION = "1";
|
|
});
|
|
|
|
const validPayload = (): SessionPayload => ({
|
|
userId: "11111111-1111-1111-1111-111111111111",
|
|
role: "admin",
|
|
iat: NOW,
|
|
exp: NOW + DEFAULT_TTL_SECONDS,
|
|
v: 1,
|
|
});
|
|
|
|
describe("auth-cookie (AES-256-GCM)", () => {
|
|
it("signSession + verifySession round-trips a valid payload", async () => {
|
|
const cookie = await signSession(validPayload(), SECRET);
|
|
const verified = await verifySession(cookie, SECRET, NOW);
|
|
expect(verified).toEqual(validPayload());
|
|
});
|
|
|
|
it("uses a fresh IV per call so two encryptions of the same payload differ", async () => {
|
|
// Repeating the IV under AES-GCM is catastrophic (it leaks the XOR
|
|
// of plaintexts and the auth key). Lock in that signSession draws
|
|
// a new nonce every time — the byte-for-byte cookies must not match
|
|
// even when the inputs are identical.
|
|
const a = await signSession(validPayload(), SECRET);
|
|
const b = await signSession(validPayload(), SECRET);
|
|
expect(a).not.toBe(b);
|
|
// Both still decrypt correctly with the same secret.
|
|
expect(await verifySession(a, SECRET, NOW)).toEqual(validPayload());
|
|
expect(await verifySession(b, SECRET, NOW)).toEqual(validPayload());
|
|
});
|
|
|
|
it("ciphertext does NOT leak the userId in plaintext (confidentiality)", async () => {
|
|
const cookie = await signSession(validPayload(), SECRET);
|
|
// The whole point of the GCM upgrade: someone with only the cookie
|
|
// value should not be able to read the userId / role straight off
|
|
// it the way they could with the old base64-encoded JSON.
|
|
expect(cookie).not.toContain(validPayload().userId);
|
|
expect(cookie).not.toContain("admin");
|
|
});
|
|
|
|
it("rejects when the ciphertext has been tampered with (auth tag mismatch)", async () => {
|
|
const cookie = await signSession(validPayload(), SECRET);
|
|
const [iv, ct] = cookie.split(".");
|
|
// Flip the last character of the ciphertext (still valid base64url).
|
|
const lastCh = ct!.slice(-1);
|
|
const replacement = lastCh === "A" ? "B" : "A";
|
|
const tampered = `${iv}.${ct!.slice(0, -1)}${replacement}`;
|
|
expect(await verifySession(tampered, SECRET, NOW)).toBeNull();
|
|
});
|
|
|
|
it("rejects when the IV has been swapped for another (auth tag mismatch)", async () => {
|
|
const cookie = await signSession(validPayload(), SECRET);
|
|
const otherIv = await signSession(validPayload(), SECRET);
|
|
const [, ct] = cookie.split(".");
|
|
const [otherIvB64] = otherIv.split(".");
|
|
const tampered = `${otherIvB64}.${ct}`;
|
|
expect(await verifySession(tampered, SECRET, NOW)).toBeNull();
|
|
});
|
|
|
|
it("rejects when verified with a different secret", async () => {
|
|
const cookie = await signSession(validPayload(), SECRET);
|
|
expect(await verifySession(cookie, "different-secret", NOW)).toBeNull();
|
|
});
|
|
|
|
it("rejects an expired cookie (exp <= now)", async () => {
|
|
const expired = { ...validPayload(), exp: NOW - 1 };
|
|
const cookie = await signSession(expired, SECRET);
|
|
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
|
|
});
|
|
|
|
it("rejects a cookie issued in the future beyond clock-skew tolerance", async () => {
|
|
const future = { ...validPayload(), iat: NOW + 120 };
|
|
const cookie = await signSession(future, SECRET);
|
|
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
|
|
});
|
|
|
|
it("accepts a cookie issued slightly in the future (within 60s skew)", async () => {
|
|
const future = { ...validPayload(), iat: NOW + 30 };
|
|
const cookie = await signSession(future, SECRET);
|
|
expect(await verifySession(cookie, SECRET, NOW)).not.toBeNull();
|
|
});
|
|
|
|
it("rejects a cookie whose v is stale (token-version bumped)", async () => {
|
|
const cookie = await signSession({ ...validPayload(), v: 1 }, SECRET);
|
|
process.env.OPERATOR_TOKEN_VERSION = "2";
|
|
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
|
|
process.env.OPERATOR_TOKEN_VERSION = "1";
|
|
});
|
|
|
|
it("rejects a cookie with an unknown role string", async () => {
|
|
const cookie = await signSession(
|
|
{ ...validPayload(), role: "superadmin" as never },
|
|
SECRET,
|
|
);
|
|
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
|
|
});
|
|
|
|
it("rejects a cookie that doesn't have a '.' separator", async () => {
|
|
expect(await verifySession("not-a-cookie", SECRET, NOW)).toBeNull();
|
|
expect(await verifySession("", SECRET, NOW)).toBeNull();
|
|
});
|
|
|
|
it("rejects a cookie whose IV decodes to the wrong byte length", async () => {
|
|
// GCM requires a 12-byte nonce. Swap the IV portion for something
|
|
// that decodes to a different length and confirm we bounce it
|
|
// before handing weird input to crypto.subtle.decrypt.
|
|
const cookie = await signSession(validPayload(), SECRET);
|
|
const [, ct] = cookie.split(".");
|
|
// 8 bytes encoded — too short.
|
|
const shortIv = "AAAAAAAAAAA";
|
|
expect(await verifySession(`${shortIv}.${ct}`, SECRET, NOW)).toBeNull();
|
|
});
|
|
|
|
it("rejects malformed (non-base64url) input gracefully — no throw, just null", async () => {
|
|
expect(await verifySession("$$$.$$$", SECRET, NOW)).toBeNull();
|
|
});
|
|
|
|
it("exposes COOKIE_NAME as 'session'", () => {
|
|
expect(COOKIE_NAME).toBe("session");
|
|
});
|
|
});
|