Repository navigation
Conversation
Path-based domain activation fetched http://<customer domain>/.well-known/ nostr.json?name=<hash> with a redirect-following client and no address validation. The domain is customer-supplied, so activation doubled as an SSRF primitive: a name resolving into loopback/private/link-local space, a redirect into one, or plain DNS rebinding let a customer probe or reach internal services from the worker host. The check now resolves each hop itself and refuses non-public addresses (new is_non_public in lnvps_api_common::network), connects to the validated address while TLS/Host still name the real hostname (DNS rebinding cannot swap it), and follows redirects manually with a 3-hop bound — so the common http->https upgrade on the customer's own host keeps working while a hostile hop cannot bounce the worker into internal space. redirect_target unit-tests Location resolution. Found by the 2026-11-01 security audit.
mptfire
force-pushed
the
audit/activation-ssrf-guard
branch
from
November 1, 2026 22:50
6221ca3 to
b7d2382
Compare
v0l
requested changes
Nov 2, 2026
v0l
left a comment
Contributor
There was a problem hiding this comment.
The pinning and the per-hop validation are the right shape. Blocking on these:
is_non_publicmisses ranges that matter for SSRF.::ffff:127.0.0.1/::ffff:10.0.0.1(IPv4-mapped v6) pass as public, as do100.64.0.0/10(CGNAT, which is where a lot of our internal routing lives),0.0.0.0/8, multicast,198.18.0.0/15,240.0.0.0/4, and NAT6464:ff9b::/96. Map v4-mapped/compatible v6 back to v4 withto_ipv4_mapped()before classifying, and add the rest. Add each toprivate_and_special_ranges_are_rejected.- Scheme is derived from the port.
let scheme = if port == 443 { "https" } else { "http" }dropsnext_schemeon the floor, sohttps://host:8443/is fetched over plain http andhttp://host:443/over TLS. Carry the scheme through the loop instead oflet _ = next_scheme;. - The redirect path has no test. The interesting behaviour of this PR is "follows http->https on the customer's host, refuses a hop into private space". That is testable without the e2e harness: bind a
TcpListeneron a public-looking hostname is not possible, butredirect_target+pinned_activation_clientcan be exercised against awiremockserver onceis_non_publicis injectable (e.g. afn(&IpAddr) -> boolparameter, defaulting tois_non_publicat the call site). Please add one test that follows a redirect and one that refuses it. let _ = hop;andlet _ = next_scheme;are leftovers, remove them.- The CHANGELOG entry sits outside
## [Unreleased]'s existing sections with a stray blank line; fold it into the Unreleased block.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found by an independent security audit of the tree at
e5d95b0(2026-11-01).Problem
Path-based domain activation (
check_path_activation,lnvps_api/src/worker.rs) fetcheshttp://<domain>/.well-known/nostr.json?name=<hash>where<domain>is customer-supplied (validated as a syntactic public domain at registration, but its DNS is fully customer-controlled). The fetch used the worker's shared redirect-following client with no address validation, so activation doubled as an SSRF primitive:Bare-IP domains are already rejected at registration (TLD must be alphabetic,
dns_name.rs), so this needs a customer-controlled hostname — still fully within a customer's power.Fix
check_path_activationnow:is_non_publicinlnvps_api_common::network— loopback/private/link-local/unspecified/broadcast/documentation, ULA, v6 link-local; the same classesgeoipalready treats as non-geolocatable);Hostheader, which kills DNS rebinding for this fetch;redirect(Policy::none())), so a customer server cannot bounce the worker to an address the guard never saw.Validation
is_non_publicclassifier (12 negative + 2 positive cases) andpath_activation_never_fetches_a_non_public_address(alocalhost-resolving domain returns false before any request).cargo build -p lnvps_apiand clippy green; no behaviour change for public-resolving domains beyond the (correct) refusal of redirects.Audit reference: LNVM-008 (Medium). The same guard should eventually back every outbound fetch of user-supplied hosts (NWC relay dialling is noted separately in the audit).
Update: redirects are now followed, one validated hop at a time
The original version of this PR used a no-redirect client. That was too blunt: the activation URL is hardcoded
http://, so any customer server answering with the commonhttp → httpsredirect previously activated fine and would have failed. The branch now follows redirects manually, at most 3 hops, validating every hop:is_non_public);reqwestper-clientresolve) — DNS rebinding cannot swap in an internal address, and the http→https upgrade keeps working;Locationtargets are resolved by a unit-tested helper (redirect_target): relative locations stay on the current validated hop, absolute http(s) targets become the next validated hop, and non-http(s) schemes are refused.Behavior summary: same-host http→https upgrades work; cross-host redirects work only when the target resolves public; loopback/private/link-local and non-http(s) targets fail activation.
Also added the
API_CHANGELOG.mdentry.