Files
msd-core/.pr-body-3514.md
Tom Boucher 6badb839a0 fix(#3514): deny internal fetch hosts; disclose unverified integrity (#3516)
* test(#3514): add failing-first denylist and integrity suites

* fix(#3514): deny internal fetch hosts; disclose unverified integrity

* docs(#3514): trust-model, glossary, and changeset entries

* fix(#3514): scope v6 checks to literals; exact pin kinds in prompt

* chore(#3514): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
2026-08-14 21:34:29 -04:00

4.4 KiB

Fix PR

Using the wrong template? — Enhancement: use enhancement.md — Feature: use feature.md


Linked Issue

Required. This PR will be auto-closed if no valid issue link is found.

Fixes #3514

The linked issue must have the confirmed-bug label. If it doesn't, ask a maintainer to confirm the bug before continuing.


What was broken

Three hardening gaps at the ADR-1244 D3/D5 edges (epic #1900, finding F21): the capability URL-import fetcher never validated the resolved host (a loopback or cloud-metadata https endpoint was fetched like any URL); an http:// tarball spec failed with a raw ERR_INVALID_PROTOCOL instead of a named reason; and an install without an integrity pin produced a consent prompt that did not distinguish verified from unverified content.

What this fix does

  • Pre-transport fetch gate (realHttpsGet → assertFetchableUrl): loopback/link-local/metadata/unspecified hosts (127/8, 169.254/16 incl. 169.254.169.254, 0/8, ::1, fe80::/10, ::, IPv4-mapped spellings in both dotted and hex-normalized forms) and localhost/*.localhost names are denied with a named error before any bytes leave — the injected transport is provably never invoked (tests assert call-count 0). The WHATWG URL parser normalizes alternate IP spellings (decimal 2130706433, hex 0x7f000001, octal, short forms) to dotted-quad before the gate sees them — locked by tests.
  • Non-https, per the adopted split decision: parseSpec still classifies http:// tarball specs (internal-mirror flows are not broken at parse); the fetcher refuses with a clear https-only reason naming the mirror alternative.
  • Unverified-integrity disclosure: evaluateInstallTrust accepts integrityPinned (a verified --integrity pin, or a git #sha:<commit> ref); the consent prompt renders content: NO PINNED HASH — staged unverified when no pin was supplied (and the pinned counterpart when one was). The status is prompt-only — deliberately excluded from disclosureSignature (consent's content binding is bundleContentHash, #1459; tests lock that it can never fire a spurious re-consent). Legacy callers see byte-identical output.

Root cause

The D3 fetcher was built with a byte cap and a timeout but no host policy — the finding is an edge the pipeline's safe posture simply wasn't applied to. The integrity line was missing because the disclosure was built from the manifest alone; the pin fact lives on the resolve path, and nothing threaded it into the verdict.

Testing

How I verified the fix

  • Failing-first: gsd-test at the tests-only commit (holodeck, linux-node24) — verdict failed with exactly the 20 expected failures (12-case denylist matrix, https-reason, trust/lifecycle integrity suites), 34,343 green.
  • GREEN: full gsd-test matrix at the final HEAD — verdict below.
  • Controls lock the negative space: a public host and a private-range mirror still fetch through the gate; parseSpec still classifies http://; legacy summarizeDisclosure output is line-identical.

Regression test added?

  • Yes — added a test that would have caught this bug

Platforms tested

  • macOS
  • Windows (including backslash path handling)
  • Linux

Runtimes tested

  • Claude Code
  • Gemini CLI
  • OpenCode
  • Other: ___
  • N/A (not runtime-specific — engine-internal source resolver + trust gate)

Checklist

  • Issue linked above with Fixes #3514 — PR will be auto-closed if missing
  • Linked issue has the confirmed-bug label
  • Fix is scoped to the reported bug — no unrelated changes included
  • Regression test added (or explained why not)
  • All existing tests pass (npm test) — full gsd-test matrix at final HEAD
  • .changeset/ fragment added — Security type
  • No unnecessary dependencies added

Breaking changes

None intended. http:// tarball URLs already failed at the transport; they now fail with a better message. Denied hosts (loopback/link-local/metadata/localhost) are not legitimate install sources. Known limits, documented in docs/explanation/capability-trust-model.md: RFC1918 private ranges are deliberately not denied (internal mirrors); the check is on the URL's host literal — DNS rebinding is out of scope. Local/unpinned-git installs render the unverified line even though their trust basis is the path/commit (honest, slightly noisy).