Skip to content

Fix safeLinkHref scheme check running before entity decoding - #22

Merged
jonathanKingston merged 2 commits into
mainfrom
claude/safelinkhref-scheme-validation-p391l4
Jul 5, 2026
Merged

jonathanKingston merged 2 commits into
mainfrom
claude/safelinkhref-scheme-validation-p391l4

Conversation

@jonathanKingston

Copy link
Copy Markdown
Collaborator

Summary

safeLinkHref validated the link destination's scheme before HTML character references were decoded, so an entity-encoded dangerous scheme slipped past the check and only decoded to a live URL afterwards (during encodeHrefForOutput).

The javascript|data|vbscript denylist tested the raw string; encodeHrefForOutput then called decodeHTMLStrict. So javascript:alert(1) passed the check and decoded to a working javascript: URL in the emitted href.

  • Reproduced: renderMarkdown('[click][r]\n\n[r]: &#x6a;avascript:alert(1)')<a href="javascript:alert(1)"> (also data: via &#100;ata:…, and the double-encoded &amp;#x6a;…).
  • The inline [x](dest) path was safe only by accident (entities are pre-decoded to inert PUA before safeLinkHref runs). The reference-definition path — whose destination is stored raw — was exploitable.
  • autolinkHref has the same shape but is not vulnerable: the autolink grammar requires a literal scheme: (letters + literal colon), so entities can't disguise the scheme there. Left unchanged.

Fix

  • Decode before validating. safeLinkHref now fully resolves the href (undo source HTML-escaping → decode PUA punctuation → decode HTML character references), then validates, then percent-encodes the already-decoded string directly — no second decode pass that could re-hide a scheme behind double-encoded entities.
  • Split percentEncodeHref out of encodeHrefForOutput so validation and encoding share one decode.
  • Allowlist instead of denylist. Scheme-less destinations (relative paths, fragments, queries) are always allowed; a scheme is permitted only if it's in the allowlist, so unknown/future dangerous schemes fail closed.
  • A control-char bypass (java&Tab;script:) is neutralized too — the percent-encoder turns the tab into %09, breaking the scheme.

Configurable allowlist

  • DEFAULT_SAFE_HREF_SCHEMEShttp, https, mailto, tel, sms, ftp, ftps.
  • setSafeHrefSchemes(schemes | null) — override the active set (case-insensitive) or restore the default; mirrors the existing setLinkDecorator injection pattern.
  • getSafeHrefSchemes() — read the active set.

Relative destinations stay allowed regardless of the set, and resolution still decodes entities before the scheme check, so a custom allowlist can't be bypassed with entity-encoded schemes. Documented in the README (narrowing is always safe; widening must avoid script-executing schemes).

Tests

  • npm run typecheck clean; 435/435 tests pass, including the CommonMark conformance baseline (unchanged — the spec's non-http schemes are all autolinks).
  • Added regression tests: entity-encoded, double-encoded, dangerous, unknown, and control-char schemes; the reference-definition end-to-end path; and the setSafeHrefSchemes narrow/restore, case-insensitivity, and no-bypass-on-widen behavior.

🤖 Generated with Claude Code

https://claude.ai/code/session_01B88dLwh4TdWDbsDwuqxQUs


Generated by Claude Code

claude added 2 commits July 5, 2026 09:43
The javascript|data|vbscript denylist tested the raw destination string,
but encodeHrefForOutput decoded HTML character references afterwards. So a
reference definition like `[r]: &#x6a;avascript:alert(1)` passed the scheme
check and then decoded to a live `javascript:` URL in the emitted href. The
inline `[x](dest)` path was safe only by accident (entities are pre-decoded
to inert PUA before safeLinkHref runs); the reference-definition path was
exploitable.

Fix: decode HTML character references (undoing source escaping and PUA
punctuation first) BEFORE validating, then percent-encode the already-decoded
href directly without a second decode pass (which could re-hide a scheme
behind double-encoded entities). Replace the 3-entry denylist with a
scheme allowlist (http, https, mailto, tel, sms, ftp, ftps + scheme-less
relative/fragment/path forms) so unknown and future dangerous schemes fail
closed by default.

Split percentEncodeHref out of encodeHrefForOutput so validation and
encoding share one decode. Add regression tests covering entity-encoded,
double-encoded, dangerous, and unknown schemes plus the reference-definition
end-to-end path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B88dLwh4TdWDbsDwuqxQUs
Expose the safeLinkHref allowlist as configuration instead of a hardcoded
const, mirroring the existing setLinkDecorator injection pattern:

- DEFAULT_SAFE_HREF_SCHEMES: the built-in set (http, https, mailto, tel, sms,
  ftp, ftps).
- setSafeHrefSchemes(schemes | null): override the active set (matched
  case-insensitively) or pass null to restore the default.
- getSafeHrefSchemes(): read the active set.

Relative/fragment/path destinations remain allowed regardless of the set, and
resolution still decodes character references before checking the scheme, so a
custom allowlist cannot be bypassed with entity-encoded schemes. Documented in
the README with a note that narrowing is always safe but widening must avoid
script-executing schemes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B88dLwh4TdWDbsDwuqxQUs
@jonathanKingston
jonathanKingston merged commit 128c47b into main Jul 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants