From 16fdbbb1f0618efd3231ccee92455582a16ae59e Mon Sep 17 00:00:00 2001 From: minsoo0506 Date: Tue, 23 Jun 2026 23:08:23 +0900 Subject: [PATCH] =?UTF-8?q?[Fix]=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89?= =?UTF-8?q?=ED=8A=B8=20caution=20=EC=98=A4=ED=83=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/domain_heuristic/patterns.py | 22 ++++++++++++++++++- tests/services/domain_heuristic/test_check.py | 13 +++++++++++ .../domain_heuristic/test_patterns.py | 13 +++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/app/services/domain_heuristic/patterns.py b/app/services/domain_heuristic/patterns.py index 1e2e583..e75303f 100644 --- a/app/services/domain_heuristic/patterns.py +++ b/app/services/domain_heuristic/patterns.py @@ -263,6 +263,26 @@ def _brand_labels_in_url_text(text: str) -> set[str]: return found +def _registered_domain_from_url(url: str) -> str: + ext = extract_url_parts(url) + return (ext.top_domain_under_public_suffix or urlparse(url).hostname or "").lower() + + +def _has_external_open_redirect_target(qs: dict[str, list[str]], registered_domain: str) -> bool: + for key, values in qs.items(): + if key.lower() not in _OPEN_REDIRECT_PARAMS: + continue + if not values: + return True + for value in values: + parsed_value = urlparse(value) + if parsed_value.scheme not in {"http", "https"} or not parsed_value.hostname: + return True + if _registered_domain_from_url(value) != registered_domain: + return True + return False + + def check_patterns(url: str) -> list[DomainHeuristicSignal]: signals: list[DomainHeuristicSignal] = [] parsed = urlparse(url) @@ -307,7 +327,7 @@ def check_patterns(url: str) -> list[DomainHeuristicSignal]: # 오픈 리다이렉트 파라미터 qs = parse_qs(parsed.query, keep_blank_values=True) - if any(k.lower() in _OPEN_REDIRECT_PARAMS for k in qs): + if _has_external_open_redirect_target(qs, registered_domain): signals.append(DomainHeuristicSignal.OPEN_REDIRECT_PARAM) # private suffix(github.io, vercel.app 등)는 tld parser에서 suffix로 잡히므로 diff --git a/tests/services/domain_heuristic/test_check.py b/tests/services/domain_heuristic/test_check.py index c1c081a..2c5753b 100644 --- a/tests/services/domain_heuristic/test_check.py +++ b/tests/services/domain_heuristic/test_check.py @@ -166,3 +166,16 @@ async def test_known_safe_alias_domain_is_not_typo(): assert DomainHeuristicSignal.TYPO_DOMAIN not in result.signals assert result.score < 31 + + +@pytest.mark.asyncio +async def test_naver_bridge_to_naver_shorts_is_below_caution_threshold(): + with patch(_RDAP_PATH, new_callable=AsyncMock) as mock_rdap: + mock_rdap.return_value = (None, "not_found") + result = await check_domain_heuristic( + "https://link.naver.com/bridge?" + "url=https%3A%2F%2Fm.naver.com%2Fshorts%2F%3FserviceType%3DCHZZK" + ) + + assert DomainHeuristicSignal.OPEN_REDIRECT_PARAM not in result.signals + assert result.score < 31 diff --git a/tests/services/domain_heuristic/test_patterns.py b/tests/services/domain_heuristic/test_patterns.py index 5e70e15..744dd2c 100644 --- a/tests/services/domain_heuristic/test_patterns.py +++ b/tests/services/domain_heuristic/test_patterns.py @@ -86,6 +86,19 @@ def test_open_redirect_param_url() -> None: assert DomainHeuristicSignal.OPEN_REDIRECT_PARAM in signals +def test_same_registered_domain_redirect_param_is_not_open_redirect_signal() -> None: + signals = check_patterns( + "https://link.naver.com/bridge?" + "url=https%3A%2F%2Fm.naver.com%2Fshorts%2F%3FserviceType%3DCHZZK" + ) + assert DomainHeuristicSignal.OPEN_REDIRECT_PARAM not in signals + + +def test_external_redirect_param_on_trusted_domain_remains_open_redirect_signal() -> None: + signals = check_patterns("https://link.naver.com/bridge?url=https%3A%2F%2Fevil.test%2F") + assert DomainHeuristicSignal.OPEN_REDIRECT_PARAM in signals + + def test_no_open_redirect_param() -> None: signals = check_patterns("https://example.com/search?q=hello") assert DomainHeuristicSignal.OPEN_REDIRECT_PARAM not in signals