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>
368 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|