OAuth handle resolution stalls 100 s when the handle's domain drops packets on :443 #52
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET#52
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Symptom
Starting the OAuth flow for a handle whose domain silently drops packets on port 443 blocks for 100 seconds (the
HttpClientdefault timeout) before the flow continues. From the user's side, sign-in appears to hang forever — most browsers and reverse proxies give up long before the request returns.Measured against a Blazor Server app on
ATProtoNet.Blazor0.4.0, signing in asgrandiras.net:The flow does eventually succeed — the DNS TXT record resolves fine — it just does so 100 s late.
Cause
Two things combine in
AuthorizationServerDiscovery:ResolveHandleToDidAsynctries the resolution methods sequentially, HTTPS first (src/ATProtoNet/Auth/OAuth/AuthorizationServerDiscovery.cs):https://{handle}/.well-known/atproto-did, then DNS-over-HTTPS TXT_atproto.{handle}, then thebsky.socialappview. The DNS fallback is not attempted until the HTTPS attempt has fully failed.No per-attempt timeout, and the
HttpClienthas noTimeoutconfigured.AtProtoOAuthService.GetOrCreateClient(src/ATProtoNet.Blazor/Authentication/AtProtoOAuthService.cs:105) does a barenew HttpClient(), so every request inherits the 100 s default. Nothing in the SDK setsTimeout.A parked or firewalled apex domain that drops SYNs on :443 — very common for handles that live on a subdomain and whose apex is registrar-parked — therefore costs the full 100 s.
grandiras.netresolves to a parked IP that drops :443, while_atproto.grandiras.netTXT answers instantly withdid=did:plc:....The same shape applies to
ResolveHandleAuthoritativeAsync: it does start HTTPS and DNS concurrently, but thenawait Task.WhenAll(httpsTask, dnsTask), so a dead HTTPS host still stalls it for 100 s.Suggested fix
CancellationTokenSource, independent of the ambient token, so one dead authority cannot dominate the flow.ResolveHandleToDidAsyncinstead of trying them in sequence — either first-success-wins, orWhenAllunder a short shared deadline asResolveHandleAuthoritativeAsyncdoes.HttpClient.Timeoutwhere the SDK constructs its own clients, and/or let callers supply theHttpClient(AtProtoOAuthServerOptionscurrently has no hook for this, so a consuming app cannot fix the timeout itself).Also worth considering while in there
ResolveHandleViaHttpsAsyncfollows redirects with default settings and buffers the full response before checking thedid:prefix. Since the target host is derived from untrusted user input, a hostile handle domain can redirect the server into its own network or hand back a large body. CappingMaxResponseContentBufferSizeand deciding deliberately whether to follow redirects on the well-known endpoint would tighten that.Workaround in use downstream
Ponente now pre-resolves the identifier to a DID itself (raced HTTPS + DoH with a 5 s budget) and passes the DID as the OAuth identifier, which skips the SDK's handle resolution while keeping the bidirectional DID verification that the
pdsUrlshortcut would disable. The wholeStartLoginAsynccall is also wrapped in a deadline. That workaround can be deleted once this is fixed.@claude-bot Take a shot
🤖 On it — follow along in the action run; the reply will link the full session transcript.
Pushed
claude/issue-52(commit7aeb1a5).What changed
1. Handle resolution now has its own budget —
AuthorizationServerDiscovery.HandleResolutionTimeout(default 5 s, exposed asDefaultHandleResolutionTimeout) bounds each resolution round with aCancellationTokenSourcelinked to the caller's token. Caller cancellation still propagates; only budget expiry is mapped to "no answer", so a dead authority degrades to a fallback instead of throwing.Timeout.InfiniteTimeSpanrestores the old unbounded behaviour.2. HTTPS and DNS are raced —
ResolveHandleToDidAsyncstarts the well-known lookup and the DoH TXT lookup together and returns the first DID (losing lookup is cancelled and observed). Thebsky.socialappview fallback gets a fresh budget so a slow handle domain can't eat its time.ResolveHandleAuthoritativeAsynckeeps its fail-closedWhenAllsemantics — conflicting DIDs still throw — but now under the shared budget, so a dead HTTPS host bounds at ~5 s instead of 100 s.3. Timeout hooks for consuming apps —
AtProtoOAuthServerOptionsgainsHttpClient(caller-supplied:Timeoutuntouched, not disposed with the service,User-Agentonly set if absent),HttpClientTimeout(30 s, applied to the SDK-created client — this was the barenew HttpClient()atAtProtoOAuthService.cs:105), andHandleResolutionTimeout.OAuthOptions.HandleResolutionTimeoutcovers core-SDK callers.DidWebResolver's parameterless ctor now uses 10 s.I deliberately left
AtProtoClient's ownHttpClientat the 100 s default — it carries blob uploads where that can be legitimate; callers can pass a pre-configured client. Flagging it since it's the one SDK-constructed client I didn't touch.4. Well-known endpoint hardening (the "also worth considering" item) — responses are read with
ResponseHeadersReadand capped at 1 KiB, enforced both viaContent-Lengthand during the read so a chunked body can't bypass it. A response whose final request URI landed on a different host than the handle is ignored rather than trusted, so a hostile handle domain can't redirect resolution at an arbitrary host and have that host's DID accepted. Same-host redirects (http→https, trailing slash) still resolve, and the comparison usesUri.Hoston both sides so punycode/IDN handles aren't falsely rejected.Verification
Installed the .NET 10 SDK in the container (it wasn't present) and ran the canonical check:
dotnet build— 0 errors, no new warnings on any touched file.dotnet test tests/ATProtoNet.Tests/— 934 passed, 0 failed, including 13 new tests inHandleResolutionTestsandAtProtoOAuthServiceHttpClientTests.The new tests reproduce your scenario against a scripted handler: the well-known request hangs until cancelled while DNS answers instantly. The whole
HandleResolutionTestsclass finishes in ~1 s — before the fix those cases block on theHttpClienttimeout. Also covered: everything hanging fails within the budget rather than after 100 s, caller cancellation surfaces asOperationCanceledException(not a swallowed fallthrough), authoritative resolution returns the DNS-only answer when HTTPS is dead, cross-host-redirect and oversized-body responses (with and withoutContent-Length) are ignored, and the caller-suppliedHttpClientis used for discovery and survivesservice.Dispose().Not verified end-to-end against a real parked domain — no live PDS or outbound
:443blackhole in this container, so the timing evidence is mock-transport.CHANGELOG.md(Added / Fixed / Security under[Unreleased]) anddocs/blazor.mdare updated. The Ponente workaround should be safe to delete once this ships, though your outerStartLoginAsyncdeadline is still worth keeping as a backstop for the non-resolution steps.⏱ 10m 43s · 45 turns · tokens: 99.9k in / 42.8k out (+3375.4k cache-read)
📜 Full session transcript
→ Opened PR: #53