Skip to content
Closed
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
15 changes: 11 additions & 4 deletions node/coinstacks/common/api/src/evm/blockbookService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -824,15 +824,21 @@ export class BlockbookService implements Omit<BaseAPI, 'getInfo'>, 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
if (err instanceof AxiosError && err.code === AxiosError.ECONNABORTED) return {}

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
Expand All @@ -847,7 +853,8 @@ export class BlockbookService implements Omit<BaseAPI, 'getInfo'>, 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
Expand Down
7 changes: 5 additions & 2 deletions node/coinstacks/common/api/src/evm/moralisService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -789,7 +789,10 @@ export class MoralisService implements Omit<BaseAPI, 'getInfo'>, 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
Expand Down
53 changes: 53 additions & 0 deletions node/coinstacks/common/api/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Comment on lines +92 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Reject hex-form IPv4-mapped IPv6 literals.

Line 97 only catches ::ffff:127.0.0.1-style mapped addresses. Valid literals like ::ffff:7f00:1 still decode to loopback/private IPv4 targets, but isPrivateIPv6() currently treats them as public, so this guard can still be bypassed with a direct IPv6 literal or AAAA answer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@node/coinstacks/common/api/src/utils.ts` around lines 92 - 99, isPrivateIPv6
currently only recognizes IPv4-mapped IPv6 addresses in dotted-decimal form and
ignores hex-form mappings like ::ffff:7f00:1, letting private IPv4 targets slip
through; update the isPrivateIPv6 function to detect IPv4-mapped hex forms (e.g.
::ffff:hhhh:hhhh or ::ffff:hhhh) as well as the dotted-decimal form, decode the
final 32 bits from hex to a dotted IPv4 string, then call
isPrivateIPv4(mappedIPv4) as you do for the dotted form; use a regex that
captures both ::ffff:IPv4 and ::ffff:hex[:hex] variants and convert the hex
groups into the four octets before delegating to isPrivateIPv4.

}

// 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<void> => {
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}`)
}
}
Comment on lines +106 to +126

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Locate usages of assertSafeOutboundUrl
rg -n "assertSafeOutboundUrl" -S . || true

# 2) Inspect the referenced files
for f in node/coinstacks/common/api/src/evm/blockbookService.ts node/coinstacks/common/api/src/evm/moralisService.ts node/coinstacks/common/api/src/utils.ts; do
  echo "----- $f -----"
  if [ -f "$f" ]; then
    wc -l "$f"
    sed -n '1,220p' "$f" | nl -ba | sed -n '1,220p'
  else
    echo "Missing: $f"
  fi
done

# 3) Search for Axios/URL/agent usage near the call sites
for f in node/coinstacks/common/api/src/evm/blockbookService.ts node/coinstacks/common/api/src/evm/moralisService.ts; do
  echo "----- Axios-related in $f -----"
  rg -n "axios\.|Axios|http(s)?Agent|dns|lookup|hostname|host:|connect|net\.|lookup:" "$f" -S || true
done

Repository: shapeshift/unchained

Length of output: 1319


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show relevant sections around the call sites
for f in node/coinstacks/common/api/src/utils.ts \
         node/coinstacks/common/api/src/evm/blockbookService.ts \
         node/coinstacks/common/api/src/evm/moralisService.ts; do
  echo "===== $f ====="
  wc -l "$f"
done

echo "===== blockbookService.ts: around assertSafeOutboundUrl calls ====="
sed -n '760,920p' node/coinstacks/common/api/src/evm/blockbookService.ts | cat -n

echo "===== moralisService.ts: around assertSafeOutboundUrl calls ====="
sed -n '720,860p' node/coinstacks/common/api/src/evm/moralisService.ts | cat -n

echo "===== utils.ts: assertSafeOutboundUrl ====="
sed -n '70,170p' node/coinstacks/common/api/src/utils.ts | cat -n

echo "===== blockbookService.ts: axios creation/config near top ====="
sed -n '1,120p' node/coinstacks/common/api/src/evm/blockbookService.ts | cat -n

echo "===== moralisService.ts: axios creation/config near top ====="
sed -n '1,120p' node/coinstacks/common/api/src/evm/moralisService.ts | cat -n

# Also find axios invocation arguments near those call sites
echo "===== blockbookService.ts: axios usage ====="
rg -n "axios|createAxiosRetry|http[s]?Agent|lookup|hostname|host:|url:" node/coinstacks/common/api/src/evm/blockbookService.ts -S

echo "===== moralisService.ts: axios usage ====="
rg -n "axios|createAxiosRetry|http[s]?Agent|lookup|hostname|host:|url:" node/coinstacks/common/api/src/evm/moralisService.ts -S

Repository: shapeshift/unchained

Length of output: 27124


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "httpsAgent|httpAgent|agent:|lookup:|createConnection|dns\.lookup|family:" node/coinstacks/common/api/src/evm/blockbookService.ts node/coinstacks/common/api/src/evm/moralisService.ts node/coinstacks/common/api/src/utils.ts -S || true

# Also scan axiosNoRetry.get/head call option objects for agent-related keys
rg -n "axiosNoRetry\.(get|head)\([^)]*,\s*\{[^}]*\}" node/coinstacks/common/api/src/evm/blockbookService.ts node/coinstacks/common/api/src/evm/moralisService.ts -S || true

Repository: shapeshift/unchained

Length of output: 776


Bind DNS validation to the outbound connection

  • assertSafeOutboundUrl() performs a DNS lookup and rejects private/loopback destinations, but blockbookService still calls axiosNoRetry.get(hexUrl/strUrl, { maxRedirects: 0 }) and axiosNoRetry.head(mediaUrl, { maxRedirects: 0 }) using the original hostname (e.g., blockbookService.ts around lines 831/841/857); moralisService does the same for mediaUrl (around line 795).
  • These axiosNoRetry clients are created with only a timeout and no custom http(s)Agent/DNS lookup, so the hostname can be re-resolved at socket connect time (DNS rebinding window). maxRedirects: 0 only prevents redirect-based SSRF, not re-DNS.
  • Enforce the resolved address set at request time (e.g., pin to an IP literal and set Host/SNI as needed, or use an agent with a deterministic DNS lookup).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@node/coinstacks/common/api/src/utils.ts` around lines 106 - 126,
assertSafeOutboundUrl currently resolves and validates DNS but the actual HTTP
requests in blockbookService (axiosNoRetry.get called with hexUrl/strUrl) and
moralisService (axiosNoRetry.head called with mediaUrl) still use the original
hostname and can be re-resolved at connect time; fix by binding the validated
resolution to the socket: after calling assertSafeOutboundUrl(resolve) obtain
the resolved IP(s) and either (A) replace the request URL host with the chosen
IP literal and set the Host header (and ensure TLS SNI remains the original
hostname for https requests) or (B) supply a custom http(s).Agent to
axiosNoRetry that implements a deterministic lookup() returning the validated IP
address(es) so the same IPs are used at connect time; apply this change where
axiosNoRetry.get(hexUrl/strUrl, { maxRedirects: 0 }) and
axiosNoRetry.head(mediaUrl, { maxRedirects: 0 }) are invoked in blockbookService
and moralisService.

Loading