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
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ public String missingFieldNames() {

/**
* 구조화 파싱과 LLM 추출이 함께 통과하는 정규화·범위검증의 단일 진실 원천.
* imageUrl 을 https 로만 좁히는 것은 클라이언트가 {@code <img src>} 로 쓸 때의 XSS 사다리를 끊기 위한 것이다.
* imageUrl 을 https 로 좁히는 것은 클라이언트가 {@code <img src>} 로 쓸 때의 XSS 사다리를 끊기 위한 것이다.
* 다만 좁히는 방식은 스킴에 따라 다르다 — 자세한 근거는 normalizeImageUrl 참조.
* <p>범위를 벗어난 값은 {@link ProductSnapshotException#untrustworthyValue()} 로 막고, 그 뒤 처리는 호출부가 고른다:
* 구조화 경로는 예외를 흡수해 Miss(LLM fallback)로, LLM 경로는 그대로 흘려 확정 실패로 떨어뜨린다.
* 같은 검증, 실패 표현만 다르다.
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,12 @@ void blankNameToNull() {
}

@Test
@DisplayName("imageUrl 은 https 가 아니면 null 로 정규화된다")
void nonHttpsImageUrlToNull() {
@DisplayName("스킴을 갈아끼워 살릴 수 없는 imageUrl 만 null 로 정규화된다")
void unusableImageUrlToNull() {
List<String> 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(
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
Loading