Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 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