From 967b8e1ed2088eb2626b5297f29abfaeac79380b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:09:06 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20SSRF=20=EA=B2=80=EC=82=AC=EB=A5=BC?= =?UTF-8?q?=20=EC=A7=81=EC=A0=91=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20?= =?UTF-8?q?=ED=95=9C=20=EA=B3=B3=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 내부 대역 판정을 infra contracts/internal-ranges.txt 로 바꿈 - 빌드가 정본을 classpath 에 실음. 없으면 가드를 만드는 빈 생성에서 기동 실패 - 이전: JDK 주소 분류 + CGNAT·ULA·메타데이터 개별 판정 - 결과: renderer 판정과 6개 대역에서 어긋남 - 렌더 호출 전의 첫 URL 재검사 삭제 - 원인: 렌더는 직접 가져오기가 실패한 뒤에만 탐. 같은 URL 이 이미 매 홉 검사를 통과함 - 렌더 홉 URL 의 재검사 삭제 - 이전: https 홉만 다시 이름 해석해 검사, http 홉은 건너뜀 - 원인: 렌더와 다른 시점의 이름 해석이라 브라우저가 실제로 간 IP 와 어긋날 수 있음 - renderer 가 실제 연결 IP 로 판정해 internal_address_hit 로 알리면 BLOCKED_HOST 로 번역 - https 아닌 redirect 를 MALFORMED_REDIRECT 로 내고 헤드리스로 승격 - 이전: BLOCKED_HOST. 내부 주소가 아닌데 SSRF 로 집계되고 승격도 막힘 - host 없는 주소는 INVALID_URL - 이전: BLOCKED_HOST - 판정을 끄는 대조: 정본에서 100.64.0.0/10 을 빼면 가드 테스트 2건 실패 --- build.gradle.kts | 8 ++ .../headless/HttpHeadlessRenderer.java | 62 ++++---------- .../extraction/http/HttpPageFetcher.java | 4 +- .../http/InternalAddressRanges.java | 81 +++++++++++++++++++ .../extraction/http/InternalHostGuard.java | 59 ++------------ .../extraction/http/PageFetchException.java | 7 +- .../headless/HttpHeadlessRendererTest.java | 65 ++++----------- .../http/HttpPageFetcherRedirectTest.java | 11 ++- .../http/InternalHostGuardTest.java | 53 +++++++++--- 9 files changed, 186 insertions(+), 164 deletions(-) create mode 100644 src/main/java/com/depromeet/piki/extractor/extraction/http/InternalAddressRanges.java diff --git a/build.gradle.kts b/build.gradle.kts index 5a32a46..2143451 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -97,6 +97,14 @@ sourceSets { } } +// 내부 대역 정본도 같은 자리에서 읽어 classpath 에 싣는다(InternalAddressRanges). +tasks.processResources { + from("shared-infra/contracts") { + include("internal-ranges.txt") + into("contracts") + } +} + tasks.withType { useJUnitPlatform() // zstd-jni 의 JNI 네이티브 로드 허용 — Java 25 는 경고만 내지만 미래 JDK 는 차단한다(JEP 472). diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java index 151adb1..aaac358 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java @@ -4,13 +4,10 @@ import com.depromeet.piki.contracts.render.v1.RenderProto; import com.depromeet.piki.contracts.render.v1.RenderRequest; import com.depromeet.piki.contracts.render.v1.RenderResponse; -import com.depromeet.piki.extractor.common.exception.ExtractionErrorCode; import com.depromeet.piki.extractor.common.exception.ExtractionException; import com.depromeet.piki.extractor.domain.ProductLink; import com.depromeet.piki.extractor.extraction.HeadlessExtractionProperties; -import com.depromeet.piki.extractor.extraction.http.InternalHostGuard; import com.depromeet.piki.extractor.extraction.http.PageFetchException; -import com.depromeet.piki.extractor.extraction.http.RequestScopedDnsResolver; import com.github.luben.zstd.ZstdInputStream; import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.util.JsonFormat; @@ -20,6 +17,7 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.MediaType; @@ -30,9 +28,11 @@ import org.springframework.web.client.RestClientResponseException; /** - * renderer 의 렌더 호출 wire 구현(계약 정본: TeamPiKi/infra 의 contracts/render.proto). 응답의 홉 목록을 {@link RenderedHop} 으로 옮기고 매 홉의 host 를 - * SSRF 가드에 통과시킨다 — 렌더 서비스가 대신 따라간 redirect 로 내부망 응답이 상품 HTML 로 흘러드는 것을 막는 - * 마지막 층이다(요청 전의 원본 URL 검증과 이중). 홉의 해석은 하지 않는다. + * renderer 의 렌더 호출 wire 구현(계약 정본: TeamPiKi/infra 의 contracts/render.proto). 응답의 홉 목록을 {@link RenderedHop} 으로 옮긴다. + * 홉의 해석은 하지 않는다. + * + *

내부 주소 판정은 renderer 가 브라우저의 실제 연결 IP 로 하고 {@code internal_address_hit} 로 알린다. 여기서 홉 URL 을 + * 다시 이름 해석하면 렌더와 다른 시점의 답이라 브라우저가 실제로 간 IP 와 어긋날 수 있어 하지 않는다. * *

전송: 요청 compress=true 면 응답이 zstd raw 바이트로 온다. 해제 분기는 요청이 아니라 응답 헤더 * ({@code X-Encoding: zstd}) 기준 — compress 를 모르는 구버전 renderer 는 plain JSON 을 주므로, 헤더 분기여야 @@ -40,6 +40,7 @@ * 롤아웃 규약 참조. */ @Slf4j +@RequiredArgsConstructor @Component public class HttpHeadlessRenderer implements HeadlessRenderer { @@ -60,37 +61,18 @@ public class HttpHeadlessRenderer implements HeadlessRenderer { private static final int MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024; private static final Pattern URL_PATTERN = Pattern.compile("https?://\\S+"); + @Qualifier(HeadlessRenderHttpClientConfig.HEADLESS_RENDER_REST_CLIENT) private final RestClient restClient; private final HeadlessExtractionProperties properties; - private final RequestScopedDnsResolver dnsResolver; - private final InternalHostGuard internalHostGuard; private final ZstdDictionaries dictionaries; - public HttpHeadlessRenderer( - @Qualifier(HeadlessRenderHttpClientConfig.HEADLESS_RENDER_REST_CLIENT) RestClient restClient, - HeadlessExtractionProperties properties, - RequestScopedDnsResolver dnsResolver, - ZstdDictionaries dictionaries - ) { - this.restClient = restClient; - this.properties = properties; - this.dnsResolver = dnsResolver; - this.internalHostGuard = new InternalHostGuard(dnsResolver); - this.dictionaries = dictionaries; - } - @Override public List render(ProductLink link, boolean authorized) { - try { - internalHostGuard.verify(link); - return renderVerified(link, authorized); - } finally { - dnsResolver.clear(); - } - } - - private List renderVerified(ProductLink link, boolean authorized) { RenderResponse response = requestRender(link, authorized); + if (response.getInternalAddressHit()) { + log.error("[SSRF] blocked: render reached internal address url={}", link.safeLogString()); + throw PageFetchException.blockedHost(); + } List hops = response.getHopsList(); if (hops.isEmpty()) { String error = response.hasError() ? maskUrls(response.getError()) : null; @@ -191,31 +173,19 @@ private byte[] resolveDict(String dictId) { } /** - * 홉 URL 은 Jsoup baseUri 이자 응답 계약의 finalUrl 로 호출자에게 나간다. 형식 위반은 원본 link 로 폴백한다 — - * baseUri 부정확은 치명이 아니고, 여기서 INVALID_URL 을 새면 렌더는 성공했는데 확정 실패로 종결되는 오판이 된다. - * 단 SSRF 판정은 폴백하지 않고 렌더 전체를 거부한다. + * 홉 URL 은 Jsoup baseUri 이자 응답 계약의 finalUrl 로 호출자에게 나간다. 형식 위반(http 홉 포함)은 원본 link 로 + * 폴백한다 — baseUri 부정확은 치명이 아니고, 여기서 INVALID_URL 을 새면 렌더는 성공했는데 확정 실패로 종결되는 + * 오판이 된다. */ private ProductLink resolveHopUrl(String url, ProductLink link) { if (url == null || url.isBlank()) { return link; } - ProductLink parsed; try { - parsed = ProductLink.parse(url); + return ProductLink.parse(url); } catch (ExtractionException e) { return link; } - try { - internalHostGuard.verify(parsed); - } catch (PageFetchException e) { - if (e.code() == ExtractionErrorCode.BLOCKED_HOST) { - throw e; - } - // 그 외(DNS 미해결 등)는 검증 불가일 뿐 내부망 근거가 아니다. - log.warn("headless render hop url 검증 실패 code={} url={}", e.code(), link.safeLogString()); - return link; - } - return parsed; } /** diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcher.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcher.java index a7b8435..67c46ff 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcher.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcher.java @@ -316,12 +316,12 @@ private ProductLink nextRedirect(ProductLink current, HttpHeaders headers) { URI location = redirectLocation(current, headers); URI target = current.value().resolve(location); // https 강제(다운그레이드 차단)와 형식 검증을 ProductLink.parse 가 겸한다 — 둘 중 하나라도 어긋난 - // redirect 는 따라가지 않는다. + // redirect 는 따라가지 않고 헤드리스로 넘긴다. 내부 주소가 아니므로 SSRF 차단(blockedHost)이 아니다. try { return ProductLink.parse(target.toString()); } catch (ProductLinkException e) { log.warn("link fetch redirect rejected (non-https or malformed) url={}", current.safeLogString()); - throw PageFetchException.blockedHost(); + throw PageFetchException.malformedRedirect(e); } } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/InternalAddressRanges.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/InternalAddressRanges.java new file mode 100644 index 0000000..b045975 --- /dev/null +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/InternalAddressRanges.java @@ -0,0 +1,81 @@ +package com.depromeet.piki.extractor.extraction.http; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * 내부 대역 판정. 목록의 정본은 TeamPiKi/infra 의 {@code contracts/internal-ranges.txt} 이고 renderer 와 서버 벽이 같은 + * 파일을 읽는다. 빌드가 그 파일을 classpath 에 싣는다(build.gradle.kts). + * + *

IPv4 를 담은 IPv6({@code ::ffff:a.b.c.d})는 따로 풀지 않는다 — JDK 가 그런 주소를 Inet4Address 로 돌려준다. + */ +final class InternalAddressRanges { + + private static final String CONTRACT = "/contracts/internal-ranges.txt"; + + /** 정본이 빠진 빌드는 판정 없이 뜨지 않고 이 클래스를 처음 쓰는 빈 생성에서 기동이 실패한다. */ + static final InternalAddressRanges CONTRACT_RANGES = load(); + + private final List ranges; + + private InternalAddressRanges(List ranges) { + this.ranges = ranges; + } + + boolean contains(InetAddress address) { + return ranges.stream().anyMatch(range -> range.contains(address)); + } + + private static InternalAddressRanges load() { + try (InputStream in = InternalAddressRanges.class.getResourceAsStream(CONTRACT)) { + if (in == null) { + throw new IllegalStateException("내부 대역 정본이 classpath 에 없다: " + CONTRACT); + } + List ranges = new String(in.readAllBytes(), StandardCharsets.UTF_8).lines() + .map(line -> line.split("#", 2)[0].strip()) + .filter(entry -> !entry.isEmpty()) + .map(Range::parse) + .toList(); + return new InternalAddressRanges(ranges); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private record Range(byte[] network, int prefixLength) { + + /** 정본의 주소는 IP 리터럴이라 getByName 이 이름 조회를 하지 않는다. */ + static Range parse(String cidr) { + String[] parts = cidr.split("/", 2); + try { + return new Range(InetAddress.getByName(parts[0]).getAddress(), Integer.parseInt(parts[1])); + } catch (UnknownHostException | RuntimeException e) { + throw new IllegalStateException("내부 대역 정본의 깨진 줄: " + cidr, e); + } + } + + boolean contains(InetAddress address) { + byte[] candidate = address.getAddress(); + if (candidate.length != network.length) { + return false; + } + int fullBytes = prefixLength / 8; + for (int i = 0; i < fullBytes; i++) { + if (candidate[i] != network[i]) { + return false; + } + } + int remainingBits = prefixLength % 8; + if (remainingBits == 0) { + return true; + } + int mask = 0xFF << (8 - remainingBits); + return (candidate[fullBytes] & mask) == (network[fullBytes] & mask); + } + } +} diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuard.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuard.java index a33e02f..4a48a2e 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuard.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuard.java @@ -1,20 +1,14 @@ package com.depromeet.piki.extractor.extraction.http; import com.depromeet.piki.extractor.domain.ProductLink; -import java.net.Inet4Address; -import java.net.Inet6Address; import java.net.InetAddress; import java.net.UnknownHostException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** - * SSRF 가드: host 가 외부 라우팅 불가 주소(loopback/사설/링크로컬/메타데이터/IPv6 ULA/CGNAT)로 resolve 되면 차단. - * 외부 쇼핑몰만 접근하는 것이 추출 파이프라인의 책임이라 외부 라우팅 가능 IP 만 허용한다. - * - *

HttpPageFetcher(정적 fetch, 매 redirect hop)와 HttpHeadlessRenderer(브라우저 직행 경로)가 공유한다 — - * 직행 경로는 plain fetch 를 아예 타지 않아 fetch 안의 가드로는 커버되지 않으므로, 렌더 서비스로 URL 을 - * 넘기기 전에 같은 판정을 거쳐야 내부망에 헤드리스 브라우저를 겨누는 구멍이 닫힌다. + * SSRF 가드: host 가 내부 대역({@link InternalAddressRanges})으로 resolve 되면 차단한다. 직접 가져오기(HttpPageFetcher)가 매 + * redirect hop 마다 부른다. 렌더 경로는 renderer 가 브라우저의 실제 연결 IP 로 같은 정본을 판정하므로 여기서 다시 보지 않는다. * *

빈이 아니라 소유자가 자기 RequestScopedDnsResolver 로 직접 조립한다 — 가드가 resolve 한 그 IP 를 이후 연결이 * 공유(IP pin)하는 계약은 "같은 resolver 인스턴스" 로만 성립하므로, 조립 책임을 소유자에게 둬 그 결합을 명시한다. @@ -24,12 +18,14 @@ public final class InternalHostGuard { private final RequestScopedDnsResolver dnsResolver; + private final InternalAddressRanges ranges = InternalAddressRanges.CONTRACT_RANGES; public void verify(ProductLink link) { String host = link.value().getHost(); if (host == null) { - log.warn("[SSRF] blocked: missing host url={}", link.safeLogString()); - throw PageFetchException.blockedHost(); + // https 스킴만 맞고 host 가 없는 주소(https:///p 등). 갈 곳이 없어 형식 위반이다. + log.info("link fetch missing host url={}", link.safeLogString()); + throw PageFetchException.unresolvableHost(null); } InetAddress[] addresses; try { @@ -39,51 +35,10 @@ public void verify(ProductLink link) { throw PageFetchException.unresolvableHost(e); } for (InetAddress addr : addresses) { - if (isInternalAddress(addr)) { + if (ranges.contains(addr)) { log.error("[SSRF] blocked: internal address resolved url={}", link.safeLogString()); throw PageFetchException.blockedHost(); } } } - - /** - * 외부 라우팅 불가 주소면 SSRF 위험으로 본다. Java 의 isSiteLocalAddress 는 IPv4 사설만 잡고 IPv6 ULA(fc00::/7)는 - * 못 잡으므로(GCP·Alibaba 등의 IPv6 메타데이터·내부망), 이를 별도로 차단한다. - * (같은 패키지 테스트가 직접 호출하도록 package-private.) - */ - boolean isInternalAddress(InetAddress addr) { - return addr.isLoopbackAddress() - || addr.isAnyLocalAddress() - || addr.isSiteLocalAddress() - || addr.isLinkLocalAddress() - || addr.isMulticastAddress() - || isUniqueLocalIpv6(addr) - || isCarrierGradeNatIpv4(addr) - // AWS / GCP IPv4 메타데이터(169.254.169.254 는 link-local 이라 위에서 잡히지만 방어적으로 명시). - || "169.254.169.254".equals(addr.getHostAddress()); - } - - /** IPv6 Unique Local Address(fc00::/7). 첫 바이트의 상위 7비트가 1111110 (0xfc 또는 0xfd)이면 ULA 다. */ - private boolean isUniqueLocalIpv6(InetAddress addr) { - if (!(addr instanceof Inet6Address)) { - return false; - } - byte[] bytes = addr.getAddress(); - int first = bytes.length == 0 ? 0 : bytes[0]; - return (first & 0xfe) == 0xfc; - } - - /** - * IPv4 Carrier-Grade NAT(100.64.0.0/10). 외부 라우팅이 안 되는 캐리어 NAT 대역으로, 일부 클라우드 - * 메타데이터 엔드포인트(예: 100.100.100.200)가 여기 속해 SSRF 차단 대상이다. - */ - private boolean isCarrierGradeNatIpv4(InetAddress addr) { - if (!(addr instanceof Inet4Address)) { - return false; - } - byte[] bytes = addr.getAddress(); - int first = bytes[0] & 0xFF; - int second = bytes[1] & 0xFF; - return first == 100 && second >= 64 && second <= 127; - } } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.java index 1f05134..19f21da 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/PageFetchException.java @@ -121,13 +121,16 @@ public static PageFetchException tooManyRedirects() { return new PageFetchException(LINK_UNREACHABLE, ExtractionErrorCode.TOO_MANY_REDIRECTS, true, null, true); } - /** 대상 서버가 3xx 를 주면서 Location 이 없거나 깨진 값을 준 비정상 redirect. 확정 실패, escalatable=true. */ + /** + * 대상 서버가 3xx 를 주면서 Location 이 없거나 깨졌거나 https 가 아닌 위치를 준 redirect. 확정 실패, + * escalatable=true — 브라우저는 http 위치도 따라간다. + */ public static PageFetchException malformedRedirect(Throwable cause) { return new PageFetchException(LINK_UNREACHABLE, ExtractionErrorCode.MALFORMED_REDIRECT, true, cause, true); } /** - * host 가 사설/메타데이터/loopback 영역으로 resolve 될 때의 SSRF 차단 신호. escalatable=false — 내부망에 + * host 가 내부 대역으로 resolve 되거나, 렌더가 그 대역 주소에서 응답을 받았을 때의 SSRF 차단 신호. escalatable=false — 내부망에 * 헤드리스를 겨누는 것 자체가 SSRF 취약점이라 "무조건 폴백"의 유일한 예외다(recall 트레이드오프가 아니라 보안). */ public static PageFetchException blockedHost() { diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java index 6504c14..a11a4a0 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java @@ -14,12 +14,10 @@ import com.depromeet.piki.extractor.domain.ProductLink; import com.depromeet.piki.extractor.extraction.HeadlessExtractionProperties; import com.depromeet.piki.extractor.extraction.http.PageFetchException; -import com.depromeet.piki.extractor.extraction.http.RequestScopedDnsResolver; import com.github.luben.zstd.ZstdOutputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.UncheckedIOException; -import java.net.InetAddress; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; @@ -33,8 +31,8 @@ import org.springframework.web.client.RestClient; /** - * POST /render 의 wire 계약(요청 필드·홉 변환·SSRF 가드·홉 url 폴백·zstd 해제)을 네트워크 없이 검증한다. - * 홉의 해석은 HeadlessProductLinkExtractorTest 가 진다. DNS 는 가짜 공인 IP 로 주입해 SSRF 가드를 통과시킨다. + * POST /render 의 wire 계약(요청 필드·홉 변환·내부 주소 신호·홉 url 폴백·zstd 해제)을 네트워크 없이 검증한다. + * 홉의 해석은 HeadlessProductLinkExtractorTest 가 진다. */ class HttpHeadlessRendererTest { @@ -48,27 +46,15 @@ class HttpHeadlessRendererTest { private final ProductLink link = ProductLink.parse("https://kream.co.kr/products/6963"); - private final RequestScopedDnsResolver.HostResolver publicIp = - host -> new InetAddress[] {InetAddress.getByName("93.184.216.34")}; - - private HttpHeadlessRenderer rendererWith( - RequestScopedDnsResolver.HostResolver hostResolver, - ZstdDictionaries dictionaries, - Consumer configure - ) { + private HttpHeadlessRenderer rendererWith(ZstdDictionaries dictionaries, Consumer configure) { RestClient.Builder builder = RestClient.builder().baseUrl(BASE_URL); MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); configure.accept(server); - return new HttpHeadlessRenderer( - builder.build(), - HeadlessExtractionProperties.of(true), - new RequestScopedDnsResolver(hostResolver), - dictionaries - ); + return new HttpHeadlessRenderer(builder.build(), HeadlessExtractionProperties.of(true), dictionaries); } private HttpHeadlessRenderer rendererWith(Consumer configure) { - return rendererWith(publicIp, ZstdDictionaries.none(), configure); + return rendererWith(ZstdDictionaries.none(), configure); } @Test @@ -127,7 +113,7 @@ void zstdResponseIsDecompressed() { @DisplayName("사전 압축 응답(X-Zstd-Dict: 사전ID)은 보유한 같은 사전으로 해제한다") void zstdDictResponseUsesSharedDictionary() { byte[] dict = "공용 boilerplate".getBytes(StandardCharsets.UTF_8); - HttpHeadlessRenderer renderer = rendererWith(publicIp, ZstdDictionaries.of("mall-v1.dict", dict), server -> server + HttpHeadlessRenderer renderer = rendererWith(ZstdDictionaries.of("mall-v1.dict", dict), server -> server .expect(requestTo(BASE_URL + "/render")) .andRespond(withSuccess(zstdCompress(TWO_HOPS, dict), MediaType.APPLICATION_OCTET_STREAM) .headers(zstdHeaders("mall-v1.dict")))); @@ -167,23 +153,26 @@ void undecodableBodyIsTransient() { } @Test - @DisplayName("내부망으로 resolve 되는 host 는 렌더 서비스 호출 전에 SSRF 로 차단된다 — 직행 경로의 방어선") - void internalHostIsBlockedBeforeRender() { - RequestScopedDnsResolver.HostResolver internalIp = - host -> new InetAddress[] {InetAddress.getByName("169.254.169.254")}; - // 서버에 expect 를 하나도 걸지 않는다 — 가드가 먼저 던지므로 렌더 서비스로 요청이 나가면 안 된다. - HttpHeadlessRenderer renderer = rendererWith(internalIp, ZstdDictionaries.none(), server -> { }); + @DisplayName("renderer 가 내부 주소 응답을 알리면 SSRF 확정 실패(BLOCKED_HOST)이고 헤드리스 승격 대상이 아니다") + void internalAddressHitIsBlockedHost() { + HttpHeadlessRenderer renderer = rendererWith(server -> server + .expect(requestTo(BASE_URL + "/render")) + .andRespond(withSuccess( + "{\"error\":\"internal address response\",\"internal_address_hit\":true,\"hops\":[]}", + MediaType.APPLICATION_JSON + ))); PageFetchException ex = assertThrows(PageFetchException.class, () -> renderer.render(link, false)); assertEquals(ExtractionErrorCode.BLOCKED_HOST, ex.code()); assertTrue(ex.permanent()); + assertFalse(ex.escalatable()); } @Test - @DisplayName("홉 url 이 없거나 우리 형식이 아니면 원본 link 로 폴백한다 — 렌더 성공을 실패로 오판하지 않는다") + @DisplayName("홉 url 이 없거나 우리 형식(https)이 아니면 원본 link 로 폴백한다 — 렌더 성공을 실패로 오판하지 않는다") void invalidHopUrlFallsBackToLink() { - for (String urlField : List.of("", "\"url\":\"chrome-error://failed\",")) { + for (String urlField : List.of("", "\"url\":\"chrome-error://failed\",", "\"url\":\"http://kream.co.kr/p\",")) { HttpHeadlessRenderer renderer = rendererWith(server -> server .expect(requestTo(BASE_URL + "/render")) .andRespond(withSuccess( @@ -195,26 +184,6 @@ void invalidHopUrlFallsBackToLink() { } } - @Test - @DisplayName("어느 홉이든 내부망으로 resolve 되면 렌더 전체를 거부한다 — 원본만 검증하면 redirect 로 가드를 우회한다") - void internalHopIsBlocked() { - RequestScopedDnsResolver.HostResolver byHost = host -> "metadata.internal".equals(host) - ? new InetAddress[] {InetAddress.getByName("169.254.169.254")} - : new InetAddress[] {InetAddress.getByName("93.184.216.34")}; - HttpHeadlessRenderer renderer = rendererWith(byHost, ZstdDictionaries.none(), server -> server - .expect(requestTo(BASE_URL + "/render")) - .andRespond(withSuccess( - "{\"hops\":[" - + "{\"url\":\"https://metadata.internal/latest/meta-data/\",\"status\":200,\"dom\":\"secret\"}," - + "{\"url\":\"https://kream.co.kr/p\",\"status\":200,\"dom\":\"ok\"}]}", - MediaType.APPLICATION_JSON - ))); - - PageFetchException ex = assertThrows(PageFetchException.class, () -> renderer.render(link, false)); - - assertEquals(ExtractionErrorCode.BLOCKED_HOST, ex.code()); - } - @Test @DisplayName("렌더 서비스의 5xx·비정상 응답은 일시 실패다") void renderServiceErrorIsTransient() { diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectTest.java index d0f0dc7..0415a15 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; @@ -21,7 +22,7 @@ import org.springframework.web.client.RestClient; /** - * redirect 루프(3xx 따라가기·cross-domain 차단·다운그레이드 차단·hop 상한·비-redirect 3xx)를 네트워크 없이 검증한다. + * redirect 루프(3xx 따라가기·cross-domain 차단·다운그레이드 승격·hop 상한·비-redirect 3xx)를 네트워크 없이 검증한다. * *

DNS 를 가짜 공인 IP 로 주입해 SSRF 가드를 통과시켜 redirect 로직만 격리한다. */ @@ -100,16 +101,18 @@ void blocksCrossDomainRedirectToPrivateIp() { } @Test - @DisplayName("https 에서 http 로 다운그레이드하는 redirect 는 차단한다") - void blocksHttpsToHttpDowngrade() { + @DisplayName("https 에서 http 로 다운그레이드하는 redirect 는 따라가지 않고 헤드리스로 넘긴다 — 내부 주소가 아니라 SSRF 차단이 아니다") + void handsHttpsToHttpDowngradeToHeadless() { HttpPageFetcher fetcher = fetcherWith(server -> server.expect(requestTo("https://www.zigzag.kr/p")) .andRespond(withStatus(HttpStatus.MOVED_PERMANENTLY).location(URI.create("http://zigzag.kr/p")))); - assertThrows( + PageFetchException ex = assertThrows( PageFetchException.class, () -> fetcher.fetch(ProductLink.parse("https://www.zigzag.kr/p"))); + assertEquals(ExtractionErrorCode.MALFORMED_REDIRECT, ex.code()); + assertTrue(ex.escalatable()); } @Test diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuardTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuardTest.java index 6459c76..0d9aa7a 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuardTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuardTest.java @@ -1,5 +1,6 @@ package com.depromeet.piki.extractor.extraction.http; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; @@ -16,14 +17,15 @@ import org.junit.jupiter.params.provider.ValueSource; /** - * SSRF 가드의 internal-address 판정과, 조회 실패를 어떤 실패로 번역하는지를 검증한다. - * - *

redirect 가 매 hop 새 host 를 허용하고 헤드리스 직행 경로도 이 판정을 거치므로, 이 판정이 보안의 최종 방어선이다. - * 특히 IPv6 ULA(fc00::/7)는 Java 의 {@code isSiteLocalAddress} 가 못 잡아 별도로 막는다. + * SSRF 가드의 내부 대역 판정(infra 정본)과, 조회 실패를 어떤 실패로 번역하는지를 검증한다. */ class InternalHostGuardTest { - private final InternalHostGuard guard = new InternalHostGuard(new RequestScopedDnsResolver()); + private static final ProductLink LINK = ProductLink.parse("https://shop.example/p/1"); + + private static InternalHostGuard guardResolvingTo(String ip) { + return new InternalHostGuard(new RequestScopedDnsResolver(host -> new InetAddress[] {InetAddress.getByName(ip)})); + } @ParameterizedTest @ValueSource(strings = { @@ -34,15 +36,22 @@ class InternalHostGuardTest { "169.254.0.1", // link-local "169.254.169.254", // 클라우드 메타데이터 "0.0.0.0", // any-local + "0.0.0.1", // 0.0.0.0/8 - 리눅스는 자기 서버로 보낸다 + "240.0.0.1", // 예약 + "224.0.0.1", // 멀티캐스트 "::1", // IPv6 loopback + "::ffff:127.0.0.1", // IPv4 를 담은 IPv6 - JDK 가 Inet4Address 로 돌려준다 + "fe80::1", // IPv6 link-local "fc00::1", // IPv6 ULA "fd00:ec2::254", // IPv6 ULA (클라우드 IPv6 메타데이터 대역) "100.64.0.1", // CGNAT (100.64.0.0/10) "100.100.100.200", // CGNAT — 일부 클라우드 메타데이터 엔드포인트 }) - @DisplayName("내부·메타데이터 주소는 차단된다") - void internalAndMetadataAddressesAreBlocked(String ip) throws UnknownHostException { - assertTrue(guard.isInternalAddress(InetAddress.getByName(ip)), ip + " 는 차단되어야 함"); + @DisplayName("내부·메타데이터 주소로 resolve 되면 SSRF 확정 실패(BLOCKED_HOST)다") + void internalAndMetadataAddressesAreBlocked(String ip) { + PageFetchException thrown = assertThrows(PageFetchException.class, () -> guardResolvingTo(ip).verify(LINK)); + + assertEquals(ExtractionErrorCode.BLOCKED_HOST, thrown.code(), ip + " 는 차단되어야 함"); } @ParameterizedTest @@ -51,10 +60,34 @@ void internalAndMetadataAddressesAreBlocked(String ip) throws UnknownHostExcepti "1.1.1.1", "93.184.216.34", "2606:4700:4700::1111", // 공인 IPv6 (Cloudflare) + "198.18.0.1", // 벤치마크 대역 - 인터넷에 라우팅되지 않을 뿐 우리 서버에서 닿을 곳이 없다 }) @DisplayName("공인 라우팅 가능 주소는 허용된다") - void publicRoutableAddressesAreAllowed(String ip) throws UnknownHostException { - assertFalse(guard.isInternalAddress(InetAddress.getByName(ip)), ip + " 는 허용되어야 함"); + void publicRoutableAddressesAreAllowed(String ip) { + assertDoesNotThrow(() -> guardResolvingTo(ip).verify(LINK), ip + " 는 허용되어야 함"); + } + + @Test + @DisplayName("공인 주소와 내부 주소가 함께 오면 차단한다 — 연결이 어느 주소로 갈지 가드가 고르지 않는다") + void mixedAnswerIsBlocked() { + InternalHostGuard mixed = new InternalHostGuard(new RequestScopedDnsResolver(host -> new InetAddress[] { + InetAddress.getByName("93.184.216.34"), InetAddress.getByName("10.0.1.5")})); + + PageFetchException thrown = assertThrows(PageFetchException.class, () -> mixed.verify(LINK)); + + assertEquals(ExtractionErrorCode.BLOCKED_HOST, thrown.code()); + } + + @Test + @DisplayName("host 가 없는 주소는 형식 위반(INVALID_URL)이지 SSRF 차단이 아니다") + void missingHostIsInvalidUrl() { + InternalHostGuard guard = guardResolvingTo("93.184.216.34"); + + PageFetchException thrown = assertThrows( + PageFetchException.class, + () -> guard.verify(ProductLink.parse("https:///p/1"))); + + assertEquals(ExtractionErrorCode.INVALID_URL, thrown.code()); } @Test From e80d6235a888567f444da2faaa12c53eb6918874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:09:06 +0900 Subject: [PATCH 2/4] =?UTF-8?q?infra:=20extractor=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=EC=97=90=20=EC=84=9C=EB=B2=84=20=EB=B2=BD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배포가 infra 의 egress_wall.sh 로 서버 벽을 건 뒤 새 슬롯을 띄움 - 이 서비스 고유 예외는 renderer 주소 하나 - 배포가 매번 계산하는 renderer 주소를 그대로 넘김. 포트·박스가 바뀌어도 같이 따라감 --- .github/workflows/deploy.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3b3975c..57be461 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -168,6 +168,8 @@ jobs: run: | ssh -n "$HOST" 'mkdir -p /tmp/piki-blocks' scp shared-infra/blocks/deploy_slot.sh \ + shared-infra/blocks/egress_wall.sh \ + shared-infra/contracts/internal-ranges.txt \ shared-infra/blocks/healthcheck.sh \ shared-infra/blocks/prune_images.sh \ shared-infra/blocks/run_container.sh \ @@ -270,6 +272,10 @@ jobs: # 배포 끝에서 정리하면 디스크가 찬 상태에서 pull 이 먼저 죽는다(renderer 2026-09-13). 규칙 정본은 infra conventions/infra.md. bash /tmp/piki-blocks/prune_images.sh + # 컨테이너가 내부 대역으로 여는 새 연결을 서버가 거절한다. 새 슬롯보다 먼저 건다. 이 서비스가 부르는 renderer 만 예외다. + sudo bash /tmp/piki-blocks/egress_wall.sh --ranges-file /tmp/piki-blocks/internal-ranges.txt \ + --allow-tcp "${RENDERER_URL#http://}" + bash /tmp/piki-blocks/deploy_slot.sh \ --state-file /etc/nginx/piki-extractor-upstream.conf \ --service-port "$SERVICE_PORT" --name-prefix piki-extractor \ From 915c20cedfa8d695f8603454d3d6f45e3d0f5579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:42:55 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EB=82=B4=EB=B6=80=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=EB=A5=BC=20=ED=8C=90=EC=A0=95=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20renderer=20=EC=9D=98=20=ED=99=89=20?= =?UTF-8?q?=EA=B1=B0=EB=B6=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 렌더 응답에 internal_address_hit 가 없으면 홉을 쓰지 않고 HEADLESS_UPSTREAM 일시 실패 - 원인: extractor 가 홉 재검사를 지웠음. renderer 가 판정 전 버전이면(배포 순서가 어긋나거나 롤백) 내부 응답이 그대로 들어옴 (독립 리뷰 지적) - 계약이 이 필드를 optional 로 바꿔 판정 여부를 실음 (TeamPiKi/infra#138) - 결과: 머지 순서를 코드가 지킴. renderer 가 먼저면 안전, extractor 가 먼저면 렌더만 일시 실패 - 음성 대조: 확인을 끄면 새 테스트 1건 실패 --- .../headless/HttpHeadlessRenderer.java | 5 ++++ .../headless/HttpHeadlessRendererTest.java | 26 +++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java index aaac358..eb127ba 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java @@ -69,6 +69,11 @@ public class HttpHeadlessRenderer implements HeadlessRenderer { @Override public List render(ProductLink link, boolean authorized) { RenderResponse response = requestRender(link, authorized); + if (!response.hasInternalAddressHit()) { + // 홉 재검사를 하지 않으므로, 내부 주소를 판정하지 않는 renderer(배포 순서가 어긋났거나 롤백됨)의 홉은 쓸 수 없다. + log.error("headless render without internal address judgement url={}", link.safeLogString()); + throw HeadlessRenderException.upstream("renderer 가 내부 주소를 판정하지 않는다", null); + } if (response.getInternalAddressHit()) { log.error("[SSRF] blocked: render reached internal address url={}", link.safeLogString()); throw PageFetchException.blockedHost(); diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java index a11a4a0..1bdc44a 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java @@ -39,7 +39,7 @@ class HttpHeadlessRendererTest { private static final String BASE_URL = "http://headless.test:8000"; /** proxied 는 계약에 없는 필드다 - 구버전 renderer 가 실어 보내도 무시되는지를 이 픽스처가 겸해서 본다. */ private static final String TWO_HOPS = - "{\"proxied\":true,\"hops\":[" + "{\"proxied\":true,\"internal_address_hit\":false,\"hops\":[" + "{\"url\":\"https://kream.co.kr/products/6963\",\"status\":302,\"headers\":{\"location\":\"/p\"}}," + "{\"url\":\"https://kream.co.kr/p\",\"status\":200,\"headers\":{\"content-type\":\"text/html\"}," + "\"body\":\"ssr\",\"dom\":\"dom\"}]}"; @@ -83,9 +83,9 @@ void hopsAreMappedInOrder() { @DisplayName("홉이 없으면(브라우저 오류·빈 응답) 일시 실패(HEADLESS_UPSTREAM)다") void noHopsIsTransient() { for (String body : List.of( - "{\"error\":\"TimeoutError: boom\",\"hops\":[]}", - "{\"error\":\"TimeoutError: boom\"}", - "{}" + "{\"error\":\"TimeoutError: boom\",\"internal_address_hit\":false,\"hops\":[]}", + "{\"error\":\"TimeoutError: boom\",\"internal_address_hit\":false}", + "{\"internal_address_hit\":false}" )) { HttpHeadlessRenderer renderer = rendererWith(server -> server .expect(requestTo(BASE_URL + "/render")) @@ -152,6 +152,22 @@ void undecodableBodyIsTransient() { } } + @Test + @DisplayName("내부 주소를 판정하지 않는 renderer(필드 없음)의 홉은 쓰지 않고 일시 실패(HEADLESS_UPSTREAM)다") + void rendererWithoutJudgementIsRefused() { + HttpHeadlessRenderer renderer = rendererWith(server -> server + .expect(requestTo(BASE_URL + "/render")) + .andRespond(withSuccess( + "{\"hops\":[{\"url\":\"https://kream.co.kr/p\",\"status\":200,\"dom\":\"secret\"}]}", + MediaType.APPLICATION_JSON + ))); + + HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link, false)); + + assertEquals(ExtractionErrorCode.HEADLESS_UPSTREAM, ex.code()); + assertFalse(ex.permanent()); + } + @Test @DisplayName("renderer 가 내부 주소 응답을 알리면 SSRF 확정 실패(BLOCKED_HOST)이고 헤드리스 승격 대상이 아니다") void internalAddressHitIsBlockedHost() { @@ -176,7 +192,7 @@ void invalidHopUrlFallsBackToLink() { HttpHeadlessRenderer renderer = rendererWith(server -> server .expect(requestTo(BASE_URL + "/render")) .andRespond(withSuccess( - "{\"hops\":[{" + urlField + "\"status\":200,\"dom\":\"ok\"}]}", + "{\"internal_address_hit\":false,\"hops\":[{" + urlField + "\"status\":200,\"dom\":\"ok\"}]}", MediaType.APPLICATION_JSON ))); From e99fced3f810fdf2db52ca933066a5dbaf16832b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:41:00 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20host=20=EC=A1=B4=EC=9E=AC=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=EB=A5=BC=20=EB=A7=81=ED=81=AC=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=20=EA=B2=80=EC=82=AC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductLink.parse 가 host 없는 주소를 INVALID_URL 로 거절 - 입구와 매 redirect 가 같은 함수를 거쳐 두 곳 모두 검사 - InternalHostGuard 의 host 없음 분기 삭제. 가드는 내부 대역 판정만 맡음 - host 없는 redirect 위치를 MALFORMED_REDIRECT 로 바꿈 - 이전: 다음 홉의 가드에서 걸려 INVALID_URL. 사용자 입력 오류로 잘못 분류됨 - 음성 대조: host 검사를 빼면 새 테스트 2건 실패 --- .../piki/extractor/domain/ProductLink.java | 5 ++++- .../extraction/http/InternalHostGuard.java | 8 +------- .../piki/extractor/domain/ProductLinkTest.java | 13 +++++++++++++ .../http/HttpPageFetcherRedirectTest.java | 15 +++++++++++++++ .../extraction/http/InternalHostGuardTest.java | 12 ------------ 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/depromeet/piki/extractor/domain/ProductLink.java b/src/main/java/com/depromeet/piki/extractor/domain/ProductLink.java index f060c01..d445501 100644 --- a/src/main/java/com/depromeet/piki/extractor/domain/ProductLink.java +++ b/src/main/java/com/depromeet/piki/extractor/domain/ProductLink.java @@ -7,7 +7,7 @@ /** * 미지원 플랫폼 판정은 등록 입력 경계의 정책이라 호출자(core) 소관이다 — - * 이 서비스의 ProductLink 는 형식 불변식(https·URI 형식)과 안전 로깅만 책임진다. + * 이 서비스의 ProductLink 는 형식 불변식(https·URI 형식·host 존재)과 안전 로깅만 책임진다. */ public final class ProductLink { @@ -36,6 +36,9 @@ public static ProductLink parse(String raw) { if (scheme == null || !HTTP_SCHEMES.contains(scheme.toLowerCase(Locale.ROOT))) { throw ProductLinkException.unsupportedScheme(); } + if (uri.getHost() == null) { + throw ProductLinkException.invalidFormat(null); + } return new ProductLink(uri); } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuard.java b/src/main/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuard.java index 4a48a2e..606080e 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuard.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuard.java @@ -21,15 +21,9 @@ public final class InternalHostGuard { private final InternalAddressRanges ranges = InternalAddressRanges.CONTRACT_RANGES; public void verify(ProductLink link) { - String host = link.value().getHost(); - if (host == null) { - // https 스킴만 맞고 host 가 없는 주소(https:///p 등). 갈 곳이 없어 형식 위반이다. - log.info("link fetch missing host url={}", link.safeLogString()); - throw PageFetchException.unresolvableHost(null); - } InetAddress[] addresses; try { - addresses = dnsResolver.resolve(host); + addresses = dnsResolver.resolve(link.value().getHost()); } catch (UnknownHostException e) { log.info("link fetch unknown host url={}", link.safeLogString()); throw PageFetchException.unresolvableHost(e); diff --git a/src/test/java/com/depromeet/piki/extractor/domain/ProductLinkTest.java b/src/test/java/com/depromeet/piki/extractor/domain/ProductLinkTest.java index 18477bc..656bb4e 100644 --- a/src/test/java/com/depromeet/piki/extractor/domain/ProductLinkTest.java +++ b/src/test/java/com/depromeet/piki/extractor/domain/ProductLinkTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.depromeet.piki.extractor.common.exception.ExtractionErrorCode; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -54,6 +55,18 @@ void rejectsMalformedUri() { assertEquals("올바른 링크 형식이 아니에요. 다시 확인해 주세요.", ex.getMessage()); } + @Test + @DisplayName("https 스킴만 맞고 host 가 없는 raw 는 형식 오류로 거부한다") + void rejectsMissingHost() { + for (String raw : List.of("https:///p/1", "https:/p/1", "https:p/1")) { + ProductLinkException ex = assertThrows( + ProductLinkException.class, + () -> ProductLink.parse(raw), + raw + " 는 거부되어야 함"); + assertEquals(ExtractionErrorCode.INVALID_URL, ex.code()); + } + } + @Test @DisplayName("예외 메시지에 원본 raw 는 포함되지 않는다") void doesNotLeakRawInMessage() { diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectTest.java index 0415a15..7ece98a 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/HttpPageFetcherRedirectTest.java @@ -115,6 +115,21 @@ void handsHttpsToHttpDowngradeToHeadless() { assertTrue(ex.escalatable()); } + @Test + @DisplayName("host 가 없는 redirect 위치는 사용자 입력 오류가 아니라 비정상 redirect 로 헤드리스로 넘긴다") + void handsHostlessRedirectToHeadless() { + HttpPageFetcher fetcher = + fetcherWith(server -> + server.expect(requestTo("https://www.zigzag.kr/p")) + .andRespond(withStatus(HttpStatus.FOUND).location(URI.create("https:///p")))); + + PageFetchException ex = assertThrows( + PageFetchException.class, + () -> fetcher.fetch(ProductLink.parse("https://www.zigzag.kr/p"))); + assertEquals(ExtractionErrorCode.MALFORMED_REDIRECT, ex.code()); + assertTrue(ex.escalatable()); + } + @Test @DisplayName("redirect 가 상한을 넘으면 tooManyRedirects 로 끊는다") void breaksOnTooManyRedirects() { diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuardTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuardTest.java index 0d9aa7a..5f4d5ed 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuardTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/http/InternalHostGuardTest.java @@ -78,18 +78,6 @@ void mixedAnswerIsBlocked() { assertEquals(ExtractionErrorCode.BLOCKED_HOST, thrown.code()); } - @Test - @DisplayName("host 가 없는 주소는 형식 위반(INVALID_URL)이지 SSRF 차단이 아니다") - void missingHostIsInvalidUrl() { - InternalHostGuard guard = guardResolvingTo("93.184.216.34"); - - PageFetchException thrown = assertThrows( - PageFetchException.class, - () -> guard.verify(ProductLink.parse("https:///p/1"))); - - assertEquals(ExtractionErrorCode.INVALID_URL, thrown.code()); - } - @Test @DisplayName("host 를 조회하지 못하면 확정 실패로 던져 호출자가 재시도를 태우지 않는다") void unresolvableHostFailsPermanently() {