From 67e849a60448a759590f77560261d091e864938d 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: Sun, 16 Aug 2026 06:44:30 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20host=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A5=BC=20=ED=99=95=EC=A0=95=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=EB=A1=9C=20=EB=82=B4=EB=A0=A4=20=EC=9E=AC=EC=8B=9C=EB=8F=84?= =?UTF-8?q?=EB=A5=BC=20=ED=83=9C=EC=9A=B0=EC=A7=80=20=EC=95=8A=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InternalHostGuard 가 UnknownHostException 을 upstreamError(UPSTREAM_ERROR, 일시)로 번역해, 호출자가 재시도 2회를 소진한 뒤 reason=retry_exhausted 로 종결하던 것을 unresolvableHost(INVALID_URL, 확정)로 바꾼다 - 두 가지가 어긋나 있었다. 운영 사유가 "외부 불안정"으로 기록돼 실제 사유(등록된 주소가 잘못됨)를 가렸고, 확정적으로 실패할 주소에 재등록마다 호출 2회를 다시 태웠다. dev 에서 같은 https://df 가 2회 등록돼 두 번 다 attempt=2 를 소진한 것이 실측이다 - code 는 새로 만들지 않고 INVALID_URL 을 재사용한다. 형식 위반과 결론이 같고 카탈로그에 이미 permanent 로 있어 호출자 매핑이 그대로 맞는다 - escalatable=false — resolve 되지 않는 host 는 헤드리스 브라우저도 도달하지 못한다. blockedHost 에 이어 "무조건 폴백"의 두 번째 예외이며, 근거가 보안이 아니라 성립 불가라는 점을 클래스 Javadoc 에 갈라 적었다 - UnknownHostException 이 NXDOMAIN 과 리졸버 일시 장애를 구분하지 않는 한계는 확정 처리 쪽으로 감수한다. 리졸버 장애는 박스 전역 문제라 호스트 관측이 다룰 일이고, 그 창의 건은 재등록이 새 시도를 만든다. RCODE 로 가르려면 SSRF 가드·IP pin 계약까지 바꿔야 해 별건이다 Closes #42 --- .../extraction/http/InternalHostGuard.java | 2 +- .../extraction/http/PageFetchException.java | 29 ++++++++++++++++--- .../api/ExtractionLinkIntegrationTest.java | 16 ++++++++++ .../ExtractionErrorCodeCatalogTest.java | 1 + .../http/InternalHostGuardTest.java | 26 ++++++++++++++++- 5 files changed, 68 insertions(+), 6 deletions(-) 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 f5a252c..a33e02f 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 @@ -36,7 +36,7 @@ public void verify(ProductLink link) { addresses = dnsResolver.resolve(host); } catch (UnknownHostException e) { log.info("link fetch unknown host url={}", link.safeLogString()); - throw PageFetchException.upstreamError(e); + throw PageFetchException.unresolvableHost(e); } for (InetAddress addr : addresses) { if (isInternalAddress(addr)) { 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 891d4fe..ba2de44 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 @@ -15,11 +15,15 @@ public final class PageFetchException extends ExtractionException { /** * 정적 fetch 실패를 실제 브라우저(헤드리스)로 재시도(escalate)할지 표시한다. FallbackProductLinkExtractor 가 - * 이 값으로 정한다(에스컬레이션 축은 호출자의 outbox 재시도 축과 직교). 정책은 "무조건 폴백": SSRF(blockedHost, - * 보안)만 빼고 모든 fetch 실패가 escalatable 이다 — 봇 방어가 어떤 status 로도 위장해 status·body 로 - * 차단/genuine 을 못 가른다. + * 이 값으로 정한다(에스컬레이션 축은 호출자의 outbox 재시도 축과 직교). 정책은 "무조건 폴백": 예외는 둘뿐이고 + * 나머지 fetch 실패는 전부 escalatable 이다 — 봇 방어가 어떤 status 로도 위장해 status·body 로 차단/genuine 을 + * 못 가른다. * - *

기본 false(fail-closed): 각 팩토리가 명시적으로 true 를 줘야 escalate 되고, SSRF 만 default false 를 유지한다. + *

false 인 둘은 서로 다른 이유로 그렇다. blockedHost 는 보안 판단(내부망에 브라우저를 겨누는 것 자체가 SSRF)이고, + * unresolvableHost 는 성립 불가 판단(주소가 없으면 브라우저도 갈 곳이 없다)이다. 앞은 recall 을 포기한 것이지만 + * 뒤는 포기할 recall 자체가 없다. + * + *

