Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -271,6 +273,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 \
Expand Down
8 changes: 8 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ sourceSets {
}
}

// 내부 대역 정본도 같은 자리에서 읽어 classpath 에 싣는다(InternalAddressRanges).
tasks.processResources {
from("shared-infra/contracts") {
include("internal-ranges.txt")
into("contracts")
}
}

tasks.withType<Test> {
useJUnitPlatform()
// zstd-jni 의 JNI 네이티브 로드 허용 — Java 25 는 경고만 내지만 미래 JDK 는 차단한다(JEP 472).
Expand Down
4 changes: 3 additions & 1 deletion docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@
|---|---|
| 동작 규칙 (응답 3갈래·타임아웃 예산·진화 규칙) | `contracts/extraction-api.md` |
| 요청·응답의 모양, 필드와 code 의 의미, code 분류 | `contracts/extraction.proto` |
| renderer 응답의 모양 (내부 주소를 만났는지 포함) | `contracts/render.proto` |
| SSRF 검사의 내부 주소 목록 | `contracts/internal-ranges.txt` |

proto 는 로컬에서 infra 의 `install.sh` 가, CI 에서는 `ci.yml`·`deploy.yml` 의 checkout 스텝이 `shared-infra/contracts/` 에 놓는다. `generateProto` 가 `com.depromeet.piki.contracts.extraction.v1` 패키지의 클래스를 만들고, `ExtractionErrorCodeCatalogTest` 가 도메인 code 와 계약의 일치를 강제한다. 와이어는 JSON 그대로다(`ProtobufJsonConverterConfig`).
계약 파일은 로컬에서 infra 의 `install.sh` 가, CI 에서는 `ci.yml`·`deploy.yml` 의 checkout 스텝이 `shared-infra/contracts/` 에 놓는다. `generateProto` 가 `com.depromeet.piki.contracts.extraction.v1` 패키지의 클래스를 만들고, `ExtractionErrorCodeCatalogTest` 가 도메인 code 와 계약의 일치를 강제한다. 와이어는 JSON 그대로다(`ProtobufJsonConverterConfig`). 내부 주소 목록은 빌드가 classpath 의 `contracts/` 에 싣고, 파일이 빠진 빌드는 기동에서 실패한다.
4 changes: 2 additions & 2 deletions docs/style-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,11 @@ xhigh 리뷰(파인더 10 앵글 → 검증 → 스윕)로 확정 15건 + 백로

주석을 쓰려면 코드를 정독해야 하므로 부수적으로 드러난 것들이다. 심각도 순.

1. ~~**헤드리스 경로의 redirect SSRF 비대칭**~~ — **부분 해소(#20)**. `final_url` 도 `InternalHostGuard` 를 태워 내부 주소가 최종 귀결점이면 렌더 전체를 거부한다. 다만 이 검증은 **사후**라 내부 주소로의 요청 자체는 못 막고, "외부 → 내부 → 외부" 체인도 못 잡는다(CodeRabbit 지적, 타당). **hop 단위 차단은 renderer repo 몫으로 남았다** — 실측 확인 결과 renderer 에는 SSRF·egress 가드가 전혀 없고(inbound SG 경계만) 브라우저가 redirect 를 자유롭게 따라간다. renderer 의 navigation 계층에서 자동 redirect 를 끄고 Location 마다 판정하거나, 렌더 박스에 egress 정책을 세워야 완결된다.
1. ~~**헤드리스 경로의 redirect SSRF 비대칭**~~ — **해소(#112)**. #20 의 `final_url` 사후 검증은 "외부 → 내부 → 외부" 체인을 못 잡아 지웠다. 이제 직접 가져오기는 매 홉 보내기 전에 IP 를 검사하고 그 IP 로만 연결한다. renderer 는 이름 풀이와 연결을 브라우저가 직접 해 보내기 전에 막지 못하므로, 응답마다 실제 연결 IP 를 검사해 내부면 결과를 버리고 `internal_address_hit` 로 알린다(TeamPiKi/renderer#69). 그 사이의 요청은 두 서버의 egress 방화벽(infra `blocks/egress_wall.sh`)이 막는다. 내부 주소 목록은 infra `contracts/internal-ranges.txt` 하나를 셋이 읽는다.
2. ~~**`ProductImage.of(null, ...)` → 500**~~ — **해소(#20)**. — null 검사 전에 `bytes.length` 를 읽어 NPE 가 된다. 계약상 422(IMAGE_UNSUPPORTED)여야 할 입력이 일시 실패로 오분류된다.
3. ~~**이미지 경로의 정규화 비대칭**~~ — **해소(#20)**. `GeminiImageResult` 가 `ProductSnapshot.fromExtracted` 를 우회해, LLM 이 준 음수 가격·blank name 이 link 경로와 달리 검증 없이 통과한다(주석으로만 경고돼 있었다).
4. **`ProductImage.mimeTypeOfExtension` 하드코딩 switch** — `MIME_TO_EXTENSION` Javadoc 이 약속한 "포맷 추가는 한 곳"이 실제로는 깨져 있다(포맷 추가 시 두 곳 수정). 앞선 리뷰에서 파생 역맵으로 고쳤으나 그 워킹트리가 폐기돼 미반영.
5. **`ImageCropper.crop` 의 첫 `catch (IOException)` 이 로그 없이 null 반환** — 디코딩 IO 실패가 관측되지 않는다(두 번째 catch 만 `log.warn`).
6. **기본값 이중 정본** — `FetchProperties.defaults()`·`HeadlessExtractionProperties.of()` 가 `@DefaultValue` 수치를 코드로 다시 박는다. 한쪽만 바뀌면 바인딩 경로와 테스트 경로 기본값이 조용히 갈린다.
7. 잔가지: `ProductImage.EXTENSIONS` 가 repo 전체에서 미사용(죽은 public 상수 가능성) · `GeminiApiException.clientError` 와 `parseError` 가 code·permanent·cause 모두 동일해 관측상 구분 불가 · `ProductLink.HTTP_SCHEMES` 는 원소가 `https` 하나인데 이름이 복수형 · `parsePrice` 가 가격 범위 표기("10,000-20,000")를 INVALID_VALUE 로 떨구는 것이 의도인지 미확인.
7. 잔가지: `ProductImage.EXTENSIONS` 가 repo 전체에서 미사용(죽은 public 상수 가능성) · `GeminiApiException.clientError` 와 `parseError` 가 code·permanent·cause 모두 동일해 관측상 구분 불가 · ~~`ProductLink.HTTP_SCHEMES` 는 원소가 `https` 하나인데 이름이 복수형~~(#112 에서 입력·홉 scheme 집합으로 나뉘어 소멸) · `parsePrice` 가 가격 범위 표기("10,000-20,000")를 INVALID_VALUE 로 떨구는 것이 의도인지 미확인.
8. 테스트 잔가지: `ProductLinkTest` 가 주석으로 주장했던 `safeLogString` 커버리지가 실제로는 없음(주석은 정정) · `ProductLinkExtractE2ETest` 가 preview 모델을 하드코딩해 "기본값은 GA 만" 정책과 충돌 · `HttpHeadlessRendererTest` 의 `assertEquals(null, ...)` → `assertNull` · `GeminiImageResultTest` 의 `@DisplayName` 이 없는 메서드명(`toProductSnapshot`)을 가리킴 · `ExtractionLinkIntegrationTest` 만 `Assertions` 대신 수동 `throw new AssertionError`.
22 changes: 19 additions & 3 deletions src/main/java/com/depromeet/piki/extractor/domain/ProductLink.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@

/**
* 미지원 플랫폼 판정은 등록 입력 경계의 정책이라 호출자(core) 소관이다 —
* 이 서비스의 ProductLink 는 형식 불변식(https·URI 형식)과 안전 로깅만 책임진다.
* 이 서비스의 ProductLink 는 형식 불변식(scheme·URI 형식·host 존재)과 안전 로깅만 책임진다.
*/
public final class ProductLink {

private static final Set<String> HTTP_SCHEMES = Set.of("https");
private static final Set<String> ENTRY_SCHEMES = Set.of("https");
private static final Set<String> HOP_SCHEMES = Set.of("http", "https");

private final URI value;

Expand All @@ -20,6 +21,18 @@ private ProductLink(URI value) {
}

public static ProductLink parse(String raw) {
return parse(raw, ENTRY_SCHEMES);
}

/**
* redirect·렌더로 도달한 홉 주소. 입력과 달리 http 도 받는다. 내부 주소 판정은 scheme 과 무관하게 매 홉에 걸리고,
* 거절해도 헤드리스가 같은 http 홉을 따라가 막는 효과가 없다.
*/
public static ProductLink parseHop(String raw) {
return parse(raw, HOP_SCHEMES);
}

private static ProductLink parse(String raw, Set<String> schemes) {
String trimmed = raw == null ? "" : raw.trim();
if (trimmed.isBlank()) {
throw ProductLinkException.blank();
Expand All @@ -33,9 +46,12 @@ public static ProductLink parse(String raw) {
// URI.create 는 스킴 없는 "example.com/product" 도 relative URI 로 통과시키므로 명시 검증.
// RFC 3986 은 scheme 을 case-insensitive 로 정의하므로 비교 전에 lowercase 정규화한다.
String scheme = uri.getScheme();
if (scheme == null || !HTTP_SCHEMES.contains(scheme.toLowerCase(Locale.ROOT))) {
if (scheme == null || !schemes.contains(scheme.toLowerCase(Locale.ROOT))) {
throw ProductLinkException.unsupportedScheme();
}
if (uri.getHost() == null) {
throw ProductLinkException.invalidFormat(null);
}
return new ProductLink(uri);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -30,16 +28,19 @@
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} 으로 옮긴다.
* 홉의 해석은 하지 않는다.
*
* <p>내부 주소 판정은 renderer 가 브라우저의 실제 연결 IP 로 하고 {@code internal_address_hit} 로 알린다. 여기서 홉 URL 을
* 다시 이름 해석하면 렌더와 다른 시점의 답이라 브라우저가 실제로 간 IP 와 어긋날 수 있어 하지 않는다.
*
* <p>전송: 요청 compress=true 면 응답이 zstd raw 바이트로 온다. 해제 분기는 요청이 아니라 응답 헤더
* ({@code X-Encoding: zstd}) 기준 — compress 를 모르는 구버전 renderer 는 plain JSON 을 주므로, 헤더 분기여야
* renderer 와 어느 쪽이 먼저 배포되든 안전하다. 사전({@code X-Zstd-Dict})은 {@code ZstdDictionaries} 의
* 롤아웃 규약 참조.
*/
@Slf4j
@RequiredArgsConstructor
@Component
public class HttpHeadlessRenderer implements HeadlessRenderer {

Expand All @@ -60,37 +61,23 @@ 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<RenderedHop> render(ProductLink link, boolean authorized) {
try {
internalHostGuard.verify(link);
return renderVerified(link, authorized);
} finally {
dnsResolver.clear();
}
}

private List<RenderedHop> renderVerified(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();
}
List<Hop> hops = response.getHopsList();
if (hops.isEmpty()) {
String error = response.hasError() ? maskUrls(response.getError()) : null;
Expand Down Expand Up @@ -191,31 +178,19 @@ private byte[] resolveDict(String dictId) {
}

/**
* 홉 URL 은 Jsoup baseUri 이자 응답 계약의 finalUrl 로 호출자에게 나간다. 형식 위반은 원본 link 로 폴백한다 —
* baseUri 부정확은 치명이 아니고, 여기서 INVALID_URL 을 새면 렌더는 성공했는데 확정 실패로 종결되는 오판이 된다.
* 단 SSRF 판정은 폴백하지 않고 렌더 전체를 거부한다.
* 홉 URL 은 Jsoup baseUri 이자 응답 계약의 finalUrl 로 호출자에게 나간다. 형식 위반은 원본 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.parseHop(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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

/**
* fetch 용 RestClient 와 host→IP 해석(dnsResolver)을 생성자로 주입받는다. 둘 다 밖에서 교체할 수 있어야
* 네트워크 없이 redirect 루프(3xx 따라가기·cross-domain 따라가기·다운그레이드 차단·hop 상한)를 단위 테스트로
* 네트워크 없이 redirect 루프(3xx 따라가기·cross-domain 따라가기·hop 상한)를 단위 테스트로
* 검증할 수 있다.
*
* <p>dnsResolver 는 PageFetchHttpClientConfig 의 RestClient 와 같은 인스턴스를 공유한다 — 가드가 검증한 IP 로
Expand Down Expand Up @@ -307,21 +307,20 @@ private Charset detectMetaCharset(byte[] head) {
}

/**
* 3xx 응답의 Location 을 절대 URI 로 만들고, https 면 다음 hop 으로 따라간다.
* 3xx 응답의 Location 을 절대 URI 로 만들고, http·https 면 다음 hop 으로 따라간다.
* cross-domain redirect 도 따라간다(무신사 OneLink·bit.ly 등 단축·딥링크가 다른 도메인의 최종 상품 페이지로
* 보낸다). 사설망 SSRF 는 매 hop 의 {@link InternalHostGuard#verify}(IP 검증)가 막으므로, 도메인 단위 차단
* 없이도 내부망 접근은 닫혀 있다.
*/
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());
return ProductLink.parseHop(target.toString());
} catch (ProductLinkException e) {
log.warn("link fetch redirect rejected (non-https or malformed) url={}", current.safeLogString());
throw PageFetchException.blockedHost();
log.warn("link fetch redirect rejected (unsupported scheme or malformed) url={}", current.safeLogString());
throw PageFetchException.malformedRedirect(e);
}
}

Expand Down
Loading
Loading