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));
`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);
`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'],
});
`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
`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));
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 });
});
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에게 돌려주면 됩니다.
}
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