Skip to content

Read the host out of a URL for the internal_host match - #152

Merged
patchstackdave merged 2 commits into
mainfrom
eng-3630-internal-host-url
Aug 20, 2026
Merged

Read the host out of a URL for the internal_host match#152
patchstackdave merged 2 commits into
mainfrom
eng-3630-internal-host-url

Conversation

@patchstackdave

Copy link
Copy Markdown
Contributor

Found by running the whole chain on a real app rather than checking that each part looked right.

What happened

A vulnerability detected on a site, with a reachability flow and a matching vPatch template, generated a rule pinned to the app's own parameter — get.url on /api/preview, no placeholders left unbound — and the platform served it. Then the rule matched nothing at all.

internal_host classifies its value as a hostname. That is correct for the egress phase, where the value is the destination host, and wrong for the request phase, where the same question arrives as an application parameter whose value is a full URL. isInternalHost('http://169.254.169.254/latest/meta-data/') is false, so a request-phase SSRF rule was expressible, servable, and permanently inert — the failure mode this engine has been hardened against repeatedly, in the one match type meant to prevent it.

Measured before the fix, six of fifteen destinations were wrong, every one a false negative:

value before after
169.254.169.254 match match
http://169.254.169.254/latest/meta-data/ no match match
http://localhost:3000/admin no match match
//10.0.0.5/x no match match
169.254.169.254:80 no match match
https://api.stripe.example/v1/charges no match no match
how to use localhost in docker no match no match

The change

The match extracts a host before classifying: a URL (any scheme, including protocol-relative) is parsed and its hostname classified; host:port and [v6]:port lose the port; a bare host passes through untouched, so the egress path and the built-in egress-internal-address default behave exactly as before.

Classification itself is unchanged, so every canonicalisation defence still applies to what comes out — decimal/hex IPv4, expanded and IPv4-mapped IPv6, trailing dots.

Parsed rather than sliced, deliberately. Userinfo puts a trusted-looking name before the real host (http://api.stripe.example@169.254.169.254/ must match); a fragment puts one after it (http://evil.example/#@127.0.0.1 must not). A substring check reads the wrong host in both directions — a bypass in the first case, a false positive in the second. Both are asserted, and the slice-instead-of-parse mutation fails on exactly those two.

Verification

1216 tests, typecheck clean. Three new test groups: URL-valued destinations, the two evasions above, and a bare-host group whose job is to prove extraction is a no-op for the egress path.

Mutation-checked: removing the extraction, and replacing the URL parse with a textual slice, each fail two assertions and no others.

Then re-run end to end against the rule the platform actually served:

  • as served — detects, does not block. The entry carries enforcement: dry-run, which correctly overrides the site's block mode; a generated pinned rule stays dry-run until promoted.
  • with enforcement promoted — 403 on the exploit.
  • a third-party URL on the same route passes, and an internal URL on a different route passes, so the route scope still holds.

Independent of the two other open PRs (#151 and the platform's presentation work); this branch is off main.

Found by running the whole chain on a real app: a vulnerability detected on a site, with a
reachability flow and a vPatch template, generated a rule pinned to the app's own parameter
(`get.url` on `/api/preview`), served it — and the rule matched nothing at all.

`internal_host` classifies its value AS a hostname. That is right for the egress phase, where the
value is the destination host, and wrong for the request phase, where the same question arrives as
an application parameter whose value is a full URL. `isInternalHost('http://169.254.169.254/…')`
is false, so every request-phase SSRF rule was expressible, servable, and permanently inert — the
exact failure this engine has been hardened against repeatedly, in the one match type meant to
prevent it. Six of fifteen destinations were wrong before this, every one a false negative.

The match now extracts a host first: a URL (any scheme, including protocol-relative) is parsed and
its hostname classified; `host:port` and `[v6]:port` lose the port; a bare host passes through
untouched, so the egress path and the built-in default rule behave exactly as before.

Parsed rather than sliced, deliberately. Userinfo puts a trusted-looking name before the real host
(`http://api.stripe.example@169.254.169.254/` must match) and a fragment puts one after it
(`http://evil.example/#@127.0.0.1` must not). A substring check reads the wrong host in both
directions — a bypass in the first case, a false positive in the second — and the slice-instead-of-
parse mutation fails on exactly those two.

Classification itself is unchanged, so every canonicalisation defence still applies to what comes
out: decimal/hex IPv4, expanded and IPv4-mapped IPv6, trailing dots.

Verified end to end after the fix, against the rule the platform actually served: as served it
detects and does not block (per-rule `dry-run` overriding the site's block mode, as designed);
with enforcement promoted it returns 403; a third-party URL on the same route passes; an internal
URL on a different route passes, so the route scope holds.

1216 tests, typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderbuds

coderbuds Bot commented Aug 20, 2026

Copy link
Copy Markdown

hostFromValue cleanly extracts hosts from diverse URL inputs to fix SSRF matches.

🎯 Quality: 100% Elite · 📦 Size: Medium

📈 This month: Your 96th PR — above team average · Averaging Excellent

See how your team is trending →

The matcher test that came with the fix exercises `matchValue()` directly, and the defect was not a
matcher in isolation: it was a rule bound to the right parameter, scoped to the right route,
carrying the right per-rule enforcement, arriving at the runtime intact, and never firing. Every
part was individually correct and every unit test passed.

`pulse-chain.test.ts` cannot catch that. It covers the same transport with a static lodash rule
whose conditions read `raw` — no route scope, no `get.*` source, and a match type that had only
ever been exercised on the egress path. So the pinned shape now has its own chain test, fetching
from a mock Pulse and enforcing through the HTTP guard, with the four assertions that separate
"protecting" from "present": detected in dry-run, 403 once promoted, a third-party destination on
the same route allowed, and an internal destination on another route allowed.

The fixture is captured from a real `GET pulse/rules/{uuid}` response and carries the template
beside the served copy, so the substitution is visible rather than described — and the unbound
template is asserted to be inert, which is the other half of the same failure: `<param>` is not a
parameter source, so a rule that reached an app with placeholders intact would load, report as
shipped protection, and never match.

Writing it turned up a second silent widening. The engine's scope key is `when.path`; the first
draft wrote `when.route`, which is not an error — the scope is ignored and the rule applies to
every request. Fail-open is right for a scope that cannot be EVALUATED, but a scope that cannot be
UNDERSTOOD is an authoring mistake with the opposite consequence: for a blocking rule it is a
false-positive surface across the whole app instead of one endpoint. The engine now warns once and
says which keys it understands; behaviour is unchanged, and the widening is pinned by a test so it
is documented rather than rediscovered.

1221 tests, typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@patchstackdave

Copy link
Copy Markdown
Contributor Author

/review

@patchstackdave
patchstackdave merged commit ee23d81 into main Aug 20, 2026
6 checks passed
@patchstackdave
patchstackdave deleted the eng-3630-internal-host-url branch August 20, 2026 08:35
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