jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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.com sau 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.origin phả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 tabnabbingtarget="_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?

CLIENT — untrusted · user can edit JS, DOM, requests, localStorage · validation here = UX only · no secrets, no real authz trust boundary validate + authz here SERVER — trusted · re-validate every input · enforce authn / authz · hold secrets & business rules · never trust the client every input crossing the boundary is hostile until proven safe
Client is hostile: validate and authorize on the server — every input crossing the boundary is untrusted until proven safe

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ênhCó thể sai gìTrỏ series
URL, query, hash, document.referrerOpen redirect, DOM XSS sinks, leaked tokens in fragmentPart 2, this post
Forms & fetch bodiesCSRF, validation bypass, IDOR if server trusts client IDsPart 4, this post
Cookies & AuthorizationCSRF auto-send, token theft via XSSParts 1, 4, 5
localStorage / sessionStorageAny XSS reads secrets; never store refresh tokens for SPAs without hardeningPart 5
Cross-origin fetch + credentialsMisconfigured CORS exposing data to wrong originsPart 6
Third-party <script> / npm depsSupply-chain RCE in your originPart 9
<iframe> embed + postMessageClickjacking, confused-deputy messagingPart 7
HTML you generateXSS stored/reflected/DOMPart 2
Policy surfaceMissing CSP, weak frame-ancestors, no HSTSParts 3, 7, 8
Build & envSecrets in client bundle, .env in repoPart 9

Đi từng bước:

  1. Vẽ biên — trình duyệt + JS bạn = không tin; API + DB = tin.
  2. Liệt kê input — mọi param, header, cookie, message, upload, frame WebSocket.
  3. Liệt kê tài sản — phiên, PII, hành động admin, billing, API key (không được trong bundle client).
  4. Map kiểm soát — mỗi cặp input→asset: validate server, authz, encoding, cứng trình duyệt (CSP, SameSite, header).
  5. 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 coi localStorage an toàn (Phần 5).
  • CORS — allowlist rõ; không * kèm credentials (Phần 6).
  • Chống clickjackingframe-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(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:

  1. frame-ancestors chặt chỉ origin đối tác (Phần 7) — không 'self' toàn cục trên route admin.
  2. postMessage kiểm event.origintargetOrigin rõ — không '*' cho trạng thái thanh toán (Phần 7 + bài này).
  3. 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.
  • postMessage cần kiểm event.origintargetOrigin rõ — không '*' cho secret.
  • Rủi ro DOM gồm URL javascript:, tabnabbing opener, 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.

  1. Part 1 — The Browser Security Model & Same-Origin Policy
  2. Part 2 — Cross-Site Scripting (XSS)
  3. Part 3 — Content Security Policy (CSP)
  4. Part 4 — CSRF & SameSite Cookies
  5. Part 5 — Auth Tokens & Secure Storage
  6. Part 6 — CORS Explained
  7. Part 7 — Clickjacking & Framing
  8. Part 8 — Secure Headers & HTTPS/TLS
  9. Part 9 — Secrets, Data Leakage & Supply-Chain
  10. 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:

  1. Part 11 — Prototype Pollution
  2. Part 12 — DOM Clobbering
  3. Part 13 — Strict CSP & CSP Bypasses
  4. Part 14 — postMessage & Cross-Window Exploits
  5. Part 15 — JWT & Token Attacks
  6. 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.