diff --git a/.jules/sentinel.md b/.jules/sentinel.md index fa902ea..7352d8d 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -20,7 +20,7 @@ **Prevention:** 적용 가능할 때는 항상 CSP에 Trusted Types를 적용하여 DOM XSS 회귀를 선제적으로 방지해야 함. ## 2026-07-03 - Native Trusted Types enforcement **Vulnerability:** Trusted Types 정책 부재로 인한 DOM 기반 XSS (Cross-Site Scripting) 취약점 위험. -**Learning:** 이 정적 웹사이트는 `innerHTML` 같은 위험한 Sink를 사용하지 않고 `textContent`, `setAttribute` 등 안전한 DOM API만을 사용하고 있으므로, 별도의 Trusted Types 정책이나 외부 Sanitizer(예: DOMPurify) 없이도 CSP에서 `require-trusted-types-for 'script'`를 안전하게 기본 강제할 수 있음을 확인했습니다. +**Learning:** 이 정적 웹사이트는 `innerHTML` 같은 위험한 Sink를 사용하지 않고 `textContent`, `setAttribute` 등 안전한 DOM API만 사용하고 있으므로, 별도의 Trusted Types 정책이나 외부 Sanitizer(예: DOMPurify) 없이도 CSP에서 `require-trusted-types-for 'script'`를 안전하게 기본 강제할 수 있음을 확인했습니다. **Prevention:** CSP에 `require-trusted-types-for 'script'`를 적용하여 XSS를 방어하고, 앞으로도 안전한 DOM API만 사용하도록 합니다. 부득이하게 `innerHTML`을 도입해야 할 경우에는 반드시 적절한 Sanitizer를 함께 구성해야 합니다. ## 2026-07-01 - Add Trusted Types Policy via DOMPurify **Vulnerability:** Application lacked Trusted Types enforcement, which left it potentially vulnerable to DOM-based XSS if DOM sinks (like `innerHTML`) were manipulated. diff --git a/CHANGELOG.md b/CHANGELOG.md index e097a55..e9a814a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # CHANGELOG ## [Unreleased] +- **런타임 복원력**: `i18n.js`가 `window`, `document`, `navigator`가 없는 비브라우저 실행 환경에서도 예외 없이 기본 언어 경로를 종료하도록 브라우저 전역 접근을 경계 처리했습니다. 이 변경만으로 SSR 지원을 선언하지는 않습니다. - **보안 개선**: `i18n.js`에서 잘못된 언어 요청 시 `console.warn` 메시지에 사용자 입력값이 직접 포함되지 않도록 수정하여 로그 인젝션(Log Injection) 취약점을 제거했습니다. - **성능 개선**: `.skip-link` 애니메이션을 `top`에서 `transform: translateY()`로 변경하여 전환 중 레이아웃 재계산을 줄일 수 있도록 했습니다. 실제 효과는 브라우저별 측정 대상입니다. - **렌더링 힌트 정합성**: 첫 화면의 eager 이미지와 단일 LCP 후보에서 강제 `decoding="async"`를 제거해 HTML 표준의 기본 `auto` 판단에 맡기고, 지연 로드 이미지에는 비동기 디코딩 힌트를 유지했습니다. 정적 테스트가 eager, lazy, LCP 후보 집합의 존재와 조합을 검증하며, 실제 LCP 효과는 배포 후 실측 대상으로 유지합니다. diff --git a/i18n.js b/i18n.js index 00b81d3..f67c479 100644 --- a/i18n.js +++ b/i18n.js @@ -297,17 +297,25 @@ const messages = { function preferredLanguage() { const allowed = ["ko", "en"]; - const query = new URLSearchParams(window.location.search).get("lang"); - if (allowed.includes(query)) return query; + + if (typeof window !== 'undefined' && window.location) { + const query = new URLSearchParams(window.location.search).get("lang"); + if (allowed.includes(query)) return query; + } try { - const saved = localStorage.getItem("cwl-language"); - if (allowed.includes(saved)) return saved; + if (typeof window !== 'undefined' && window.localStorage) { + const saved = window.localStorage.getItem("cwl-language"); + if (allowed.includes(saved)) return saved; + } } catch (error) { // Fail securely: ignore localStorage errors in strict privacy modes } - return navigator.language?.toLowerCase().startsWith("ko") ? "ko" : "en"; + if (typeof navigator !== 'undefined' && navigator.language) { + return navigator.language.toLowerCase().startsWith("ko") ? "ko" : "en"; + } + return "en"; } // ⚡ Bolt: Cache DOM queries and current state to prevent redundant lookups and layout thrashing @@ -327,6 +335,7 @@ function setLanguage(lang) { } if (currentLang === lang) return; // Skip if already in the requested language + if (typeof document === 'undefined') return; const dict = messages[lang] || messages.ko; @@ -385,7 +394,9 @@ function setLanguage(lang) { }); try { - localStorage.setItem("cwl-language", lang); + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.setItem("cwl-language", lang); + } } catch (error) { // Fail securely: ignore localStorage errors } @@ -393,8 +404,10 @@ function setLanguage(lang) { } // Event listeners can just use the initial querySelectorAll -document.querySelectorAll("[data-lang]").forEach((button) => { - button.addEventListener("click", () => setLanguage(button.dataset.lang)); -}); +if (typeof document !== 'undefined') { + document.querySelectorAll("[data-lang]").forEach((button) => { + button.addEventListener("click", () => setLanguage(button.dataset.lang)); + }); +} setLanguage(preferredLanguage()); diff --git a/tests/test_i18n_security.py b/tests/test_i18n_security.py index 833b464..51407b0 100644 --- a/tests/test_i18n_security.py +++ b/tests/test_i18n_security.py @@ -1,4 +1,10 @@ -"""Test i18n security input validation.""" +"""Test i18n security and runtime boundary behavior.""" + +import shutil +import subprocess + +import pytest + def test_i18n_input_validation() -> None: """Test that allowedLanguages validation logic is correctly implemented in i18n.js.""" @@ -9,6 +15,7 @@ def test_i18n_input_validation() -> None: assert "allowedLanguages = [\"ko\", \"en\"]" in content or "allowedLanguages = ['ko', 'en']" in content assert "allowedLanguages.includes" in content + def test_i18n_html_security_tests_present() -> None: """Test that explicit __proto__ and XSS payload checks exist in the HTML test harness.""" with open("test_i18n.html", "r", encoding="utf-8") as f: @@ -17,9 +24,34 @@ def test_i18n_html_security_tests_present() -> None: assert "setLanguage(\"__proto__\")" in content assert "setLanguage(\"