From a4e73aba717bfe65a4783228a85d25be5f234670 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Sun, 8 Nov 2026 06:36:38 +0900 Subject: [PATCH 1/2] fix(privacy): keep masked account label validation linear The domain-label pattern in maskedAccountLabel let each label match two ways, so an address ending in an invalid character backtracked 2^n within the 254-character cap (CodeQL js/redos on the 2.99.0 promotion). Anchor each label on a single leading character; the accepted language is unchanged. --- src/lib/privacy.ts | 2 +- tests/lib/privacy-mask-account.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/privacy.ts b/src/lib/privacy.ts index 78a08a9813c..a26bff18ef9 100644 --- a/src/lib/privacy.ts +++ b/src/lib/privacy.ts @@ -12,7 +12,7 @@ export function maskEmail(value: string | null | undefined): string | null { /** A display label that never falls back to an unmasked or malformed identity. */ export function maskedAccountLabel(value: string | null | undefined): string | undefined { - if (!value || value.length > 254 || !/^[A-Za-z0-9_%+\-]+(?:\.[A-Za-z0-9_%+\-]+)*@[A-Za-z0-9]+(?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9]+(?:[A-Za-z0-9-]*[A-Za-z0-9])?)*$/.test(value)) return undefined; + if (!value || value.length > 254 || !/^[A-Za-z0-9_%+\-]+(?:\.[A-Za-z0-9_%+\-]+)*@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*$/.test(value)) return undefined; return maskEmail(value) ?? undefined; } diff --git a/tests/lib/privacy-mask-account.test.ts b/tests/lib/privacy-mask-account.test.ts index 3f0d1af10f8..5acf51f8010 100644 --- a/tests/lib/privacy-mask-account.test.ts +++ b/tests/lib/privacy-mask-account.test.ts @@ -9,6 +9,16 @@ test("account display labels omit malformed identities and mask valid addresses" } }); +test("account label validation stays linear on adversarial domain labels", () => { + // Each "00." label had two ways to match, so a trailing invalid character backtracked 2^n. + const adversarial = "%@0." + "00.".repeat(26) + "!"; + const started = performance.now(); + expect(maskedAccountLabel(adversarial)).toBeUndefined(); + expect(performance.now() - started).toBeLessThan(50); + expect(maskedAccountLabel("%@0." + "00.".repeat(80) + "!")).toBeUndefined(); + expect(maskedAccountLabel("a@b-c.d0-e.f")).toBe("*@b-c.d0-e.f"); +}); + describe("maskAccountId", () => { test("redacts long account ids to account-…suffix", () => { expect(maskAccountId("acct_abcdefghijklmnopqrstuvwxyz")).toBe("account-…wxyz"); From fd22beb2a00752d934c656a6bbb5b71bd8e42aaf Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Sun, 8 Nov 2026 07:03:30 +0900 Subject: [PATCH 2/2] test(privacy): sample fastest run and pin valid lengths for label timing --- tests/lib/privacy-mask-account.test.ts | 29 +++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/tests/lib/privacy-mask-account.test.ts b/tests/lib/privacy-mask-account.test.ts index 5acf51f8010..005be8d381b 100644 --- a/tests/lib/privacy-mask-account.test.ts +++ b/tests/lib/privacy-mask-account.test.ts @@ -9,13 +9,32 @@ test("account display labels omit malformed identities and mask valid addresses" } }); +function fastestLabelMs(input: string, runs = 5): number { + let best = Infinity; + for (let i = 0; i < runs; i++) { + const started = performance.now(); + maskedAccountLabel(input); + const elapsed = performance.now() - started; + if (elapsed < best) best = elapsed; + } + return best; +} + test("account label validation stays linear on adversarial domain labels", () => { // Each "00." label had two ways to match, so a trailing invalid character backtracked 2^n. - const adversarial = "%@0." + "00.".repeat(26) + "!"; - const started = performance.now(); - expect(maskedAccountLabel(adversarial)).toBeUndefined(); - expect(performance.now() - started).toBeLessThan(50); - expect(maskedAccountLabel("%@0." + "00.".repeat(80) + "!")).toBeUndefined(); + // The vulnerable pattern took ~400ms at 26 labels and would not finish at 80; a linear + // check lands orders of magnitude below the bound. The fastest of several runs keeps a + // single scheduler pause from failing the test. + for (const labels of [26, 80]) { + const domain = "0." + "00.".repeat(labels); + const invalid = `%@${domain}!`; + const valid = `%@${domain}a`; + // Valid addresses of the same length prove these inputs still reach the pattern. + expect(valid.length).toBeLessThanOrEqual(254); + expect(maskedAccountLabel(valid)).toBe(`*@${domain}a`); + expect(maskedAccountLabel(invalid)).toBeUndefined(); + expect(fastestLabelMs(invalid)).toBeLessThan(50); + } expect(maskedAccountLabel("a@b-c.d0-e.f")).toBe("*@b-c.d0-e.f"); });