diff --git a/src/main/kotlin/com/depromeet/piki/item/service/ItemIdentityRecorder.kt b/src/main/kotlin/com/depromeet/piki/item/service/ItemIdentityRecorder.kt index 23f0e0a9d..f00d8439d 100644 --- a/src/main/kotlin/com/depromeet/piki/item/service/ItemIdentityRecorder.kt +++ b/src/main/kotlin/com/depromeet/piki/item/service/ItemIdentityRecorder.kt @@ -60,8 +60,8 @@ class ItemIdentityRecorder( return } val parsed = - runCatching { ProductLink.parse(finalUrl) }.getOrElse { - // 귀결점이 URL 계약(https 등)을 벗어남 — 대상 서버의 비정상 리다이렉트다. 정체성 미확정으로 둔다. + runCatching { ProductLink.parseHop(finalUrl) }.getOrElse { + // 귀결점이 URL 계약(http·https·host)을 벗어남 — 대상 서버의 비정상 리다이렉트다. 정체성 미확정으로 둔다. log.warn("canonical 확정 건너뜀 - 귀결점 파싱 불가 item={}", itemId) ItemIdentityMetrics.record(meterRegistry, ItemIdentityMetrics.CANONICAL_UNPARSABLE) return diff --git a/src/main/kotlin/com/depromeet/piki/product/domain/ProductLink.kt b/src/main/kotlin/com/depromeet/piki/product/domain/ProductLink.kt index b240c834c..e356276d4 100644 --- a/src/main/kotlin/com/depromeet/piki/product/domain/ProductLink.kt +++ b/src/main/kotlin/com/depromeet/piki/product/domain/ProductLink.kt @@ -35,9 +35,25 @@ class ProductLink private constructor( override fun hashCode(): Int = value.hashCode() companion object { - private val HTTP_SCHEMES = setOf("https") + private val ENTRY_SCHEMES = setOf("https") - fun parse(raw: String): ProductLink { + // 귀결점은 extractor 가 매 홉 http 도 따라가(내부 주소 판정은 scheme 과 무관하게 거기서 끝남) http 일 수 있다. + // 정체성은 CanonicalLink 가 https 로 다시 조립하므로 같은 host·path 의 http·https 가 하나로 모인다. + private val HOP_SCHEMES = setOf("http", "https") + + fun parse(raw: String): ProductLink = parse(raw, ENTRY_SCHEMES) + + // 파싱 귀결점(redirect·렌더가 도착한 주소) 전용. CanonicalLink 가 host 를 요구하므로 여기서 함께 거른다. + fun parseHop(raw: String): ProductLink { + val link = parse(raw, HOP_SCHEMES) + requireNotNull(link.value.host) { "host 없는 귀결점" } + return link + } + + private fun parse( + raw: String, + schemes: Set, + ): ProductLink { val trimmed = raw.trim() // 계약이 아니라 불변식이라 require 다 — 두 등록 DTO 의 @field:NotBlank 가 빈 값을 먼저 걸러 // 멀쩡한 클라이언트는 여기 닿을 수 없다. 닿았다면 어느 입력 경계가 자기 검증을 빠뜨린 것이다. @@ -52,7 +68,7 @@ class ProductLink private constructor( } // URI.create 는 스킴 없는 "example.com/product" 도 relative URI 로 통과시키므로 명시 검증. // RFC 3986 은 scheme 을 case-insensitive 로 정의하므로 비교 전에 lowercase 정규화한다. - if (uri.scheme?.lowercase() !in HTTP_SCHEMES) throw ProductLinkException.unsupportedScheme() + if (uri.scheme?.lowercase() !in schemes) throw ProductLinkException.unsupportedScheme() return ProductLink(uri) } } diff --git a/src/test/kotlin/com/depromeet/piki/item/service/ItemIdentityRecordingIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/item/service/ItemIdentityRecordingIntegrationTest.kt index a10bf65ee..bf708c91b 100644 --- a/src/test/kotlin/com/depromeet/piki/item/service/ItemIdentityRecordingIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/item/service/ItemIdentityRecordingIntegrationTest.kt @@ -187,13 +187,26 @@ class ItemIdentityRecordingIntegrationTest : IntegrationTestSupport() { itemIdentityRecorder.recordParsingIdentity(item.getId(), null) assertNull(itemRepository.findById(item.getId())?.canonicalHash) - itemIdentityRecorder.recordParsingIdentity(item.getId(), "http://insecure.example.com/p/1") + itemIdentityRecorder.recordParsingIdentity(item.getId(), "ftp://insecure.example.com/p/1") assertNull(itemRepository.findById(item.getId())?.canonicalHash) } finally { cleanup(listOf(item.getId())) } } + @Test + fun `http 귀결점은 같은 주소의 https 와 같은 정체성으로 확정된다`() { + val item = newItem("https://unknown-mall.example.com/p/1000008") + try { + itemIdentityRecorder.recordParsingIdentity(item.getId(), "http://unknown-mall.example.com/product/1000008") + + val expected = CanonicalLink.of(ProductLink.parse("https://unknown-mall.example.com/product/1000008")) + assertEquals(expected.hash, itemRepository.findById(item.getId())?.canonicalHash) + } finally { + cleanup(listOf(item.getId())) + } + } + @Test fun `저장 상한을 넘는 귀결점은 확정·별칭 모두 건너뛴다 - 절단은 거짓 정체성`() { val item = newItem("https://unknown-mall.example.com/p/1000007") diff --git a/src/test/kotlin/com/depromeet/piki/product/domain/ProductLinkTest.kt b/src/test/kotlin/com/depromeet/piki/product/domain/ProductLinkTest.kt index ed22645c3..862eb4081 100644 --- a/src/test/kotlin/com/depromeet/piki/product/domain/ProductLinkTest.kt +++ b/src/test/kotlin/com/depromeet/piki/product/domain/ProductLinkTest.kt @@ -40,6 +40,22 @@ class ProductLinkTest { } } + @Test + fun `귀결점은 http 와 https 를 받는다`() { + assertEquals("http", ProductLink.parseHop("http://shop.example.com/items/42").value.scheme) + assertEquals("https", ProductLink.parseHop("https://shop.example.com/items/42").value.scheme) + } + + @Test + fun `귀결점도 http·https 가 아닌 scheme 과 host 없는 주소는 거부한다`() { + listOf("ftp://example.com", "file:///etc/passwd", "javascript:alert(1)").forEach { raw -> + assertFailsWith("$raw 는 거부되어야 함") { ProductLink.parseHop(raw) } + } + listOf("http:///p", "https:p").forEach { raw -> + assertFailsWith("$raw 는 거부되어야 함") { ProductLink.parseHop(raw) } + } + } + @Test fun `URI 파싱 자체가 실패하는 raw 는 형식 오류로 거부한다`() { // 예: data: URI 의 본문에 illegal character 가 들어가면 URI_create 단계에서 IllegalArgumentException