OAuth handle resolution stalls 100 s when the handle's domain drops packets on :443 #52

Closed
opened 2026-07-25 01:06:49 +00:00 by Grandiras · 3 comments
Owner

Symptom

Starting the OAuth flow for a handle whose domain silently drops packets on port 443 blocks for 100 seconds (the HttpClient default 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.Blazor 0.4.0, signing in as grandiras.net:

$ time curl -s -D - "http://127.0.0.1:8090/atproto/login?handle=grandiras.net"
HTTP/1.1 302 Found
Location: https://pds.grandiras.net/oauth/authorize?request_uri=...

real    1m40.624s

The flow does eventually succeed — the DNS TXT record resolves fine — it just does so 100 s late.

Cause

Two things combine in AuthorizationServerDiscovery:

  1. ResolveHandleToDidAsync tries 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 the bsky.social appview. The DNS fallback is not attempted until the HTTPS attempt has fully failed.

  2. No per-attempt timeout, and the HttpClient has no Timeout configured. AtProtoOAuthService.GetOrCreateClient (src/ATProtoNet.Blazor/Authentication/AtProtoOAuthService.cs:105) does a bare new HttpClient(), so every request inherits the 100 s default. Nothing in the SDK sets Timeout.

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.net resolves to a parked IP that drops :443, while _atproto.grandiras.net TXT answers instantly with did=did:plc:....

The same shape applies to ResolveHandleAuthoritativeAsync: it does start HTTPS and DNS concurrently, but then await Task.WhenAll(httpsTask, dnsTask), so a dead HTTPS host still stalls it for 100 s.

Suggested fix

  • Give handle resolution a short per-attempt budget (a few seconds) with its own linked CancellationTokenSource, independent of the ambient token, so one dead authority cannot dominate the flow.
  • Race HTTPS and DNS in ResolveHandleToDidAsync instead of trying them in sequence — either first-success-wins, or WhenAll under a short shared deadline as ResolveHandleAuthoritativeAsync does.
  • Set a sane default HttpClient.Timeout where the SDK constructs its own clients, and/or let callers supply the HttpClient (AtProtoOAuthServerOptions currently has no hook for this, so a consuming app cannot fix the timeout itself).

Also worth considering while in there

ResolveHandleViaHttpsAsync follows redirects with default settings and buffers the full response before checking the did: 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. Capping MaxResponseContentBufferSize and 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 pdsUrl shortcut would disable. The whole StartLoginAsync call is also wrapped in a deadline. That workaround can be deleted once this is fixed.

## Symptom Starting the OAuth flow for a handle whose domain silently drops packets on port 443 blocks for **100 seconds** (the `HttpClient` default 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.Blazor` 0.4.0, signing in as `grandiras.net`: ``` $ time curl -s -D - "http://127.0.0.1:8090/atproto/login?handle=grandiras.net" HTTP/1.1 302 Found Location: https://pds.grandiras.net/oauth/authorize?request_uri=... real 1m40.624s ``` The flow *does* eventually succeed — the DNS TXT record resolves fine — it just does so 100 s late. ## Cause Two things combine in `AuthorizationServerDiscovery`: 1. **`ResolveHandleToDidAsync` tries 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 the `bsky.social` appview. The DNS fallback is not attempted until the HTTPS attempt has fully failed. 2. **No per-attempt timeout, and the `HttpClient` has no `Timeout` configured.** `AtProtoOAuthService.GetOrCreateClient` (`src/ATProtoNet.Blazor/Authentication/AtProtoOAuthService.cs:105`) does a bare `new HttpClient()`, so every request inherits the 100 s default. Nothing in the SDK sets `Timeout`. 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.net` resolves to a parked IP that drops :443, while `_atproto.grandiras.net` TXT answers instantly with `did=did:plc:...`. The same shape applies to `ResolveHandleAuthoritativeAsync`: it *does* start HTTPS and DNS concurrently, but then `await Task.WhenAll(httpsTask, dnsTask)`, so a dead HTTPS host still stalls it for 100 s. ## Suggested fix - Give handle resolution a short per-attempt budget (a few seconds) with its own linked `CancellationTokenSource`, independent of the ambient token, so one dead authority cannot dominate the flow. - Race HTTPS and DNS in `ResolveHandleToDidAsync` instead of trying them in sequence — either first-success-wins, or `WhenAll` under a short shared deadline as `ResolveHandleAuthoritativeAsync` does. - Set a sane default `HttpClient.Timeout` where the SDK constructs its own clients, and/or let callers supply the `HttpClient` (`AtProtoOAuthServerOptions` currently has no hook for this, so a consuming app cannot fix the timeout itself). ## Also worth considering while in there `ResolveHandleViaHttpsAsync` follows redirects with default settings and buffers the full response before checking the `did:` 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. Capping `MaxResponseContentBufferSize` and 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 `pdsUrl` shortcut would disable. The whole `StartLoginAsync` call is also wrapped in a deadline. That workaround can be deleted once this is fixed.
Author
Owner

@claude-bot Take a shot

@claude-bot Take a shot
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/22); the reply will link the full session transcript.
Collaborator

