diff --git a/extensions/dsh-browser/src/content/privacy.ts b/extensions/dsh-browser/src/content/privacy.ts index f5c871629..887b64f97 100644 --- a/extensions/dsh-browser/src/content/privacy.ts +++ b/extensions/dsh-browser/src/content/privacy.ts @@ -21,6 +21,22 @@ const SENSITIVE_PATTERNS = [ /pwd/i, ] +/** + * Whether an `autocomplete` attribute marks the field as payment-related. + * + * The HTML attribute is a case-insensitive space-separated token list, so + * real checkout markup like `section-checkout billing cc-number` must be + * tokenized before matching — a whole-string `startsWith('cc-')` check + * silently misses every multi-token value. + * + * @param autocomplete - raw `autocomplete` attribute value. + * @returns true when any token is `credit-card` or a `cc-*` detail token. + */ +function hasPaymentAutocompleteToken(autocomplete: string): boolean { + const tokens = autocomplete.trim().toLowerCase().split(/\s+/) + return tokens.includes('credit-card') || tokens.some((token) => token.startsWith('cc-')) +} + /** * Whether a form field must never be echoed back to the model. * @param el - the form element (input/select/textarea). @@ -30,8 +46,11 @@ const SENSITIVE_PATTERNS = [ export function isSensitiveField(el: Element): boolean { if (el instanceof HTMLInputElement) { if (el.type === 'password') return true - const autocomplete = String(el.autocomplete) - if (autocomplete === 'credit-card' || autocomplete.startsWith('cc-')) return true + // Read the CONTENT attribute, not the IDL property: Chrome's + // `autocomplete` getter is limited to known values and returns '' for + // token lists it cannot parse ('cc-number foo', 'bogus cc-number'), so + // the IDL would silently drop markup the mask must see. + if (hasPaymentAutocompleteToken(el.getAttribute('autocomplete') ?? '')) return true } const name = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement ? el.name diff --git a/extensions/dsh-browser/tests/privacy.spec.ts b/extensions/dsh-browser/tests/privacy.spec.ts index f4ecda3a2..c7856de79 100644 --- a/extensions/dsh-browser/tests/privacy.spec.ts +++ b/extensions/dsh-browser/tests/privacy.spec.ts @@ -26,6 +26,47 @@ describe('isSensitiveField', () => { } }) + it('flags the raw attribute when the IDL getter blanks the value (Chrome)', () => { + // Chrome's `input.autocomplete` IDL getter is limited to known values: + // it returns '' for token lists it cannot parse (e.g. 'cc-number foo', + // 'bogus cc-number'). The mask must read the content attribute, or those + // markup variants leak. Emulate the Chrome getter on the instance while + // the content attribute keeps the real value. + for (const value of ['cc-number foo', 'bogus cc-number', 'section-x billing cc-csc extra']) { + const input = document.createElement('input') + input.id = 'field1' + input.setAttribute('autocomplete', value) + Object.defineProperty(input, 'autocomplete', { get: () => '' }) + expect(isSensitiveField(input), value).toBe(true) + } + }) + + it('flags multi-token cc autocomplete values (HTML token lists)', () => { + // Real checkout markup: `autocomplete="section-checkout billing cc-number"`. + // Neutral id so ONLY the autocomplete token can flag the field. Set the + // CONTENT attribute, as production markup does. + for (const value of ['section-checkout billing cc-number', 'shipping cc-csc', 'billing cc-exp']) { + const input = document.createElement('input') + input.id = 'field1' + input.setAttribute('autocomplete', value) + expect(isSensitiveField(input), value).toBe(true) + } + }) + + it('matches cc autocomplete tokens case-insensitively', () => { + const input = document.createElement('input') + input.id = 'field1' + input.setAttribute('autocomplete', 'CC-Number') + expect(isSensitiveField(input)).toBe(true) + }) + + it('leaves multi-token autocomplete without a cc token alone', () => { + const input = document.createElement('input') + input.id = 'field1' + input.setAttribute('autocomplete', 'section-contact billing email') + expect(isSensitiveField(input)).toBe(false) + }) + it('leaves ordinary fields alone', () => { const input = document.createElement('input') input.id = 'email'