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