mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
78 lines
2.9 KiB
TypeScript
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);
|
|
});
|
|
});
|