ssrf-guard-js v0.7.1

사용자가 준 URL을 서버에서 fetch하기 전에 막아야 할 것을 막습니다.

`@devslab/ssrf-guard-js`는 TypeScript/JavaScript용 SSRF guard입니다. URL allowlist, private IP 차단, redirect 재검증, LLM tool input 검사를 제공합니다. Node에서는 DNS 검증·pinning까지(`safeFetch`), Cloudflare Workers·브라우저 같은 edge 런타임에서는 URL-time 검증 + redirect 재검증(`guardedFetch`)으로 동작합니다. 아래 예제는 그대로 복사해서 실행할 수 있습니다.

Node 22+ Workers / Edge TypeScript ready Apache-2.0 LLM tool guard

5분 시작하기

빈 폴더를 만들고 패키지를 설치합니다.

mkdir ssrf-guard-demo
cd ssrf-guard-demo
npm init -y
npm pkg set type=module
npm install @devslab/ssrf-guard-js

`demo.mjs` 파일을 만들고 아래 코드를 붙여넣습니다.

import { validateUrl } from '@devslab/ssrf-guard-js';

const policy = {
  exactHosts: ['api.example.com'],
  allowedSchemes: ['https'],
  allowedPorts: [-1, 443],
};

validateUrl('https://api.example.com/v1/users', policy);
console.log('allowed');

try {
  validateUrl('http://169.254.169.254/latest/meta-data/', policy);
} catch (error) {
  console.log('blocked:', error.reason);
}

실행합니다.

node demo.mjs

정상 출력:

allowed
blocked: blocked_ip_literal

가장 중요한 규칙

1. 기본은 fail-closed

`exactHosts`나 `suffixes`를 설정하지 않으면 어떤 host도 허용하지 않습니다.

2. IP literal은 기본 차단

`127.0.0.1`, `2130706433`, `[::1]` 같은 우회형 IP를 URL 단계에서 막습니다.

3. redirect도 다시 검사

`safeFetch`는 302 Location이 private IP나 허용되지 않은 host로 향하면 차단합니다.

API 사용법

`validateUrl(input, policy)`

URL을 fetch하기 전에 먼저 검사합니다. 통과하면 `URL` 객체를 반환하고, 실패하면 `SsrfGuardError`를 던집니다.

import { validateUrl } from '@devslab/ssrf-guard-js';

const url = validateUrl('https://api.example.com/data', {
  exactHosts: ['api.example.com'],
  allowedSchemes: ['https'],
  allowedPorts: [-1, 443],
});

console.log(url.href);

`checkUrl(input, policy)` / `isUrlAllowed(input, policy)`

같은 검사를 예외 없이 물어봅니다. fetch를 **감싸는** 판단 — 크롤러가 어떤 링크를 큐에 넣을지, 여러 URL 중 무엇을 거부로 보고할지 — 에 쓰세요.

import { checkUrl, isUrlAllowed } from '@devslab/ssrf-guard-js';

const result = checkUrl(link, policy);
if (!result.allowed) {
  console.log(`skipped ${link}: ${result.error.reason}`);
}

const crawlable = links.filter((link) => isUrlAllowed(link, policy));
`validateUrl`과 **같은 코드 경로**를 탑니다. 그래서 답이 언제나 fetch 가드의 판단과 일치합니다 — 손으로 쓴 host 비교는 그렇지 않습니다. 단, URL 시점의 답입니다: `safeFetch`의 DNS 검사에는 예외를 던지지 않는 대응물이 없습니다. 그걸 알려면 실제로 resolve해야 하기 때문입니다.

`safeFetch(input, policy, init?)`

URL 검증, DNS private IP 검사, redirect 재검증을 포함한 fetch helper입니다.

import { safeFetch } from '@devslab/ssrf-guard-js';

const response = await safeFetch('https://api.example.com/data', {
  exactHosts: ['api.example.com'],
  allowedSchemes: ['https'],
  allowedPorts: [-1, 443],
});

const text = await response.text();
console.log(text);
optional `undici` 의존성을 설치하면 `safeFetch`가 DNS 검증과 socket 연결을 하나의 resolution으로 고정(pinning)해 DNS-rebinding 창을 닫습니다 (`pinDns` 옵션). 위험도가 높은 임의 URL 크롤링은 strict allowlist 또는 별도 guarded egress service를 사용하세요.

`guardedFetch(input, policy, init?)` — Workers / Edge