Pushed claude/issue-52 (commit 7aeb1a5).

What changed

1. Handle resolution now has its own budgetAuthorizationServerDiscovery.HandleResolutionTimeout (default 5 s, exposed as DefaultHandleResolutionTimeout) bounds each resolution round with a CancellationTokenSource linked 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.InfiniteTimeSpan restores the old unbounded behaviour.

2. HTTPS and DNS are racedResolveHandleToDidAsync starts the well-known lookup and the DoH TXT lookup together and returns the first DID (losing lookup is cancelled and observed). The bsky.social appview fallback gets a fresh budget so a slow handle domain can't eat its time. ResolveHandleAuthoritativeAsync keeps its fail-closed WhenAll semantics — 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 appsAtProtoOAuthServerOptions gains HttpClient (caller-supplied: Timeout untouched, not disposed with the service, User-Agent only set if absent), HttpClientTimeout (30 s, applied to the SDK-created client — this was the bare new HttpClient() at AtProtoOAuthService.cs:105), and HandleResolutionTimeout. OAuthOptions.HandleResolutionTimeout covers core-SDK callers. DidWebResolver's parameterless ctor now uses 10 s.

I deliberately left AtProtoClient's own HttpClient at 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 ResponseHeadersRead and capped at 1 KiB, enforced both via Content-Length and 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 uses Uri.Host on 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 in HandleResolutionTests and AtProtoOAuthServiceHttpClientTests.

The new tests reproduce your scenario against a scripted handler: the well-known request hangs until cancelled while DNS answers instantly. The whole HandleResolutionTests class finishes in ~1 s — before the fix those cases block on the HttpClient timeout. Also covered: everything hanging fails within the budget rather than after 100 s, caller cancellation surfaces as OperationCanceledException (not a swallowed fallthrough), authoritative resolution returns the DNS-only answer when HTTPS is dead, cross-host-redirect and oversized-body responses (with and without Content-Length) are ignored, and the caller-supplied HttpClient is used for discovery and survives service.Dispose().

Not verified end-to-end against a real parked domain — no live PDS or outbound :443 blackhole in this container, so the timing evidence is mock-transport. CHANGELOG.md (Added / Fixed / Security under [Unreleased]) and docs/blazor.md are updated. The Ponente workaround should be safe to delete once this ships, though your outer StartLoginAsync deadline 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

Pushed **`claude/issue-52`** (commit `7aeb1a5`). ## What changed **1. Handle resolution now has its own budget** — `AuthorizationServerDiscovery.HandleResolutionTimeout` (default 5 s, exposed as `DefaultHandleResolutionTimeout`) bounds each resolution round with a `CancellationTokenSource` linked 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.InfiniteTimeSpan` restores the old unbounded behaviour. **2. HTTPS and DNS are raced** — `ResolveHandleToDidAsync` starts the well-known lookup and the DoH TXT lookup together and returns the first DID (losing lookup is cancelled and observed). The `bsky.social` appview fallback gets a *fresh* budget so a slow handle domain can't eat its time. `ResolveHandleAuthoritativeAsync` keeps its fail-closed `WhenAll` semantics — 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** — `AtProtoOAuthServerOptions` gains `HttpClient` (caller-supplied: `Timeout` untouched, not disposed with the service, `User-Agent` only set if absent), `HttpClientTimeout` (30 s, applied to the SDK-created client — this was the bare `new HttpClient()` at `AtProtoOAuthService.cs:105`), and `HandleResolutionTimeout`. `OAuthOptions.HandleResolutionTimeout` covers core-SDK callers. `DidWebResolver`'s parameterless ctor now uses 10 s. I deliberately left `AtProtoClient`'s own `HttpClient` at 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 `ResponseHeadersRead` and capped at 1 KiB, enforced both via `Content-Length` and 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 uses `Uri.Host` on 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 in `HandleResolutionTests` and `AtProtoOAuthServiceHttpClientTests`. The new tests reproduce your scenario against a scripted handler: the well-known request hangs until cancelled while DNS answers instantly. The whole `HandleResolutionTests` class finishes in ~1 s — before the fix those cases block on the `HttpClient` timeout. Also covered: everything hanging fails within the budget rather than after 100 s, caller cancellation surfaces as `OperationCanceledException` (not a swallowed fallthrough), authoritative resolution returns the DNS-only answer when HTTPS is dead, cross-host-redirect and oversized-body responses (with and without `Content-Length`) are ignored, and the caller-supplied `HttpClient` is used for discovery and survives `service.Dispose()`. Not verified end-to-end against a real parked domain — no live PDS or outbound `:443` blackhole in this container, so the timing evidence is mock-transport. `CHANGELOG.md` (Added / Fixed / Security under `[Unreleased]`) and `docs/blazor.md` are updated. The Ponente workaround should be safe to delete once this ships, though your outer `StartLoginAsync` deadline 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](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-52/run-22.md) → Opened PR: https://git.grandiras.net/Grandiras/ATProto.NET/pulls/53
Grandiras referenced this issue from a commit 2026-07-25 22:39:22 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/ATProto.NET#52
No description provided.