fix(security): harden account-key handling against prototype pollution

This commit is contained in:
Peter Steinberger
2026-02-24 01:09:23 +00:00
parent 12cc754332
commit f97c0922e1
24 changed files with 141 additions and 111 deletions

View File

@@ -20,6 +20,15 @@ describe("account id normalization", () => {
expect(normalizeAccountId(" Prod/US East ")).toBe("prod-us-east");
});
it("rejects prototype-pollution key vectors", () => {
expect(normalizeAccountId("__proto__")).toBe(DEFAULT_ACCOUNT_ID);
expect(normalizeAccountId("constructor")).toBe(DEFAULT_ACCOUNT_ID);
expect(normalizeAccountId("prototype")).toBe(DEFAULT_ACCOUNT_ID);
expect(normalizeOptionalAccountId("__proto__")).toBeUndefined();
expect(normalizeOptionalAccountId("constructor")).toBeUndefined();
expect(normalizeOptionalAccountId("prototype")).toBeUndefined();
});
it("preserves optional semantics without forcing default", () => {
expect(normalizeOptionalAccountId(undefined)).toBeUndefined();
expect(normalizeOptionalAccountId(" ")).toBeUndefined();

View File

@@ -1,3 +1,5 @@
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
export const DEFAULT_ACCOUNT_ID = "default";
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
@@ -17,12 +19,20 @@ function canonicalizeAccountId(value: string): string {
.slice(0, 64);
}
function normalizeCanonicalAccountId(value: string): string | undefined {
const canonical = canonicalizeAccountId(value);
if (!canonical || isBlockedObjectKey(canonical)) {
return undefined;
}
return canonical;
}
export function normalizeAccountId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) {
return DEFAULT_ACCOUNT_ID;
}
return canonicalizeAccountId(trimmed) || DEFAULT_ACCOUNT_ID;
return normalizeCanonicalAccountId(trimmed) || DEFAULT_ACCOUNT_ID;
}
export function normalizeOptionalAccountId(value: string | undefined | null): string | undefined {
@@ -30,5 +40,5 @@ export function normalizeOptionalAccountId(value: string | undefined | null): st
if (!trimmed) {
return undefined;
}
return canonicalizeAccountId(trimmed) || undefined;
return normalizeCanonicalAccountId(trimmed) || undefined;
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { resolveAccountEntry } from "./account-lookup.js";
describe("resolveAccountEntry", () => {
it("resolves direct and case-insensitive account keys", () => {
const accounts = {
default: { id: "default" },
Business: { id: "business" },
};
expect(resolveAccountEntry(accounts, "default")).toEqual({ id: "default" });
expect(resolveAccountEntry(accounts, "business")).toEqual({ id: "business" });
});
it("ignores prototype-chain values", () => {
const inherited = { default: { id: "polluted" } };
const accounts = Object.create(inherited) as Record<string, { id: string }>;
expect(resolveAccountEntry(accounts, "default")).toBeUndefined();
});
});

View File

@@ -0,0 +1,14 @@
export function resolveAccountEntry<T>(
accounts: Record<string, T> | undefined,
accountId: string,
): T | undefined {
if (!accounts || typeof accounts !== "object") {
return undefined;
}
if (Object.hasOwn(accounts, accountId)) {
return accounts[accountId];
}
const normalized = accountId.toLowerCase();
const matchKey = Object.keys(accounts).find((key) => key.toLowerCase() === normalized);
return matchKey ? accounts[matchKey] : undefined;
}