Bài này nói về bảo mật frontend mức senior cho phỏng vấn: threat model, không phải list helper escapeHTML. Nó nói XSS cùng origin cướp được gì, site độc hại khác làm được gì với cookie, client bundle đã lộ gì, và bạn đã tin gì từ npm. Dành cho engineer đã ship Vue 3 / Nuxt, giờ cần ra quyết định dưới attacker thật, không phải câu “mình dùng HTTPS”.
Đọc 10.1–10.3 trước (XSS, CSRF, authentication). Người phỏng vấn xoáy chỗ cất token và v-html nhiều hơn trivia CORS. Rồi 10.4–10.7 cho validation khác sanitization, CORS như quyền đọc của trình duyệt, supply chain, và secret Vite/Nuxt. Nêu rõ attacker, sink, và control, không phải list header cho có.
Tóm tắt: XSS (cross-site scripting) là HTML hoặc JS của attacker chạy trong origin của bạn. Stored nằm trong DB. Reflected được server echo từ request. DOM-based là client đọc location / postMessage rồi đưa vào sink. Vue escape {{ }}; không escape v-html.
Vì sao quan trọng: alert(1) chỉ là demo. Bug thật là cướp token khi access token nằm trong localStorage (xem Best practices về authentication). Một field CMS dùng v-html là session bị cắp, không phải popup.
- Cách hoạt động: Stored: payload được lưu (comment, tên hiển thị, khối CMS) rồi mọi người xem sau đều dính. Reflected: server echo
qtừ URL vào HTML. Search SSR vẫn làm vậy. DOM-based:innerHTML = location.hashkhông đụng tới server. - Compiler của Vue ghi interpolation thành text.
v-html,innerHTMLcủa widget, hrefjavascript:, vàel.innerHTML = props.codevẫn là sink. Sanitize ở ranh giới bằng DOMPurify, không phải regex. Sanitize cuối để không dính lại mutation XSS. - CSP (Content Security Policy) là containment:
script-srckhông'unsafe-inline', Report-Only trước, header chứ không phải<meta>gắn muộn. CSP không thay sanitization và không chặn navigationjavascript:.
<script setup lang="ts">
import DOMPurify from 'dompurify'
const dirty = inject('cmsHtml') as string
// Chỉ profile HTML, đừng cho SVG/MathML trừ khi cố ý
const html = DOMPurify.sanitize(dirty, { USE_PROFILES: { html: true } })
</script>
<template>
<p>{{ dirty }}</p> <!-- text đã escape, không phải sink -->
<div v-html="html" /> <!-- sink: chỉ sau DOMPurify -->
</template>
// Cũng allowlist protocol href. javascript: là XSS lúc navigate.Điều gì xảy ra: {{ dirty }} không chạy script được. v-html thì được, nên HTML duy nhất tới DOM đã qua profile HTML của DOMPurify. Đồng nghiệp gán el.innerHTML = props.code là bỏ qua Vue hoàn toàn. Ghép với token mà JS không đọc được (10.3).
| Loại | Payload nằm ở đâu | Ai bị dính |
|---|---|---|
| Stored | DB: comment, tên, field CMS | Mọi người xem sau |
| Reflected | URL / request, được echo vào HTML | Người gửi request; search SSR vẫn vậy |
| DOM-based | Client đọc location / postMessage vào sink | Ai chạy path client đó |
Góc nhìn senior
- Vue escape text không phải chương trình bảo mật. Audit
v-html,innerHTML, và href do user kiểm soát. - Một sink kèm token JS đọc được là session bị cắp. Đưa token ra khỏi
localStorage(10.3). - DOMPurify ở ranh giới, không phải regex tự viết. Sanitize cuối để tránh mutation XSS.
- CSP là containment (Report-Only, header, không
unsafe-inline). Nó không thay sanitization. - Widget markdown / rich-text là XSS stored thêm vài bước. Coi output của chúng là HTML không tin được.
Lỗi hay gặp
- Triệu chứng:
alert(1)“chỉ là popup”. Nguyên nhân: coi XSS như demo. Cách xử lý: nêu rõ session bị cắp.- Triệu chứng: “Vue an toàn”. Nguyên nhân: bỏ qua
v-html/innerHTML. Cách xử lý: audit mọi sink.- Triệu chứng: CSP
<meta>muộn, vẫn script inline. Nguyên nhân: không phải header, cònunsafe-inline. Cách xử lý: header Report-Only trước.- Triệu chứng: sanitizer rồi nối chuỗi vào HTML. Nguyên nhân: mutation XSS. Cách xử lý: sanitize cuối.
Nhà tuyển dụng hay hỏi gì
Một field CMS render bằng v-html, JWT nằm trong localStorage, và ai đó báo alert(1). Họ muốn loại XSS, Vue có “an toàn” không, v-html khi nào được phép, CSP kìm được gì, và chuyện này thành cướp token thế nào.
Trả lời khi phỏng vấn
Vue escape text không phải chương trình bảo mật. XSS stored là field CMS hoặc comment; XSS reflected là query search được echo vào HTML; XSS DOM là location hoặc postMessage vào sink. Interpolation Vue là text; v-html, innerHTML, và href javascript vẫn là sink. Mình sanitize ở ranh giới bằng DOMPurify, không phải regex, và coi CSP là containment: Report-Only trước, header chứ không phải thẻ meta muộn, không unsafe-inline. Mình nối XSS với cướp token: JWT nằm localStorage thì một field CMS là session bị cắp. CSP không thay sanitization.
Nhớ nhanh
v-html/innerHTML/ URL do user đưa là lỗ của Vue.{{ }}thì không.- Stored, reflected, DOM-based: payload nằm ở đâu quyết định ai bị dính.
- DOMPurify ở ranh giới. Sanitize cuối. Regex không phải sanitizer.
- CSP kìm một phần XSS. Nó không thay sanitization hay sửa chỗ cất token.
Câu hỏi nối tiếp thường gặp
v-htmlkhi nào được phép? Sau DOMPurify ở ranh giới, cho CMS bạn không slot được. Không bao giờ cho comment lẽ ra để text.- CSP có chặn href
javascript:? Không. Allowlist protocol (http:,https:,mailto:). - Preview markdown? Coi HTML đã render là không tin được. Cùng sanitizer với CMS.
- Vì sao
alert(1)là cách đóng sai? Nêu rõ session bị cắp nếu token JS đọc được.
Tóm tắt: CSRF (cross-site request forgery) là trang độc hại kích request đổi state mà trình duyệt tự gắn cookie. Bearer token do JS gắn lên header thì không tự gắn. Cookie gây CSRF; custom header và SameSite là cách chống.
Vì sao quan trọng: “Mình là SPA, CSRF biến mất” là miss thường gặp. Nó biến mất chỉ khi không có gì tự gắn: không cookie session, không cookie refresh tự POST /logout. Login cookie kèm Access-Control-Allow-Origin: * và credentials là CSRF thêm vài bước (xem 10.5).
- Cách hoạt động: Nạn nhân đã login. Trang của attacker submit POST (hoặc GET đổi state). Trình duyệt gắn cookie của nạn nhân cho API của bạn. Không cần XSS trên origin của bạn.
SameSite=Lax(mặc định SPA thường) không gửi cookie trên POST xuyên site, vẫn gửi trên GET top-level. Đổi state trên GET (/logout,/unsubscribe) vẫn là CSRF với Lax.Strictcũng không gửi cookie ở navigation inbound đầu từ email.None; Securecho embed và cần thêm control.- Header tùy chỉnh (
X-Requested-With) buộc preflight CORS mà trang lạ không qua được. Token double-submit thì ổn; chúng không sống sót XSS. JWT trong headerAuthorizationtừ memory không tự gắn, đến khi ai đó cất nó, hoặc cookie refresh vẫn đổi state không cần header. Xem Authentication.
// Session cookie: header tùy chỉnh là control CSRF (không phải secret)
await api.post('/orders', body, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
withCredentials: true,
})
// Flag cookie (Set-Cookie từ API):
// HttpOnly; Secure; SameSite=Lax; Path=/
// Mutation là POST. GET /logout vẫn là CSRF với Lax.
// Bearer từ memory: bỏ withCredentials; CSRF thường N/A.Điều gì xảy ra: Cookie đi cùng POST vì withCredentials đang bật. Header tùy chỉnh buộc một preflight. Form HTML lạ không gán X-Requested-With được, nên preflight fail. Nếu đây là header Bearer lấy từ memory, trang lạ không gắn được token từ đầu.
| SameSite | Dùng khi | Gãy gì |
|---|---|---|
Strict | Chuẩn first-party cao nhất | GET inbound từ email: lần nav đầu cookie không gửi |
Lax | Mặc định session SPA thường | Không chặn đổi state trên GET |
None; Secure | Chỉ third-party / embed | Cần thêm control CSRF |
Góc nhìn senior
- CSRF tồn tại khi cookie tự gắn vào mutation mà attacker kích được. Bearer từ memory không phải vậy.
SameSite=Laxlà mặc định, không phải control đủ. Đừng đổi state trên GET.- Double-submit / header tùy chỉnh chết dưới XSS. XSS là bug khác (10.1).
- JSON nhận
text/plaincó thể bỏ preflight. Đừng coi “mình gửi JSON” là chống CSRF. - CSRF lúc login là chuyện khác: chống automation kèm SameSite trên cookie session bạn sắp gán.
Lỗi hay gặp
- Triệu chứng: “SPA nên không CSRF”. Nguyên nhân: cookie session hoặc refresh vẫn tự gắn. Cách xử lý: nêu rõ cookie.
- Triệu chứng: logout từ preview email. Nguyên nhân: GET
/logoutdưới Lax. Cách xử lý: POST + xác nhận.- Triệu chứng: CORS
*+ credentials. Nguyên nhân: coi CORS như tường. Cách xử lý: origin cụ thể; xem 10.5.- Triệu chứng: double-submit sau XSS. Nguyên nhân: JS đọc token được. Cách xử lý: sửa XSS; đừng coi token là lớp phòng thủ.
Nhà tuyển dụng hay hỏi gì
Login là session cookie. Đồng nghiệp nói “mình là SPA với header Bearer, CSRF biến mất”. Họ muốn CSRF còn khi nào, token khác SameSite chỗ nào, và trang độc hại có đổi state bằng cookie của user mà không cần header thêm không.
Trả lời khi phỏng vấn
CSRF quan trọng khi trình duyệt gắn cookie giúp attacker. Bearer token từ memory không tự gắn, nên CSRF cổ điển thường không còn, đến khi ai đó cất token hoặc cookie refresh vẫn POST logout một mình. Session cookie thì mình mặc định SameSite Lax, HTTPS, mutation POST, và header tùy chỉnh buộc preflight. Double-submit thì được; nó không sống sót XSS. Mình không lấy chuyện là SPA làm control, và không đổi state trên GET dưới Lax.
Nhớ nhanh
- CSRF = cookie tự gắn vào mutation mà attacker kích được.
- Bearer từ memory không tự gắn. Cookie refresh thì vẫn có thể.
SameSite=Lax+ POST + header tùy chỉnh. Đừng đổi state trên GET.- Double-submit vô dụng khi dính XSS. Sửa XSS là chuyện riêng (10.1).
Câu hỏi nối tiếp thường gặp
- JWT trong header có tránh CSRF? Có, nếu JS gán từ memory và không phải cookie. Cookie refresh là chuyện khác.
- CSRF lúc login? Attacker login nạn nhân vào account của attacker. SameSite kèm chống automation.
- Vì sao không luôn
SameSite=Strict? Click inbound đầu từ email mất cookie session. - Form lạ có gán
X-Requested-Withđược? Không theo cách qua được preflight CORS.
Tóm tắt: Ưu tiên cookie session httpOnly, hoặc access token trong memory kèm cookie refresh httpOnly. Đừng mặc định access token vào localStorage. Rotate refresh mỗi lần dùng. Single-flight các 401 để một burst không thu hồi cả family. Broadcast logout sang mọi tab.
Vì sao quan trọng: XSS kèm token JS đọc được là session bị cắp (10.1). 401 song song không hàng đợi sẽ rotate family refresh và đá hết tab trừ một. Hàng đợi 401 và redirect nằm ở Xử lý 401.
- Cách hoạt động: Cookie session: server nắm session; JS không cướp được; CSRF giờ là việc của bạn (10.2). Access token trong memory + refresh httpOnly: XSS không đọc được cookie refresh; reload phải refresh; CSRF áp vào endpoint refresh.
- Rotate refresh lúc dùng: token bị cắp dùng được một lần. Dùng lại refresh cũ nên thu hồi cả family. Interceptor 401 single-flight một lần refresh, waiter retry. Xem practical-questions 18.1.
- Logout: server thu hồi kèm
BroadcastChannel(hoặc eventstorage) để mọi tab bỏ memory. Persist Pinia của user store chỉ làlocalStoragethêm vài bước. Mình không implement OAuth implicit trong SPA.
let refreshInFlight: Promise<string> | null = null
async function refreshAccess(): Promise<string> {
if (!refreshInFlight) {
refreshInFlight = api.post('/auth/refresh').then((r) => {
tokenMemory.set(r.accessToken)
return r.accessToken
}).finally(() => { refreshInFlight = null })
}
return refreshInFlight // các waiter share một round-trip
}
const authCh = new BroadcastChannel('auth')
export function logoutAllTabs() {
tokenMemory.set(null)
authCh.postMessage({ type: 'logout' })
void api.post('/auth/logout')
}Điều gì xảy ra: Mười 401 share một /auth/refresh. Không có promise, rotation đá hết tab trừ một. Logout xóa memory ở tab này và các tab khác, rồi nhờ server thu hồi. Access token không đụng Web Storage.
| Pattern | XSS | CSRF | UX |
|---|---|---|---|
| Access memory + refresh httpOnly | Access khó bị cắp | Cookie refresh cần SameSite | Refresh lúc reload |
| Chỉ cookie session httpOnly | Tốt nhất chống XSS | CSRF trên mutation | Đơn giản nhất |
Token trong localStorage | Tệ nhất | CSRF thường N/A | Dễ, không phải câu senior |
Góc nhìn senior
- Mặc định: session httpOnly + Secure + SameSite, hoặc access memory + refresh httpOnly. Chi tiết CSRF ở 10.2.
- Hàng đợi 401 nằm ở practical-questions 18.1. Auth ở đây là token sống ở đâu và refresh rotate thế nào.
- Reverse proxy không được log
Authorization. Đó là leak trên production, không phải nit frontend. - Step-up (mật khẩu / WebAuthn) cho lệnh tiền. Expire-all là list session trên server, hoặc tăng version token.
- Persist Pinia / “encrypted localStorage” vẫn XSS đọc được. Đừng nhét access token vào đó.
Lỗi hay gặp
- Triệu chứng: session bị cắp sau một XSS. Nguyên nhân: access token trong
localStorage. Cách xử lý: memory hoặc cookie httpOnly.- Triệu chứng: mọi tab trừ một bị logout. Nguyên nhân: 401 song song, mỗi cái rotate refresh. Cách xử lý: single-flight.
- Triệu chứng: một tab logout, tab khác vẫn vào. Nguyên nhân: không broadcast / không thu hồi phía server. Cách xử lý:
BroadcastChannel+/auth/logout.- Triệu chứng: “localStorage mã hóa”. Nguyên nhân: XSS cùng origin giải mã được. Cách xử lý: đừng cất access token ở đó.
Nhà tuyển dụng hay hỏi gì
Một burst 401 cùng lúc đụng /refresh, một tab logout còn tab khác vẫn vào, và ai đó persist user store. Access token sống ở đâu, rotate refresh thế nào, single-flight 401 ra sao, và logout mọi tab thế nào?
Trả lời khi phỏng vấn
Access token nằm trong memory; refresh là httpOnly, Secure, SameSite, rotate lúc dùng. Interceptor 401 single-flight refresh để một burst không rotate family và đá các tab khác. Hàng đợi đó là topic 401 trong practical questions. Logout broadcast và gọi auth logout để server thu hồi. Mình coi XSS là game over với mọi thứ JS đọc được: persist Pinia chỉ là localStorage thêm bước. Mình không nhét JWT vào localStorage, và không implement OAuth implicit trong SPA.
Nhớ nhanh
- Access token: memory hoặc cookie session httpOnly. Không bao giờ
localStorage. - Refresh: httpOnly + Secure + SameSite, rotate mỗi lần dùng, thu hồi family khi dùng lại.
- Single-flight các 401. Broadcast logout. Chi tiết hàng đợi ở practical-questions 18.1.
- XSS là game over với token JS đọc được. Plugin persist vẫn là Web Storage.
Câu hỏi nối tiếp thường gặp
- Hàng đợi 401 nằm ở đâu? Practical-questions 18.1. Topic này là chỗ cất token và cách rotate.
- Tab share token trong memory?
BroadcastChannelhoặc lock kèm refresh. Đừng copy access token vàolocalStorage. - Step-up cho lệnh tiền? Mật khẩu hoặc WebAuthn lần nữa. Đừng tin một access token sống lâu một mình.
- OAuth implicit trong SPA? Không. Authorization code kèm PKCE trên BFF.
Tóm tắt: Validation hỏi “đây có phải order?” Sanitization hỏi “HTML này render có an toàn?” Encoding là cách nhét chuỗi vào một ngữ cảnh (text HTML, URL, JS). Check phía client là UX. Check phía server mới là ranh giới bảo mật.
Vì sao quan trọng: Regex trên form checkout không chặn role: 'admin' trong body. Bỏ Zod trong handler Nitro “vì TypeScript” là bug bảo mật: type biến mất lúc runtime. Sanitize HTML không làm email hợp lệ; markdown hợp lệ vẫn có thể là XSS (10.1).
- Cách hoạt động: Share một schema Zod (hoặc Valibot) khi bạn phụ trách cả hai phía. Form dùng
safeParsecho lỗi field. Handler Nitro dùng.parsetrênreadBodyvà vẫn làm authz: body đã parse không được gánisAdminhayprice. - Sanitize HTML bằng DOMPurify ở ranh giới render (10.1). Đừng “sửa” chuỗi bẩn thành data nghiệp vụ. Encoding theo ngữ cảnh:
{{ }}của Vue encode text HTML; nó không encode URL hay chuỗi JS bạn tự ghép. - SQL/NoSQL injection là việc parameterized query / ORM trên server. Frontend không chặn được. Allowlist thắng blocklist. Đừng lấy price, role, hay discount từ client.
const createOrderSchema = z.object({
sku: z.string().min(1).max(64),
qty: z.number().int().positive().max(99),
// không bao giờ: price, role, isAdmin. Đó là fact phía server
})
// Client: UX. Hiện lỗi field. Form bị bypass vẫn đụng API.
const parsed = createOrderSchema.safeParse(form)
if (!parsed.success) return parsed.error.flatten()
// Server (Nitro): ranh giới. TypeScript biến mất lúc runtime.
const body = createOrderSchema.parse(await readBody(event))
// authz vẫn: body.sku không phải quyền muaĐiều gì xảy ra: Cùng schema từ chối qty không hợp lệ trên form lẫn trên server. POST tự chế bỏ qua form vẫn fail .parse. price vắng mặt có chủ đích: server tự tra. TypeScript trên handler lẽ ra đã nhận role: 'admin' như any.
| Validation (Zod) | Sanitization (DOMPurify) | |
|---|---|---|
| Câu hỏi | Đây có phải order? | HTML này render có an toàn? |
| Khi fail | 400, hiện lỗi field | Strip/reject; đừng “sửa” thành data |
| SQL / NoSQL | Parameterized queries / ORM | Không liên quan HTML |
Góc nhìn senior
- Validation client là UX. Tắt nó trong DevTools thì server vẫn phải nói không.
- Bỏ
.parsetrên server vì TypeScript là bug bảo mật. Type là lúc compile. - Authz không phải validation. Body hợp lệ không được gán
isAdminhayprice. - Allowlist thắng blocklist. Schema dùng chung drift ít hơn hai bản: generate hoặc import một module.
- i18n lỗi Zod mà không lộ internals schema (
expected '-infinity') ra UI.
Lỗi hay gặp
- Triệu chứng: API tin
roletừ body. Nguyên nhân: validation không authz. Cách xử lý: bỏ role phía client; lấy từ session.- Triệu chứng: “TypeScript trên handler”. Nguyên nhân: không parse lúc runtime. Cách xử lý:
.parseZod/Valibot.- Triệu chứng: sanitizer “sửa” SKU. Nguyên nhân: sửa thành data nghiệp vụ. Cách xử lý: reject; đừng đoán.
- Triệu chứng: frontend “chặn SQL injection”. Nguyên nhân: nhầm với encoding. Cách xử lý: parameterized query trên server.
Nhà tuyển dụng hay hỏi gì
Form checkout validate email bằng regex, API tin role: 'admin' từ body, và đồng nghiệp bỏ Zod trong handler Nitro “vì TypeScript”. Họ muốn phân biệt client với server, có nên share Zod không, và vì sao sanitization không phải validation.
Trả lời khi phỏng vấn
Form là UX; server là ranh giới. Mình share một schema Zod khi phụ trách cả hai phía, rồi vẫn làm authorization: body đã parse không được gán isAdmin hay price. Sanitize HTML không làm email hợp lệ, và markdown hợp lệ vẫn có thể là XSS. Mình không lấy price từ client, và không nhận là frontend chặn SQL injection. Bỏ parse trên server vì TypeScript là bug bảo mật: type biến mất lúc runtime.
Nhớ nhanh
- Validation client là UX.
.parsetrên server là ranh giới bảo mật. - Validation ≠ sanitization ≠ encoding. Mỗi cái trả lời một câu hỏi khác.
- Authz là chuyện riêng. Đừng lấy giá, role, hay discount từ client.
- Bỏ parse trên server vì TypeScript là bug bảo mật.
Câu hỏi nối tiếp thường gặp
- Share Zod với SPA? Có nếu bạn phụ trách cả hai. Vẫn parse trên server. Drift mới là bug thường gặp.
- OpenAPI thì sao? Generate type và parser.
as Orderviết tay không phải parser. - Upload file? Validate type và size trên server. Content-Type là lời khai, không phải sự thật.
- Encoding Vue có chặn XSS trong
v-html? Không. Đó là sanitisation, topic 10.1.
Tóm tắt: CORS (Cross-Origin Resource Sharing) là quyền đọc của trình duyệt. Nó không bảo vệ API khỏi curl. HTTPS là HSTS và cookie Secure ở edge, không phải redirect Vue Router. API dùng cookie không bao giờ ghép credentials với Access-Control-Allow-Origin: *.
Vì sao quan trọng: Người phỏng vấn đánh trượt câu “CORS là lớp bảo mật của ta”. Thiếu Vary: Origin, * kèm credentials, hoặc redirect HTTP→HTTPS chỉ trong Vue sẽ hiện thành “cookie biến mất trên prod” hoặc cảnh báo mixed-content, chứ không phải câu lý thuyết.
- Cách hoạt động: JS chỉ đọc được response cross-origin khi server opt-in. Request đơn giản (GET/POST kiểu form, không custom header) bỏ preflight.
application/jsonvàX-Requested-Withthì trigger OPTIONS. Trang lạ không đọc body nếu thiếuAllow-Originđúng. - Credentials (
withCredentials/credentials: 'include') cần origin cụ thể, không phải*. ReflectOrigintheo allow-list rồi gửiVary: Origin. Authz thật nằm ở session và check quyền. CORS không phải ACL. - HTTPS: HSTS ở edge, cookie
Secure, không mixed content. Redirect Vue Router không set HSTS và không chặn request HTTP đầu tiên. BFF cùng origin thì gần như khỏi bàn chuyện này.
const allow = new Set(['https://app.example.com', 'https://admin.example.com'])
res.setHeader('Vary', 'Origin')
if (origin && allow.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin) // không bao giờ *
res.setHeader('Access-Control-Allow-Credentials', 'true')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With')
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE')
}
// curl bỏ qua block này. Authz là session, không phải mấy header này.Điều gì xảy ra: Chỉ hai origin đọc được response có credentials. Vary: Origin ngăn cache đưa ACAO của app sang admin. curl vẫn chạy được: đó mới là ý của “CORS không phải tường”. Kèm CSRF control khi cookie được gắn (10.2).
| Khẳng định | Đúng? | Cách senior xử lý |
|---|---|---|
| CORS bảo vệ API | Không, chỉ trình duyệt | Session + authz; BFF nếu được |
* + credentials | Không hợp lệ; trình duyệt reject | Origin cụ thể + Vary: Origin |
| HTTPS = redirect Vue | Không | HSTS + cookie Secure ở edge |
Góc nhìn senior
- Cookie:
Secure,HttpOnlycho refresh/session,SameSite=Laxfirst-party,Pathscope có chủ đích. - Một ảnh
http://trên app HTTPS là mixed content: vừa report vừa rò tin cậy, không phải warning để bỏ qua. - Cache preflight (
Access-Control-Max-Age) là đòn bẩy hiệu năng. Đừng nhầm với authz. - BFF cùng origin (server route Nuxt / Route Handler Next) gỡ CORS có credentials khỏi SPA.
- “Blocked by CORS” trong DevTools là trình duyệt. Server vẫn thấy request nếu nó đơn giản hoặc đã preflight.
Lỗi hay gặp
- Triệu chứng: cookie thiếu chỉ trên trình duyệt. Nguyên nhân:
*+ credentials, hoặc thiếuAllow-Credentials. Cách xử lý: origin cụ thể.- Triệu chứng: chạy được localhost, fail trên prod. Nguyên nhân: không
Vary: Origin/ allow-list sai. Cách xử lý: preview origin prod.- Triệu chứng: “mình redirect sang HTTPS trong Vue”. Nguyên nhân: request HTTP đầu vẫn xảy ra. Cách xử lý: HSTS ở edge.
- Triệu chứng: coi CORS như ACL. Nguyên nhân: nhầm policy trình duyệt với authz. Cách xử lý: check session;
curlđể thử.
Nhà tuyển dụng hay hỏi gì
Trình duyệt chặn API, cookie không hiện, và đồng nghiệp muốn Access-Control-Allow-Origin: * kèm credentials. CORS có phải lớp bảo mật cho API, flag cookie nào quan trọng, và vì sao HTTPS everywhere không phải “mình redirect trong Vue Router”?
Trả lời khi phỏng vấn
CORS không bảo vệ API khỏi curl. Mình để authorization thật trong session và check quyền; SPA cần cookie thì mình allow đúng một origin kèm credentials, không bao giờ dùng *. HTTPS là HSTS và cookie Secure ở edge, không phải redirect Vue Router. BFF cùng origin là cách mình khỏi phải bàn chuyện này. Preflight là điệu OPTIONS của trình duyệt cho JSON và custom header; GET đơn giản vẫn đụng server.
Nhớ nhanh
- CORS là quyền đọc của trình duyệt, không phải tường API.
curlbỏ qua nó. - Không bao giờ
*kèm credentials. Origin cụ thể vàVary: Origin. - HTTPS là HSTS + cookie
Secureở edge, không phải redirect Vue. - BFF cùng origin gỡ gần hết CORS có credentials khỏi SPA.
Câu hỏi nối tiếp thường gặp
- Simple vs preflight? Header tùy chỉnh và
application/jsonthì preflight. POST form có thể không. - Vì sao
Vary: Origin? Để CDN không tái sử dụng ACAO của app cho admin. - Mixed content? Một ảnh HTTP trên HTTPS. Sửa từ nguồn, đừng lấy
upgrade-insecure-requestslàm control duy nhất. - CSRF và CORS cùng lúc? Cookie + CORS
*+ credentials là CSRF. Xem 10.2.
Tóm tắt: Supply chain là mọi thứ chạy vì bạn đã cài: lockfile, script lúc install, plugin Vite/Nuxt, và thẻ <script> third-party. Commit lockfile. Install frozen trên CI. Coi npm audit là tín hiệu ồn. Pin những thứ reachable. SRI (Subresource Integrity) cho script bạn không bundle.
Vì sao quan trọng: Xóa package-lock.json để “sửa conflict” là cách event-stream xảy ra lại. Helper CSS 10 dòng với preinstall: curl | sh là RCE lúc build. Plugin Vite bị compromise chạy trên máy bạn và trên CI, không chỉ trên trình duyệt. npm audit --force vào Friday có thể gãy Vue.
- Cách hoạt động: Commit
package-lock.json/pnpm-lock.yaml. CI dùngnpm ci/pnpm i --frozen-lockfile. Review PR chỉ lockfile như supply chain, không phải “không có code app”. npm auditbáo CVE đã biết. Không có nghĩa path đó reachable từ app. Triage: khai thác được không, path đó có ship ra client không, rồi pin / override / thay. Đừng--forcemột major Vue chỉ để im một transitive.- Provenance (
npm audit signatures/ sigstore) chứng nhận ai publish, không phải code an toàn. Pin version cụ thể cho dep rủi ro. Bỏ script lúc CI (ignore-scripts) trừ khi đã allow-list. SRI hash script CDN. Plugin Vite là niềm tin lúc build.
<!-- Script third-party: pin URL và bắt buộc hash -->
<script
src="https://cdn.example.com/analytics@1.4.2.js"
integrity="sha384-OQin2XLpTiFXgcs5Ar4jRCU8BLTAzqI3T1M1zN9z5F2eH0pQ=="
crossorigin="anonymous"
></script>
<!-- package.json: đừng xóa lockfile để “giải quyết” conflict -->
<!-- CI: npm ci --ignore-scripts (allow-list build tool riêng) -->
<!-- preinstall của plugin Vite là RCE. Đọc package trước khi thêm. -->Điều gì xảy ra: File CDN phải khớp hash, không thì trình duyệt từ chối. Account CDN bị cắp không đổi được script nếu không đổi luôn HTML bạn đang kiểm soát. Comment chính là policy lúc cài: lockfile frozen, không preinstall bất ngờ, hãy đọc plugin.
| Control | Làm gì | Không làm |
|---|---|---|
| Lockfile + CI đóng băng | Install lặp lại được | Chứng package trung thực |
npm audit | Tín hiệu CVE đã biết | Reachability hay bump an toàn |
| Provenance / SRI | Ai publish / byte nào | Code không độc hại |
Góc nhìn senior
^float trên minor bị compromise là cáchevent-streamxảy ra lại. Pin thứ gây đau.- Signed commit và protected branch là process, không phải npm. Người phỏng vấn thích chỗ nối này.
- Plugin Nuxt/Vite chạy lúc build. Cái độc là RCE trên CI, rồi thứ nó inline vào
dist. overrides/pnpm.overridespin transitive mà không--forceVue.- Tag third-party (GTM, chat) là supply chain trên trình duyệt. SRI hoặc proxy first-party, kèm chi phí INP (xem performance 9.2.5).
Lỗi hay gặp
- Triệu chứng: PR “nhỏ” thêm SDK cloud. Nguyên nhân: không đọc script lúc cài. Cách xử lý:
npm pack --dry-runvà fieldpreinstall.- Triệu chứng: xóa lockfile để merge. Nguyên nhân: coi nó như nhiễu generate. Cách xử lý: generate lại cùng package manager, commit.
- Triệu chứng:
audit --forcevào Friday. Nguyên nhân: dùng audit như gate. Cách xử lý: triage reachability; pin; lên lịch major.- Triệu chứng: script CDN không SRI. Nguyên nhân: cho tiện. Cách xử lý: hash kèm version ghim trong URL.
Nhà tuyển dụng hay hỏi gì
Conflict lockfile khiến ai đó muốn xóa package-lock.json, npm audit đỏ trên một transitive, và helper CSS 10 dòng có preinstall: curl | sh. Bạn commit gì, audit nói gì, và bạn làm gì khi maintainer bị compromise?
Trả lời khi phỏng vấn
Mình commit lockfile và CI install frozen. npm audit là tín hiệu mình triage theo reachability, không phải gate mình fail mù. Maintainer bị compromise nghĩa là gỡ, pin, và rotate secret từng nằm trong môi trường build. Plugin Vite độc là RCE lúc build, không chỉ XSS. Mình không xóa lockfile để sửa conflict và không audit force một major vào Friday. Script third-party có URL ghim và hash SRI. Provenance cho mình biết ai publish, không phải code an toàn.
Nhớ nhanh
- Commit lockfile. Install đóng băng trên CI. PR chỉ lockfile là supply chain.
npm auditlà tín hiệu ồn. Triage reachability. Đừng--forceVue.- Provenance là ai publish. SRI là byte nào. Không cái nào chứng code hiền.
- Plugin Vite/Nuxt là niềm tin lúc build.
preinstalllà RCE đến khi chứng minh ngược.
Câu hỏi nối tiếp thường gặp
- pnpm vs npm lockfile? Cùng rule: commit, đóng băng CI, đừng trộn manager trên một repo.
- Provenance là gì? Attestation sigstore của người publish. Không phải review bảo mật source.
- Maintainer bị compromise hôm nay? Gỡ, pin override, rotate secret CI, build lại, thông báo.
- Widget GTM / chat? Supply chain trên trình duyệt. SRI hoặc proxy; để ý INP.
Tóm tắt: Mọi thứ có prefix VITE_ hoặc nằm trong runtimeConfig.public / NUXT_PUBLIC_ không phải secret. Vite và Nuxt inline nó vào client bundle. .env không phải ranh giới bí mật. Key chỉ-server nằm ở Nitro, CI, hoặc BFF.
Vì sao quan trọng: “Để key Stripe trong .env” là bẫy phỏng vấn. VITE_STRIPE_SECRET thành chuỗi trong dist. Return runtimeConfig private từ composable thì đổ hết vào window.__NUXT__. rg trên dist sau khi lộ, rồi rotate, là cách xử lý incident.
- Cách hoạt động: Vite thay
import.meta.env.VITE_*lúc build. Env không prefix thìundefinedtrên trình duyệt. Đừng “sửa” bằng cách thêmVITE_. Key publishable (pk_, token public Mapbox) được phép công khai; key secret (sk_, URL DB, webhook Slack) thì không. - Nuxt:
runtimeConfig.stripeSecretchỉ chạy trên server (NUXT_STRIPE_SECRET).runtimeConfig.public.stripePklà phía client (NUXT_PUBLIC_STRIPE_PK). Composable nào return cảuseRuntimeConfig()khi có key chỉ-server thì secret lộ vào payload. - Secret CI nằm trong vault của provider, không trong repo, không trong
console.logcủa plugin. Sau khi lộ: rotate, grepdistvà artifact cũ, rồi thêm check CI fail khi gặp prefix secret đã biết trong client bundle.
// nuxt.config.ts: cách hiểu
runtimeConfig: {
stripeSecret: '', // chỉ-server; env NUXT_STRIPE_SECRET
public: {
stripePk: '', // client; env NUXT_PUBLIC_STRIPE_PK
},
}
// Vite: chỉ VITE_* được đưa vào code client
const publishable = import.meta.env.VITE_MAPBOX_TOKEN // OK nếu publishable
// import.meta.env.DATABASE_URL là undefined trên trình duyệt
// đừng "sửa" bằng cách thêm prefix VITE_
// Lộ: VITE_STRIPE_SECRET trong dist → rotate key StripeĐiều gì xảy ra: stripeSecret không bao giờ được để trong <script setup> chạy trên client. stripePk thì được: đó là key publishable. Thêm prefix VITE_ vào DATABASE_URL vì nó undefined trên trình duyệt là cách credential DB production lọt vào dist.
| Tên | Client? | Ví dụ |
|---|---|---|
VITE_* | Có, bị inline | Token public Mapbox, không bao giờ sk_ |
runtimeConfig.public | Có, payload / window.__NUXT__ | NUXT_PUBLIC_STRIPE_PK |
runtimeConfig (root) | Không, trừ khi bạn return | NUXT_STRIPE_SECRET, URL DB |
Góc nhìn senior
.envchỉ cho tiện, không phải ranh giới bí mật. Git history vàdistmới là.- Stripe
pk_publishable / token public Mapbox được thiết kế để công khai.sk_, Slack, URL DB thì không. - Return
runtimeConfigprivate từ composable thì đổ hết vàowindow.__NUXT__. Chỉ lấy field cần dùng. console.log(config)trong plugin Vite ghi secret vào log CI. Coi log là công khai.- Sau khi lộ: rotate trước, rồi grep
distvà artifact cũ, rồi thêm grep CI chosk_/SECRET.
Lỗi hay gặp
- Triệu chứng:
VITE_STRIPE_SECRETtrongdist. Nguyên nhân: gắn prefix cho secret để trình duyệt thấy được. Cách xử lý: rotate; chuyển sang Nitro/BFF.- Triệu chứng:
DATABASE_URLundefined trên client. Nguyên nhân: đúng vậy. Cách xử lý: đừng thêmVITE_.- Triệu chứng: secret trong
window.__NUXT__. Nguyên nhân: return cảuseRuntimeConfig(). Cách xử lý: chỉ return fieldpublic.- Triệu chứng:
.envtrong git. Nguyên nhân: coi file đó là private. Cách xử lý: gitignore, rotate mọi thứ từng nằm ở đó.
Nhà tuyển dụng hay hỏi gì
“Để key Stripe trong .env, vậy ổn chứ?” Họ muốn nghe rule VITE_ / NUXT_PUBLIC_, runtimeConfig đưa gì lên client, và bạn làm gì khi rg tìm thấy secret trong dist.
Trả lời khi phỏng vấn
Dotenv không phải ranh giới bí mật. VITE và NUXT PUBLIC bị inline vào bundle. Mình grep dist rồi rotate nếu dính. Secret nằm ở Nitro, CI, hoặc BFF. Mình không sửa DATABASE URL undefined trên trình duyệt bằng cách thêm prefix VITE, và mình không console log config trong plugin. VITE STRIPE SECRET bị lộ là một incident: rotate key Stripe, grep artifact cũ, rồi thêm check CI cho prefix secret trong client bundle. Key pk publishable thì được phép; key sk thì không.
Nhớ nhanh
VITE_vàruntimeConfig.publiclà công khai. Cả hai bị inline vào client..envkhông phải ranh giới bí mật. Git history vàdistmới là.- Đừng prefix
VITE_để “sửa” envundefinedtrên trình duyệt. - Lộ trong
dist: rotate, grep artifact, thêm grep CI cho prefix secret.
Câu hỏi nối tiếp thường gặp
- Next
NEXT_PUBLIC_? Cùng rule vớiVITE_/NUXT_PUBLIC_. Key chỉ-server thì không prefix. - Token auth Sentry trong plugin Vite? Secret CI. Không phải
VITE_. Upload source map từ CI. - Mã hóa secret trong bundle được không? Không. XSS hoặc View Source vẫn lấy được byte.
useRuntimeConfig()trong composable? Chỉ return fieldpublic, không thì payload lộ.