fix(core-consent): validate migrated categories against an allow-list

The anonymous consent cookie is client-controlled, yet its categories
were granted verbatim at sign-up migration (audit finding A12; the
migration itself is already invoked in the auth sign-up use case and
bindAllProduction now threads a consentFactory so it runs in
production). Adds KNOWN_CONSENT_CATEGORIES + isKnownConsentCategory to
core-consent, filters in extractAnonymousConsent and
migrateAnonymousConsent, and mirrors the allow-list in the auth
sign-up cookie extractor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 18:11:29 +02:00
parent 08cf939e1f
commit 68a142fa6b
6 changed files with 117 additions and 4 deletions

View File

@@ -131,6 +131,43 @@ describe("signUpUseCase", () => {
expect(result.clearCookie?.attributes.maxAge).toBe(0); expect(result.clearCookie?.attributes.maxAge).toBe(0);
}); });
it("drops unknown categories from the client-controlled cookie (A12)", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
await useCase({
username: "ivy",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "cc_consent=analytics,evil-made-up,__proto__; session=x",
});
expect(consent.grants.map((g) => g.category)).toEqual(["analytics"]);
});
it("does not migrate consent when every cookie category is unknown (A12)", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
const result = await useCase({
username: "jack",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "cc_consent=hax,not-a-category",
});
expect(consent.grants).toHaveLength(0);
expect(result.clearCookie).toBeUndefined();
});
it("does not migrate consent when no cc_consent cookie is present", async () => { it("does not migrate consent when no cc_consent cookie is present", async () => {
const users = new MockUsersRepository([]); const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users); const auth = new MockAuthenticationService(users);

View File

@@ -14,6 +14,16 @@ import type { IAuthenticationService } from "../services/authentication.service.
// Cookie name written by the anonymous consent banner (mirrors CONSENT_COOKIE_NAME in @repo/core-consent). // Cookie name written by the anonymous consent banner (mirrors CONSENT_COOKIE_NAME in @repo/core-consent).
const ANONYMOUS_CONSENT_COOKIE = "cc_consent"; const ANONYMOUS_CONSENT_COOKIE = "cc_consent";
// Category allow-list (mirrors KNOWN_CONSENT_CATEGORIES in @repo/core-consent).
// The cookie is client-controlled: unknown strings are dropped, never granted
// (audit finding A12).
const KNOWN_CONSENT_CATEGORIES = [
"necessary",
"functional",
"analytics",
"marketing",
];
function extractConsentFromCookieHeader(cookieHeader: string): string[] | null { function extractConsentFromCookieHeader(cookieHeader: string): string[] | null {
for (const part of cookieHeader.split(";")) { for (const part of cookieHeader.split(";")) {
const eqIdx = part.indexOf("="); const eqIdx = part.indexOf("=");
@@ -24,7 +34,8 @@ function extractConsentFromCookieHeader(cookieHeader: string): string[] | null {
const cats = value const cats = value
.split(",") .split(",")
.map((c) => c.trim()) .map((c) => c.trim())
.filter(Boolean); .filter(Boolean)
.filter((c) => KNOWN_CONSENT_CATEGORIES.includes(c));
return cats.length > 0 ? cats : null; return cats.length > 0 ? cats : null;
} }
return null; return null;

View File

@@ -10,6 +10,27 @@ export type ConsentCategory =
| "marketing" | "marketing"
| (string & {}); | (string & {});
/**
* The known consent categories (audit finding A12). Untrusted inputs — e.g.
* the anonymous banner cookie migrated at sign-up — MUST be validated against
* this list before being granted; the open ConsentCategory union is for
* first-party code registering custom categories deliberately, not for
* client-controlled strings.
*/
export const KNOWN_CONSENT_CATEGORIES = [
"necessary",
"functional",
"analytics",
"marketing",
] as const;
/** Type guard for the allow-list above. */
export function isKnownConsentCategory(
value: string,
): value is (typeof KNOWN_CONSENT_CATEGORIES)[number] {
return (KNOWN_CONSENT_CATEGORIES as readonly string[]).includes(value);
}
/** Whether a subject has granted or denied consent for a category. */ /** Whether a subject has granted or denied consent for a category. */
export type ConsentState = "granted" | "denied" | "pending"; export type ConsentState = "granted" | "denied" | "pending";

View File

@@ -4,6 +4,10 @@ export type {
UserConsentState, UserConsentState,
ConsentGrantMeta, ConsentGrantMeta,
} from "./consent-types"; } from "./consent-types";
export {
KNOWN_CONSENT_CATEGORIES,
isKnownConsentCategory,
} from "./consent-types";
export type { IConsent } from "./consent.interface"; export type { IConsent } from "./consent.interface";
export type { ConsentChecked } from "./with-consent"; export type { ConsentChecked } from "./with-consent";
export { withConsent } from "./with-consent"; export { withConsent } from "./with-consent";

View File

@@ -43,6 +43,21 @@ describe("extractAnonymousConsent", () => {
}); });
}); });
describe("extractAnonymousConsent — category allow-list (A12)", () => {
it("drops unknown categories from the client-controlled cookie", () => {
const result = extractAnonymousConsent(
`${CONSENT_COOKIE_NAME}=necessary,evil-injection,analytics`,
);
expect(result).toEqual(["necessary", "analytics"]);
});
it("returns null when every category is unknown", () => {
expect(
extractAnonymousConsent(`${CONSENT_COOKIE_NAME}=hax,__proto__`),
).toBeNull();
});
});
describe("migrateAnonymousConsent", () => { describe("migrateAnonymousConsent", () => {
it("calls IConsent.grant with method signup-migration for each category", async () => { it("calls IConsent.grant with method signup-migration for each category", async () => {
const consent = new RecordingConsent(); const consent = new RecordingConsent();
@@ -105,3 +120,17 @@ describe("migrateAnonymousConsent", () => {
expect(consent.isGranted("marketing")).toBe(true); expect(consent.isGranted("marketing")).toBe(true);
}); });
}); });
describe("migrateAnonymousConsent — category allow-list (A12)", () => {
it("never grants unknown categories even when passed directly", async () => {
const consent = new RecordingConsent();
await migrateAnonymousConsent({
consent,
cookieState: ["analytics", "totally-made-up", "marketing"],
});
expect(consent.grants.map((g) => g.category)).toEqual([
"analytics",
"marketing",
]);
});
});

View File

@@ -1,4 +1,8 @@
import type { ConsentCategory, ConsentGrantMeta } from "./consent-types"; import {
isKnownConsentCategory,
type ConsentCategory,
type ConsentGrantMeta,
} from "./consent-types";
import type { IConsent } from "./consent.interface"; import type { IConsent } from "./consent.interface";
/** Cookie name written by the anonymous consent banner. */ /** Cookie name written by the anonymous consent banner. */
@@ -11,6 +15,10 @@ export const CONSENT_COOKIE_NAME = "cc_consent";
* *
* Expected cookie value format: comma-separated category names, * Expected cookie value format: comma-separated category names,
* e.g. "necessary,analytics,marketing". * e.g. "necessary,analytics,marketing".
*
* The cookie is client-controlled, so values are validated against
* KNOWN_CONSENT_CATEGORIES (audit finding A12) — unknown strings are
* dropped rather than granted.
*/ */
export function extractAnonymousConsent( export function extractAnonymousConsent(
cookieHeader: string, cookieHeader: string,
@@ -21,7 +29,8 @@ export function extractAnonymousConsent(
const categories = raw const categories = raw
.split(",") .split(",")
.map((c) => c.trim()) .map((c) => c.trim())
.filter(Boolean) as ConsentCategory[]; .filter(Boolean)
.filter(isKnownConsentCategory) as ConsentCategory[];
return categories.length > 0 ? categories : null; return categories.length > 0 ? categories : null;
} }
@@ -42,7 +51,9 @@ export async function migrateAnonymousConsent(opts: {
const meta: ConsentGrantMeta = { method: "signup-migration" }; const meta: ConsentGrantMeta = { method: "signup-migration" };
if (bannerVersion !== undefined) meta.bannerVersion = bannerVersion; if (bannerVersion !== undefined) meta.bannerVersion = bannerVersion;
if (policyVersion !== undefined) meta.policyVersion = policyVersion; if (policyVersion !== undefined) meta.policyVersion = policyVersion;
for (const category of cookieState) { // Defense in depth (A12): even a caller that bypassed
// extractAnonymousConsent cannot grant unknown categories.
for (const category of cookieState.filter(isKnownConsentCategory)) {
await consent.grant(category, meta); await consent.grant(category, meta);
} }
} }