Files
jobtrackingapp/job-tracker-ui/rate-limit-tests/isolation.spec.ts
T
cesnimda 0954876295 fix(security): enforce audited account, network and resource boundaries
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
2026-09-09 18:21:47 +02:00

209 lines
13 KiB
TypeScript

import { expect, test as base, type APIRequestContext, type APIResponse } from "@playwright/test";
import { createHmac, randomUUID } from "node:crypto";
import http from "node:http";
const url = "http://127.0.0.1:5302/api";
const password = "E2ePassword123!";
type Account = { api: APIRequestContext; headers: Record<string, string>; token: string; id: string; sid: string; email: string };
type Accounts = { create: () => Promise<Account>; login: (email: string) => Promise<Account> };
const test = base.extend<{ accounts: Accounts }>({
accounts: async ({ playwright }, provide, info) => {
const contexts: APIRequestContext[] = [];
async function login(email: string): Promise<Account> {
const api = await playwright.request.newContext();
contexts.push(api);
const ip = `198.18.${Math.floor(info.line / 250)}.${info.line % 250 + 1}`;
expect((await api.post(`${url}/auth/login`, { headers: { "X-Forwarded-For": ip }, data: { email, password } })).status()).toBe(200);
const me = await (await api.get(`${url}/auth/me`)).json();
const cookies = (await api.storageState()).cookies;
return { api, email, id: me.id, sid: me.sessionContext,
token: cookies.find(cookie => cookie.name === "jobtracker_auth")!.value,
headers: { "X-CSRF-TOKEN": cookies.find(cookie => cookie.name === "XSRF-TOKEN")!.value, "X-Session-Context": me.sessionContext } };
}
try {
const admin = await login("e2e@example.test");
await provide({ login, create: async () => {
const email = `rate-${randomUUID()}@example.test`;
expect((await admin.api.post(`${url}/users`, { headers: admin.headers, data: { email, password, roles: [] } })).status()).toBe(200);
return login(email);
} });
} finally { await Promise.all(contexts.map(context => context.dispose())); }
},
});
function exportAccount(account: Account, ip: string) {
return account.api.post(`${url}/export/account`, { headers: { ...account.headers, "X-Forwarded-For": ip }, data: {} });
}
async function limited(response: APIResponse) {
expect(response.status()).toBe(429);
expect(Number(response.headers()["retry-after"])).toBeGreaterThan(0);
expect(response.headers()["cache-control"]).toBe("no-store");
expect(await response.json()).toMatchObject({ status: 429, code: "rate_limit_exceeded" });
}
for (const first of ["A", "B"]) {
test(`${first} exhausting account exports does not throttle the other account on the same IP`, async ({ accounts }, info) => {
const a = await accounts.create();
const b = await accounts.create();
const ordered = first === "A" ? [a, b] : [b, a];
const results = [];
for (const account of ordered) {
const responses = [await exportAccount(account, "203.0.113.10"), await exportAccount(account, "203.0.113.10")];
results.push(responses.map(response => response.status()));
}
// This assertion reproduces F06 against the old real middleware order.
expect(results).toEqual([[200, 200], [200, 200]]);
await limited(await exportAccount(a, "203.0.113.10"));
await limited(await exportAccount(b, "203.0.113.10"));
await info.attach("shared-ip-observations", { contentType: "application/json", body: JSON.stringify({ first, ip: "203.0.113.10", A: { admitted: 2, rejected: 1 }, B: { admitted: 2, rejected: 1 } }) });
});
}
test("concurrent accounts share an IP while sessions, bearer/cookie and changed IPs share each owner's allowance", async ({ accounts, request }, info) => {
const a = await accounts.create();
const b = await accounts.create();
const a2 = await accounts.login(a.email);
const b2 = await accounts.login(b.email);
expect(a.sid).not.toBe(a2.sid);
expect(b.sid).not.toBe(b2.sid);
const bearerExport = (account: Account, ip: string) => request.post(`${url}/export/account`, {
headers: { Authorization: `Bearer ${account.token}`, "X-Session-Context": account.sid, "X-Forwarded-For": ip }, data: {},
});
const responses = await Promise.all([
exportAccount(a, "203.0.113.20"), bearerExport(a2, "203.0.113.20"),
exportAccount(b, "203.0.113.20"), bearerExport(b2, "203.0.113.20"),
]);
expect(responses.map(response => response.status())).toEqual([200, 200, 200, 200]);
await limited(await bearerExport(a, "203.0.113.21"));
await limited(await exportAccount(a2, "203.0.113.22"));
await limited(await bearerExport(b, "203.0.113.21"));
await limited(await exportAccount(b2, "203.0.113.22"));
await info.attach("concurrent-session-observations", { contentType: "application/json", body: JSON.stringify({ concurrentAdmitted: 4, A: { sessions: 2, admitted: 2, rejectedAcrossOtherIPs: 2 }, B: { sessions: 2, admitted: 2, rejectedAcrossOtherIPs: 2 }, auth: ["cookie", "bearer"] }) });
});
test("crafted body/query/headers and B02 context cannot select the email-send owner", async ({ accounts }) => {
const a = await accounts.create();
const b = await accounts.create();
const send = (owner: Account, extra: Record<string, string> = {}) => owner.api.post(`${url}/email/send?userId=${a.id}&accountId=${a.id}`, {
headers: { ...owner.headers, "X-Forwarded-For": "203.0.113.30", "X-User-Id": a.id, "X-Account-Id": a.id, ...extra },
// Refused before providers/delivery: no email is sent by this fixture.
data: { confirmed: false, userId: a.id, accountId: a.id, ownerId: a.id },
});
expect((await send(b, { "X-Session-Context": a.sid })).status()).toBe(409);
expect((await send(b, { "X-Session-Context": "forged-session" })).status()).toBe(409);
// Existing auth precedence: cookie B wins over an additional bearer A; keep context B.
expect((await send(b, { Authorization: `Bearer ${a.token}` })).status()).toBe(400);
for (let i = 0; i < 7; i++) expect((await send(b)).status()).toBe(400);
await limited(await send(b, { "X-User-Id": randomUUID(), "X-Session-Context": a.sid }));
for (let i = 0; i < 10; i++) expect((await send(a)).status()).toBe(400);
await limited(await send(a));
});
test("export and disabled deletion consume the same account-data policy without affecting another account", async ({ accounts }) => {
const a = await accounts.create();
const b = await accounts.create();
expect((await exportAccount(a, "203.0.113.31")).status()).toBe(200);
const disabled = await a.api.post(`${url}/account-lifecycle/delete`, { headers: { ...a.headers, "X-Forwarded-For": "203.0.113.31" }, data: { confirmation: "not a deletion confirmation" } });
expect(disabled.status()).toBe(503); // Deletion explicitly disabled in this test host.
await limited(await exportAccount(a, "203.0.113.31"));
expect((await exportAccount(b, "203.0.113.31")).status()).toBe(200);
});
function signedFixtureToken(token: string, changes: Record<string, unknown>, key = "e2e-only-key-at-least-thirty-two-characters-long") {
const [header, encoded] = token.split(".");
const payload = Buffer.from(JSON.stringify({ ...JSON.parse(Buffer.from(encoded, "base64url").toString()), ...changes })).toString("base64url");
const content = `${header}.${payload}`;
return `${content}.${createHmac("sha256", key).update(content).digest("base64url")}`;
}
test("invalid credentials are rejected before account partitioning and cannot select anonymous login partitions", async ({ accounts, request }) => {
const a = await accounts.create();
const revoked = await accounts.login(a.email);
expect((await a.api.delete(`${url}/auth/sessions/${revoked.sid}`, { headers: a.headers })).status()).toBe(204);
const past = Math.floor(Date.now() / 1000) - 600; // Beyond the existing two-minute clock skew.
const cases: Record<string, string>[] = [
{},
{ Authorization: "Bearer not-a-jwt" },
{ Authorization: `Bearer ${signedFixtureToken(a.token, { nbf: past - 60, exp: past })}` },
{ Cookie: "jobtracker_auth=invalid-cookie; XSRF-TOKEN=synthetic", "X-CSRF-TOKEN": "synthetic" },
{ Authorization: `Bearer ${revoked.token}` },
{ Authorization: `Bearer ${signedFixtureToken(a.token, { sub: "forged-owner", sid: randomUUID() }, "incorrect-signing-key-for-synthetic-test-only")}` },
{ Authorization: `Bearer ${signedFixtureToken(a.token, { sid: randomUUID() })}` },
];
for (let i = 0; i < cases.length; i++) {
const ip = `203.0.113.${40 + i}`;
const attempt = (headers: Record<string, string>) => request.post(`${url}/export/account?accountId=${randomUUID()}`, {
headers: { ...headers, "X-Forwarded-For": ip, "X-User-Id": randomUUID(), "X-Session-Context": randomUUID() }, data: { userId: randomUUID() },
});
// More than the account allowance: authorization must reject every attempt first.
// This also guards against rate limiting before endpoint-specific scheme selection.
for (let n = 0; n < 3; n++) expect((await attempt(cases[i])).status()).toBe(401);
expect((await attempt({ Authorization: `Bearer malformed-${i}` })).status()).toBe(401);
expect((await attempt({})).status()).toBe(401);
// Anonymous login still reaches its IP limiter; invalid claims cannot mint keys.
const loginAttempt = (headers: Record<string, string>) => request.post(`${url}/auth/login`, {
headers: { ...headers, "X-Forwarded-For": ip, "X-User-Id": randomUUID() }, data: { email: "", password: "" },
});
expect((await loginAttempt(cases[i])).status()).toBe(400);
for (let n = 0; n < 9; n++) expect((await loginAttempt({ Authorization: `Bearer malformed-${n}` })).status()).toBe(400);
await limited(await loginAttempt({}));
}
// Invalid traffic and an exhausted anonymous login policy do not consume A's allowance.
expect((await exportAccount(a, "203.0.113.40")).status()).toBe(200);
expect((await exportAccount(a, "203.0.113.40")).status()).toBe(200);
await limited(await exportAccount(a, "203.0.113.40"));
});
test("login/security policies remain IP based for authenticated and anonymous requests", async ({ accounts, request }) => {
const a = await accounts.create();
const b = await accounts.create();
for (let i = 0; i < 10; i++) {
const account = i % 2 ? a : b;
expect((await account.api.post(`${url}/auth/login`, { headers: { ...account.headers, "X-Forwarded-For": "203.0.113.60" }, data: { email: "", password: "" } })).status()).toBe(400);
}
await limited(await request.post(`${url}/auth/register`, { headers: { "X-Forwarded-For": "203.0.113.60" }, data: {} }));
expect((await request.post(`${url}/auth/login`, { headers: { "X-Forwarded-For": "203.0.113.61" }, data: { email: "", password: "" } })).status()).toBe(400);
for (let i = 0; i < 5; i++) {
expect((await request.post(`${url}/auth/request-password-reset`, { headers: { "X-Forwarded-For": "203.0.113.60" }, data: { email: "" } })).status()).toBe(204);
}
await limited(await a.api.post(`${url}/auth/resend-verification-email`, { headers: { ...a.headers, "X-Forwarded-For": "203.0.113.60" }, data: { email: "" } }));
expect((await request.post(`${url}/auth/request-password-reset`, { headers: { "X-Forwarded-For": "203.0.113.61" }, data: { email: "" } })).status()).toBe(204);
for (let i = 0; i < 5; i++) {
expect((await request.post(`${url}/auth/2fa/challenge`, { headers: { "X-Forwarded-For": "203.0.113.60" }, data: { pendingToken: "", code: "" } })).status()).toBe(401);
}
await limited(await request.post(`${url}/auth/2fa/challenge`, { headers: { "X-Forwarded-For": "203.0.113.60" }, data: {} }));
expect((await request.post(`${url}/auth/2fa/challenge`, { headers: { "X-Forwarded-For": "203.0.113.61" }, data: { pendingToken: "", code: "" } })).status()).toBe(401);
});
test("public PDF remains IP plus slug scoped even after authentication", async ({ accounts, request }) => {
const a = await accounts.create();
const b = await accounts.create();
const missing = `missing-${randomUUID()}`;
for (const client of [request, a.api, b.api]) {
expect((await client.get(`${url}/public-cv/${missing}/pdf`, { headers: { "X-Forwarded-For": "203.0.113.70" } })).status()).toBe(404);
}
await limited(await request.get(`${url}/public-cv/${missing}/pdf`, { headers: { "X-Forwarded-For": "203.0.113.70" } }));
expect((await request.get(`${url}/public-cv/${missing}/pdf`, { headers: { "X-Forwarded-For": "203.0.113.71" } })).status()).toBe(404);
expect((await request.get(`${url}/public-cv/other-${missing}/pdf`, { headers: { "X-Forwarded-For": "203.0.113.70" } })).status()).toBe(404);
});
test("untrusted direct peers cannot evade IP limits with forged forwarding headers", async () => {
const attempt = (source: string, claimed: string) => new Promise<number>((resolve, reject) => {
const call = http.request(`${url}/auth/login`, {
method: "POST", localAddress: source,
headers: { "Content-Type": "application/json", "X-Forwarded-For": claimed, "X-Real-IP": claimed, Forwarded: `for=${claimed}` },
}, response => { response.resume(); response.on("end", () => resolve(response.statusCode!)); });
call.on("error", reject);
call.setTimeout(5000, () => call.destroy(new Error("Local probe timed out")));
call.end(JSON.stringify({ email: "", password: "" }));
});
for (let i = 0; i < 10; i++) expect(await attempt("127.0.0.2", `203.0.113.${100 + i}`)).toBe(400);
expect(await attempt("127.0.0.2", "203.0.113.199")).toBe(429);
expect(await attempt("127.0.0.3", "203.0.113.199")).toBe(400);
});