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:
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user