diff --git a/CHANGELOG.md b/CHANGELOG.md index e097a55..dc08985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # CHANGELOG ## [Unreleased] +- **외부 링크 회귀 방지**: 현재 `target="_blank"` 링크가 명시적 `rel="noopener noreferrer"` 정책을 유지하는지 DOM 파서 기반 테스트를 추가했습니다. `noopener`는 opener 격리, `noreferrer`는 referrer 비공개 의미를 각각 검증하며 둘을 하나의 Reverse Tabnabbing 요구사항으로 일반화하지 않습니다. - **보안 개선**: `i18n.js`에서 잘못된 언어 요청 시 `console.warn` 메시지에 사용자 입력값이 직접 포함되지 않도록 수정하여 로그 인젝션(Log Injection) 취약점을 제거했습니다. - **성능 개선**: `.skip-link` 애니메이션을 `top`에서 `transform: translateY()`로 변경하여 전환 중 레이아웃 재계산을 줄일 수 있도록 했습니다. 실제 효과는 브라우저별 측정 대상입니다. - **렌더링 힌트 정합성**: 첫 화면의 eager 이미지와 단일 LCP 후보에서 강제 `decoding="async"`를 제거해 HTML 표준의 기본 `auto` 판단에 맡기고, 지연 로드 이미지에는 비동기 디코딩 힌트를 유지했습니다. 정적 테스트가 eager, lazy, LCP 후보 집합의 존재와 조합을 검증하며, 실제 LCP 효과는 배포 후 실측 대상으로 유지합니다. diff --git a/tests/test_index_security.py b/tests/test_index_security.py index a558f32..d722169 100644 --- a/tests/test_index_security.py +++ b/tests/test_index_security.py @@ -1,6 +1,7 @@ """Security regression tests for the main page (index.html).""" import re +from html.parser import HTMLParser from pathlib import Path @@ -8,6 +9,21 @@ INDEX = ROOT / "index.html" +class _BlankTargetAnchorParser(HTMLParser): + """Collect anchors that intentionally open a new browsing context.""" + + def __init__(self) -> None: + super().__init__() + self.anchors: list[dict[str, str]] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag.lower() != "a": + return + normalized = {name.lower(): value or "" for name, value in attrs} + if normalized.get("target", "").lower() == "_blank": + self.anchors.append(normalized) + + def _index_html() -> str: """Return the main index.html source.""" return INDEX.read_text(encoding="utf-8") @@ -45,6 +61,7 @@ def test_index_declares_strict_csp() -> None: assert "'unsafe-inline'" not in policy assert "'unsafe-eval'" not in policy + def test_index_has_no_inline_active_content() -> None: """Strict CSP remains enforceable without inline script or style exceptions.""" html = _index_html() @@ -60,3 +77,19 @@ def test_index_has_no_inline_active_content() -> None: assert ( '' in html ) + + +def test_blank_target_links_keep_explicit_opener_and_referrer_policy() -> None: + """New-context links keep explicit opener isolation and referrer suppression.""" + parser = _BlankTargetAnchorParser() + parser.feed(_index_html()) + + assert parser.anchors, "index.html must exercise the outbound-link policy" + for anchor in parser.anchors: + rel_tokens = {token.lower() for token in anchor.get("rel", "").split()} + assert "noopener" in rel_tokens, ( + f"target=_blank link must keep explicit opener isolation: {anchor}" + ) + assert "noreferrer" in rel_tokens, ( + f"target=_blank link must keep the product referrer policy: {anchor}" + )