diff --git a/node/coinstacks/common/api/src/evm/blockbookService.ts b/node/coinstacks/common/api/src/evm/blockbookService.ts index ba885cfa0..13615f723 100644 --- a/node/coinstacks/common/api/src/evm/blockbookService.ts +++ b/node/coinstacks/common/api/src/evm/blockbookService.ts @@ -5,7 +5,7 @@ import BigNumber from 'bignumber.js' import { erc1155Abi, erc721Abi, getAddress, getContract, isHex, parseUnits, PublicClient, toHex } from 'viem' import type { BadRequestError, BaseAPI, EstimateGasBody, RPCRequest, RPCResponse, SendTxBody } from '..' import { ApiError } from '..' -import { createAxiosRetry, exponentialDelay, handleError, rpcId, validatePageSize } from '../utils' +import { assertSafeOutboundUrl, createAxiosRetry, exponentialDelay, handleError, rpcId, validatePageSize } from '../utils' import type { Account, API, @@ -824,7 +824,11 @@ export class BlockbookService implements Omit, API { try { // attempt to get metadata using hex encoded id as per erc spec - const { data } = await axiosNoRetry.get(makeUrl(substitue(uri, id, true))) + // uri is derived from on-chain tokenURI(), which is attacker-controllable; validate + // before fetching and disable redirects to prevent SSRF into the internal network. + const hexUrl = makeUrl(substitue(uri, id, true)) + await assertSafeOutboundUrl(hexUrl) + const { data } = await axiosNoRetry.get(hexUrl, { maxRedirects: 0 }) return data } catch (err) { // don't retry on timeout, assume host is offline @@ -832,7 +836,9 @@ export class BlockbookService implements Omit, API { try { // not everyone follows the spec, attempt to get metadata using id string - const { data } = await axiosNoRetry.get(makeUrl(substitue(uri, id, false))) + const strUrl = makeUrl(substitue(uri, id, false)) + await assertSafeOutboundUrl(strUrl) + const { data } = await axiosNoRetry.get(strUrl, { maxRedirects: 0 }) return data } catch (err) { // swallow error and return empty object if unable to fetch metadata @@ -847,7 +853,8 @@ export class BlockbookService implements Omit, API { if (!mediaUrl) return try { - const { headers } = await axiosNoRetry.head(mediaUrl) + await assertSafeOutboundUrl(mediaUrl) + const { headers } = await axiosNoRetry.head(mediaUrl, { maxRedirects: 0 }) return headers['content-type']?.includes('video') ? 'video' : 'image' } catch (err) { return diff --git a/node/coinstacks/common/api/src/evm/moralisService.ts b/node/coinstacks/common/api/src/evm/moralisService.ts index eede43f7b..2bc6f429b 100644 --- a/node/coinstacks/common/api/src/evm/moralisService.ts +++ b/node/coinstacks/common/api/src/evm/moralisService.ts @@ -9,7 +9,7 @@ import PQueue from 'p-queue' import { getAddress, isHex, parseUnits, PublicClient, toHex } from 'viem' import type { BaseAPI, EstimateGasBody, RPCRequest, RPCResponse, SendTxBody } from '..' import { ApiError, BadRequestError } from '..' -import { createAxiosRetry, exponentialDelay, handleError, rpcId, validatePageSize } from '../utils' +import { assertSafeOutboundUrl, createAxiosRetry, exponentialDelay, handleError, rpcId, validatePageSize } from '../utils' import type { Account, API, Tx, TxHistory, GasFees, InternalTx, GasEstimate, TokenMetadata } from './models' import { Fees, TokenBalance, TokenTransfer, TokenType } from './models' import type { BlockNativeResponse, ExplorerApiResponse, ExplorerInternalTxByAddress, TraceCall } from './types' @@ -789,7 +789,10 @@ export class MoralisService implements Omit, API, AddressSub if (!mediaUrl) return try { - const { headers } = await axiosNoRetry.head(mediaUrl) + // mediaUrl is derived from attacker-controllable NFT metadata, so validate it before + // fetching and disable redirects to prevent SSRF into the internal network. + await assertSafeOutboundUrl(mediaUrl) + const { headers } = await axiosNoRetry.head(mediaUrl, { maxRedirects: 0 }) return headers['content-type']?.includes('video') ? 'video' : 'image' } catch (err) { return diff --git a/node/coinstacks/common/api/src/utils.ts b/node/coinstacks/common/api/src/utils.ts index 2f8d3c9b5..5334fbb28 100644 --- a/node/coinstacks/common/api/src/utils.ts +++ b/node/coinstacks/common/api/src/utils.ts @@ -2,6 +2,8 @@ import { ApiError as BlockbookApiError } from '@shapeshiftoss/blockbook' import { ApiError } from '.' import axios, { CreateAxiosDefaults, isAxiosError } from 'axios' import axiosRetry, { isNetworkOrIdempotentRequestError } from 'axios-retry' +import { promises as dns } from 'dns' +import { isIP } from 'net' const MAX_PAGE_SIZE = 100 @@ -71,3 +73,54 @@ export const rpcId = (): number => { if (_rpcId === 0) _rpcId = 1 return _rpcId } + +const isPrivateIPv4 = (ip: string): boolean => { + const parts = ip.split('.').map(Number) + if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true + const [a, b] = parts + if (a === 0) return true // 0.0.0.0/8 "this network" + if (a === 10) return true // 10.0.0.0/8 private + if (a === 127) return true // 127.0.0.0/8 loopback + if (a === 169 && b === 254) return true // 169.254.0.0/16 link-local + cloud metadata + if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12 private + if (a === 192 && b === 168) return true // 192.168.0.0/16 private + if (a === 100 && b >= 64 && b <= 127) return true // 100.64.0.0/10 CGNAT + if (a >= 224) return true // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved + return false +} + +const isPrivateIPv6 = (ip: string): boolean => { + const v = ip.toLowerCase() + if (v === '::1' || v === '::') return true // loopback / unspecified + if (v.startsWith('fe80:')) return true // link-local + if (v.startsWith('fc') || v.startsWith('fd')) return true // fc00::/7 unique-local + const mapped = v.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/) // IPv4-mapped + if (mapped) return isPrivateIPv4(mapped[1]) + return false +} + +// assertSafeOutboundUrl validates a caller-influenced URL before the server fetches it, to prevent +// SSRF: it allows only http(s), resolves the host, and rejects any private/loopback/link-local/CGNAT +// or cloud-metadata destination. Pair with `maxRedirects: 0` on the request so a public host can't +// 3xx-bounce to an internal one after this check. +export const assertSafeOutboundUrl = async (rawUrl: string): Promise => { + let url: URL + try { + url = new URL(rawUrl) + } catch { + throw new ApiError('Bad Request', 400, `invalid outbound url: ${rawUrl}`) + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new ApiError('Bad Request', 400, `unsupported outbound url scheme: ${url.protocol}`) + } + + const host = url.hostname.replace(/^\[|\]$/g, '') // strip brackets from IPv6 literals + + const addresses = isIP(host) ? [host] : (await dns.lookup(host, { all: true })).map((r) => r.address) + + for (const address of addresses) { + const blocked = isIP(address) === 6 ? isPrivateIPv6(address) : isPrivateIPv4(address) + if (blocked) throw new ApiError('Bad Request', 400, `blocked outbound url host: ${host}`) + } +}