diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe52c0..e958657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `guardedFetch` — a runtime-agnostic guarded fetch for environments that + cannot run `safeFetch`'s DNS checks (Cloudflare Workers, browsers, edge + runtimes). Same redirect revalidation, cross-origin credential stripping, + and `303`/`301`/`302`-`POST` method-downgrade semantics as `safeFetch`, + minus DNS resolution and IP pinning — the policy allowlist is the primary + control. Accepts a `fetchImpl` override for tests and instrumented clients. +- `sameSitePolicy(url, overrides?)` — derives a `UrlPolicyOptions` allowlist + from a submitted URL, locking the whole fetch (redirects included) to that + domain with a leading `www.` stripped. Overrides merge additively, so + extra hosts can be allowlisted alongside. + +### Changed + +- `node:dns` is now imported lazily. Importing the package no longer + requires a functional `node:dns` module — on runtimes without one, + `safeFetch` throws a typed `SsrfGuardError` directing callers to + `guardedFetch` instead of failing at module load. +- `safeFetch` and `guardedFetch` share one redirect-revalidation loop + (internal refactor; behavior unchanged, covered by the existing suite). + ## [0.3.0] - 2026-07-05 ### Added diff --git a/README.ko.md b/README.ko.md index 743a33d..afadf5a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -105,7 +105,8 @@ service를 사용하세요. | --- | --- | --- | | `validateUrl` / `UrlPolicy` / `HostPolicy` | ✅ | ✅ (순수 URL/문자열 검증) | | `guardToolInput` / `guardToolInputJson` / `createGuardedToolHandler` | ✅ | ✅ | -| `safeFetch` (DNS 검증, redirect 재검증) | ✅ | ❌ 런타임에서 throw | +| `guardedFetch` + `sameSitePolicy` (URL-time + redirect 재검증) | ✅ | ✅ | +| `safeFetch` (DNS 검증 추가) | ✅ | ❌ 런타임에서 throw | | `undici` DNS pinning | ✅ optional | ❌ | **Workers에서 `safeFetch`가 동작할 수 없는 이유.** `safeFetch`는 연결 전에 @@ -114,36 +115,35 @@ service를 사용하세요. 구현하지만 `lookup`은 `Not implemented`를 던집니다 — 설령 resolve가 되더라도 Workers의 `fetch`는 내부적으로 자체 resolution을 수행하므로, Node의 `undici` connector처럼 검증한 IP를 socket에 고정하는 것이 userland에서 불가능합니다. -check-then-fetch 간극은 Worker 안에서는 닫을 수 없습니다. +check-then-fetch 간극은 Worker 안에서는 닫을 수 없습니다. (패키지 import는 +항상 안전합니다 — `node:dns`는 lazy load되고, 없는 환경에서 `safeFetch`를 +호출하면 여기를 가리키는 typed `SsrfGuardError`가 던져집니다.) -**Workers에서는 이렇게 하세요.** URL-time 검증 + 모든 redirect hop 재검증을 -직접 수행하고, strict allowlist를 1차 방어선으로 삼으세요: +**Workers에서는 `guardedFetch`를 쓰세요.** `safeFetch`와 같은 redirect +재검증·credential 스트리핑·method 다운그레이드 의미론에서 DNS 검증만 뺀 +것입니다 — 따라서 policy allowlist가 1차 방어선이고, fail-closed 기본값 +(빈 allowlist는 아무것도 허용하지 않음)이 실질적인 역할을 합니다: ```ts -import { validateUrl, type UrlPolicyOptions } from '@devslab/ssrf-guard-js'; - -const MAX_REDIRECTS = 5; - -async function guardedWorkersFetch(input: string, policy: UrlPolicyOptions): Promise { - let url = validateUrl(input, policy); - for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) { - const res = await fetch(url, { redirect: 'manual' }); - if (res.status < 300 || res.status >= 400) return res; - const location = res.headers.get('location'); - if (!location) return res; - url = validateUrl(new URL(location, url), policy); // 모든 hop이 같은 policy를 다시 통과 - } - throw new Error(`too many redirects for ${input}`); -} +import { guardedFetch } from '@devslab/ssrf-guard-js'; + +const res = await guardedFetch('https://api.example.com/data', { + exactHosts: ['api.example.com'], + allowedSchemes: ['https'], +}); ``` -"고객이 제출한 자기 사이트 크롤링" 흐름이라면 allowlist를 하드코딩하지 말고 -제출된 URL에서 유도하세요 — 한 번 검증한 뒤, redirect를 포함한 크롤링 전체를 -그 도메인에 잠급니다: +특정 host를 열고 싶으면 allowlist에 넣으세요 — 그것이 곧 bypass 메커니즘이고, +한 곳에서 감사 가능하게 유지됩니다. "고객이 제출한 자기 사이트 크롤링" +흐름이라면 `sameSitePolicy`로 제출된 URL에서 allowlist를 유도하세요 — +redirect를 포함한 fetch 전체가 그 도메인에 잠깁니다 (`www.`는 벗겨서 +apex ↔ www redirect가 살아남음): ```ts -const first = validateUrl(input, { rejectIpLiteralHosts: true, suffixes: [new URL(input).hostname] }); -const policy = { allowedSchemes: ['https'], suffixes: [first.hostname.replace(/^www\./, '')] }; +import { guardedFetch, sameSitePolicy } from '@devslab/ssrf-guard-js'; + +const input = 'https://www.customer-site.example/about'; +const res = await guardedFetch(input, sameSitePolicy(input, { allowedSchemes: ['https'] })); ``` Workers에서 진짜 임의 URL fetch가 필요하면, `pinDns: true`로 `safeFetch`를 diff --git a/README.md b/README.md index 319dcf9..bb138ea 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,8 @@ you are getting: | --- | --- | --- | | `validateUrl` / `UrlPolicy` / `HostPolicy` | ✅ | ✅ (pure URL/string checks) | | `guardToolInput` / `guardToolInputJson` / `createGuardedToolHandler` | ✅ | ✅ | -| `safeFetch` (DNS check, redirect revalidation) | ✅ | ❌ throws at runtime | +| `guardedFetch` + `sameSitePolicy` (URL-time + redirect revalidation) | ✅ | ✅ | +| `safeFetch` (adds DNS checks) | ✅ | ❌ throws at runtime | | DNS pinning via `undici` | ✅ optional | ❌ | **Why `safeFetch` cannot work in Workers.** It resolves the target with @@ -128,37 +129,36 @@ DNS-over-HTTPS but `lookup` throws `Not implemented` — and even if it resolved, Workers `fetch` performs its own resolution internally, so userland cannot pin the checked IP to the socket the way the `undici` connector does in Node. The check-then-fetch gap cannot be closed from -inside a Worker. +inside a Worker. (Importing the package is always safe — `node:dns` is +loaded lazily; calling `safeFetch` without it throws a typed +`SsrfGuardError` pointing here.) -**What to do in Workers instead.** Do URL-time validation and revalidate -every redirect hop yourself, with a strict allowlist as the primary -control: +**What to use in Workers instead: `guardedFetch`.** Same redirect +revalidation, credential stripping, and method-downgrade semantics as +`safeFetch`, minus the DNS checks — so the policy allowlist is the +primary control, and the fail-closed default (empty allowlist allows +nothing) is doing real work: ```ts -import { validateUrl, type UrlPolicyOptions } from '@devslab/ssrf-guard-js'; - -const MAX_REDIRECTS = 5; - -async function guardedWorkersFetch(input: string, policy: UrlPolicyOptions): Promise { - let url = validateUrl(input, policy); - for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) { - const res = await fetch(url, { redirect: 'manual' }); - if (res.status < 300 || res.status >= 400) return res; - const location = res.headers.get('location'); - if (!location) return res; - url = validateUrl(new URL(location, url), policy); // every hop re-passes the policy - } - throw new Error(`too many redirects for ${input}`); -} +import { guardedFetch } from '@devslab/ssrf-guard-js'; + +const res = await guardedFetch('https://api.example.com/data', { + exactHosts: ['api.example.com'], + allowedSchemes: ['https'], +}); ``` -For "crawl the customer's own site" flows, derive the allowlist from the -submitted URL instead of hardcoding one — validate once, then lock the -whole crawl (redirects included) to that registrable domain: +To open a specific host, put it in the allowlist — that IS the bypass +mechanism, and it stays auditable in one place. For "crawl the customer's +own site" flows, derive the allowlist from the submitted URL with +`sameSitePolicy`: the whole fetch — redirects included — is locked to +that domain (`www.` stripped so apex ↔ www redirects survive): ```ts -const first = validateUrl(input, { rejectIpLiteralHosts: true, suffixes: [new URL(input).hostname] }); -const policy = { allowedSchemes: ['https'], suffixes: [first.hostname.replace(/^www\./, '')] }; +import { guardedFetch, sameSitePolicy } from '@devslab/ssrf-guard-js'; + +const input = 'https://www.customer-site.example/about'; +const res = await guardedFetch(input, sameSitePolicy(input, { allowedSchemes: ['https'] })); ``` For genuinely arbitrary URL fetching from Workers, route the request diff --git a/src/guarded-fetch.ts b/src/guarded-fetch.ts new file mode 100644 index 0000000..bb42bda --- /dev/null +++ b/src/guarded-fetch.ts @@ -0,0 +1,84 @@ +import { SsrfGuardError } from './error.js'; +import { normalizeHost } from './net.js'; +import { UrlPolicy, validateUrl } from './policy.js'; +import { followRedirectsGuarded, type FetchImpl } from './redirect.js'; +import type { UrlPolicyOptions } from './types.js'; + +/** + * URL-time guarded fetch for runtimes that cannot run `safeFetch`'s + * DNS checks — Cloudflare Workers, browsers, edge runtimes. Validates + * the URL and EVERY redirect hop against the policy before following, + * with the same header-stripping and method-downgrade semantics as + * `safeFetch`, but performs no DNS resolution and no IP pinning. + * + * That gap is real: a hostname that resolves to a private address + * passes URL-time checks. The policy allowlist (`exactHosts` / + * `suffixes`) is therefore the primary control here — `guardedFetch` + * inherits the package's fail-closed default (empty allowlist allows + * nothing). To open a specific host, put it in the allowlist; for + * "crawl the customer's own site" flows, derive the allowlist from the + * submitted URL with `sameSitePolicy`. For genuinely arbitrary URL + * fetching, use `safeFetch` in a Node egress service instead. + */ + +export interface GuardedFetchOptions extends RequestInit { + maxRedirects?: number; + /** Override the fetch implementation (tests, instrumented clients). */ + fetchImpl?: FetchImpl; +} + +export async function guardedFetch( + input: string | URL, + policy: UrlPolicyOptions | UrlPolicy, + init: GuardedFetchOptions = {}, +): Promise { + const urlPolicy = policy instanceof UrlPolicy ? policy : new UrlPolicy(policy); + const url = validateUrl(input, urlPolicy); + const { maxRedirects = 5, fetchImpl, ...requestInit } = init; + + return followRedirectsGuarded({ + url, + policy: urlPolicy, + init: requestInit, + maxRedirects, + fetchImpl: fetchImpl ?? ((u, i) => fetch(u, i)), + }); +} + +/** + * Policy for "fetch pages from the site the user just submitted": + * locks the whole fetch — redirects included — to the submitted URL's + * domain. A leading `www.` is stripped so apex ↔ www redirects + * survive; subdomains of the registered host stay reachable. + * + * `overrides` merges on top, so callers can still open extra hosts + * (`exactHosts`/`suffixes` are additive) or tighten schemes/ports: + * + * ```ts + * await guardedFetch(input, sameSitePolicy(input, { allowedSchemes: ['https'] })); + * ``` + */ +export function sameSitePolicy( + input: string | URL, + overrides: UrlPolicyOptions = {}, +): UrlPolicyOptions { + let host: string | null; + try { + host = normalizeHost((input instanceof URL ? input : new URL(input)).hostname); + } catch (error) { + throw new SsrfGuardError('blocked_other', `Invalid URL: ${String(input)}`, { + url: String(input), + cause: error, + }); + } + if (!host) { + throw new SsrfGuardError('blocked_host', 'URL is missing a host', { + url: String(input), + }); + } + const site = host.replace(/^www\./, ''); + return { + ...overrides, + suffixes: [...(overrides.suffixes ?? []), site], + }; +} diff --git a/src/index.ts b/src/index.ts index 9b5cef5..3dd5ada 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,9 @@ export { normalizeHost, } from './net.js'; export { UrlPolicy, validateUrl } from './policy.js'; +export { guardedFetch, sameSitePolicy } from './guarded-fetch.js'; +export type { GuardedFetchOptions } from './guarded-fetch.js'; +export type { FetchImpl } from './redirect.js'; export { assertResolvedIpsAllowed, safeFetch } from './safe-fetch.js'; export { createGuardedToolHandler, guardToolInput, guardToolInputJson } from './tool-input.js'; export type { diff --git a/src/redirect.ts b/src/redirect.ts new file mode 100644 index 0000000..e306111 --- /dev/null +++ b/src/redirect.ts @@ -0,0 +1,114 @@ +import { SsrfGuardError } from './error.js'; +import { UrlPolicy, validateUrl } from './policy.js'; + +/** + * The shared redirect-revalidation loop behind `safeFetch` (Node, adds + * DNS checks via hooks) and `guardedFetch` (any runtime, URL-time + * checks only). Kept free of Node imports so the guarded-fetch module + * graph loads on Workers/browsers/Deno. + * + * Semantics ported from the fetch spec and the Java adapters: + * every hop re-passes the policy before it is followed; `303` (and + * `301`/`302` for `POST`) downgrade to `GET` without replaying the + * body; credentials are stripped when a redirect changes origin. + */ + +export type FetchImpl = ( + url: URL, + init: RequestInit & Record, +) => Promise; + +const SENSITIVE_HEADERS = ['authorization', 'proxy-authorization', 'cookie']; + +export interface RedirectLoopArgs { + url: URL; + policy: UrlPolicy; + init: RequestInit; + maxRedirects: number; + fetchImpl: FetchImpl; + /** Runs before each hop's fetch — safeFetch's unpinned DNS check. */ + beforeHop?: (url: URL) => Promise; + /** Unwrap fetch errors — safeFetch's pinned-lookup guard-error unwrap. */ + mapFetchError?: (error: unknown) => unknown; + /** Extra init merged into every hop's request (undici dispatcher). */ + extraInit?: Record; +} + +export async function followRedirectsGuarded(args: RedirectLoopArgs): Promise { + const { policy, maxRedirects, fetchImpl, beforeHop, mapFetchError, extraInit } = args; + const requestInit = args.init; + const headers = new Headers(requestInit.headers); + let url = args.url; + let method = (requestInit.method ?? 'GET').toUpperCase(); + let body = requestInit.body ?? null; + + for (let redirects = 0; redirects <= maxRedirects; redirects += 1) { + if (beforeHop) await beforeHop(url); + + let response: Response; + try { + response = await fetchImpl(url, { + ...requestInit, + method, + body, + headers, + redirect: 'manual', + ...(extraInit ?? {}), + }); + } catch (error) { + throw mapFetchError ? mapFetchError(error) : error; + } + + if (!isRedirect(response.status)) return response; + + const location = response.headers.get('location'); + if (!location) return response; + + await response.body?.cancel(); + + const nextUrl = new URL(location, url); + let validated: URL; + try { + validated = validateUrl(nextUrl, policy); + } catch (error) { + if (error instanceof SsrfGuardError) { + throw new SsrfGuardError('blocked_redirect', `Blocked redirect: ${error.message}`, { + scheme: nextUrl.protocol.replace(/:$/, ''), + host: nextUrl.hostname, + url: nextUrl.toString(), + }); + } + throw error; + } + + // Per the fetch spec's redirect handling: 303 always downgrades to GET; + // 301/302 downgrade POST to GET. The body must not be replayed. + if ( + (response.status === 303 && method !== 'GET' && method !== 'HEAD') || + ((response.status === 301 || response.status === 302) && method === 'POST') + ) { + method = 'GET'; + body = null; + const contentHeaders: string[] = []; + headers.forEach((_value, name) => { + if (name.startsWith('content-')) contentHeaders.push(name); + }); + for (const name of contentHeaders) headers.delete(name); + } + + // Credentials must not leak to a different origin on redirect. + if (url.origin !== validated.origin) { + for (const name of SENSITIVE_HEADERS) headers.delete(name); + } + + url = validated; + } + + throw new SsrfGuardError('blocked_redirect', `Too many redirects: ${maxRedirects}`, { + url: url.toString(), + }); +} + +function isRedirect(status: number): boolean { + return status >= 300 && status < 400 && status !== 304; +} diff --git a/src/safe-fetch.ts b/src/safe-fetch.ts index 8bce4ea..b826597 100644 --- a/src/safe-fetch.ts +++ b/src/safe-fetch.ts @@ -1,7 +1,7 @@ -import { lookup } from 'node:dns/promises'; import { SsrfGuardError } from './error.js'; import { isPrivateOrLocalIp, normalizeHost } from './net.js'; import { UrlPolicy, validateUrl } from './policy.js'; +import { followRedirectsGuarded, type FetchImpl } from './redirect.js'; import type { UrlPolicyOptions } from './types.js'; export interface SafeFetchOptions extends RequestInit { @@ -18,18 +18,42 @@ export interface SafeFetchOptions extends RequestInit { pinDns?: boolean; } -const SENSITIVE_HEADERS = ['authorization', 'proxy-authorization', 'cookie']; - -type FetchLike = ( - input: string | URL, - init: RequestInit & { dispatcher?: unknown }, -) => Promise; - interface DnsAddress { address: string; family: number; } +type LookupFn = ( + hostname: string, + options: { all: true; verbatim: true }, +) => Promise; + +// node:dns is imported lazily so merely loading this module (it is +// re-exported from the package index) works on runtimes without a +// functional dns.lookup — Workers, browsers, Deno. Calling safeFetch +// there fails with a pointer to guardedFetch instead of a load error. +let lookupPromise: Promise | undefined; + +function loadLookup(): Promise { + lookupPromise ??= import('node:dns/promises').then( + (module) => module.lookup as unknown as LookupFn, + () => null, + ); + return lookupPromise; +} + +async function requireLookup(url: URL): Promise { + const lookup = await loadLookup(); + if (!lookup) { + throw new SsrfGuardError( + 'blocked_other', + 'safeFetch requires node:dns (Node.js). On runtimes without it — Cloudflare Workers, browsers — use guardedFetch with a strict allowlist instead.', + { url: url.toString() }, + ); + } + return lookup; +} + type ConnectorLookup = ( hostname: string, options: { all?: boolean; family?: number }, @@ -38,7 +62,7 @@ type ConnectorLookup = ( interface UndiciLike { Agent: new (options: { connect: { lookup: ConnectorLookup } }) => object; - fetch: FetchLike; + fetch: FetchImpl; } let undiciPromise: Promise | undefined; @@ -64,44 +88,55 @@ function getPinnedAgent(undici: UndiciLike, policy: UrlPolicy): object { function createValidatingLookup(policy: UrlPolicy): ConnectorLookup { return (hostname, options, callback) => { - lookup(hostname, { all: true, verbatim: true }).then( - (addresses) => { - if (policy.options.blockPrivateNetworks) { - const blocked = addresses.find((address) => isPrivateOrLocalIp(address.address)); - if (blocked) { + loadLookup() + .then((lookup) => { + if (!lookup) { + throw new SsrfGuardError( + 'blocked_other', + 'safeFetch requires node:dns (Node.js) for its DNS checks', + { host: hostname }, + ); + } + return lookup(hostname, { all: true, verbatim: true }); + }) + .then( + (addresses) => { + if (policy.options.blockPrivateNetworks) { + const blocked = addresses.find((address) => isPrivateOrLocalIp(address.address)); + if (blocked) { + callback( + new SsrfGuardError( + 'blocked_private_ip', + `DNS resolved a private/local address for ${hostname}: ${blocked.address}`, + { host: hostname }, + ), + ); + return; + } + } + + const candidates = + options.family && addresses.some((address) => address.family === options.family) + ? addresses.filter((address) => address.family === options.family) + : addresses; + const picked = candidates[0]; + if (!picked) { callback( - new SsrfGuardError( - 'blocked_private_ip', - `DNS resolved a private/local address for ${hostname}: ${blocked.address}`, - { host: hostname }, - ), + new SsrfGuardError('blocked_private_ip', `DNS returned no addresses for ${hostname}`, { + host: hostname, + }), ); return; } - } - const candidates = - options.family && addresses.some((address) => address.family === options.family) - ? addresses.filter((address) => address.family === options.family) - : addresses; - const picked = candidates[0]; - if (!picked) { - callback( - new SsrfGuardError('blocked_private_ip', `DNS returned no addresses for ${hostname}`, { - host: hostname, - }), - ); - return; - } - - if (options.all) { - callback(null, [{ address: picked.address, family: picked.family }]); - } else { - callback(null, picked.address, picked.family); - } - }, - (error) => callback(error as Error), - ); + if (options.all) { + callback(null, [{ address: picked.address, family: picked.family }]); + } else { + callback(null, picked.address, picked.family); + } + }, + (error) => callback(error as Error), + ); }; } @@ -120,11 +155,8 @@ export async function safeFetch( init: SafeFetchOptions = {}, ): Promise { const urlPolicy = policy instanceof UrlPolicy ? policy : new UrlPolicy(policy); - let url = validateUrl(input, urlPolicy); + const url = validateUrl(input, urlPolicy); const { maxRedirects = 5, pinDns, ...requestInit } = init; - const headers = new Headers(requestInit.headers); - let method = (requestInit.method ?? 'GET').toUpperCase(); - let body = requestInit.body ?? null; const undici = pinDns === false ? null : await loadUndici(); if (pinDns === true && !undici) { @@ -134,78 +166,23 @@ export async function safeFetch( { url: url.toString() }, ); } - const doFetch: FetchLike = undici ? undici.fetch : fetch; + const fetchImpl: FetchImpl = undici ? undici.fetch : (u, i) => fetch(u, i); const dispatcher = undici ? getPinnedAgent(undici, urlPolicy) : undefined; - for (let redirects = 0; redirects <= maxRedirects; redirects += 1) { + return followRedirectsGuarded({ + url, + policy: urlPolicy, + init: requestInit, + maxRedirects, + fetchImpl, // Unpinned mode checks DNS before connecting; pinned mode validates inside // the connector's lookup, so check and connection share one resolution. - if (!undici) await assertResolvedIpsAllowed(url, urlPolicy); - - let response: Response; - try { - response = await doFetch(url, { - ...requestInit, - method, - body, - headers, - redirect: 'manual', - ...(dispatcher ? { dispatcher } : {}), - }); - } catch (error) { - // Pinned lookups surface SsrfGuardError wrapped in undici's fetch error. - const guardError = findGuardError(error); - if (guardError) throw guardError; - throw error; - } - - if (!isRedirect(response.status)) return response; - - const location = response.headers.get('location'); - if (!location) return response; - - await response.body?.cancel(); - - const nextUrl = new URL(location, url); - let validated: URL; - try { - validated = validateUrl(nextUrl, urlPolicy); - } catch (error) { - if (error instanceof SsrfGuardError) { - throw new SsrfGuardError('blocked_redirect', `Blocked redirect: ${error.message}`, { - scheme: nextUrl.protocol.replace(/:$/, ''), - host: nextUrl.hostname, - url: nextUrl.toString(), - }); - } - throw error; - } - - // Per the fetch spec's redirect handling: 303 always downgrades to GET; - // 301/302 downgrade POST to GET. The body must not be replayed. - if ( - (response.status === 303 && method !== 'GET' && method !== 'HEAD') || - ((response.status === 301 || response.status === 302) && method === 'POST') - ) { - method = 'GET'; - body = null; - const contentHeaders: string[] = []; - headers.forEach((_value, name) => { - if (name.startsWith('content-')) contentHeaders.push(name); - }); - for (const name of contentHeaders) headers.delete(name); - } - - // Credentials must not leak to a different origin on redirect. - if (url.origin !== validated.origin) { - for (const name of SENSITIVE_HEADERS) headers.delete(name); - } - - url = validated; - } - - throw new SsrfGuardError('blocked_redirect', `Too many redirects: ${maxRedirects}`, { - url: url.toString(), + ...(undici + ? {} + : { beforeHop: (hopUrl: URL) => assertResolvedIpsAllowed(hopUrl, urlPolicy) }), + // Pinned lookups surface SsrfGuardError wrapped in undici's fetch error. + mapFetchError: (error) => findGuardError(error) ?? error, + ...(dispatcher ? { extraInit: { dispatcher } } : {}), }); } @@ -221,6 +198,7 @@ export async function assertResolvedIpsAllowed(url: URL, policy: UrlPolicy): Pro }); } + const lookup = await requireLookup(url); const addresses = await lookup(host, { all: true, verbatim: true }); const blocked = addresses.find((address) => isPrivateOrLocalIp(address.address)); if (blocked || addresses.length === 0) { @@ -237,7 +215,3 @@ export async function assertResolvedIpsAllowed(url: URL, policy: UrlPolicy): Pro ); } } - -function isRedirect(status: number): boolean { - return status >= 300 && status < 400 && status !== 304; -} diff --git a/test/guarded-fetch.test.ts b/test/guarded-fetch.test.ts new file mode 100644 index 0000000..dff60a0 --- /dev/null +++ b/test/guarded-fetch.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, vi } from 'vitest'; +import { guardedFetch, sameSitePolicy, SsrfGuardError, UrlPolicy } from '../src/index.js'; +import type { FetchImpl } from '../src/index.js'; + +// guardedFetch never touches DNS, so unlike safe-fetch.test.ts nothing +// is mocked at the module level — a scripted fetchImpl is the whole +// runtime boundary. That is the point of the API. + +function okResponse(body = 'ok'): Response { + return new Response(body, { status: 200 }); +} + +function redirectResponse(status: number, location?: string): Response { + return new Response(null, { + status, + headers: location ? { location } : {}, + }); +} + +function scriptedFetch( + responses: Response[], +): FetchImpl & { calls: Array<{ url: string; init: RequestInit }> } { + const calls: Array<{ url: string; init: RequestInit }> = []; + let i = 0; + const impl: FetchImpl = async (url, init) => { + calls.push({ url: url.toString(), init }); + const res = responses[i++]; + if (!res) throw new Error('no more scripted responses'); + return res; + }; + return Object.assign(impl, { calls }); +} + +const policy = { exactHosts: ['api.example.com', 'cdn.example.com'], allowedSchemes: ['https'] }; + +describe('guardedFetch', () => { + it('fetches an allowlisted URL', async () => { + const fetchImpl = scriptedFetch([okResponse()]); + const res = await guardedFetch('https://api.example.com/data', policy, { fetchImpl }); + expect(res.status).toBe(200); + expect(fetchImpl.calls).toHaveLength(1); + expect(fetchImpl.calls[0]!.init.redirect).toBe('manual'); + }); + + it('fails closed on an empty allowlist', async () => { + const fetchImpl = scriptedFetch([]); + await expect(guardedFetch('https://anywhere.example/', {}, { fetchImpl })).rejects.toMatchObject( + { reason: 'blocked_host' }, + ); + expect(fetchImpl.calls).toHaveLength(0); + }); + + it('follows an allowlisted redirect and revalidates the hop', async () => { + const fetchImpl = scriptedFetch([ + redirectResponse(302, 'https://cdn.example.com/asset'), + okResponse(), + ]); + const res = await guardedFetch('https://api.example.com/asset', policy, { fetchImpl }); + expect(res.status).toBe(200); + expect(fetchImpl.calls.map((c) => c.url)).toEqual([ + 'https://api.example.com/asset', + 'https://cdn.example.com/asset', + ]); + }); + + it('blocks a redirect that leaves the allowlist, without fetching it', async () => { + const fetchImpl = scriptedFetch([redirectResponse(302, 'https://evil.example/steal')]); + await expect( + guardedFetch('https://api.example.com/', policy, { fetchImpl }), + ).rejects.toMatchObject({ reason: 'blocked_redirect' }); + expect(fetchImpl.calls).toHaveLength(1); + }); + + it('strips credentials when a redirect changes origin', async () => { + const fetchImpl = scriptedFetch([ + redirectResponse(302, 'https://cdn.example.com/asset'), + okResponse(), + ]); + await guardedFetch('https://api.example.com/asset', policy, { + fetchImpl, + headers: { authorization: 'Bearer secret', accept: 'text/html' }, + }); + const secondHeaders = new Headers(fetchImpl.calls[1]!.init.headers); + expect(secondHeaders.get('authorization')).toBeNull(); + expect(secondHeaders.get('accept')).toBe('text/html'); + }); + + it('downgrades a 303 POST to GET and drops the body', async () => { + const fetchImpl = scriptedFetch([ + redirectResponse(303, 'https://api.example.com/next'), + okResponse(), + ]); + await guardedFetch('https://api.example.com/form', policy, { + fetchImpl, + method: 'POST', + body: 'payload', + headers: { 'content-type': 'text/plain' }, + }); + const second = fetchImpl.calls[1]!; + expect(second.init.method).toBe('GET'); + expect(second.init.body).toBeNull(); + expect(new Headers(second.init.headers).get('content-type')).toBeNull(); + }); + + it('gives up after maxRedirects', async () => { + const hops = Array.from({ length: 4 }, () => + redirectResponse(302, 'https://api.example.com/again'), + ); + await expect( + guardedFetch('https://api.example.com/', policy, { + fetchImpl: scriptedFetch(hops), + maxRedirects: 2, + }), + ).rejects.toMatchObject({ reason: 'blocked_redirect' }); + }); + + it('accepts a prebuilt UrlPolicy', async () => { + const fetchImpl = scriptedFetch([okResponse()]); + const res = await guardedFetch('https://api.example.com/', new UrlPolicy(policy), { + fetchImpl, + }); + expect(res.status).toBe(200); + }); +}); + +describe('sameSitePolicy', () => { + it('locks the policy to the submitted domain, www stripped', () => { + expect(sameSitePolicy('https://www.acme.example/about')).toEqual({ + suffixes: ['acme.example'], + }); + expect(sameSitePolicy(new URL('https://acme.example/'))).toEqual({ + suffixes: ['acme.example'], + }); + }); + + it('lets the crawl reach subdomains but not other sites', async () => { + const input = 'https://www.acme.example/about'; + const fetchImpl = scriptedFetch([ + redirectResponse(302, 'https://docs.acme.example/about'), + okResponse(), + ]); + await expect( + guardedFetch(input, sameSitePolicy(input), { fetchImpl }), + ).resolves.toMatchObject({ status: 200 }); + + const escape = scriptedFetch([redirectResponse(302, 'https://not-acme.example/')]); + await expect( + guardedFetch(input, sameSitePolicy(input), { fetchImpl: escape }), + ).rejects.toMatchObject({ reason: 'blocked_redirect' }); + }); + + it('merges overrides additively — extra hosts stay reachable', () => { + const merged = sameSitePolicy('https://acme.example/', { + suffixes: ['assets.example'], + allowedSchemes: ['https'], + }); + expect(merged.suffixes).toEqual(['assets.example', 'acme.example']); + expect(merged.allowedSchemes).toEqual(['https']); + }); + + it('throws a typed error for unparseable or hostless input', () => { + expect(() => sameSitePolicy('not a url')).toThrowError(SsrfGuardError); + expect(() => sameSitePolicy('not a url')).toThrowError(/Invalid URL/); + }); +});