Skip to content

[ANCHOR-1267]: Unauthenticated client_domain pre-auth thread exhaustion in SEP-10/SEP-45 takes the whole anchor SEP API offline from one host - #1990

Draft
amandagonsalves wants to merge 2 commits into
developfrom
fix/anchor-1267
Draft

[ANCHOR-1267]: Unauthenticated client_domain pre-auth thread exhaustion in SEP-10/SEP-45 takes the whole anchor SEP API offline from one host#1990
amandagonsalves wants to merge 2 commits into
developfrom
fix/anchor-1267

Conversation

@amandagonsalves

@amandagonsalves amandagonsalves commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Description

Sep10Service.createChallenge and both Sep45Service call sites (createArgsFromRequest, verifyArguments) fetch the wallet-supplied client_domain's stellar.toml synchronously, on the servlet request thread, before authorization is checked. The fetch has no bulkhead: ClientDomainHelper.fetchSigningKeyFromClientDomain can block for up to ~30s (a 15s OkHttp call timeout, doubled by the HTTPS→HTTP retry off mainnet), and nothing caps how many of these can run concurrently. /auth and /sep45/auth are both unauthenticated by design (SEP-10 issues the JWT, so it can't require one), and the shipped default (sep10.client_attribution_required: false, and SEP-45 had no allow-list option at all) means any public hostname is accepted with zero preconditions. An unauthenticated attacker pointing client_domain at a host that just never answers can park a servlet worker per request; ~20 req/s (3.2 KB/s) exhausts the default 200-thread Tomcat pool and takes down every SEP endpoint on that connector. The project's own Helm chart makes this worse: livenessProbe/readinessProbe hit /health on the same connector/pool, so starvation escalates to actual pod destruction and a crash-loop that outlives the attack.

Correcting only the allow-list gap (giving SEP-45 the same opt-in client_allow_list SEP-10 already has) does not close this: the DoS doesn't depend on the domain being disallowed. Any allow-listed domain — or any domain the attacker can influence the DNS/routing of — can still park a thread if it's slow to respond. The actual fix has to bound concurrency, not which domains are trusted. So this PR does both: extends the allow-list to SEP-45 (closes the confirmed SSRF-scope gap, report claim (ii)), and moves the client_domain fetch off the servlet thread onto a small, bounded, reject-not-queue executor (closes the thread-exhaustion DoS, report claim (iii), the report's headline finding).

Report claim (i) — that the existing SEP-10 allow-list guard "checks the wrong getter" — is not addressed here because it isn't a bug: the current guard matches the deliberate, already-shipped, already-tested design from the prior SEP-10 SSRF fix (0979bd19, #3824178), which was itself reverted from a stricter draft specifically to avoid breaking operators who configure clients: for unrelated reasons.

Changes

  • ClientDomainHelper.java: added a static, bounded ThreadPoolExecutor (core=4, max=8, keepAlive=60s, SynchronousQueue, AbortPolicy) and a new fetchSigningKeyFromClientDomainBounded(clientDomain, allowHttpRetry) that submits the existing unbounded fetch to it and waits at most 2.5s via Future.get. RejectedExecutionException/TimeoutException (pool full or timed out) and ExecutionException (unwrapped to the original SepException when that's the cause) both surface as a generic SepException("client_domain resolution unavailable") — no behavior change for the normal-latency case, since the timeout is well above any real anchor's expected TOML fetch time.
  • Sep10Service.java: createChallenge and the package-private fetchSigningKeyFromClientDomain wrapper (renamed fetchSigningKeyFromClientDomainBounded) now call the bounded method instead of the direct one.
  • Sep45Service.java: both call sites switched to the bounded method the same way. Added validateClientDomainAllowed(clientDomain), called before either fetch — mirrors Sep10Service.validateChallengeRequestClient's opt-in branch exactly: only enforced when sep45Config.getClientAllowList() is explicitly non-empty, so operators who never set the new field see no behavior change.
  • Sep45Config.java (interface): added getClientAllowList() / getAllowedClientDomains().
  • PropertySep45Config.java: added clientAllowList field and a ClientService dependency (new constructor param); getAllowedClientDomains() derives from the clients: section using the identical logic PropertySep10Config already uses (falls back to all non-custodial clients' domains when the list is unset); validate() rejects any allow-list entry that doesn't name a configured client.
  • SepBeans.java: sep45Config(...) bean now takes and passes ClientService.
  • anchor-config-default-values.yaml / anchor-config-schema-v1.yaml: documented sep45.client_allow_list, mirroring the existing sep10.client_allow_list entry.
  • ClientDomainHelperTest.kt: added tests proving (a) a normal, unsaturated call still fails with the ordinary fetch error, not the bulkhead one, and (b) once the bounded pool's maximumPoolSize is saturated by blocking tasks, a further call rejects in well under the 2.5s bound rather than hanging.
  • Sep10ServiceTest.kt: updated the two references to the renamed fetchSigningKeyFromClientDomainBounded.
  • Sep45ServiceTest.kt: added three tests mirroring Sep10ServiceTest's allow-list coverage — rejects an out-of-list client_domain without ever calling the fetch; allows any client_domain when no explicit list is set; allows an unlisted client_domain when clients: exists only for unrelated configuration.
  • Sep45ConfigTest.kt: updated the PropertySep45Config constructor call for the new ClientService param; added allow-list derivation and validation tests mirroring Sep10ConfigTest.

Acceptance Criteria

  • A client_domain pointed at a host that never responds fails in ~2.5s, not ~15–30s.
  • 20 concurrent requests against such a host never block the servlet/Tomcat thread pool: /health and other SEP endpoints keep responding normally throughout.
  • Of those 20, only as many as the bounded pool's capacity (≤8) actually wait out the 2.5s bound; the rest are rejected immediately.
  • SEP-45 with an explicit sep45.client_allow_list configured rejects a client_domain outside that list with SepNotAuthorizedException, before any outbound fetch is attempted.
  • SEP-45 with no explicit client_allow_list behaves exactly as before this PR (no regression for existing deployments).
  • SEP-10's existing allow-list behavior is unchanged.

Context

HackerOne #3903968

Testing

  • Unit: ./gradlew :core:test --tests "org.stellar.anchor.sep10.Sep10ServiceTest" --tests "org.stellar.anchor.sep45.Sep45ServiceTest" --tests "org.stellar.anchor.util.ClientDomainHelperTest"
  • Unit: ./gradlew :platform:test --tests "org.stellar.anchor.platform.config.Sep45ConfigTest" --tests "org.stellar.anchor.platform.config.Sep10ConfigTest"
  • Full regression: ./gradlew :core:test :platform:test

Documentation

N/A

Known limitations

N/A

* add client domain allow list for sep-45 requests

* update client domain fetching with a bounded executor

  this prevents external calls from hanging indefinitely by enforcing a timeout
* add `client_allow_list` configuration for sep45

* update sep45 challenge generation to enforce client allow list

* refactor client domain signing key fetching with a bounded executor

* add tests for new sep45 rules and executor behavior
@amandagonsalves amandagonsalves self-assigned this Aug 4, 2026
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.

1 participant