Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 을
* 못 가른다.
*
* <p>기본 false(fail-closed): 각 팩토리가 명시적으로 true 를 줘야 escalate 되고, SSRF 만 default false 를 유지한다.
* <p>false 인 둘은 서로 다른 이유로 그렇다. blockedHost 는 보안 판단(내부망에 브라우저를 겨누는 것 자체가 SSRF)이고,
* unresolvableHost 는 성립 불가 판단(주소가 없으면 브라우저도 갈 곳이 없다)이다. 앞은 recall 을 포기한 것이지만
* 뒤는 포기할 recall 자체가 없다.
*
* <p>기본 false(fail-closed): 각 팩토리가 명시적으로 true 를 줘야 escalate 된다.
*/
private final boolean escalatable;

Expand Down Expand Up @@ -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 이다: 두 경우 모두 결론이
* "이 주소로는 갈 수 없다"라 호출자가 달리 행동할 여지가 없다.
*
* <p>escalatable=false — resolve 되지 않는 host 는 헤드리스 브라우저도 같은 이유로 도달하지 못한다.
*
* <p>{@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(헤드리스면
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ private static List<FactoryCase> 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)),
Expand Down
Original file line number Diff line number Diff line change
@@ -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 판정과, 조회 실패를 어떤 실패로 번역하는지를 검증한다.
*
* <p>redirect 가 매 hop 새 host 를 허용하고 헤드리스 직행 경로도 이 판정을 거치므로, 이 판정이 보안의 최종 방어선이다.
* 특히 IPv6 ULA(fc00::/7)는 Java 의 {@code isSiteLocalAddress} 가 못 잡아 별도로 막는다.
Expand Down Expand Up @@ -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());
}
}
Loading