Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 효과는 배포 후 실측 대상으로 유지합니다.
Expand Down
31 changes: 22 additions & 9 deletions i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

Expand Down Expand Up @@ -385,16 +394,20 @@ 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
}
currentLang = 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());
34 changes: 33 additions & 1 deletion tests/test_i18n_security.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand All @@ -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:
Expand All @@ -17,9 +24,34 @@ def test_i18n_html_security_tests_present() -> None:
assert "setLanguage(\"__proto__\")" in content
assert "setLanguage(\"<script>alert(1)<\\/script>\")" in content


def test_i18n_avoids_log_injection() -> None:
"""Test that console.warn does not interpolate user input."""
with open("i18n.js", "r", encoding="utf-8") as f:
content = f.read()

assert 'console.warn("[Security] Invalid language requested. Falling back to default.");' in content


def test_i18n_environment_validation() -> None:
"""Execute the i18n runtime without browser globals and require a clean exit."""
node = shutil.which("node")
if node is None:
pytest.skip("Node.js is required for the non-browser i18n runtime contract")

probe = r"""
const fs = require("fs");
const vm = require("vm");
const source = fs.readFileSync("i18n.js", "utf8");
const context = { console };
vm.createContext(context);
vm.runInContext(source + "\npreferredLanguage(); setLanguage('en');", context);
"""
completed = subprocess.run(
[node, "-e", probe],
check=False,
capture_output=True,
text=True,
)

assert completed.returncode == 0, completed.stderr
Loading