Web Security for Frontend Devs · Part 10 — Input Validation, Open Redirects & a Frontend Threat Model
Series finale: why client validation is UX not security, open-redirect fixes, DOM risks beyond XSS, postMessage hygiene, and a practical frontend threat-model checklist tying Parts 1–9 — with exercises.
Phần 10/10 (series lõi) trong series Web Security for Frontend Devs. Trước: Sau đó series tiếp tục với Nhánh nâng cao:
Đây là Phần 10 của series 10 bài (chốt series) về những kiến thức bảo mật web mà mọi frontend dev nên biết — và chủ động phòng tránh. Bạn đã đi qua origin, XSS, CSP, CSRF, token, CORS, framing, header, và supply chain. Bài chốt nối các mảnh: thứ gì vượt biên tin cậy, validate input thù địch trên server, và threat-model frontend trước khi ship.
Validate phía client là UX, không phải bảo mật
Phần 1 đã nêu quy tắc: đừng tin client. Người dùng điều khiển DevTools, bỏ ràng buộc JS, replay request bằng curl, và vá bundle minify. Thuộc tính required, nút submit disabled, hay Zod trên trình duyệt chỉ gợi ý — giúp UX và bắt lỗi vô ý, nhưng không phải kiểm soát bảo mật.
Mô hình tư duy lỗi: “Chúng ta disabled nút Pay cho đến khi form hợp lệ, nên attacker không gửi được dữ liệu xấu.”
<!-- UX only — trivially bypassed -->
<form id="checkout">
<input name="amount" type="number" required min="1" max="10_000" />
<button type="submit" disabled>Pay</button>
</form>
Attacker gửi POST /api/checkout với amount=-1 hoặc amount=999999 trực tiếp — server phải từ chối.
Sửa: lặp rule trên server (cùng schema, cùng giới hạn) và authorization cũng ở đó — “user này có được trả hóa đơn này?” không chỉ trả lời trên trình duyệt.
// server/handler.ts — authoritative layer
import { z } from 'zod';
const CheckoutBody = z.object({
invoiceId: z.string().uuid(),
amountCents: z.number().int().min(1).max(10_000_00),
});
export async function postCheckout(req: Request, session: Session): Promise<Response> {
const parsed = CheckoutBody.safeParse(await req.json());
if (!parsed.success) {
return Response.json({ error: 'invalid_body' }, { status: 400 });
}
const invoice = await db.invoices.find(parsed.data.invoiceId);
if (!invoice || invoice.userId !== session.userId) {
return Response.json({ error: 'forbidden' }, { status: 403 });
}
if (invoice.amountCents !== parsed.data.amountCents) {
return Response.json({ error: 'amount_mismatch' }, { status: 400 });
}
// … charge
return Response.json({ ok: true });
}
Vẫn validate trên client — user xứng đáng phản hồi nhanh. Coi lớp đó là lần kiểm tra đầu, không tin cậy:
// client/checkout.ts — same schema, UX only
import { z } from 'zod';
const CheckoutForm = z.object({
invoiceId: z.string().uuid(),
amountCents: z.coerce.number().int().min(1).max(10_000_00),
});
function onSubmit(raw: unknown): void {
const result = CheckoutForm.safeParse(raw);
if (!result.success) {
showFieldErrors(result.error.flatten());
return;
}
void fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result.data),
});
}
Chia sẻ kiểu schema qua package hoặc type sinh từ OpenAPI để client/server không lệch. Server luôn parse riêng — không “tin SPA đã validate rồi”.
Open redirect
Open redirect xuất hiện khi app phản chiếu URL không tin cậy vào Location, res.redirect(), hoặc location.href sau đăng nhập — thường ?next=, ?returnUrl=, ?redirect_uri=. Attacker gửi nạn nhân link tin cậy trên domain bạn rồi nhảy sang https://evil.com:
- Phishing — nhái login trên
evil.comsau khi nạn nhân tưởng bắt đầu từ bạn. - Rò token OAuth/SSO — một số flow nhét token vào fragment trên URL đích.
Lỗ hổng:
// ❌ reflects attacker-controlled absolute URL
export function GET(req: Request): Response {
const next = new URL(req.url).searchParams.get('next') ?? '/dashboard';
return Response.redirect(next, 302);
}
// ❌ client-side equivalent
const params = new URLSearchParams(window.location.search);
const next = params.get('returnUrl') ?? '/home';
location.href = next; // ?returnUrl=https://evil.com
Đã sửa — allowlist path tương đối cùng origin; từ chối //, http:, https:, backslash, và bypass encode:
const ALLOWED_POST_LOGIN_PATHS = new Set([
'/dashboard',
'/settings/profile',
'/checkout/complete',
]);
function resolveSafeRedirect(raw: string | null, fallback = '/dashboard'): string {
if (!raw) return fallback;
// Reject absolute, protocol-relative, and backslash tricks
if (
raw.startsWith('http:') ||
raw.startsWith('https:') ||
raw.startsWith('//') ||
raw.includes('\\') ||
raw.includes('%5c') ||
raw.includes('%2f%2f')
) {
return fallback;
}
// Must be a single leading-slash path (no open redirect via userinfo)
if (!raw.startsWith('/') || raw.startsWith('//') || raw.includes('://')) {
return fallback;
}
const pathOnly = raw.split('?')[0]?.split('#')[0] ?? raw;
if (!ALLOWED_POST_LOGIN_PATHS.has(pathOnly)) {
return fallback;
}
return raw;
}
export function GET(req: Request): Response {
const next = new URL(req.url).searchParams.get('next');
const safe = resolveSafeRedirect(next);
return Response.redirect(new URL(safe, req.url).toString(), 302);
}
Với OAuth redirect_uri, dùng URL redirect đăng ký sẵn trên server — không param query tự do chỉ từ trình duyệt. Điều hướng sau login map tới route đã biết, không chuỗi tùy ý.
postMessage và biên iframe
Phần 7 đã nói framing và messaging giữa frame. Tóm tắt cho biên tin cậy:
event.originphải khớp allowlist trước khi xử lýevent.data.postMessage(payload, targetOrigin)— origin rõ;'*'không bao giờ phù hợp cho secret.- Ưu tiên trao đổi qua server (mã một lần) thay vì đẩy token qua
postMessage.
const TRUSTED = new Set(['https://app.example.com']);
window.addEventListener('message', (event: MessageEvent) => {
if (!TRUSTED.has(event.origin)) return;
// narrow event.data with a type guard, then act
});
Rủi ro DOM ngoài XSS cổ điển
Phần 2 tập trung inject script. Sink phía client khác vẫn quan trọng khi dữ liệu bị attacker chi phối:
Scheme URL nguy hiểm — Gán input user vào a.href, location.href, window.open không validate có thể chạy javascript:…:
function isSafeHttpUrl(raw: string): boolean {
try {
const u = new URL(raw, window.location.origin);
return u.protocol === 'https:' || u.protocol === 'http:';
} catch {
return false;
}
}
function navigateUserSuppliedLink(raw: string): void {
if (!isSafeHttpUrl(raw)) return;
window.location.assign(new URL(raw, window.location.origin).href);
}
Reverse tabnabbing — target="_blank" thiếu rel cho phép tab mới gọi window.opener.location = 'https://evil.com':
<a href="https://docs.example.com/guide" target="_blank" rel="noopener noreferrer">
External docs
</a>
Template injection — Template client biên dịch chuỗi HTML là sink XSS; xử lý như innerHTML Phần 2, ưu tiên binding escape mặc định. CSP (Phần 3) và Trusted Types vẫn là lớp cuối khi sink lọt review.
Biên tin cậy
Mọi quyết định bảo mật frontend cuối cùng trả lời: thứ gì được phép vượt từ client không tin cậy sang server tin cậy?
Diagram là ảnh chốt: trái = client (không tin), phải = server (tin), mũi tên = biên tin cậy. Mọi thứ trình duyệt gửi — field form, body JSON, header user giả mạo, localStorage nhét vào header, param URL — thù địch cho đến khi validate.
Checklist threat-model frontend
Dùng trên feature thật trước khi merge. Mỗi dòng: input, tài sản rủi ro, phần dạy cách sửa.
| Input / kênh | Có thể sai gì | Trỏ series |
|---|---|---|
URL, query, hash, document.referrer | Open redirect, DOM XSS sinks, leaked tokens in fragment | Part 2, this post |
Forms & fetch bodies | CSRF, validation bypass, IDOR if server trusts client IDs | Part 4, this post |
Cookies & Authorization | CSRF auto-send, token theft via XSS | Parts 1, 4, 5 |
localStorage / sessionStorage | Any XSS reads secrets; never store refresh tokens for SPAs without hardening | Part 5 |
Cross-origin fetch + credentials | Misconfigured CORS exposing data to wrong origins | Part 6 |
Third-party <script> / npm deps | Supply-chain RCE in your origin | Part 9 |
<iframe> embed + postMessage | Clickjacking, confused-deputy messaging | Part 7 |
| HTML you generate | XSS stored/reflected/DOM | Part 2 |
| Policy surface | Missing CSP, weak frame-ancestors, no HSTS | Parts 3, 7, 8 |
| Build & env | Secrets in client bundle, .env in repo | Part 9 |
Đi từng bước:
- Vẽ biên — trình duyệt + JS bạn = không tin; API + DB = tin.
- Liệt kê input — mọi param, header, cookie, message, upload, frame WebSocket.
- Liệt kê tài sản — phiên, PII, hành động admin, billing, API key (không được trong bundle client).
- Map kiểm soát — mỗi cặp input→asset: validate server, authz, encoding, cứng trình duyệt (CSP, SameSite, header).
- Giả định một lớp hỏng — XSS vào thì token còn trong cookie
httpOnly? CSRF xảy ra thì server có cần header tùy chỉnh?.
Checklist pre-ship bảo mật frontend
Chạy trước release lớn:
- Hiểu SOP — biết đọc vs gửi/nhúng cross-origin (Phần 1).
- Encode / sanitize output — escape text; sanitize HTML; tránh sink thô (Phần 2).
- CSP với nonce/hash; thử report-only trước (Phần 3).
- CSRF — cookie
SameSite+ token anti-CSRF hoặc header tùy chỉnh trên mutation (Phần 4). - Lưu token — ưu tiên cookie phiên
httpOnly; không coilocalStoragean toàn (Phần 5). - CORS — allowlist rõ; không
*kèm credentials (Phần 6). - Chống clickjacking —
frame-ancestors/ XFO; sandbox iframe bên thứ ba (Phần 7). - HTTPS + header + SRI — HSTS, X-Content-Type-Options, Referrer-Policy, SRI cho script CDN (Phần 8, 9).
- Vệ sinh dependency — lockfile, audit, pin version; duyệt package mới (Phần 9).
- Validate & authz server trên mọi API đổi trạng thái — kiểm tra client chỉ là UX (bài này).
- Allowlist redirect — không open
next/returnUrl(bài này).
Mô hình tư duy: Bạn không “thêm bảo mật cuối”. Bạn vẽ biên sớm và khiến mỗi lớp hỏng không gây thảm họa.
Bài tập / Exercises
1. Hai câu: vì sao required trên <input> không đủ giới hạn amount trên /api/checkout, server phải làm gì.
Lời giải
Attacker không bị ràng buộc form HTML — họ POST JSON tùy ý với mọi amount. Server phải parse và validate body (schema + rule nghiệp vụ) và authorize phiên được trả hóa đơn đó với số tiền đó.
2. Giá trị next nào resolveSafeRedirect phải từ chối, vì sao? (a) /dashboard (b) //evil.com/path (c) https://evil.com (d) /dashboard?tab=1
Lời giải
Từ chối (b) //evil.com và (c) https:// — đều đưa nạn nhân ra ngoài origin. (a) ổn nếu /dashboard trong allowlist. (d) chấp nhận chỉ khi logic allowlist cho phép query trên path được phép (mẫu tách pathOnly trước ? — mở rộng rõ nếu product cần ?tab=).
3. Trang checkout nhúng trong iframe đối tác, liệt kê ba kiểm soát từ các phần khác nhau trong series.
Lời giải
Ví dụ xếp lớp:
frame-ancestorschặt chỉ origin đối tác (Phần 7) — không'self'toàn cục trên route admin.postMessagekiểmevent.originvàtargetOriginrõ — không'*'cho trạng thái thanh toán (Phần 7 + bài này).- Token CSRF hoặc
SameSite+ header tùy chỉnh trên POST từ embed, cộng validate server số tiền (Phần 4, 10).
Nâng cao:Chọn một feature trong app bạn duy trì. Vẽ diagram biên tin cậy, liệt kê năm input và năm tài sản, map mỗi input tới ít nhất một kiểm soát từ Phần 1–9.
Lời giải
Không có một đáp án chuẩn — đạt khi mỗi input có kiểm soát phía server và tài sản có phòng thủ nhiều lớp (vd PII sau authz + CSP + không secret trong bundle). Nếu input chỉ map “validate trong React,” đánh fail cho đến khi API ép cùng rule.
Điểm chính
- Validate client là UX — server validate lại và authorize mọi request đổi trạng thái.
- Open redirect sửa bằng allowlist path, không tin mù
next/returnUrl. postMessagecần kiểmevent.originvàtargetOriginrõ — không'*'cho secret.- Rủi ro DOM gồm URL
javascript:, tabnabbingopener, sink template — không chỉ inject<script>. - Threat-model = biên + input + tài sản + kiểm soát map theo series.
Toàn bộ series
Bạn đã đi hết 10 phần. Dùng danh sách này để ôn từng chủ đề — mỗi link là bài sâu độc lập có diagram, code lỗi/sửa, và bài tập.
- Part 1 — The Browser Security Model & Same-Origin Policy
- Part 2 — Cross-Site Scripting (XSS)
- Part 3 — Content Security Policy (CSP)
- Part 4 — CSRF & SameSite Cookies
- Part 5 — Auth Tokens & Secure Storage
- Part 6 — CORS Explained
- Part 7 — Clickjacking & Framing
- Part 8 — Secure Headers & HTTPS/TLS
- Part 9 — Secrets, Data Leakage & Supply-Chain
- Part 10 — Input Validation, Open Redirects & a Frontend Threat Model (you are here)
Nhánh nâng cao — các bài sâu hơn, đặc thù JavaScript, xây trên 10 phần lõi:
- Part 11 — Prototype Pollution
- Part 12 — DOM Clobbering
- Part 13 — Strict CSP & CSP Bypasses
- Part 14 — postMessage & Cross-Window Exploits
- Part 15 — JWT & Token Attacks
- Part 16 — Web Cache Deception & Open-Redirect Chaining
Giờ bạn có cẩm nang bảo mật frontend: không phải checklist dán một lần, mà cách đặt tên biên tin cậy, nhận phần series nào áp dụng, và ship mà không trông trình duyệt cứu bạn. Đọc lại phần nào khi PR thêm ?next=, listener postMessage, hay “thêm một innerHTML nữa” — đó là lúc series làm đúng việc.