diff --git a/packages/auth/src/application/use-cases/sign-up.use-case.test.ts b/packages/auth/src/application/use-cases/sign-up.use-case.test.ts index a3ed0c2..6f89aea 100644 --- a/packages/auth/src/application/use-cases/sign-up.use-case.test.ts +++ b/packages/auth/src/application/use-cases/sign-up.use-case.test.ts @@ -131,6 +131,43 @@ describe("signUpUseCase", () => { 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 () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); diff --git a/packages/auth/src/application/use-cases/sign-up.use-case.ts b/packages/auth/src/application/use-cases/sign-up.use-case.ts index 279abee..cec6dcd 100644 --- a/packages/auth/src/application/use-cases/sign-up.use-case.ts +++ b/packages/auth/src/application/use-cases/sign-up.use-case.ts @@ -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). 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 { for (const part of cookieHeader.split(";")) { const eqIdx = part.indexOf("="); @@ -24,7 +34,8 @@ function extractConsentFromCookieHeader(cookieHeader: string): string[] | null { const cats = value .split(",") .map((c) => c.trim()) - .filter(Boolean); + .filter(Boolean) + .filter((c) => KNOWN_CONSENT_CATEGORIES.includes(c)); return cats.length > 0 ? cats : null; } return null; diff --git a/packages/core-consent/src/consent-types.ts b/packages/core-consent/src/consent-types.ts index 053dc4b..afdeec5 100644 --- a/packages/core-consent/src/consent-types.ts +++ b/packages/core-consent/src/consent-types.ts @@ -10,6 +10,27 @@ export type ConsentCategory = | "marketing" | (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. */ export type ConsentState = "granted" | "denied" | "pending"; diff --git a/packages/core-consent/src/index.ts b/packages/core-consent/src/index.ts index de08935..f7dc110 100644 --- a/packages/core-consent/src/index.ts +++ b/packages/core-consent/src/index.ts @@ -4,6 +4,10 @@ export type { UserConsentState, ConsentGrantMeta, } from "./consent-types"; +export { + KNOWN_CONSENT_CATEGORIES, + isKnownConsentCategory, +} from "./consent-types"; export type { IConsent } from "./consent.interface"; export type { ConsentChecked } from "./with-consent"; export { withConsent } from "./with-consent"; diff --git a/packages/core-consent/src/migration.test.ts b/packages/core-consent/src/migration.test.ts index 2236061..42d7897 100644 --- a/packages/core-consent/src/migration.test.ts +++ b/packages/core-consent/src/migration.test.ts @@ -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", () => { it("calls IConsent.grant with method signup-migration for each category", async () => { const consent = new RecordingConsent(); @@ -105,3 +120,17 @@ describe("migrateAnonymousConsent", () => { 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", + ]); + }); +}); diff --git a/packages/core-consent/src/migration.ts b/packages/core-consent/src/migration.ts index b3f13d5..f4205f7 100644 --- a/packages/core-consent/src/migration.ts +++ b/packages/core-consent/src/migration.ts @@ -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"; /** 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, * 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( cookieHeader: string, @@ -21,7 +29,8 @@ export function extractAnonymousConsent( const categories = raw .split(",") .map((c) => c.trim()) - .filter(Boolean) as ConsentCategory[]; + .filter(Boolean) + .filter(isKnownConsentCategory) as ConsentCategory[]; return categories.length > 0 ? categories : null; } @@ -42,7 +51,9 @@ export async function migrateAnonymousConsent(opts: { const meta: ConsentGrantMeta = { method: "signup-migration" }; if (bannerVersion !== undefined) meta.bannerVersion = bannerVersion; 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); } }