mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Merge branch 'fix/step-up-and-session-revocation' into release/v1.39.3
# Conflicts: # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/ko.json # messages/pl.json
This commit is contained in:
commit
03ccb21227
96 files changed
+3238
-495
No files matched your search
@@ -183,18 +183,22 @@ describe("every provider-resolving module carries a consent helper", () => {
|
||||
});
|
||||
|
||||
describe("the consent guard itself stays the single source of the policy", () => {
|
||||
it("keeps the server-managed provider set defined in exactly one place", () => {
|
||||
// Both operator-credential tags must be classified in consent-guard.ts and
|
||||
// nowhere else — a second copy of this list is how the policy drifts.
|
||||
const guard = read("lib/ai/consent-guard.ts");
|
||||
expect(guard).toContain('"admin-openai"');
|
||||
expect(guard).toContain('"admin-codex"');
|
||||
it("keeps the operator-held provider set defined in exactly one place", () => {
|
||||
// The two operator-credential tags are listed in provider-egress.ts and
|
||||
// nowhere else, and the consent guard reads them from there. A second copy
|
||||
// is how the policy drifts. This guard used to match one variable NAME, so
|
||||
// a copy under another name in the capability resolver went unseen for as
|
||||
// long as it existed; it now matches the list itself, in either order.
|
||||
const pair =
|
||||
/\[\s*"admin-(?:openai|codex)"\s*,\s*"admin-(?:openai|codex)"\s*,?\s*\]/;
|
||||
const holders = sourceFiles().filter((rel) => pair.test(read(rel)));
|
||||
expect(holders).toEqual(["lib/ai/provider-egress.ts"]);
|
||||
|
||||
const copies = sourceFiles().filter(
|
||||
(rel) =>
|
||||
rel !== "lib/ai/consent-guard.ts" &&
|
||||
/SERVER_MANAGED_PROVIDER_TYPES/.test(read(rel)),
|
||||
expect(read("lib/ai/consent-guard.ts")).toMatch(
|
||||
/from "@\/lib\/ai\/provider-egress"/,
|
||||
);
|
||||
expect(read("lib/ai/capabilities/resolve.ts")).toMatch(
|
||||
/from "@\/lib\/ai\/provider-egress"/,
|
||||
);
|
||||
expect(copies).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,8 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
resetAt: Date.now() + 1e6,
|
||||
ip: "203.0.113.7",
|
||||
}),
|
||||
checkRateLimit: vi.fn(),
|
||||
refundRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/auth/login-response", () => ({
|
||||
@@ -61,6 +63,7 @@ import { POST } from "@/app/api/auth/login/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
function loginRequest() {
|
||||
return new Request("http://localhost/api/auth/login", {
|
||||
@@ -85,6 +88,12 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: no registered security key. Individual tests override as needed.
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 10,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 1e6,
|
||||
});
|
||||
});
|
||||
|
||||
describe("login MFA branch", () => {
|
||||
@@ -155,3 +164,52 @@ describe("login MFA branch", () => {
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-account login ceiling", () => {
|
||||
it("refuses a throttled account before any password is verified", async () => {
|
||||
const { verifyPasswordOrDummy } = await import("@/lib/auth/password");
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
...BASE_USER,
|
||||
totpConfirmedAt: null,
|
||||
} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
limit: 10,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 1e6,
|
||||
});
|
||||
|
||||
const res = await POST(loginRequest());
|
||||
expect(res.status).toBe(429);
|
||||
expect(verifyPasswordOrDummy).not.toHaveBeenCalled();
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(checkRateLimit).mock.calls[0][0]).toBe(
|
||||
"auth:login:account:u:user-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("keys an unknown identifier on its hash, so a lock says nothing about existence", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null as never);
|
||||
const res = await POST(loginRequest());
|
||||
expect(res.status).toBe(401);
|
||||
expect(vi.mocked(checkRateLimit).mock.calls[0][0]).toMatch(
|
||||
/^auth:login:account:i:/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refunds a successful sign-in and keeps a failed one counted", async () => {
|
||||
const { verifyPasswordOrDummy } = await import("@/lib/auth/password");
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
...BASE_USER,
|
||||
totpConfirmedAt: null,
|
||||
} as never);
|
||||
|
||||
await POST(loginRequest());
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("auth:login:account:u:user-1");
|
||||
|
||||
vi.mocked(refundRateLimit).mockClear();
|
||||
vi.mocked(verifyPasswordOrDummy).mockResolvedValueOnce(false);
|
||||
await POST(loginRequest());
|
||||
expect(refundRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,7 @@ const EXPECTED = [
|
||||
"api/auth/passkey/login-verify/route.ts",
|
||||
"api/auth/passkey/register-options/route.ts",
|
||||
"api/auth/password/route.ts",
|
||||
"api/auth/reproof/route.ts",
|
||||
"api/auth/step-up/route.ts",
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Structural guard on the recent-proof gate.
|
||||
*
|
||||
* Exporting the whole record, making a clinician share link, minting a token,
|
||||
* and the admin backup, restore, wipe and reset actions ask for a fresh proof,
|
||||
* not only a live session (`requireRecentProof` / `assertRecentCookieProof` in
|
||||
* `src/lib/api-handler.ts`). This file pins which routes those are, so a route
|
||||
* cannot drop the gate, and a new one cannot quietly join the list of routes
|
||||
* whose Bearer arm still takes the token alone.
|
||||
*
|
||||
* Matching is on the IMPORT and then on calls of the local name, the way
|
||||
* `step-up-elevation-guard.test.ts` does it, so an aliased import is followed.
|
||||
* Every sweep asserts it matched something: a matcher that finds nothing agrees
|
||||
* with every list.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function importedAs(rel: string, exportName: string): string[] {
|
||||
const names: string[] = [];
|
||||
for (const m of read(rel).matchAll(
|
||||
/import\s*\{([\s\S]*?)\}\s*from\s*["']([^"']+)["']/g,
|
||||
)) {
|
||||
if (!/api-handler$/.test(m[2])) continue;
|
||||
for (const part of m[1].split(",")) {
|
||||
const piece = part.trim().replace(/^type\s+/, "");
|
||||
const aliased = piece.match(
|
||||
/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/,
|
||||
);
|
||||
if (aliased && aliased[1] === exportName) names.push(aliased[2]);
|
||||
else if (piece === exportName) names.push(piece);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Every call of `exportName` in `rel`, as the source text of its argument. */
|
||||
function callArgs(rel: string, exportName: string): string[] {
|
||||
const src = read(rel);
|
||||
const args: string[] = [];
|
||||
for (const local of importedAs(rel, exportName)) {
|
||||
for (const m of src.matchAll(
|
||||
new RegExp(`\\b${local}\\s*\\(([^)]*)\\)`, "g"),
|
||||
)) {
|
||||
args.push(m[1].replace(/\s+/g, " ").trim());
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function callers(exportName: string): string[] {
|
||||
return sourceFiles().filter(
|
||||
(rel) => rel !== "lib/api-handler.ts" && callArgs(rel, exportName).length,
|
||||
);
|
||||
}
|
||||
|
||||
/** Routes that resolve their own caller and take the recent-proof gate. */
|
||||
const RECENT_PROOF_ROUTES: Record<string, string> = {
|
||||
"app/api/export/full-backup/route.ts": "elevation",
|
||||
"app/api/export/encrypted/route.ts": "elevation-if-enrolled",
|
||||
// The shipped iOS app calls these two on its token without an elevation and
|
||||
// reads a 401 outside its known paths as a dead session. Tightening them is
|
||||
// an app release first; see the iOS note for v1.39.3.
|
||||
"app/api/share-links/route.ts": "token",
|
||||
"app/api/mcp/tokens/route.ts": "token",
|
||||
};
|
||||
|
||||
/** Cookie-only routes that add the gate after their own resolver. */
|
||||
const COOKIE_PROOF_ROUTES = [
|
||||
"app/api/admin/backups/[id]/download/route.ts",
|
||||
"app/api/admin/backups/[id]/restore/route.ts",
|
||||
"app/api/admin/backups/upload/route.ts",
|
||||
"app/api/admin/data/route.ts",
|
||||
"app/api/admin/users/[id]/reset-password/route.ts",
|
||||
"app/api/auth/reproof/route.ts",
|
||||
"app/api/tokens/documents/route.ts",
|
||||
"app/api/tokens/measurements/route.ts",
|
||||
].sort();
|
||||
|
||||
describe("the recent-proof gate", () => {
|
||||
it("reads the tree it claims to sweep", () => {
|
||||
expect(sourceFiles().length).toBeGreaterThan(1500);
|
||||
});
|
||||
|
||||
it("is taken by exactly the known routes, each with its Bearer rule", () => {
|
||||
const found: Record<string, string> = {};
|
||||
for (const rel of callers("requireRecentProof")) {
|
||||
const args = callArgs(rel, "requireRecentProof");
|
||||
expect(args).toHaveLength(1);
|
||||
const rule = args[0].match(/bearer:\s*"([^"]+)"/)?.[1];
|
||||
expect(rule, rel).toBeDefined();
|
||||
found[rel] = rule!;
|
||||
}
|
||||
expect(Object.keys(found).length).toBeGreaterThan(0);
|
||||
expect(found).toEqual(RECENT_PROOF_ROUTES);
|
||||
});
|
||||
|
||||
it("the cookie arm is added by exactly the known cookie-only routes", () => {
|
||||
const found = callers("assertRecentCookieProof");
|
||||
expect(found.length).toBeGreaterThan(0);
|
||||
expect(found).toEqual(COOKIE_PROOF_ROUTES);
|
||||
});
|
||||
|
||||
it("each cookie-only route resolves a cookie before it asks", () => {
|
||||
for (const rel of COOKIE_PROOF_ROUTES) {
|
||||
const resolvers = [
|
||||
...importedAs(rel, "requireAdmin"),
|
||||
...importedAs(rel, "requireCookieAuth"),
|
||||
];
|
||||
expect(resolvers, rel).toHaveLength(1);
|
||||
expect(importedAs(rel, "requireAuth"), rel).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("every route whose Bearer arm takes an elevation spends it", () => {
|
||||
const withElevation = Object.entries(RECENT_PROOF_ROUTES).filter(
|
||||
([, rule]) => rule !== "token",
|
||||
);
|
||||
expect(withElevation.length).toBeGreaterThan(0);
|
||||
for (const [rel] of withElevation) {
|
||||
expect(read(rel), rel).toMatch(/\.commitElevation\(\)/);
|
||||
}
|
||||
});
|
||||
|
||||
it("key rotation keeps its stricter second-factor gate", () => {
|
||||
const rel = "app/api/admin/encryption/rotate/route.ts";
|
||||
expect(callArgs(rel, "requireFreshMfa").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("the gate reads the session's own stamps, and only a second factor on an enrolled account", () => {
|
||||
const src = read("lib/api-handler.ts");
|
||||
const start = src.indexOf("export async function assertRecentCookieProof(");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const body = src.slice(start, src.indexOf("\nexport ", start + 1));
|
||||
// The enrolled arm names mfaVerifiedAt alone: a password re-proof
|
||||
// (`reproofAt`) or a bare sign-in (`createdAt`) must not stand in for it.
|
||||
expect(body).toMatch(/enrolled\s*\?\s*fresh\(row\?\.mfaVerifiedAt\)\s*:/);
|
||||
});
|
||||
});
|
||||
@@ -118,11 +118,14 @@ function callers(exportName: string, moduleRe: RegExp): string[] {
|
||||
* consult this mechanism at all;
|
||||
* - the trusted-device routes. "Remember this browser" is a browser concept
|
||||
* with no native equivalent to manage;
|
||||
* - `requireFreshMfaIfEnrolled`'s callers (password change, encrypted
|
||||
* export, encryption-key rotation, the sharing invite). Those are
|
||||
* destructive account actions, not second-factor management, and letting an
|
||||
* elevation satisfy them would be exactly the silent widening this file
|
||||
* exists to prevent.
|
||||
* - `requireFreshMfaIfEnrolled`'s callers (password change, encryption-key
|
||||
* rotation, the sharing invite). Those are destructive account actions,
|
||||
* not second-factor management, and letting an elevation satisfy them
|
||||
* would be exactly the silent widening this file exists to prevent.
|
||||
*
|
||||
* The two whole-record exports reach the elevation through a third gate,
|
||||
* `requireRecentProof`, whose route list and per-route Bearer rule are frozen
|
||||
* in `recent-proof-surface-guard.test.ts`.
|
||||
*
|
||||
* Account deletion and the record wipe used to sit in that last bullet. They
|
||||
* left it deliberately, and through a different gate: see `ERASURE_ROUTES`
|
||||
|
||||
Reference in new issue
Block a user