Skip to content
Merged
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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 24 additions & 24 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`는 연결 전에
Expand All @@ -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<Response> {
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`를
Expand Down
50 changes: 25 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Response> {
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
Expand Down
84 changes: 84 additions & 0 deletions src/guarded-fetch.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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],
};
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
114 changes: 114 additions & 0 deletions src/redirect.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
) => Promise<Response>;

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<void>;
/** 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<string, unknown>;
}

export async function followRedirectsGuarded(args: RedirectLoopArgs): Promise<Response> {
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;
}
Loading
Loading