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
22 changes: 21 additions & 1 deletion app/services/domain_heuristic/patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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로 잡히므로
Expand Down
13 changes: 13 additions & 0 deletions tests/services/domain_heuristic/test_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions tests/services/domain_heuristic/test_patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading