--- id: astra-ssrf-url-fetch-guard-20260619 title: "ASTRA SSRF 방어 — 신뢰 불가 URL fetch 경계(assertPublicUrl)" category: "Security" status: "applied" verification_status: "validated" canonical_id: "" aliases: ["SSRF 방어", "assertPublicUrl", "ssrfGuard", "URL fetch 경계", "사설 IP 차단", "169.254.169.254 메타데이터 차단", "DNS 리바인딩", "fetch_url 보안", "buildUrlContext 가드"] duplicate_of: "" source_trust_level: "S" confidence_score: 0.95 created_at: 2026-06-19 updated_at: 2026-06-19 review_reason: "" merge_history: [] tags: [security, astra, ssrf, network, fetch, web, troubleshooting] raw_sources: ["E:/Wiki/astraai/src/features/web/ssrfGuard.ts", "E:/Wiki/astraai/src/features/web/webFetch.ts", "E:/Wiki/astraai/src/lib/contextBuilders/urlContext.ts", "E:/Wiki/astraai/src/agent/actions/webFetch.ts", "E:/Wiki/astraai/tests/ssrfGuard.test.ts"] applied_in: ["E:/Wiki/astraai @ branch (uncommitted, 2026-06-19)"] github_commit: "" --- # [[ASTRA SSRF 방어 URL fetch 경계]] ## 🎯 한 줄 통찰 (One-line insight) **모델/웹 콘텐츠가 준 URL을 호스트 검증 없이 fetch 하던 SSRF 경로를, "공인 IP 가 아니면 거부"하는 allowlist 성격의 `assertPublicUrl` 가드로 닫고 리다이렉트 각 홉까지 재검증한다.** ## 🧠 핵심 개념 (Core concepts) - **SSRF (Server-Side Request Forgery)**: 신뢰 불가 입력으로 서버가 내부 자원에 요청하게 만드는 공격. 여기선 "에이전트 프로세스"가 내부망/메타데이터/로컬 브릿지에 도달. - **차단 대상 IP**: 루프백(127/8·::1), 사설(10/8·172.16/12·192.168/16·fc00::/7), 링크로컬(169.254/16=클라우드 메타데이터·fe80::/10), CGNAT/멀티캐스트/예약, IPv4-mapped IPv6. - **단일 chokepoint**: `buildUrlContext` 입구에서 한 번 검증 → 브릿지 추출·직접 fetch 양 경로 모두 보호. - **리다이렉트 재검증**: `redirect:'manual'` + 각 홉 `assertPublicUrl` 재호출로 공인→사설 우회 차단. ## 🩺 증상 (Symptom) 일반 챗의 URL 주입과 에이전트 ``이 모델/페이지가 제시한 임의 URL을 `redirect:'follow'`로 그대로 fetch했다. 악성 페이지가 `http://127.0.0.1:3002`(로컬 브릿지)·`http://169.254.169.254/...`(메타데이터)·NAS/인트라넷 주소를 제시하면 그 응답 본문이 모델 컨텍스트로 주입될 수 있었다. ## 🌐 환경 / 범위 (Environment & scope) - 프로젝트: ASTRA (`E:/Wiki/astraai`). Node 18+ global `fetch`. - 경로: `urlContext.buildUrlContext`(일반 챗 + `fetch_url` 액션 공용) → ① 브릿지 추출 → ② `fetchUrlDirect` 폴백. - 비대상: `bridgeFetch`(의도적 localhost:3002 도구 호출)는 SSRF 가드 미적용 — 별도 함수. ## 🔁 재현 절차 (Reproduction) 1. 챗 또는 모델 응답에 `http://169.254.169.254/latest/meta-data/` 같은 내부 URL 포함. 2. 기존 `fetchUrlDirect`: `http/https`만 확인하고 목적지 IP 미검증 → 그대로 요청, 본문 반환([webFetch.ts](E:/Wiki/astraai/src/features/web/webFetch.ts)). 3. 공인 도메인 → 302 리다이렉트로 사설 IP 유도 시 `follow`가 그대로 추적. ## 🔥 영향 및 심각도 (Impact & severity) **High.** 내부망 스캐닝·클라우드 메타데이터 자격증명 탈취·로컬 전용 서비스(브릿지) 호출 가능. 입력 출처가 untrusted(웹/모델)라 악용 난도 낮음. ## 🧠 근본 원인 (Root cause) - `fetchUrlDirect`가 스킴(`http/https`)만 검사하고 **호스트→IP 해석 후 사설/예약 범위 검증이 전무**. - `buildUrlContext`가 URL을 브릿지·직접 양쪽으로 무검증 전달. - `redirect:'follow'`로 리다이렉트 목적지 재검증 불가. ## 🔎 조사 과정 (Investigation) - 보안 감사로 `webFetch.ts:81-103`·`agent/actions/webFetch.ts:20`의 무필터 fetch 경로 식별. - 외부 표준 리서치: OWASP는 **denylist보다 allowlist**(공인 IP 아니면 거부), DNS 리바인딩 방지엔 **해석 시점 IP 검증/피닝** 권고 [S5]. ## 🛠️ 해결 (Resolution / applied fix) 1. 신규 모듈 [ssrfGuard.ts](E:/Wiki/astraai/src/features/web/ssrfGuard.ts): `isBlockedIPv4/IPv6/Ip`, `assertPublicUrl(url)` — IP 리터럴 즉시 검사, DNS 호스트는 **모든 A/AAAA 레코드** 해석 후 하나라도 차단 대상이면 거부. `localhost`류는 해석 전 거부. 2. [urlContext.ts](E:/Wiki/astraai/src/lib/contextBuilders/urlContext.ts) 입구에 `assertPublicUrl` — 실패 시 "🚫 URL 접근 차단" 정직 블록 반환(브릿지·직접 양 경로 차단). 3. [webFetch.ts](E:/Wiki/astraai/src/features/web/webFetch.ts) `fetchUrlDirect`: `redirect:'manual'`로 전환, 최대 5홉 루프에서 각 홉 `assertPublicUrl` 재검증(이중 방어 + 독립 호출자 보호). ## 💻 코드 패턴 (Code patterns) ```ts // ssrfGuard.ts — "공인 아니면 거부" export async function assertPublicUrl(rawUrl: string): Promise { const host = new URL(rawUrl).hostname.replace(/^\[|\]$/g, ''); if (isIP(host)) { if (isBlockedIp(host)) throw new SsrfBlockedError(`차단된 내부 IP: ${host}`); return; } if (/^localhost$/i.test(host) || host.endsWith('.localhost')) throw new SsrfBlockedError('로컬 호스트'); const addrs = await dnsLookup(host, { all: true }); // 모든 레코드 검사(다중 IP 우회 방지) for (const { address } of addrs) if (isBlockedIp(address)) throw new SsrfBlockedError(`내부 IP 해석: ${host} → ${address}`); } ``` ```ts // webFetch.ts — 리다이렉트 각 홉 재검증 for (let hop = 0; hop <= 5; hop++) { try { await assertPublicUrl(current); } catch (e) { return fail(`차단됨(SSRF 방지): ${e.message}`); } res = await fetch(current, { redirect: 'manual', /* … */ }); if (res.status >= 300 && res.status < 400 && res.headers.get('location')) { current = new URL(res.headers.get('location'), current).toString(); continue; } break; } ``` ## ✅ 검증 (Verification) - 신규 [tests/ssrfGuard.test.ts](E:/Wiki/astraai/tests/ssrfGuard.test.ts) 9개: IPv4/IPv6 범위, 내부 IP URL 거부, `localhost` 거부, 잘못된 URL 거부. - `urlContextBuild.test.ts`는 SSRF 가드를 모킹(할루시네이션 로직 검증 전용, DNS 비의존화). - `tsc` 0 에러, 전체 716 통과. ## ⚖️ 모순 및 업데이트 (Contradictions & updates) **한계(정직 고지):** 해석 시점 검증이라 fetch 가 재해석하는 사이 레코드가 바뀌는 **DNS 리바인딩 창**은 완전히 닫지 못한다. 완전 차단은 연결 IP 고정(커스텀 undici dispatcher)이 필요 — 정적 사설 IP·리다이렉트 우회는 차단됨. 브릿지(:3002) 자체의 서버사이드 fetch SSRF는 브릿지(별도 프로젝트)의 책임. ## 🛠️ 적용 사례 (Applied in summary) ASTRA 일반 챗 URL 주입 + 에이전트 `fetch_url` 액션 + `/wikify` 폴백(직접 fetch) 경로에 적용. ## ✅ 검증 상태 및 신뢰도 - **상태:** applied (미커밋) - **검증 단계:** validated (단위 테스트 + 빌드/타입) - **출처 신뢰도:** S - **신뢰 점수:** 0.95 - **중복 검사 결과:** 신규 생성 ## 🔗 지식 그래프 (Knowledge Graph) - **상위/루트:** [[ASTRA]] - **관련 개념:** [[ASTRA 에이전트 셸 명령 실행 보안 게이트]], [[ASTRA 파일 경로 경계 가드]], [[ASTRA Datacollect Bridge]] - **참조 맥락:** 에이전트/LLM이 외부 URL을 가져올 때의 네트워크 경계 통제 기준. ## 📚 출처 (Sources) - [S1] `E:/Wiki/astraai/src/features/web/ssrfGuard.ts` — IP 범위 판정 + `assertPublicUrl`. - [S2] `E:/Wiki/astraai/src/features/web/webFetch.ts` — `fetchUrlDirect` 수동 리다이렉트 + 가드. - [S3] `E:/Wiki/astraai/src/lib/contextBuilders/urlContext.ts` — 입구 chokepoint. - [S4] `E:/Wiki/astraai/tests/ssrfGuard.test.ts` — 회귀 테스트. - [S5] OWASP SSRF Prevention / `azu/request-filtering-agent`, Wiz SSRF guide — allowlist·DNS 리바인딩 권고. ## 📝 변경 이력 (Change history) - 2026-06-19: 보안 감사 + 외부 표준 대조 후 SSRF 가드 신설 및 최초 문서화(Claude Opus 4.8).