From 9322831f1b40c848c6d816abea605456ce426bcf Mon Sep 17 00:00:00 2001 From: Hermes Date: Sun, 23 Aug 2026 07:28:10 -0700 Subject: [PATCH] =?UTF-8?q?fix(security):=20quoted=20local-part=20email=20?= =?UTF-8?q?mask=20=E2=80=94=20strip=20delimiter=20quotes,=20split=20on=20l?= =?UTF-8?q?ast=20@=20(DC-109)=20[glm-grade=3DA]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../logging-email-masking-dc095.test.js | 4 ++- .../__tests__/redact-log-pii.test.js | 34 +++++++++++++++++++ dashcaddy-api/src/utils/logging.js | 15 ++++++-- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/dashcaddy-api/__tests__/logging-email-masking-dc095.test.js b/dashcaddy-api/__tests__/logging-email-masking-dc095.test.js index 7d3a087..2421212 100644 --- a/dashcaddy-api/__tests__/logging-email-masking-dc095.test.js +++ b/dashcaddy-api/__tests__/logging-email-masking-dc095.test.js @@ -242,7 +242,9 @@ describe('DC-095 round 2: adversarial judge findings', () => { const out = consoleOut(); expect(out).not.toContain('john doe'); expect(out).not.toContain('"john doe"@example.com'); - expect(out).toContain('****@example.com'); + // DC-109: delimiter quotes are syntax, not PII — strip, never re-emit. + expect(out).toContain('jo****@example.com'); // 2 REAL local chars, canonical shape + expect(out).not.toMatch(/["']j\*{4}/); // old bug: stray quote among the 2 chars }); test('class instance enumerable email prop masked, prototype preserved', () => { diff --git a/dashcaddy-api/__tests__/redact-log-pii.test.js b/dashcaddy-api/__tests__/redact-log-pii.test.js index bc3f71a..f1d4e47 100644 --- a/dashcaddy-api/__tests__/redact-log-pii.test.js +++ b/dashcaddy-api/__tests__/redact-log-pii.test.js @@ -177,3 +177,37 @@ describe('DC-098 redact-log-pii.js end-to-end', () => { expect(stderr).toContain('error:'); }); }); + +describe('DC-109 quoted local-part mask edge (maskEmailAddress)', () => { + test('quoted local-part: quotes stripped, 2 REAL chars kept, no stray quote', () => { + expect(maskEmailAddress('"john doe"@example.com')).toBe('jo****@example.com'); + expect(maskEmailAddress('"a"@example.com')).toBe('a****@example.com'); + expect(maskEmailAddress('ab"cd@e.f"@example.com')).toBe('ab****@example.com'); // mixed, no strip + }); + + test('quoted local-part containing "@" splits on LAST @ (real domain boundary)', () => { + expect(maskEmailAddress('"a@b"@example.com')).toBe('a@****@example.com'); + }); + + test('empty quoted local-part masks to bare ****@domain', () => { + expect(maskEmailAddress('""@example.com')).toBe('****@example.com'); + }); + + test('plain addresses unchanged by DC-109 (canonical shape preserved)', () => { + expect(maskEmailAddress('sami@example.com')).toBe('sa****@example.com'); + expect(maskEmailAddress('ab@x.io')).toBe('a****@x.io'); + expect(maskEmailAddress('a@x.io')).toBe('a****@x.io'); + }); + + test('masked quoted output cannot re-match EMAIL_RE (idempotence on splice line)', () => { + const line = 'contact "john.doe@x"@example.com or root@web-1 ok'; + const masked = maskEmailsInString(line); + const re = new RegExp(EMAIL_RE.source, EMAIL_RE.flags); + // After one pass nothing that still contains the original address OR a + // fresh matchable email-shaped token may remain. + expect(masked).not.toContain('john.doe'); + expect(re.test(masked)).toBe(false); + const twice = maskEmailsInString(masked); + expect(twice).toBe(masked); // fully idempotent + }); +}); diff --git a/dashcaddy-api/src/utils/logging.js b/dashcaddy-api/src/utils/logging.js index 84111aa..673d8a7 100644 --- a/dashcaddy-api/src/utils/logging.js +++ b/dashcaddy-api/src/utils/logging.js @@ -85,9 +85,20 @@ function formatTime() { const EMAIL_RE = /(?:[A-Za-z0-9._%+-]{1,64}|"[^"\n\\]{1,64}")@[A-Za-z0-9.-]{0,253}\.[A-Za-z]{2,24}/g; function maskEmailAddress(addr) { - const at = addr.indexOf('@'); - const local = addr.slice(0, at); + // addr is always a full EMAIL_RE match. Quoted local-parts (RFC 5322) match + // WITH their delimiter quotes and may contain '@' inside the quotes, so + // split on the LAST '@' (the real domain boundary), never the first. + // The quotes are syntax, not PII: strip them before masking and never + // re-emit them — slice(0, 2) of '"john doe"@…' used to leave a stray + // unbalanced quote in the output that could glue onto later text and + // re-match EMAIL_RE on a second pass (DC-109). + const at = addr.lastIndexOf('@'); + let local = addr.slice(0, at); const domain = addr.slice(at); + if (local.length >= 2 && local.startsWith('"') && local.endsWith('"')) { + local = local.slice(1, -1); + } + if (local.length === 0) return '****' + domain; if (local.length <= 2) return local[0] + '****' + domain; return local.slice(0, 2) + '****' + domain; }