Skip to content

fix(evm): validate NFT media/metadata URLs before fetching#1278

Closed
0xApotheosis wants to merge 1 commit into
developfrom
fix/evm-metadata-ssrf-guard
Closed

fix(evm): validate NFT media/metadata URLs before fetching#1278
0xApotheosis wants to merge 1 commit into
developfrom
fix/evm-metadata-ssrf-guard

Conversation

@0xApotheosis

@0xApotheosis 0xApotheosis commented Jun 12, 2026

Copy link
Copy Markdown
Member

Description

GET /api/v1/metadata/token infers a media type by fetching a URL taken straight from NFT metadata (image/animation_url, and the on-chain tokenURI() in the blockbook path). Those URLs are unvalidated and redirects are followed by default, so the server can end up making requests to unintended hosts.

  • Add assertSafeOutboundUrl in common/api/src/utils.ts: allow only http(s), resolve the host, and reject private/loopback/link-local/CGNAT and metadata-service ranges.
  • Call it before each fetch and set maxRedirects: 0.

Notes

  • maxRedirects: 0 means a media URL that legitimately redirects now resolves to no media.type instead of following the hop. That field is already best-effort (errors are swallowed to undefined), so this is a minor, safe degradation.

Verification

Didn't run build/tests locally per the repo CLAUDE.md. It's a pre-fetch URL check plus disabling redirects; no change to well-formed public media URLs that respond directly.

The token metadata endpoint fetches URLs taken from attacker-controllable NFT
metadata (image/animation_url, and on-chain tokenURI() in the blockbook path)
with no scheme or destination validation and default redirect-following, which
is an unauthenticated SSRF primitive.

- Add assertSafeOutboundUrl: allow only http(s), resolve the host, and reject
  private/loopback/link-local/CGNAT and cloud-metadata destinations.
- Validate before fetching and set maxRedirects: 0 so a public host can't
  3xx-bounce to an internal one (moralisService live path + blockbookService).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@0xApotheosis 0xApotheosis requested a review from a team as a code owner June 12, 2026 05:00
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR hardens token and NFT metadata fetches against SSRF attacks. It introduces assertSafeOutboundUrl to validate outbound URLs against private IP ranges, then applies that validation in BlockbookService and MoralisService before issuing HTTP requests with redirects disabled.

Changes

SSRF Protection for Metadata Fetches

Layer / File(s) Summary
SSRF Protection Utility
node/coinstacks/common/api/src/utils.ts
New assertSafeOutboundUrl async function parses caller-provided URLs, restricts schemes to http/https, resolves hostnames to IP addresses, and rejects any address in private/loopback/link-local/CGNAT/reserved ranges. Includes IPv4 and IPv6 range classification helpers.
BlockbookService Token Metadata SSRF Protection
node/coinstacks/common/api/src/evm/blockbookService.ts
Imports assertSafeOutboundUrl and applies it to validate hex-encoded token URIs and media URLs in getTokenMetadata(). HTTP requests disable redirects (maxRedirects: 0) to prevent redirect-based SSRF.
MoralisService NFT Metadata SSRF Protection
node/coinstacks/common/api/src/evm/moralisService.ts
Imports assertSafeOutboundUrl and validates attacker-influenced NFT mediaUrl before HEAD request in media-type detection, with redirects disabled (maxRedirects: 0).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 A rabbit guards the network gates,
No trickster URL penetrates!
With IP ranges checked with care,
The metadata's now safe to share. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(evm): validate NFT media/metadata URLs before fetching' clearly and concisely summarizes the main change—adding URL validation for NFT metadata/media URLs before server-side fetches to prevent SSRF attacks.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/evm-metadata-ssrf-guard

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@node/coinstacks/common/api/src/utils.ts`:
- Around line 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 46753942-3bcf-47f4-9eee-64c996393db9

📥 Commits

Reviewing files that changed from the base of the PR and between 1b3e059 and 901afb5.

📒 Files selected for processing (3)
  • node/coinstacks/common/api/src/evm/blockbookService.ts
  • node/coinstacks/common/api/src/evm/moralisService.ts
  • node/coinstacks/common/api/src/utils.ts

Comment on lines +92 to +99
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

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.

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

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.

@0xApotheosis 0xApotheosis changed the title fix(evm): validate NFT media/metadata URLs before server-side fetch (SSRF) fix(evm): validate NFT media/metadata URLs before fetching Jun 12, 2026
@0xApotheosis 0xApotheosis deleted the fix/evm-metadata-ssrf-guard branch June 12, 2026 05:15
@0xApotheosis

Copy link
Copy Markdown
Member Author

Superseded by #1280 (branch renamed).

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.

1 participant