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
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 @@ -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 \
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

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

Expand Down Expand Up @@ -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);
}

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 로 호출자에게 나간다. 형식 위반(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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>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<Range> ranges;

private InternalAddressRanges(List<Range> 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<Range> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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 만 허용한다.
*
* <p>HttpPageFetcher(정적 fetch, 매 redirect hop)와 HttpHeadlessRenderer(브라우저 직행 경로)가 공유한다 —
* 직행 경로는 plain fetch 를 아예 타지 않아 fetch 안의 가드로는 커버되지 않으므로, 렌더 서비스로 URL 을
* 넘기기 전에 같은 판정을 거쳐야 내부망에 헤드리스 브라우저를 겨누는 구멍이 닫힌다.
* SSRF 가드: host 가 내부 대역({@link InternalAddressRanges})으로 resolve 되면 차단한다. 직접 가져오기(HttpPageFetcher)가 매
* redirect hop 마다 부른다. 렌더 경로는 renderer 가 브라우저의 실제 연결 IP 로 같은 정본을 판정하므로 여기서 다시 보지 않는다.
*
* <p>빈이 아니라 소유자가 자기 RequestScopedDnsResolver 로 직접 조립한다 — 가드가 resolve 한 그 IP 를 이후 연결이
* 공유(IP pin)하는 계약은 "같은 resolver 인스턴스" 로만 성립하므로, 조립 책임을 소유자에게 둬 그 결합을 명시한다.
Expand All @@ -24,66 +18,21 @@
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();
}
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);
}
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading
Loading