fix(onramp): bind Coinbase session token to caller IP#310
Conversation
Pass `request.ip` as `clientIp` to the Coinbase create-session-token API so the resulting Onramp session is bound to the requesting client. Coinbase documents this parameter as ensuring the quote can only be used by the requesting user. Stopgap addressing the unauthenticated `/api/v1/onramp/token` endpoint allowing arbitrary callers to mint Coinbase sessions for arbitrary Stellar destinations under SDF's API credentials. Full ownership-proof mitigation (wallet-signature challenge / Origin + per-install HMAC) is tracked separately. Relies on `FREIGHTER_TRUST_PROXY_RANGE` matching the actual upstream proxy CIDR (currently the EKS pod range). A code comment flags the invariant for future maintainers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Binds Coinbase Onramp session tokens to the requesting client by forwarding the caller IP (request.ip) as clientIp into Coinbase’s create-session-token request.
Changes:
- Forward
request.ipfromPOST /api/v1/onramp/tokenintofetchOnrampSessionTokenasclientIp. - Include
clientIpin the Coinbase/onramp/v1/tokenrequest body when present. - Add/extend Jest coverage to ensure
clientIpis forwarded and conditionally omitted.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| src/route/index.ts | Captures request.ip and forwards it to the onramp helper for Coinbase session binding. |
| src/route/index.test.ts | Asserts the route forwards a clientIp string into fetchOnrampSessionToken. |
| src/helper/onramp.ts | Extends helper signature and request body to optionally include clientIp. |
| src/helper/onramp.test.ts | New unit tests validating clientIp inclusion/omission behavior in request body. |
Comments suppressed due to low confidence (1)
src/helper/onramp.ts:59
fetchOnrampSessionTokendefinescoinbaseConfigwith an inline object type even though this module already exports aCoinbaseConfiginterface. ReusingCoinbaseConfighere would avoid type duplication and prevent future drift if the config shape changes.
coinbaseConfig: {
coinbaseApiKey: string;
coinbaseApiSecret: string;
};
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Logs clientIp alongside x-forwarded-for, x-real-ip, and the socket remote address so we can verify the trustProxy chain resolves to the real client IP. To be removed once verified in staging/prod. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs surfaced once the diagnostic log landed: 1. request.ip was resolving to the nginx-ingress pod IP (172.22.x.x) because FREIGHTER_TRUST_PROXY_RANGE (172.23.0.0/16) doesn't cover the actual proxy CIDR. We were forwarding an RFC1918 address as clientIp to Coinbase, which rejected it as invalid. 2. The route handler destructured `error` from the helper result, but the helper put the error message inside `data` on 4xx — so the route surfaced "Unable to retrieve token: undefined" with no detail. Fixes: - Add isLikelyInternalIp helper; drop clientIp and log a warn when request.ip is loopback/RFC1918/link-local. Endpoint stays functional if the trust chain is misconfigured; the warn signals the misconfig. - Move 4xx error to top-level of the helper return shape and include Coinbase's actual response body, so route logs say what's wrong. Removes the temporary onramp.token info-log; the warn-only-on-misconfig path replaces it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
| try { | ||
| detail = JSON.stringify(await res.json()); | ||
| } catch { | ||
| try { |
There was a problem hiding this comment.
Can you explain this piece?
The response body is a stream so it only should throw when its been consumed, in that case await res.text() should also throw and would leave detail to be an empty string. I would think it would be more like -
let detail = "";
try {
const text = await res.text();
try {
detail = JSON.stringify(JSON.parse(text));
} catch {
detail = text; // not JSON — use the raw body
}
} catch {
// body genuinely unreadable (network / decode error)
}
but I'm surprised we would need to handle potential non JSON bodies from this API.
There was a problem hiding this comment.
ah yeah - you're right. Let me clean this up
There was a problem hiding this comment.
Done — pushed in 190933d. Agreed on both points: the inner res.text() was dead code (body already consumed by res.json()), and Coinbase always returns JSON so we don't need the parse-normalize roundtrip. Just await res.text() once.
| export const isLikelyInternalIp = (ip: string): boolean => { | ||
| if (!ip) return true; | ||
| if (ip === "::1" || ip.startsWith("127.")) return true; | ||
| return /(?:^|:)(10\.|192\.168\.|169\.254\.|172\.(?:1[6-9]|2[0-9]|3[01])\.)/.test( |
There was a problem hiding this comment.
we can use proxy-addr which is already in the project in order to not have to rely on a hand rolled regex here.
There was a problem hiding this comment.
Done — pushed in 190933d. Switched to proxyaddr.compile(["loopback", "linklocal", "uniquelocal"]). Bonus: it correctly covers IPv6 link-local (fe80::/10) and unique-local (fc00::/7), which the regex missed, and handles IPv4-mapped IPv6 via the library's normalization. Tests extended for those cases.
- Replace hand-rolled regex in isLikelyInternalIp with proxy-addr's pre-defined classifications (loopback, linklocal, uniquelocal). Now correctly covers IPv6 link-local (fe80::/10) and unique-local (fc00::/7) ranges that the regex missed, and properly handles IPv4-mapped IPv6 via the library's normalization. proxy-addr is already a dep via Fastify. - Drop the dead res.text() fallback in fetchOnrampSessionToken's 4xx handler. The Fetch body is a stream — once res.json() reads bytes, res.text() in the catch would throw "body already used", so the fallback never ran. Coinbase always returns JSON, so reading body as text once is sufficient. - Extend tests to cover IPv6 link-local, unique-local, and IPv4-mapped IPv6 cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(onramp): fail-closed when client IP cannot be determined Follow-up to #310. The IP-binding stopgap silently degraded to "no binding" on default deployments because FREIGHTER_TRUST_PROXY_RANGE had no default and the handler dropped clientIp + proceeded when request.ip resolved to an internal address. This change makes the endpoint fail-closed and tightens the surrounding surface: - Handler returns 400 "Could not determine client IP for Coinbase session binding" when request.ip is internal, instead of dropping clientIp and minting an unbound session. - FREIGHTER_TRUST_PROXY_RANGE defaults to "loopback,linklocal, uniquelocal" when the value is empty (the env var must still be declared per ENV_KEYS; .env-EXAMPLE already declares it empty). - initApiServer throws at boot if the var is non-empty but parses to no subnets (whitespace-only, ", , ,", etc.). - clientIp is now required on fetchOnrampSessionToken; the conditional spread that omitted it when undefined is removed. Locks the IP-binding invariant at the type level so future callers can't re-introduce the unbound-token bug. - Outbound Coinbase fetch gets a 10s AbortSignal.timeout to prevent slow-Coinbase amplification of resource consumption. - Per-route rate limit on /onramp/token tightened to 20/min/IP (vs the global 100/min/IP); inherits the global Redis store. Tests cover the fail-closed 400, the AbortSignal attachment, and the boot-time validation throw. README documents the env-var declaration requirement and the local-dev workflow (curl + XFF header, or a Caddy reverse proxy for the Freighter extension). docs/runbook.md updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: remove FREIGHTER_TRUST_PROXY_RANGE from ENV_KEYS Address Copilot review feedback on PR #311. The ENV_KEYS presence check uses `process.env[key]` (truthy), so an empty-string value in a k8s ConfigMap or shell env counts as "missing" and throws INVALID_ENV — the new default in buildConfig was unreachable for any deployment that didn't put the key in a parsed .env file. Removing the key from ENV_KEYS lets the buildConfig default fall through naturally: - Unset entirely (no .env, no process.env) → default applies. - Declared empty in any source → default applies. - Declared with a value → that value used. Updates README.md and docs/runbook.md to drop the now-incorrect "must be declared" framing and describe the var as optional with a documented default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: add trust-chain configuration decision guide to README Address belt-and-suspenders concern around suggestion #3 from the security report (boot fail-fast for unset trust range). Boot fail-fast is impractical without operational fragility, so document the topology decisions and required observability instead: - Decision table mapping common deployment topologies (k8s+LB, CDN, service mesh, bare metal) to recommended FREIGHTER_TRUST_PROXY_RANGE values, with rationale for each. - Specifically calls out that the default does NOT trust CDN IPs, so CDN-fronted deployers must configure explicit CIDRs to avoid binding sessions to shared CDN egress IPs. - Required observability checklist: alert on the "Refusing to issue an unbound Coinbase session" warning log and on /onramp/token 4xx rate, plus Coinbase session-redemption-failure rate for CDN-fronted setups. - Links to wallet-eng-runbooks Cause D runbook for diagnosis. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: genericize trust-chain configuration guide for non-SDF operators Address review feedback on PR #311 from @aristidesstaffieri — the docs included references to SDF-specific infrastructure (the wallet-eng-runbooks alert URL, "SDF prod uses this shape") that aren't useful to general operators running their own freighter-backend deployment. - Decision table row about explicit-CIDR config now describes the use case ("you know your VPC CIDR exactly and want to reject other private ranges") instead of pointing to SDF prod as an example. - Observability section dropped the SDF-specific Prometheus alert link and rewrote each bullet to describe what to alert on, what signal to inspect, and how to interpret it — generic enough for any deployer regardless of their monitoring stack. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
request.ipasclientIpto Coinbase's create-session-token API so each Onramp session is bound to the requesting client.POST /api/v1/onramp/tokenis unauthenticated and currently mints sessions under SDF's Coinbase credentials for any attacker-supplied Stellar address with no client/session binding. Full ownership-proof mitigation (wallet-signature challenge or Origin + per-install HMAC) is tracked separately.clientIpas: "The client IP address of the end user. This parameter ensures the quote can only be used by the requesting user."Why this is the right stopgap
clientIpas part of the missing-binding finding.clientIpis undocumented; verification (see test plan) determines the post-fix severity.This PR includes a
logger.warn(...)block in the onramp route handler that fires wheneverrequest.ipresolves to a private/internal address. It is intentionally left in until we verify the trust chain end-to-end in production, then removed in a follow-up PR.Reason it has to stay in for prod validation: staging cannot fully validate this fix. Staging traffic enters via
ingress-private, which does not setuse-forwarded-headerson nginx and does not enablepreserve_client_ipon the NLB — so the original client IP is dropped at the NLB before it ever reaches freighter-backend. Production usesingress-public, which has both settings configured correctly. We need the warn-log present in prod logs to confirmrequest.ipresolves to a real external IP after rollout.Cleanup PR will follow once verification is complete (warn rate goes to ~0 in prod and onramp 4xx rate is normal).
Companion change (must merge first / land together)
stellar/kube#4310: widens
FREIGHTER_TRUST_PROXY_RANGEfrom172.23.0.0/16to172.16.0.0/12across prod/staging/dev. The previous /16 did not actually cover the nginx-ingress pod CIDR (172.22.x.xobserved in staging logs), so Fastify treated nginx as untrusted and refused to consumeX-Forwarded-For. Without that kube change, this PR'sclientIpwould always be the proxy's private IP and Coinbase would 4xx every request — the in-code private-IP guard here keeps the endpoint functional in that misconfigured state.Defense in depth in this PR
isLikelyInternalIp): ifrequest.ipresolves to RFC1918/loopback/link-local, dropclientIpand warn. Endpoint stays functional even when the trust chain is misconfigured; the warn is the forcing function to fix it.Unable to retrieve token: undefinedon Coinbase 4xx because the helper put the error insidedatainstead of at top level. Fixed; route logs now include Coinbase's response body.Test plan
src/helper/onramp.test.ts(new) —fetchOnrampSessionToken: forwardsclientIpwhen present, omits when undefined/empty, surfaces Coinbase 4xx body on top-levelerror.src/helper/onramp.test.ts(new) —isLikelyInternalIp: parametrized over RFC1918, loopback, link-local, and public IPs.src/route/index.test.ts— onramp route test asserts the handler dropsclientIpfor loopback test traffic and would forward it for public IPs.npx tsc --noEmitclean.pay.coinbase.comURL from a different IP (phone hotspot/VPN), observe whether Coinbase blocks or completes. Hard enforcement → severity downgrades to Low; soft scoring → severity stays Medium with mitigations in place. Coordinate with@stellar/ops-teamdue to the existingfreighter-backend-onramp-token-error-rate-highalert.logger.warndiagnostic once prod is verified clean.🤖 Generated with Claude Code