Files
HealthLog/src/__tests__/factor-verification-surface-guard.test.ts
T
Marc-André Bombeck 0fc83cb0a1 fix(auth): ask for a fresh proof before the record or a credential to it leaves
Exporting the whole record, making a clinician share link, minting a token,
and the admin backup download, upload and restore, data wipe and password
reset ran on a live session alone. The plaintext full backup did, while the
encrypted one beside it asked for a second factor. Whoever held a stolen
session could take the record, or leave behind a credential that outlives
the session.

These now go through one gate, requireRecentProof (assertRecentCookieProof
for the routes that resolve their caller themselves). A browser session
passes after a sign-in or re-proof within five minutes: a second factor on
an account that has one, otherwise the password or a passkey. The refusal is
401 auth.reproof.required with meta.methods, and the web opens the re-proof
dialog, posts the proof to the new POST /api/auth/reproof and retries. A
password proof stamps the new Session.reproofAt (migration 0359), never
mfaVerifiedAt, so it cannot stand in for a second factor anywhere.

On Bearer, the full backup asks for an X-Step-Up elevation; the encrypted
export asks for a second-factor elevation on an account that has one. Share
links and connector tokens keep taking the token alone, because the shipped
app calls them without an elevation and reads a 401 there as a dead
session; the guard test names them.

Key rotation keeps its stricter second-factor gate.
2026-09-26 19:54:38 +02:00

78 lines
2.9 KiB
TypeScript

/**
* Every route that checks a password or a second factor is a guessing oracle,
* and every one must be throttled and must record a refused guess.
*
* The passkey-enrollment re-proof verified a password and a TOTP code with
* neither: a stolen session could guess there as fast as it could send
* requests, and nothing in the audit log would show it. Behavioural tests pin
* each route that was fixed; this freezes the SET, so a new route that starts
* verifying a credential has to be listed here, and the listing asks the two
* questions at the moment it is added.
*
* Matching is on the verifier's name anywhere in the file, not on an import
* shape, so an aliased import still counts. It remains a tripwire: it proves a
* throttle and an audit call are present in the file, not that they sit on the
* right branch — the per-route tests do that.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const APP = join(process.cwd(), "src", "app");
const VERIFIER =
/\b(verifyPassword|verifyPasswordOrDummy|verifyMfaFactor|verifyTotp|verifyMfaAuthentication|verifyAuthentication|verifyExistingFactorProof|checkCookieEnrollmentProof)\b/;
const THROTTLE =
/\b(checkRateLimit|checkAuthSurfaceRateLimit|throttleReproof|checkCookieEnrollmentProof|reserveChallengeAttempt)\s*\(/;
const AUDIT =
/\b(auditLog|recordReproofFailure|checkCookieEnrollmentProof)\s*\(/;
/**
* Every file under src/app that verifies a credential. Adding one means
* answering, in its own tests, how a wrong guess is limited and recorded.
*/
const EXPECTED = [
"api/auth/login/route.ts",
"api/auth/me/mfa/disable/route.ts",
"api/auth/me/mfa/totp/confirm/route.ts",
"api/auth/me/mfa/totp/setup/route.ts",
"api/auth/me/mfa/webauthn/register/options/route.ts",
"api/auth/mfa/verify/route.ts",
"api/auth/mfa/webauthn/verify/route.ts",
"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",
];
function verifyingFiles(): string[] {
return walkSourceFiles(APP, { floor: 500 })
.filter((p) => !p.includes("__tests__"))
.filter((p) => !/\.test\.tsx?$/.test(p))
.filter((p) => VERIFIER.test(readFileSync(join(APP, p), "utf8")));
}
describe("credential-verifying routes", () => {
const found = verifyingFiles();
it("finds the verifying routes at all", () => {
// An empty match agrees with nothing and would pass on a tree never read.
expect(found.length).toBeGreaterThanOrEqual(EXPECTED.length);
});
it("is exactly the listed set", () => {
expect(found).toEqual(EXPECTED);
});
it.each(EXPECTED)("%s is throttled and audits refusals", (rel) => {
const src = readFileSync(join(APP, rel), "utf8");
expect(src).toMatch(THROTTLE);
expect(src).toMatch(AUDIT);
});
});