From 7f8308973126a221b7106153869c55d1a2abd0a9 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, 23 Aug 2026 21:41:25 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20og:image=20=EC=9D=98=20http=C2=B7?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EC=83=81=EB=8C=80=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=EB=A5=BC=20=EB=B2=84=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B3=A0=20https=20=EB=A1=9C=20=EC=98=AC=EB=A6=B0?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prod 에서 postarchivefaction 상품이 이미지만 빠진 INCOMPLETE 로 떨어졌는데, og:image 가 HTML 에 있었다. 못 찾은 게 아니라 normalizeImageUrl 이 https 로 시작하지 않는다는 이유로 조용히 버린 것이었다 - 같은 주소가 https 로는 200 image/jpeg 로 정상이다(실측, http 는 301 로 https 에 넘긴다). 사이트가 og:image 에 http 를 적어뒀을 뿐 이미지는 멀쩡했다 - 값이 처음부터 있었는데 버려진 탓에 승격 체인이 통째로 헛돌았다: LLM 86k 토큰 -> 브라우저 렌더 3.3초 -> LLM 11k 토큰 -> 같은 http 값을 또 버림 -> INCOMPLETE - http 그대로 저장하는 선택지는 없다. 클라이언트가 https 라 브라우저가 mixed content 로 막아 저장해도 안 보인다. 승격이 실패하면 이미지가 안 뜨는데, 버리면 애초에 없으므로 더 나빠지지 않는다 - 프로토콜 상대(//host/path)도 같은 성격이라 함께 살린다. 스킴만 없을 뿐 멀쩡한 주소다 - javascript:·data:·file: 은 그대로 거부한다. 가드가 원래 막으려던 것이 이쪽이고, 스킴을 갈아끼워 살릴 수 있는 값도 아니다. 뭉툭했던 건 위험한 값과 고쳐 쓸 수 있는 값을 한 덩어리로 취급한 것이었다 --- .../extractor/domain/ProductSnapshot.java | 21 +++++++++++--- .../extractor/domain/ProductSnapshotTest.java | 28 ++++++++++++++++--- .../StructuredDataExtractorTest.java | 19 ++++++++++--- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java index 44a48ae..a629312 100644 --- a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java +++ b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java @@ -81,7 +81,8 @@ public String missingFieldNames() { /** * 구조화 파싱과 LLM 추출이 함께 통과하는 정규화·범위검증의 단일 진실 원천. - * imageUrl 을 https 로만 좁히는 것은 클라이언트가 {@code } 로 쓸 때의 XSS 사다리를 끊기 위한 것이다. + * imageUrl 을 https 로 좁히는 것은 클라이언트가 {@code } 로 쓸 때의 XSS 사다리를 끊기 위한 것이다. + * 다만 좁히는 방식은 스킴에 따라 다르다 — 자세한 근거는 normalizeImageUrl 참조. *

범위를 벗어난 값은 {@link ProductSnapshotException#untrustworthyValue()} 로 막고, 그 뒤 처리는 호출부가 고른다: * 구조화 경로는 예외를 흡수해 Miss(LLM fallback)로, LLM 경로는 그대로 흘려 확정 실패로 떨어뜨린다. * 같은 검증, 실패 표현만 다르다. @@ -121,9 +122,21 @@ private static String normalizeImageUrl(String imageUrl) { if (imageUrl == null || imageUrl.isBlank()) { return null; } - if (!imageUrl.regionMatches(true, 0, "https://", 0, "https://".length())) { - return null; + if (imageUrl.regionMatches(true, 0, "https://", 0, "https://".length())) { + return imageUrl; + } + // http·프로토콜 상대(//host/path)는 버리지 않고 https 로 올린다. 스킴만 다를 뿐 같은 자원을 가리키고, + // 실제로 og:image 에 http 를 적어 둔 몰이 있다(실측: postarchivefaction, http 는 301→https). + // 올린 주소가 안 되면 이미지가 안 뜨는데, 버리면 애초에 이미지가 없으므로 더 나빠지지 않는다. + // 반대로 http 그대로 두는 선택지는 없다 — 클라이언트가 https 라 브라우저가 mixed content 로 막는다. + if (imageUrl.regionMatches(true, 0, "http://", 0, "http://".length())) { + return "https://" + imageUrl.substring("http://".length()); + } + if (imageUrl.startsWith("//")) { + return "https:" + imageUrl; } - return imageUrl; + // 나머지 스킴(javascript:·data:·file: 등)은 그대로 거부한다 — 이쪽이 원래 막으려던 것이고, + // 스킴을 갈아끼워 살릴 수 있는 값도 아니다. + return null; } } diff --git a/src/test/java/com/depromeet/piki/extractor/domain/ProductSnapshotTest.java b/src/test/java/com/depromeet/piki/extractor/domain/ProductSnapshotTest.java index 254f126..a05e2bb 100644 --- a/src/test/java/com/depromeet/piki/extractor/domain/ProductSnapshotTest.java +++ b/src/test/java/com/depromeet/piki/extractor/domain/ProductSnapshotTest.java @@ -21,13 +21,12 @@ void blankNameToNull() { } @Test - @DisplayName("imageUrl 은 https 가 아니면 null 로 정규화된다") - void nonHttpsImageUrlToNull() { + @DisplayName("스킴을 갈아끼워 살릴 수 없는 imageUrl 만 null 로 정규화된다") + void unusableImageUrlToNull() { List cases = List.of( - "http://cdn.example.com/a.jpg", - "//cdn.example.com/a.jpg", "data:image/png;base64,xxx", "javascript:alert(1)", + "file:///etc/passwd", ""); for (String raw : cases) { assertNull( @@ -44,6 +43,27 @@ void httpsImageUrlPasses() { ProductSnapshot.fromExtracted(link, "상품", "https://cdn.example.com/a.jpg", 1_000, "KRW").imageUrl()); } + @Test + @DisplayName("http·프로토콜 상대 imageUrl 은 버리지 않고 https 로 올린다") + void schemelessImageUrlUpgradedToHttps() { + // og:image 에 http 를 적어 둔 몰이 실재한다. 버리면 이름·가격만 채운 INCOMPLETE 로 떨어지는데, + // 그 값은 스킴만 올리면 그대로 쓸 수 있다(실측: 같은 주소가 http 301 -> https 200). + assertEquals( + "https://cdn.example.com/a.jpg", + ProductSnapshot.fromExtracted(link, "상품", "http://cdn.example.com/a.jpg", 1_000, "KRW").imageUrl()); + assertEquals( + "https://cdn.example.com/a.jpg", + ProductSnapshot.fromExtracted(link, "상품", "//cdn.example.com/a.jpg", 1_000, "KRW").imageUrl()); + } + + @Test + @DisplayName("스킴 대문자·혼합 표기도 https 로 올린다") + void schemeUpgradeIsCaseInsensitive() { + assertEquals( + "https://cdn.example.com/a.jpg", + ProductSnapshot.fromExtracted(link, "상품", "HTTP://cdn.example.com/a.jpg", 1_000, "KRW").imageUrl()); + } + @Test @DisplayName("currency 대소문자·공백은 ISO 4217 대문자로 정규화된다") void currencyNormalized() { diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractorTest.java index d15675a..0171533 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractorTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/structured/StructuredDataExtractorTest.java @@ -229,13 +229,24 @@ void usesFirstImageWhenArray() { } @Test - @DisplayName("https 가 아닌 image 는 imageUrl 만 null 이고 나머지로 성공한다") - void nonHttpsImageBecomesNull() { + @DisplayName("http image 는 https 로 올려서 쓴다 — 버리면 멀쩡한 이미지를 잃는다") + void httpImageIsUpgradedToHttps() { ProductSnapshot snapshot = snapshotOrNull(extractor.extract(pageOf(jsonLd( """ - {"@type":"Product","name":"비https이미지","image":"http://cdn.example.com/p.jpg","offers":{"price":"5000"}}""")))); + {"@type":"Product","name":"http이미지","image":"http://cdn.example.com/p.jpg","offers":{"price":"5000"}}""")))); - assertEquals("비https이미지", snapshot.name()); + assertEquals("http이미지", snapshot.name()); + assertEquals("https://cdn.example.com/p.jpg", snapshot.imageUrl()); + } + + @Test + @DisplayName("스킴을 살릴 수 없는 image 는 imageUrl 만 null 이고 나머지로 성공한다") + void unusableImageBecomesNull() { + ProductSnapshot snapshot = snapshotOrNull(extractor.extract(pageOf(jsonLd( + """ + {"@type":"Product","name":"데이터URI","image":"data:image/png;base64,xxx","offers":{"price":"5000"}}""")))); + + assertEquals("데이터URI", snapshot.name()); assertNull(snapshot.imageUrl()); }