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

368 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import bcrypt from "bcryptjs";
const {
cookiesSetMock,
cookiesDeleteMock,
findUserMock,
headersGetMock,
headerStore,
checkRateLimitMock,
redirectMock,
loggerMock,
} = vi.hoisted(() => ({
cookiesSetMock: vi.fn(),
cookiesDeleteMock: vi.fn(),
findUserMock: vi.fn(),
headersGetMock: vi.fn(() => "127.0.0.1"),
headerStore: new Map<string, string>(),
checkRateLimitMock: vi.fn(),
redirectMock: vi.fn((_path: string) => {
throw new Error("redirect");
}),
loggerMock: { warn: vi.fn(), info: vi.fn() },
}));
vi.mock("next/headers", () => ({
cookies: async () => ({ set: cookiesSetMock, delete: cookiesDeleteMock }),
headers: async () => ({
get: (k: string) => {
const key = k.toLowerCase();
if (key === "x-forwarded-for") return headersGetMock();
// Tests opt-in to setting origin/host/etc. via headerStore;
// unset = null which lets hasSameOriginRequest treat the
// request as same-origin (Origin omitted = same-origin per RFC).
return headerStore.get(key) ?? null;
},
}),
}));
vi.mock("next/navigation", () => ({
redirect: (path: string) => redirectMock(path),
}));
vi.mock("@/lib/db", () => ({
db: {
query: {
operators: { findFirst: (...a: unknown[]) => findUserMock(...a) },
},
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a),
}));
vi.mock("@/lib/logger", () => ({ logger: loggerMock }));
const SECRET = "test-secret-not-real";
beforeEach(() => {
process.env.AUTH_SECRET = SECRET;
process.env.OPERATOR_TOKEN_VERSION = "1";
cookiesSetMock.mockReset();
cookiesDeleteMock.mockReset();
findUserMock.mockReset();
checkRateLimitMock.mockReset();
checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 });
redirectMock.mockReset();
redirectMock.mockImplementation((_path: string) => {
throw new Error("redirect");
});
loggerMock.warn.mockReset();
headerStore.clear();
});
import { loginAction, logoutAction } from "./auth";
const REAL_HASH = bcrypt.hashSync("correct-horse", 10);
const ADMIN_ROW = {
id: "11111111-1111-1111-1111-111111111111",
username: "admin",
role: "admin" as const,
displayName: "Admin",
defaultTimezone: "UTC",
passwordHash: REAL_HASH,
};
function fd(fields: Record<string, string>): FormData {
const f = new FormData();
for (const [k, v] of Object.entries(fields)) f.append(k, v);
return f;
}
describe("loginAction", () => {
it("issues a session cookie when credentials are correct", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const prevEnv = process.env.NODE_ENV;
// @ts-expect-error - test override
process.env.NODE_ENV = "production";
try {
const r = await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(
(e) => e,
);
// Successful login redirects, so the redirect mock throws.
expect((r as Error).message).toBe("redirect");
expect(cookiesSetMock).toHaveBeenCalledTimes(1);
const [name, , attrs] = cookiesSetMock.mock.calls[0]!;
expect(name).toBe("session");
expect(attrs).toMatchObject({
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 30 * 86400,
});
} finally {
// @ts-expect-error - test restore
process.env.NODE_ENV = prevEnv;
}
});
it("sets secure=false on the cookie when NODE_ENV !== production", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const prevEnv = process.env.NODE_ENV;
// @ts-expect-error - test override
process.env.NODE_ENV = "development";
try {
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
const [, , attrs] = cookiesSetMock.mock.calls[0]!;
expect(attrs).toMatchObject({ secure: false });
} finally {
// @ts-expect-error - test restore
process.env.NODE_ENV = prevEnv;
}
});
it("returns ok:false on wrong password and does NOT set a cookie", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const r = await loginAction(fd({ username: "admin", password: "wrong" }));
expect(r).toEqual({ ok: false, error: "Invalid username or password." });
expect(cookiesSetMock).not.toHaveBeenCalled();
expect(loggerMock.warn).toHaveBeenCalled();
});
it("returns ok:false on unknown username and STILL invokes bcrypt (timing equivalence)", async () => {
findUserMock.mockResolvedValue(undefined);
const cmpSpy = vi.spyOn(bcrypt, "compare");
await loginAction(fd({ username: "nobody", password: "irrelevant" }));
expect(cmpSpy).toHaveBeenCalled(); // compared against DUMMY_HASH
cmpSpy.mockRestore();
});
it("returns a clear error when the user has no password_hash set", async () => {
findUserMock.mockResolvedValue({ ...ADMIN_ROW, passwordHash: null });
const r = await loginAction(fd({ username: "admin", password: "anything" }));
expect(r).toEqual({
ok: false,
error: "Set a password via scripts/set-password.sh before signing in.",
});
});
it("rejects empty username or password without hitting the DB", async () => {
const r = await loginAction(fd({ username: "", password: "x" }));
expect(r).toEqual({ ok: false, error: "Username and password are required." });
expect(findUserMock).not.toHaveBeenCalled();
});
it("rejects username/password >256 chars without invoking bcrypt", async () => {
const cmpSpy = vi.spyOn(bcrypt, "compare");
const long = "x".repeat(300);
const r = await loginAction(fd({ username: long, password: long }));
expect(r).toEqual({ ok: false, error: "Input too long." });
expect(cmpSpy).not.toHaveBeenCalled();
cmpSpy.mockRestore();
});
it("matches username case-insensitively", async () => {
findUserMock.mockImplementation(async () => ADMIN_ROW);
await loginAction(fd({ username: "ADMIN", password: "correct-horse" })).catch(() => {});
expect(findUserMock).toHaveBeenCalled();
});
it("returns 429 when the rate limit is exhausted", async () => {
checkRateLimitMock.mockResolvedValue({ limited: true, count: 11 });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
expect(findUserMock).not.toHaveBeenCalled();
});
it("logs the failed attempt with username and ip but never the password", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headersGetMock.mockReturnValue("203.0.113.5, 10.0.0.1");
await loginAction(fd({ username: "admin", password: "wrong" }));
const [meta, msg] = loggerMock.warn.mock.calls[0]!;
expect(meta).toMatchObject({ username: "admin", ip: "203.0.113.5" });
expect(JSON.stringify(meta)).not.toContain("wrong");
expect(msg).toMatch(/login failed/i);
});
it("redirects to safeRedirect(next) on success", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
await loginAction(fd({
username: "admin",
password: "correct-horse",
next: "/dashboard",
})).catch(() => {});
expect(redirectMock).toHaveBeenCalledWith("/dashboard");
});
it("redirects to / when next is unsafe", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
await loginAction(fd({
username: "admin",
password: "correct-horse",
next: "//evil.com",
})).catch(() => {});
expect(redirectMock).toHaveBeenCalledWith("/");
});
});
describe("logoutAction", () => {
it("clears the session cookie and redirects to /login", async () => {
await logoutAction().catch(() => {});
expect(cookiesDeleteMock).toHaveBeenCalledWith("session");
expect(redirectMock).toHaveBeenCalledWith("/login");
});
it("is idempotent — clears the cookie even when no session exists", async () => {
// Real-world: a user with an expired cookie clicks Sign out. cookies.delete
// doesn't care about pre-existing state and we still issue the redirect.
cookiesDeleteMock.mockReset();
await logoutAction().catch(() => {});
expect(cookiesDeleteMock).toHaveBeenCalledTimes(1);
expect(cookiesDeleteMock).toHaveBeenCalledWith("session");
});
});
describe("loginAction — additional cases", () => {
it("issues a cookie that decrypts to role='user' for a non-admin user", async () => {
findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "user" });
await loginAction(fd({ username: "alice", password: "correct-horse" })).catch(() => {});
expect(cookiesSetMock).toHaveBeenCalledTimes(1);
const [, cookieValue] = cookiesSetMock.mock.calls[0]!;
// The cookie is now AES-GCM encrypted, so we can't peel the payload
// off raw — decrypt with the same secret loginAction used. This
// also doubles as a confidentiality smoke test: 'user'/'alice'
// must NOT appear verbatim in the cookie bytes.
expect(cookieValue as string).not.toContain("alice");
expect(cookieValue as string).not.toContain("user");
const { verifySession } = await import("@/lib/auth-cookie");
const decoded = await verifySession(cookieValue as string, SECRET);
expect(decoded?.role).toBe("user");
expect(decoded?.userId).toBe(ADMIN_ROW.id);
});
it("rejects when the user row has an unrecognised role string", async () => {
findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "robot" });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Account is not enabled." });
expect(cookiesSetMock).not.toHaveBeenCalled();
});
it("returns ok:false when AUTH_SECRET is unset (server misconfig)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const prev = process.env.AUTH_SECRET;
delete process.env.AUTH_SECRET;
try {
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Server is not configured for sign-in." });
expect(cookiesSetMock).not.toHaveBeenCalled();
} finally {
process.env.AUTH_SECRET = prev;
}
});
it("treats whitespace-only username as missing input", async () => {
const r = await loginAction(fd({ username: " ", password: "x" }));
expect(r).toEqual({ ok: false, error: "Username and password are required." });
expect(findUserMock).not.toHaveBeenCalled();
});
it("uses three rate-limit layers: per-IP, per-username, global", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headersGetMock.mockReturnValue("198.51.100.42");
await loginAction(fd({ username: "Admin", password: "correct-horse" })).catch(() => {});
// Three checkRateLimit calls fired in parallel via Promise.all,
// in this order: ip / user / global.
expect(checkRateLimitMock).toHaveBeenCalledTimes(3);
const keys = checkRateLimitMock.mock.calls.map((c) => c[0] as string);
expect(keys[0]).toBe("login:198.51.100.42");
// Username key is normalised to lowercase so "Admin" and "admin"
// share the same bucket — otherwise an attacker rotating case
// would dodge per-username throttling.
expect(keys[1]).toBe("login-user:admin");
expect(keys[2]).toBe("login-global");
});
it("rejects login when the per-username limit alone is hit (rotating-IPs attacker)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
// First call (ip) passes, second (user) is over, third (global) passes.
checkRateLimitMock
.mockResolvedValueOnce({ limited: false, count: 1 })
.mockResolvedValueOnce({ limited: true, count: 6 })
.mockResolvedValueOnce({ limited: false, count: 5 });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
expect(findUserMock).not.toHaveBeenCalled();
// Logger captures which limit tripped so we can tune thresholds
// without leaking the answer to the attacker.
const meta = loggerMock.warn.mock.calls.find((c) => c[1] === "login rate-limited")?.[0];
expect(meta).toMatchObject({ limit: "username" });
});
it("rejects login when the global limit alone is hit (everyone-pile-on backstop)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
checkRateLimitMock
.mockResolvedValueOnce({ limited: false, count: 1 })
.mockResolvedValueOnce({ limited: false, count: 1 })
.mockResolvedValueOnce({ limited: true, count: 101 });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
const meta = loggerMock.warn.mock.calls.find((c) => c[1] === "login rate-limited")?.[0];
expect(meta).toMatchObject({ limit: "global" });
});
it("rejects a cross-origin POST before checking credentials", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.set("origin", "https://attacker.example");
headerStore.set("host", "wabot.04080616.xyz");
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Cross-origin request blocked." });
expect(checkRateLimitMock).not.toHaveBeenCalled();
expect(findUserMock).not.toHaveBeenCalled();
});
it("accepts a same-origin POST (Origin host matches Host header)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.set("origin", "https://wabot.04080616.xyz");
headerStore.set("host", "wabot.04080616.xyz");
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
// Got past the origin check → DB lookup ran.
expect(findUserMock).toHaveBeenCalled();
});
it("treats a missing Origin header as same-origin (legitimate native form POST)", async () => {
// Browsers don't always send Origin (e.g. plain top-level form
// submissions). Refusing those would brick login on some clients.
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.delete("origin");
headerStore.set("host", "wabot.04080616.xyz");
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
expect(findUserMock).toHaveBeenCalled();
});
it("rejects when Origin is malformed (non-URL string)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.set("origin", "not a url");
headerStore.set("host", "wabot.04080616.xyz");
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Cross-origin request blocked." });
});
it("still hits the DB and bcrypt on the unknown-user path so timing equivalence holds", async () => {
findUserMock.mockResolvedValue(undefined);
const cmpSpy = vi.spyOn(bcrypt, "compare");
await loginAction(fd({ username: "ghost", password: "anything" }));
// findFirst was called even though we know the user doesn't exist.
expect(findUserMock).toHaveBeenCalledTimes(1);
expect(cmpSpy).toHaveBeenCalled();
cmpSpy.mockRestore();
});
});