`safeFetch`의 DNS 검증을 뺀, 어디서나 도는 guarded fetch입니다. redirect hop마다 policy를 다시 통과시키고, cross-origin redirect에서 credential 헤더를 제거합니다. Cloudflare Workers처럼 `dns.lookup`이 없는 런타임에서 쓰세요 — allowlist가 1차 방어선이고, 빈 allowlist는 아무것도 허용하지 않습니다 (fail-closed).

import { guardedFetch } from '@devslab/ssrf-guard-js';

const response = await guardedFetch('https://api.example.com/data', {
  exactHosts: ['api.example.com'],
  allowedSchemes: ['https'],
});
`safeFetch`와 `guardedFetch` 모두 `onFinalUrl` 콜백을 받습니다 — 모든 redirect hop을 따라간 뒤의 최종 검증 URL로 호출됩니다. 일부 fetch 구현(커스텀 `fetchImpl` 포함)은 `Response.url`을 비워 두므로, 가져온 콘텐츠의 실제 출처 라벨링에는 이 콜백이 더 신뢰할 수 있습니다.

`maxBytes` — 응답 크기 상한

`safeFetch`와 `guardedFetch` 모두 받습니다. 끝없이 흘려보내는 응답이 호출부를 고갈시키지 못하게 합니다. 두 군데에서 검사합니다 — `Content-Length`가 상한을 넘으면 한 바이트도 읽기 전에 거부하고, 그 헤더를 생략하거나 축소해서 보내는 응답은 스트리밍 중 누적 카운트가 잡습니다.

import { guardedFetch } from '@devslab/ssrf-guard-js';

const response = await guardedFetch(url, policy, { maxBytes: 2_000_000 });
const body = await response.text(); // 상한을 넘기면 reject
초과는 `blocked_response_size` 사유의 `SsrfGuardError`입니다 — **조용한 잘림이 아닙니다**. 잘림이었다면 부분 문서를 "부분"이라는 신호 없이 건네주게 됩니다. 잘라 쓰려면 에러를 잡아 직접 결정하세요. `maxBytes`는 음이 아닌 정수여야 하고, 그 외의 값은 요청 전에 `TypeError`로 던집니다 — 잘못된 env 파싱이 상한을 조용히 꺼버리지 않도록.

`sameSitePolicy(url, overrides?)`

"사용자가 제출한 자기 사이트 크롤링" 흐름을 위한 policy 헬퍼입니다. 제출된 URL의 도메인으로 allowlist를 만들어, redirect를 포함한 fetch 전체를 그 도메인에 잠급니다 (`www.`는 벗겨서 apex ↔ www redirect 허용). 특정 host를 더 열고 싶으면 overrides의 `exactHosts`/`suffixes`에 추가하면 됩니다 — bypass도 policy 안에서 관리됩니다.

import { guardedFetch, sameSitePolicy } from '@devslab/ssrf-guard-js';

const input = 'https://www.customer-site.example/about';
const response = await guardedFetch(input, sameSitePolicy(input, {
  allowedSchemes: ['https'],
}));

`singleHostPolicy(url, overrides?)`

`sameSitePolicy`의 형제이고 의도가 반대입니다. 엔드포인트가 이미 정해져 있을 때 — 등록된 API base, webhook 대상, 설정된 upstream — 그 URL의 **origin**(스킴 · 호스트 · 포트)에 fetch를 잠급니다. `www.` 짝도, 서브도메인도 없습니다.

import { guardedFetch, singleHostPolicy } from '@devslab/ssrf-guard-js';

const response = await guardedFetch(target, singleHostPolicy(registeredApiBase));
포트를 함께 잠그는 게 핵심입니다. 기본 `allowedPorts`가 `[-1, 80, 443]`이라, `https://api.example.com:8443/v1` 같은 base에서 손으로 만든 `{ exactHosts: [u.hostname] }`은 **자기 base URL을 거부합니다** — 조용히, 그리고 비표준 포트를 쓰는 배포에서만 터집니다.

Express / Hono / Vite / LangChain 연동

Hono: Workers 네이티브 middleware

별도 entry point(`@devslab/ssrf-guard-js/hono`)로 제공합니다. Hono를 import하지 않고 context의 모양에만 타입을 맞춰서, 패키지는 의존성 0을 유지합니다.

import { Hono } from 'hono';
import { createHonoUrlGuard } from '@devslab/ssrf-guard-js/hono';

const app = new Hono();