기본 false(fail-closed): 각 팩토리가 명시적으로 true 를 줘야 escalate 된다. */ private final boolean escalatable; @@ -47,6 +51,23 @@ public static PageFetchException upstreamError(Throwable cause) { return new PageFetchException(LINK_UNREACHABLE, ExtractionErrorCode.UPSTREAM_ERROR, false, cause, true); } + /** + * host 를 IP 로 조회하지 못한 경우(InternalHostGuard 의 DNS 조회 실패). 없는 주소는 몇 번을 다시 물어도 없으므로 + * 확정 실패로 둔다 — 일시로 두면 호출자가 재시도 예산을 다 태운 뒤 "외부가 불안정했다"로 종결해, 실제 사유(등록된 + * 주소가 잘못됐다)를 운영 지표에서 가린다. code 는 형식 위반과 같은 INVALID_URL 이다: 두 경우 모두 결론이 + * "이 주소로는 갈 수 없다"라 호출자가 달리 행동할 여지가 없다. + * + *

escalatable=false — resolve 되지 않는 host 는 헤드리스 브라우저도 같은 이유로 도달하지 못한다. + * + *

{@code UnknownHostException} 은 NXDOMAIN 과 리졸버 일시 장애(SERVFAIL·타임아웃)를 구분하지 않아 후자도 + * 확정 실패가 된다. 그래도 확정으로 두는 이유: 리졸버 장애는 이 박스 전역의 문제라 개별 추출의 재시도가 아니라 + * 호스트 관측이 다룰 일이고, 그 창에서 확정된 건은 사용자 재등록이 새 시도를 만든다. RCODE 로 정확히 가르려면 + * SSRF 가드·IP pin 계약(RequestScopedDnsResolver)까지 바꿔야 해서 별건이다. + */ + public static PageFetchException unresolvableHost(Throwable cause) { + return new PageFetchException(LINK_UNREACHABLE, ExtractionErrorCode.INVALID_URL, true, cause, false); + } + /** * 대상 서버가 결정론적 재실패로 보는 5xx(HttpPageFetcher 의 PERMANENT_SERVER_ERRORS)를 준 경우. 우리가 fetch 하는 * 대형 몰은 사실상 상시 가용이라 대개 진짜 장애가 아니라 봇 방어다 — 확정 실패로 보고 escalatable=true(헤드리스면 diff --git a/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java b/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java index d870901..90522b9 100644 --- a/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java +++ b/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java @@ -14,6 +14,7 @@ import com.depromeet.piki.extractor.support.IntegrationTestSupport; import com.depromeet.piki.extractor.support.StubGeminiClient; import com.depromeet.piki.extractor.support.StubPageFetcher; +import java.net.UnknownHostException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -249,6 +250,21 @@ void transientUpstreamError() throws Exception { .andExpect(jsonPath("$.code").value("UPSTREAM_ERROR")); } + @Test + @DisplayName("host 를 조회하지 못하면 422 INVALID_URL 을 반환한다 (호출자가 재시도하지 않게)") + void unresolvableHost() throws Exception { + stubGeminiClient.reset(); + stubPageFetcher.build = link -> { + throw PageFetchException.unresolvableHost(new UnknownHostException("no-such-host.example")); + }; + + mockMvc().perform(post("/internal/extractions/link") + .contentType(MediaType.APPLICATION_JSON) + .content(body("https://no-such-host.example/p/7"))) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.code").value("INVALID_URL")); + } + @Test @DisplayName("url 이 형식 위반이면 422 INVALID_URL 을 반환한다 (호출자 동기 검증의 방어선)") void invalidUrl() throws Exception { diff --git a/src/test/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCodeCatalogTest.java b/src/test/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCodeCatalogTest.java index 55e9ff7..60d277e 100644 --- a/src/test/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCodeCatalogTest.java +++ b/src/test/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCodeCatalogTest.java @@ -152,6 +152,7 @@ private static List factoryCases() { Throwable cause = new IllegalStateException("catalog contract test"); return List.of( new FactoryCase("PageFetchException.upstreamError", PageFetchException.upstreamError(cause)), + new FactoryCase("PageFetchException.unresolvableHost", PageFetchException.unresolvableHost(cause)), new FactoryCase("PageFetchException.emptyBody", PageFetchException.emptyBody()), new FactoryCase("PageFetchException.permanentUpstreamError", PageFetchException.permanentUpstreamError(cause)), new FactoryCase("PageFetchException.clientError", PageFetchException.clientError(cause)), 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 40dfd83..6459c76 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,16 +1,22 @@ package com.depromeet.piki.extractor.extraction.http; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +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 com.depromeet.piki.extractor.domain.ProductLink; import java.net.InetAddress; import java.net.UnknownHostException; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; /** - * SSRF 가드의 internal-address 판정을 검증한다. + * SSRF 가드의 internal-address 판정과, 조회 실패를 어떤 실패로 번역하는지를 검증한다. * *

redirect 가 매 hop 새 host 를 허용하고 헤드리스 직행 경로도 이 판정을 거치므로, 이 판정이 보안의 최종 방어선이다. * 특히 IPv6 ULA(fc00::/7)는 Java 의 {@code isSiteLocalAddress} 가 못 잡아 별도로 막는다. @@ -50,4 +56,22 @@ void internalAndMetadataAddressesAreBlocked(String ip) throws UnknownHostExcepti void publicRoutableAddressesAreAllowed(String ip) throws UnknownHostException { assertFalse(guard.isInternalAddress(InetAddress.getByName(ip)), ip + " 는 허용되어야 함"); } + + @Test + @DisplayName("host 를 조회하지 못하면 확정 실패로 던져 호출자가 재시도를 태우지 않는다") + void unresolvableHostFailsPermanently() { + UnknownHostException resolveFailure = new UnknownHostException("no-such-host.example"); + InternalHostGuard failingGuard = new InternalHostGuard(new RequestScopedDnsResolver(host -> { + throw resolveFailure; + })); + + PageFetchException thrown = assertThrows( + PageFetchException.class, + () -> failingGuard.verify(ProductLink.parse("https://no-such-host.example"))); + + assertEquals(ExtractionErrorCode.INVALID_URL, thrown.code()); + assertTrue(thrown.permanent(), "없는 주소는 재시도해도 없으므로 확정 실패여야 함"); + assertFalse(thrown.escalatable(), "resolve 되지 않는 host 는 헤드리스도 도달하지 못함"); + assertSame(resolveFailure, thrown.getCause()); + } }