Skip to content

fix(onramp): bind Coinbase session token to caller IP#310

Merged
piyalbasu merged 4 commits into
mainfrom
security/onramp-clientip-binding
May 1, 2026
Merged

fix(onramp): bind Coinbase session token to caller IP#310
piyalbasu merged 4 commits into
mainfrom
security/onramp-clientip-binding

Conversation

@piyalbasu
Copy link
Copy Markdown
Contributor

@piyalbasu piyalbasu commented Apr 29, 2026

Summary

  • Pass request.ip as clientIp to Coinbase's create-session-token API so each Onramp session is bound to the requesting client.
  • Stopgap for an issue flagging that POST /api/v1/onramp/token is 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.
  • Coinbase documents clientIp as: "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

  • Highest-leverage change available without UX cost — no signing, no schema change, no extension release.
  • The reporter explicitly called out the absence of clientIp as part of the missing-binding finding.
  • Coinbase's hard- vs soft-enforcement of clientIp is undocumented; verification (see test plan) determines the post-fix severity.

⚠️ Reviewer note: temporary diagnostic logging is intentional

This PR includes a logger.warn(...) block in the onramp route handler that fires whenever request.ip resolves 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 set use-forwarded-headers on nginx and does not enable preserve_client_ip on the NLB — so the original client IP is dropped at the NLB before it ever reaches freighter-backend. Production uses ingress-public, which has both settings configured correctly. We need the warn-log present in prod logs to confirm request.ip resolves 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_RANGE from 172.23.0.0/16 to 172.16.0.0/12 across prod/staging/dev. The previous /16 did not actually cover the nginx-ingress pod CIDR (172.22.x.x observed in staging logs), so Fastify treated nginx as untrusted and refused to consume X-Forwarded-For. Without that kube change, this PR's clientIp would 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

  • Private-IP guard (isLikelyInternalIp): if request.ip resolves to RFC1918/loopback/link-local, drop clientIp and warn. Endpoint stays functional even when the trust chain is misconfigured; the warn is the forcing function to fix it.
  • Surface Coinbase's actual error: previously, the route returned Unable to retrieve token: undefined on Coinbase 4xx because the helper put the error inside data instead of at top level. Fixed; route logs now include Coinbase's response body.

Test plan

  • src/helper/onramp.test.ts (new) — fetchOnrampSessionToken: forwards clientIp when present, omits when undefined/empty, surfaces Coinbase 4xx body on top-level error.
  • 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 drops clientIp for loopback test traffic and would forward it for public IPs.
  • npx tsc --noEmit clean.
  • Post-deploy prod smoke check: confirm warn-log rate goes to ~0 and onramp 4xx rate is normal after rollout. (Cannot validate in staging — see reviewer note above.)
  • Prod IP-mismatch verification: mint a token from one IP via prod, open the resulting pay.coinbase.com URL 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-team due to the existing freighter-backend-onramp-token-error-rate-high alert.
  • Cleanup PR: remove the logger.warn diagnostic once prod is verified clean.

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings April 29, 2026 20:32
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ip from POST /api/v1/onramp/token into fetchOnrampSessionToken as clientIp.
  • Include clientIp in the Coinbase /onramp/v1/token request body when present.
  • Add/extend Jest coverage to ensure clientIp is 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

  • fetchOnrampSessionToken defines coinbaseConfig with an inline object type even though this module already exports a CoinbaseConfig interface. Reusing CoinbaseConfig here 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.

piyalbasu and others added 2 commits April 29, 2026 17:33
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>
@piyalbasu
Copy link
Copy Markdown
Contributor Author

Code review

No 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 👎.

Comment thread src/helper/onramp.ts Outdated
try {
detail = JSON.stringify(await res.json());
} catch {
try {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah yeah - you're right. Let me clean this up

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/helper/onramp.ts Outdated
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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can use proxy-addr which is already in the project in order to not have to rely on a hand rolled regex here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@piyalbasu piyalbasu merged commit b80295c into main May 1, 2026
6 checks passed
@piyalbasu piyalbasu deleted the security/onramp-clientip-binding branch May 1, 2026 17:35
piyalbasu added a commit that referenced this pull request May 6, 2026
* 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>
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.

3 participants