app.post('/crawl', createHonoUrlGuard({ suffixes: ['example.com'] }), async (c) => {
  const { url } = await c.req.json(); // 이미 검증됨
  return c.json({ ok: true });
});
검사하는 body는 `application/json`(및 `+json`)과 `application/x-www-form-urlencoded`입니다. **`multipart/form-data`는 검사하지 않습니다** — 매 요청마다 도는 검사 안에서 업로드 파일을 버퍼링하게 되기 때문입니다. Hono가 파싱한 body를 캐시하므로 미들웨어가 읽어도 핸들러의 `c.req.json()`은 그대로 동작합니다(실제 Hono로 검증).

Express: middleware 한 줄 추가

사용자가 보낸 body/query 안의 URL을 검사하고, 위험한 URL이면 `400` JSON 응답을 반환합니다.

import express from 'express';
import { createExpressUrlGuard } from '@devslab/ssrf-guard-js';

const app = express();
app.use(express.json());

app.post(
  '/crawl',
  createExpressUrlGuard({
    exactHosts: ['example.com'],
    suffixes: ['example.com'],
    allowedSchemes: ['https'],
  }),
  async (req, res) => {
    res.json({ ok: true });
  },
);

Vite: `vite.config.ts`에 plugin 추가

Vite dev server의 SSR/proxy endpoint가 URL을 받아 server-side fetch하는 경우에 사용합니다. 브라우저가 직접 외부로 보내는 모든 요청을 막는 도구는 아닙니다.

import { defineConfig } from 'vite';
import { ssrfGuardVitePlugin } from '@devslab/ssrf-guard-js/vite';

export default defineConfig({
  plugins: [
    ssrfGuardVitePlugin({
      routes: ['/api/crawl'],
      policy: {
        suffixes: ['example.com'],
        allowedSchemes: ['https'],
      },
    }),
  ],
});

아래 요청은 dev server middleware에서 차단됩니다.

/api/crawl?url=http://169.254.169.254/latest/meta-data/

LangChain / Agent Tool: tool 함수를 감싸기

모델이 tool에 넘긴 object 전체를 검사한 뒤, 안전할 때만 실제 tool 함수를 실행합니다.

import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';
import { createGuardedToolHandler, safeFetch } from '@devslab/ssrf-guard-js';

const policy = {
  suffixes: ['example.com'],
  allowedSchemes: ['https'],
};

export const fetchUrlTool = new DynamicStructuredTool({
  name: 'fetch_url',
  description: 'Fetch an allowed URL',
  schema: z.object({ url: z.string().url() }),
  func: createGuardedToolHandler(policy, async ({ url }) => {
    const response = await safeFetch(url, policy);
    return await response.text();
  }),
});

LLM Tool URL 검사

LLM tool input은 top-level `url` 필드만 보면 부족합니다. 공격 URL이 nested object, array, 설명 문장 안에 숨을 수 있습니다. `guardToolInputJson`은 JSON 전체를 검사합니다.

import { guardToolInputJson } from '@devslab/ssrf-guard-js';

const toolInput = JSON.stringify({
  request: {
    target: 'http://169.254.169.254/latest/meta-data/',
  },
});

const violation = guardToolInputJson(toolInput, {
  exactHosts: ['api.example.com'],
});

if (violation) {
  console.log(violation);
  // 이 문자열을 tool 결과로 LLM에게 돌려주면 됩니다.
}
기본 동작은 문자열의 전체 값이 URL인 경우만 잡습니다. 긴 문장 한가운데 묻힌 URL — `"summarize http://169.254.169.254/ please"` — 까지 잡으려면 `{ scanEmbedded: true }` 옵션을 켜세요. 기본 스캐너가 잡던 것은 전부 그대로 잡고 (strictly additive), 산문·코드 스니펫 안의 URL 모양 텍스트도 policy로 검증합니다.

Policy 옵션

옵션 기본값 설명
exactHosts [] 정확히 일치해야 하는 host 목록입니다. 예: api.example.com
suffixes [] example.com과 모든 하위 도메인을 허용합니다. badexample.com은 허용하지 않습니다.
allowedSchemes ['http', 'https'] 보통 production에서는 ['https']로 좁히는 것을 권장합니다.
allowedPorts [-1, 80, 443] -1은 URL에 명시 포트가 없는 경우입니다. 예: https://api.example.com/
rejectIpLiteralHosts true IP 주소를 host로 직접 쓰는 URL을 차단합니다.
rejectUserInfo true https://user:pass@example.com 형태를 차단합니다.
blockPrivateNetworks true DNS 결과가 loopback, private, link-local, metadata IP면 차단합니다.

차단 이유

`SsrfGuardError.reason`은 아래 문자열 중 하나입니다.

blocked_scheme
blocked_host
blocked_port
blocked_ip_literal
blocked_userinfo
blocked_private_ip
blocked_redirect
blocked_response_size
blocked_other