Validate PDS service endpoints before use - #51
Conversation
The DID document is resolver-supplied, so its serviceEndpoint was an unvalidated base URL for both credentialed and public traffic. Require https and reject loopback, link-local, private, CGNAT, ULA, and multicast hosts. One host string can name different addresses depending on the parser: IPv4Address reads 0177.0.0.1 as 177.0.0.1, inet_aton as 127.0.0.1, and 010.0.0.1 splits the other way. Since we don't control which parser the connection uses, every interpretation has to be acceptable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 3729867 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
ThisIsMissEm
left a comment
There was a problem hiding this comment.
Needs a test case for something like what https://princess.works returns (dns resolves to 127.0.0.1)
|
|
||
| guard !host.isEmpty else { return false } | ||
|
|
||
| if host == "localhost" || host.hasSuffix(".localhost") { return false } |
There was a problem hiding this comment.
| if host == "localhost" || host.hasSuffix(".localhost") { return false } | |
| if host == "localhost" || host.hasSuffix(".localhost") || host.hasSuffix(".internal")) { return false } |
| } | ||
| } | ||
|
|
||
| static func permitted(host rawHost: String) -> Bool { |
There was a problem hiding this comment.
This isn't actually doing DNS resolution on the host, correct? Because that's what you actually need prior to checking the value isn't localhost. See: https://github.com/bluesky-social/atproto/blob/main/packages/internal/fetch-node/src/safe.ts
There was a problem hiding this comment.
Correct — it isn't resolving, and after digging into the reference I don't think it should here, though the underlying point is right and I've taken the parts of it that work at this layer.
The thing that makes safe.ts work is that the check isn't resolve-then-fetch, it's new Undici6Agent({ connect: { lookup: unicastLookup } }) — the resolution that opens the socket is the one that gets validated. A separate pre-resolution pass doesn't get you that: the attacker serves a clean A record with TTL 0 to the checker and 127.0.0.1 to the connection. URLSession exposes no equivalent hook, and connecting by pre-checked IP instead of by name breaks NAT64/DNS64, Happy Eyeballs, and Private Relay, which is why Apple explicitly steers away from it. So a resolver here would add a lot of machinery and still only raise the bar rather than close the hole — notably Bluesky's own app clients don't do this check either.
What does close most of it on an https-only client: for princess.works -> 127.0.0.1 to yield more than a TCP connect and a ClientHello, whatever is listening on loopback has to complete a TLS handshake with a valid cert for the attacker's chosen name. Nothing — no header, no token, no body — leaves the app before that succeeds, and an attacker holding that key gains nothing from a reserved IP since they could just receive the traffic on a public one. That's the structural difference from the server-side case fetch-node defends, where the canonical target (169.254.169.254) is unauthenticated plain HTTP. iOS also gates connections into RFC1918/link-local behind the local-network entitlement.
So the residual gap is a TCP-level port-probe oracle, which I don't think justifies a bespoke DNS layer. I've documented that boundary explicitly on validate rather than leaving it implied.
Adopted from your review, in a3cacee:
- your
.internalsuggestion, plus the rest of the listsafe.tsrejects —.local,.test,.invalid,.example(RFC 6761/6762 + ICANN) - single-label hosts (
https://pds), which only resolve through local search domains
One follow-on in 3729867: since loopback is now rejected in every spelling, a dev-env PDS on localhost:2583 became unreachable, so there's an opt-in EndpointPolicy.developmentLoopback that forgives http only when the host is loopback. Strict by default.
On the princess.works test — I don't think it can be written meaningfully without the resolver, since there'd be nothing to assert against; the name is public and passes a name-level check by construction. If you'd rather have the guarantee than the documentation, the honest way to get it is a resolver-aware client (AsyncHTTPClient on NIO can hook resolution the way undici does) rather than this static validator, and I'd want that as its own change.
There was a problem hiding this comment.
Okay, sure, that works. Sounds like link-local may also prevent localhost connections in this case
Adds the special-use TLDs Bluesky's safe fetch rejects (RFC 6761/6762 plus ICANN .internal) and drops single-label hosts, which only resolve through local search domains. Also documents what the check deliberately does not cover: it never resolves names, so a public name pointing at a reserved address gets through. URLSession exposes no hook into the resolution its connections use, and a separate resolve-then-connect pass is defeated by rebinding, so that gap is left to TLS and the OS local-network entitlement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Now that loopback is rejected in every spelling, atproto's dev-env PDS is unreachable through pdsUrl: it serves plain http on localhost:2583. Give that case a named policy rather than an ambient debug flag, following the same call-site-names-the-leniency shape as the base64 decoder option. .developmentLoopback forgives http only when the host is loopback, so the private, CGNAT, link-local, and reserved-TLD rules all still apply — it opens the local machine, not the local network. Omitting the policy stays strict. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Atproto.DIDDocument.pdsUrlreturned the DID document'sserviceEndpointverbatim, and that URL becomes the base for DPoP-authenticated XRPC traffic and for unauthenticated public traffic. Nothing checked the scheme or host, sohttp://,localhost,169.254.169.254, and RFC-1918 addresses were all accepted from a resolver-supplied document.Tokens themselves were never replayable — DPoP proofs cover
htu/htm/athand the nonce cache is keyed by origin — so the exposure was SSRF and disclosure of request bodies, not credential theft.Now
Service.validate(endpoint:)requires https and rejects localhost, loopback, link-local, RFC-1918, CGNAT, IPv6 ULA, unspecified, multicast/broadcast, and IPv4-mapped IPv6 forms. It's called from bothpdsUrlandcheckServiceForAtproto(), so there's a single choke point.One wrinkle worth review: a host string can name different addresses depending on who parses it.
IPv4Addressreads0177.0.0.1as 177.0.0.1 whileinet_atonreads 127.0.0.1, and010.0.0.1splits the other way. Since we don't control which parser the connection ultimately uses, the validator rejects if any interpretation lands in a blocked range.Not covered, deliberately: an attacker-controlled public https host still receives credentialed traffic (that needs origin pinning against the recorded
issuingServer, plus the PDS origin, which isn't stored yet), and DNS rebinding, which URL inspection can't catch.Test notes
New
PDSEndpointTests, 28 tests passing. Covers accepted endpoints, boundary accepts just outside each blocked range (172.32.0.1,100.128.0.1), scheme rejections, and 18 host rejections including the legacy numeric spellings. Verified withswift testand an iOS Simulator build.🤖 Generated with Claude Code