cm_whatsapp_bot_v1/apps/web/src/lib/auth-cookie.test.ts
yiekheng f69652d43b feat(web): AES-GCM cookies + per-username/global rate limit + origin check
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>
2026-05-10 20:41:49 +08:00

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");
});
});