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