Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions src/helper/onramp.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
31 changes: 30 additions & 1 deletion src/helper/onramp.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
}: {
Expand Down Expand Up @@ -48,9 +65,11 @@ export const generateJWT = ({

export const fetchOnrampSessionToken = async ({
address,
clientIp,
coinbaseConfig,
}: {
address: string;
clientIp?: string;
coinbaseConfig: {
coinbaseApiKey: string;
coinbaseApiSecret: string;
Expand All @@ -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);
Expand All @@ -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();
Expand Down
27 changes: 19 additions & 8 deletions src/route/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 () => {
Expand Down
25 changes: 24 additions & 1 deletion src/route/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -1485,6 +1507,7 @@ export async function initApiServer(
try {
const { data, error } = await fetchOnrampSessionToken({
address,
clientIp,
coinbaseConfig,
});

Expand Down
Loading