diff --git a/src/helper/onramp.test.ts b/src/helper/onramp.test.ts new file mode 100644 index 0000000..7e9c032 --- /dev/null +++ b/src/helper/onramp.test.ts @@ -0,0 +1,121 @@ +import jwt from "jsonwebtoken"; +import { fetchOnrampSessionToken, isLikelyInternalIp } from "./onramp"; + +const coinbaseConfig = { + coinbaseApiKey: "test-key", + coinbaseApiSecret: "test-secret", +}; + +describe("fetchOnrampSessionToken", () => { + let fetchMock: jest.Mock; + + beforeEach(() => { + jest.spyOn(jwt, "sign").mockReturnValue("test-jwt" as any); + fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: "test-token" }), + }); + global.fetch = fetchMock as any; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("forwards clientIp into the Coinbase request body when provided", async () => { + await fetchOnrampSessionToken({ + address: "GFOO", + clientIp: "203.0.113.42", + coinbaseConfig, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, options] = fetchMock.mock.calls[0]; + const body = JSON.parse(options.body); + + expect(body).toEqual({ + addresses: [ + { address: "GFOO", blockchains: ["stellar"], assets: ["XLM"] }, + ], + clientIp: "203.0.113.42", + }); + }); + + it("omits clientIp from the body when not provided", async () => { + await fetchOnrampSessionToken({ + address: "GFOO", + coinbaseConfig, + }); + + const [, options] = fetchMock.mock.calls[0]; + const body = JSON.parse(options.body); + + expect(body).not.toHaveProperty("clientIp"); + expect(body.addresses).toEqual([ + { address: "GFOO", blockchains: ["stellar"], assets: ["XLM"] }, + ]); + }); + + it("omits clientIp from the body when passed an empty string", async () => { + await fetchOnrampSessionToken({ + address: "GFOO", + clientIp: "", + coinbaseConfig, + }); + + const [, options] = fetchMock.mock.calls[0]; + const body = JSON.parse(options.body); + + expect(body).not.toHaveProperty("clientIp"); + }); + + it("surfaces Coinbase's response body in the top-level error on 4xx", async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 400, + text: async () => '{"error":"invalid clientIp"}', + }); + + const result = await fetchOnrampSessionToken({ + address: "GFOO", + clientIp: "10.0.0.1", + coinbaseConfig, + }); + + expect(result.data.token).toBe(""); + expect(result.error).toMatch(/Coinbase 400/); + expect(result.error).toMatch(/invalid clientIp/); + }); +}); + +describe("isLikelyInternalIp", () => { + it.each([ + ["::1"], + ["127.0.0.1"], + ["10.0.0.1"], + ["10.255.255.255"], + ["172.16.0.1"], + ["172.22.89.212"], + ["172.31.255.255"], + ["192.168.1.1"], + ["169.254.1.1"], + ["fe80::1"], + ["fc00::1"], + ["fd12:3456:789a::1"], + ["::ffff:10.0.0.1"], + [""], + ])("classifies %s as internal", (ip) => { + expect(isLikelyInternalIp(ip)).toBe(true); + }); + + it.each([ + ["8.8.8.8"], + ["203.0.113.42"], + ["172.15.0.1"], + ["172.32.0.1"], + ["1.1.1.1"], + ["2001:db8::1"], + ])("classifies %s as public", (ip) => { + expect(isLikelyInternalIp(ip)).toBe(false); + }); +}); diff --git a/src/helper/onramp.ts b/src/helper/onramp.ts index 67b08aa..a9a2db3 100644 --- a/src/helper/onramp.ts +++ b/src/helper/onramp.ts @@ -1,5 +1,6 @@ import jwt from "jsonwebtoken"; import * as crypto from "crypto"; +import proxyaddr from "proxy-addr"; const requestMethod = "POST"; const requestHost = "api.developer.coinbase.com"; @@ -12,6 +13,22 @@ export interface CoinbaseConfig { coinbaseApiSecret: string; } +// Loopback, link-local, and unique-local (RFC1918 + IPv6 ULA) addresses. +// Used to avoid forwarding intra-cluster IPs to Coinbase when the trustProxy +// chain is misconfigured — Coinbase rejects private addresses, so dropping +// clientIp keeps the endpoint functional while we surface the misconfiguration +// via a warning log at the call site. +const internalAddr = proxyaddr.compile([ + "loopback", + "linklocal", + "uniquelocal", +]); + +export const isLikelyInternalIp = (ip: string): boolean => { + if (!ip) return true; + return internalAddr(ip, 0); +}; + export const generateJWT = ({ coinbaseConfig, }: { @@ -48,9 +65,11 @@ export const generateJWT = ({ export const fetchOnrampSessionToken = async ({ address, + clientIp, coinbaseConfig, }: { address: string; + clientIp?: string; coinbaseConfig: { coinbaseApiKey: string; coinbaseApiSecret: string; @@ -65,6 +84,7 @@ export const fetchOnrampSessionToken = async ({ }, body: JSON.stringify({ addresses: [{ address, blockchains: ["stellar"], assets: ["XLM"] }], + ...(clientIp ? { clientIp } : {}), }), }; const res = await fetch(`https://${requestHost}${requestPath}`, options); @@ -73,7 +93,16 @@ export const fetchOnrampSessionToken = async ({ if (res.status >= 500 && res.status < 600) { throw new Error("Server error when requesting token"); } - return { data: { token: "", error: "Error fetching token request" } }; + let detail = ""; + try { + detail = await res.text(); + } catch { + // body unreadable + } + return { + data: { token: "" }, + error: `Coinbase ${res.status}${detail ? `: ${detail}` : ""}`, + }; } const resJson = await res.json(); diff --git a/src/route/index.test.ts b/src/route/index.test.ts index c0ff36c..cd179f1 100644 --- a/src/route/index.test.ts +++ b/src/route/index.test.ts @@ -1110,14 +1110,16 @@ describe("API routes", () => { }); it("can fetch an onramp token", async () => { - jest.spyOn(OnrampHelpers, "fetchOnrampSessionToken").mockReturnValueOnce( - Promise.resolve({ - data: { - token: "token", - }, - error: null, - }), - ); + const fetchSpy = jest + .spyOn(OnrampHelpers, "fetchOnrampSessionToken") + .mockReturnValueOnce( + Promise.resolve({ + data: { + token: "token", + }, + error: null, + }), + ); const server = await getDevServer(); const url = new URL( @@ -1139,6 +1141,15 @@ describe("API routes", () => { expect(response.status).toEqual(200); expect(resJson.data.token).toEqual("token"); + // Local test traffic comes from a loopback address, which is + // classified as internal and dropped (Coinbase rejects private IPs). + // Real client traffic in prod resolves to a public IP and is forwarded. + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + address: "GFOO", + clientIp: undefined, + }), + ); await server.close(); }); it("does not fetch a token without Coinbase config", async () => { diff --git a/src/route/index.ts b/src/route/index.ts index b6180e9..a216689 100644 --- a/src/route/index.ts +++ b/src/route/index.ts @@ -46,7 +46,11 @@ import { getSdk } from "../helper/stellar"; import { getUseMercury } from "../helper/mercury"; import { getHttpRequestDurationLabels } from "../helper/metrics"; import { mode } from "../helper/env"; -import { fetchOnrampSessionToken, CoinbaseConfig } from "../helper/onramp"; +import { + fetchOnrampSessionToken, + isLikelyInternalIp, + CoinbaseConfig, +} from "../helper/onramp"; import Blockaid from "@blockaid/client"; import { PriceClient } from "../service/prices"; import { TokenPriceData } from "../service/prices/types"; @@ -1475,6 +1479,24 @@ export async function initApiServer( reply, ) => { const { address } = request.body; + // Forwarded to Coinbase to bind the resulting Onramp session to the + // requesting client. Relies on FREIGHTER_TRUST_PROXY_RANGE matching + // the actual upstream proxy CIDR. If request.ip looks like an + // intra-cluster address, the trust chain is misconfigured — drop + // clientIp (Coinbase rejects private addresses) and surface a warn. + const rawIp = request.ip; + const clientIp = isLikelyInternalIp(rawIp) ? undefined : rawIp; + if (!clientIp) { + logger.warn( + { + rawIp, + xff: request.headers["x-forwarded-for"], + xRealIp: request.headers["x-real-ip"], + socketRemote: request.socket.remoteAddress, + }, + "onramp.token: request.ip resolved to private/internal address; FREIGHTER_TRUST_PROXY_RANGE likely misconfigured. Skipping clientIp.", + ); + } if ( !coinbaseConfig.coinbaseApiKey || !coinbaseConfig.coinbaseApiSecret @@ -1485,6 +1507,7 @@ export async function initApiServer( try { const { data, error } = await fetchOnrampSessionToken({ address, + clientIp, coinbaseConfig, });