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 @@ -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
Expand Down
22 changes: 19 additions & 3 deletions src/main/kotlin/com/depromeet/piki/product/domain/ProductLink.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
): ProductLink {
val trimmed = raw.trim()
// 계약이 아니라 불변식이라 require 다 — 두 등록 DTO 의 @field:NotBlank 가 빈 값을 먼저 걸러
// 멀쩡한 클라이언트는 여기 닿을 수 없다. 닿았다면 어느 입력 경계가 자기 검증을 빠뜨린 것이다.
Expand All @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProductLinkException>("$raw 는 거부되어야 함") { ProductLink.parseHop(raw) }
}
listOf("http:///p", "https:p").forEach { raw ->
assertFailsWith<IllegalArgumentException>("$raw 는 거부되어야 함") { ProductLink.parseHop(raw) }
}
}

@Test
fun `URI 파싱 자체가 실패하는 raw 는 형식 오류로 거부한다`() {
// 예: data: URI 의 본문에 illegal character 가 들어가면 URI_create 단계에서 IllegalArgumentException
Expand Down
Loading