Skip to content

v3.0.11 - Default redaction for secrets present at page load; known-sensitive URLs suppressed outright

Latest

Choose a tag to compare

@obra obra released this 07 Oct 03:53
d2acf7f

[3.0.11] - 2026-10-06 - Default redaction for secrets present at page load; known-sensitive URLs suppressed outright

Security

  • All of #55/#59's redaction depends on a field having a recognized type/autocomplete, or having already mirrored a typed value somewhere -- neither helps a secret that is already in the DOM the moment the page loads, before any action (and so before any data-sen-secret marking, which can only happen AFTER an action reveals a value) has run at all. Real case: Slack's 2FA setup page (/account/settings/2fa_app) renders the TOTP seed into a hidden #init_key_code element from page load, so the very first auto-capture of that page wrote the raw seed to disk.
  • Default secret-pattern detection: an element whose id/name/class/autocomplete/aria-label contains a secret-looking WORD (secret, totp, otp, 2fa, mfa, key_code, seed, recovery, backup_code, api_key, token) is redacted on first sight, no marking or click required. Matched on word/segment boundaries (split on -, _, camelCase and whitespace), not a raw substring: otp no longer matches inside footprint, and token no longer matches inside a token-list/tokens-table id. A matched value-bearing field (input/textarea/select) is treated exactly like any other sensitive field. A matched LEAF element (no element children of its own) has its own text content blanked directly by element reference, regardless of length -- NOT via a page-wide substring replace: an earlier version of this collected that text into the same redaction set used for typed field values, which corrupted unrelated page content sharing the same word (a Prism.js <span class="token keyword">class</span> turned every class="..." attribute on the page into [REDACTED]="..."). A matched CONTAINER (has element children, e.g. <div id="totp-secret">) is fully blanked -- every direct text-node child, and every non-control descendant recursively, is redacted -- fixing a real leak where a secret split across child elements (<div id="totp-secret">Key: <code>SEED</code></div>, a <ul class="recovery-codes"><li> list) was written to disk in clear because the container itself has element children and was skipped outright -- but only when the match is a strong, exact compound (totp-secret, secret-key, recovery-codes, backup-codes, api-key, private-key, key-code, ...) and the container's text is 2000 characters or fewer. A WEAK match (a single broad word, e.g. a design-system class sn-token-provider or a docs landmark <section id="module-secrets">) does NOT wholesale-blank its container -- an earlier version of this fix did, which wiped ordinary docs/design-system page content with no secret in it; a weak container's own attributes are still stripped and any descendant that independently matches is still found and blanked on its own. Button/link elements (and anything nested inside one) are never text-blanked, since a control's own visible text is a UI label, not the secret. (label/summary/legend/option lost this exemption in round 4 -- see below.) The .md artifact gets the identical word-boundary redaction and strong/small-container rule independently, since it is generated by walking the live DOM directly rather than through the HTML clone -- and does so by walking each emitted block's (p/li/td/pre/h1-h6/blockquote) own subtree rather than its flattened textContent, fixing a real leak where a matched element sitting INLINE inside one of those blocks (<p>Your setup key: <code id="totp-secret">SEED</code></p>) was emitted in clear because only the block itself and its ancestors, never its descendants, were checked.
  • URL-based suppression: a page whose URL matches a known-sensitive pattern (/2fa, /two_factor, /two-step, /mfa, /totp, /security/keys, security-keys, /recovery-codes, /backup-codes, login-verification, security-info) has its html/md body capture AND its screenshot -- including an explicit screenshot action, not just auto-capture -- suppressed outright, regardless of what the page's markup looks like. Matched against the URL's pathname and hash, never its query string, and only for a URL whose pathname is a real page location: the hash is included, so a hash-routed single-page app (e.g. the AWS console's #/security_credentials/mfa route) matches; the query string is excluded because matching it over-suppressed an ordinary /login?next=/settings/2fa redirect; data:/blob:/about: URLs are excluded entirely, since a data: URL's "pathname" is its own percent-encoded document body (a page's own <title> falsely matching its own text, not any real location, previously broke this PR's own real-Chrome #init_key_code test). mfa (only) is segment-anchored, fixing an unanchored /mfa prefix over-matching /docs/mfa-best-practices. /2fa and /two_factor//two-factor are deliberately left unanchored on their suffix side: real setup routes need the open suffix (GitHub's /settings/two_factor_authentication/..., GitLab's /-/profile/two_factor_auth, 1Password's /settings/two-factor-authentication, Slack's /account/settings/2fa_app) -- segment-anchoring two_factor the same way mfa was anchored, in an earlier version of this fix, silently broke all three of those real routes. SUPERPOWERS_CHROME_ALLOW_CREDENTIAL_CAPTURE=1 actually disables this suppression, matching what the notice has always said. Configurable via SUPERPOWERS_CHROME_SENSITIVE_URL_PATTERNS (comma-separated extra regexes, ADDED to the defaults, never a replacement).
  • data-sen-secret/data-sen-nonce marking (#52) is unchanged.

Tests

  • test/lib/secret-pattern.test.mjs: the shared word-boundary detector -- negative tests for the raw-substring false positives (footprint, token-list and variants), positive tests for every real id/class this repo relies on matching, a negative control for an ordinary non-matching container id, and the strong-compound container-strength gate (isCompoundSecretMatch) with both its own positive/negative cases.
  • test/lib/page-scripts/html-with-scrub.test.mjs: Slack's real case (hidden input and hidden leaf-text forms), one test per pattern word, autocomplete/class/aria-label matches, unconditional attribute stripping, a negative test that an ordinary login form is untouched, button/link label preservation, a non-vacuous wrapping-container test (using a container id that actually matches the pattern AND is a strong compound), full blanking of a container whose secret is split across element children and of a recovery-codes list, a Prism-token-span regression test confirming no page-wide substring corruption of common words or class= attributes, and real-world weak-container negative tests (a design-system wrapper class, a docs landmark section) confirming they are not wiped.
  • test/lib/page-scripts/markdown.test.mjs: the same word-boundary redaction and strong/small-container rule applied independently to the Markdown generator, including the wrapping-container and whitespace-padded-leaf cases, a link-label preservation check, an inline-match-inside-a-block test for each of p/li/td/pre/h3/blockquote, and the same two weak-container negative tests.
  • test/lib/sensitive-url.test.mjs: default pattern list including the new words, the env-var extension (additive, not a replacement, malformed fragments ignored), non-string/unparseable input, hash-routed SPA matching (a generic case and AWS's real MFA route), Google's real /two-step-verification route, a security-keys hash anchor, negative tests for both over-suppression cases (/login?next=/settings/2fa, /docs/mfa-best-practices), GitHub/GitLab/1Password's real 2FA routes, and data:/blob:/about: URLs never matching (including a data: URL whose own title text would otherwise false-match).
  • test/lib/capture-credential-guard.test.mjs: URL suppression writes no files and takes no screenshot even with ordinary, non-credential-shaped markup; an ordinary URL is not suppressed; the suppression reason propagates through the *WithCapture wrappers; an explicit screenshot action is suppressed on a known-sensitive URL; type/hover/key actions and both dialog short-circuits are suppressed purely by URL, not just click; SUPERPOWERS_CHROME_ALLOW_CREDENTIAL_CAPTURE=1 actually restores a URL-suppressed capture/screenshot/dialog.
  • test/credential-guard-mcp.test.mjs (real Chrome): the PR's own headline #init_key_code end-to-end test, previously broken by the data: URL scheme fix above, passes again -- run and confirmed in this round, not assumed.

Security (round 4)

  • Attribute stripping inside a matched container's subtree now runs BEFORE the skip-tag early return, not after: a copy button's data-clipboard-text (holding the same seed as a sibling <code>) used to survive in the .html because the function returned before ever reaching the attribute-stripping code for that nested control. label/summary/legend/option are no longer treated as skip-tag controls either -- only button/a are, since a <label> wrapping a matched leaf is routinely the only place a secret is rendered at all; their own text is now blanked and their children recursed into, like any other element.
  • An otpauth:// URI is now redacted unconditionally, wherever it appears in the final .html or .md -- an <a href>, an <img src>, a data-* attribute, visible text, or a QR image's alt/title -- regardless of whether the carrying element also matches the word-boundary secret-pattern detector. href in particular was leaking completely unscrubbed before this (no prior mechanism touched it at all).
  • An explicit screenshot action refused by URL suppression now reports that reason, with the SUPERPOWERS_CHROME_SENSITIVE_URL_PATTERNS/ALLOW_CREDENTIAL_CAPTURE notice, instead of always saying "page shows credential-shaped content" (which was simply wrong for the URL case).
  • /2fa, /totp, security-keys and security-info are now anchored with a word-boundary check on their open (suffix) side: they match a whole path segment or a segment PREFIX followed by a non-alphanumeric separator or end of string (/2fa_app, /settings/2fa), but no longer match a prefix glued directly to more letters with no separator at all (/2fast, /totpal, /security-keyset-docs, /security-informant).

Tests (round 4)

  • test/lib/page-scripts/html-with-scrub.test.mjs: a skip-tag control's own data-* attributes stripped inside a matched container; a <label> wrapping a matched leaf fully blanked; otpauth:// redacted in an <a href>, an <img src>, a data-* attribute, plain text, and a QR image's alt/title.
  • test/lib/page-scripts/markdown.test.mjs: the same otpauth:// redaction, including inside a rendered [text](href) link and a QR image's alt text.
  • test/lib/sensitive-url.test.mjs: /2fast, /totpal, /security-keyset-docs and /security-informant no longer match; the real segment/segment-prefix routes (/settings/2fa, /2fa/setup, Slack's /2fa_app, a security-keys hash anchor) still do.
  • test/lib/capture-credential-guard.test.mjs: an action that navigates from a clean page to a known-sensitive URL suppresses the AFTER-action capture with suppressedReason: 'sensitive-url' (not falling back to credential-shape); ALLOW_CREDENTIAL_CAPTURE=1 restores it; a route change DURING a screenshot (the URL becomes sensitive mid-shot) deletes the screenshot via the existing post-shot re-check.
  • test/credential-guard-mcp.test.mjs (real Chrome): an explicit screenshot action on a known-sensitive URL reports the URL-specific refusal message, not the generic credential-shape one.
  • test/credential-guard-mcp.test.mjs (real Chrome): #init_key_code loaded via navigate never reaches any capture artifact (every file in the session dir checked, not just .html/.md); a file:// URL under a 2fa_app directory is suppressed end to end even though its markup is ordinary.

[3.0.10] - 2026-10-04 - Redaction catches a typed secret a page mirrors from an unrecognized field

Security

  • A field with no type="password" and no recognized autocomplete token was invisible to #55's redaction entirely, so a page mirroring its typed value into an attribute wrote that value to disk unredacted -- in any attribute, not just value/data-*. Google's 2-step verification page is a concrete case: its one-time-code field (#totpPin) copies the typed code into data-initial-value, which isn't type="password" or autocomplete-tagged. Such a field is now also found by comparing its live .value against its own other attributes (a self-mirror), so the existing value-redaction pass -- already attribute- and element-agnostic -- catches it. The exact-attribute-removal pass (for fields found by type/autocomplete/marker) now also strips aria-* attributes, not just data-*. #55's length floor is unchanged. Self-mirror detection skips input types nobody types into (submit, button, reset, checkbox, radio, hidden, image) and ignores attributes that name or label a field (type, name, id, aria-label, title, placeholder, for, class), so a submit button, a radio, or Google's search button whose value matches one of those is not treated as a secret.