Web Security for Frontend Devs · Part 9 — Secrets, Data Leakage & Supply-Chain
There are no secrets in the browser: env vars, source maps, and third-party scripts leak. Plus npm supply-chain threats — lockfiles, audit, SRI, typosquatting — and practical defenses, with exercises.
Phần 9/10 trong series Web Security for Frontend Devs. Trước: Tiếp:
Đây là Phần 9 của series 10 bài 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. Mỗi phần giải thích một mối đe dọa thật, cho xem code lỗ hổng, rồi cách sửa, và kết thúc bằng bài tập.
Phần 8 cứng hóa transport và header phản hồi. Bài này xử lý hai lỗi vẫn lên production mỗi tuần: coi client là két chứa secret, và tin cây dependency npm mà không kiểm tra. Mọi thứ trong bundle JS là công khai; mọi thứ trong node_modules có thể chạy lúc cài và lọt vào bundle đó.
Sự thật khó: không có secret trong code frontend
Nếu nó được ship tới trình duyệt, nó không phải secret.
- bundle JavaScript (minify hay không)
- biến môi trường nhúng lúc build (
VITE_*,NEXT_PUBLIC_*, …) - source map để trên CDN ở production
- Mọi thứ trong
localStorage,sessionStorage, hay cookie khônghttpOnly(xem Phần 5)
Một “API key” trong code client tối đa là định danh công khai — và thường là credential đầy đủ kẻ tấn công lợi dụng. Chuyển secret sang server: route backend, edge function, hoặc BFF giữ key thật và proxy request kèm rate limit và auth.
// ❌ NEVER — secret in client bundle (anyone: View Source, Network, source map)
const stripeSecret = import.meta.env.VITE_STRIPE_SECRET_KEY;
await fetch('https://api.stripe.com/v1/charges', {
headers: { Authorization: `Bearer ${stripeSecret}` },
method: 'POST',
body: JSON.stringify({ amount: 1000 }),
});
// ✅ Server holds the secret; client calls YOUR API
const res = await fetch('/api/create-charge', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 1000 }),
});
Key publishable (Stripe publishable, Maps browser với giới hạn referrer HTTP) được thiết kế cho client — nhưng vẫn cần giới hạn domain và giám sát, không tin mù quáng.
Bẫy env build tool: chỉ prefix public
Bundler chỉ expose biến môi trường bạn đánh dấu rõ là an toàn cho client. Phần còn lại phải ở server và không xuất hiện trong thay thế import.meta.env / process.env ở chunk client.
# .env — example placeholders only, never commit real values
# ❌ BAD — private key with a "public" prefix by mistake, or no prefix discipline
VITE_DATABASE_URL=postgres://app:PLACEHOLDER_PASSWORD@db.internal:5432/prod
NEXT_PUBLIC_JWT_SIGNING_SECRET=placeholder-signing-secret-do-not-ship
# ✅ OK for client — intentionally public, scoped, rotatable
VITE_PUBLIC_ANALYTICS_ID=placeholder-analytics-id
NEXT_PUBLIC_APP_URL=https://app.example.com
# ✅ Server-only — no VITE_ / NEXT_PUBLIC_ / REACT_APP_ prefix; read only in server code
DATABASE_URL=postgres://app:PLACEHOLDER_PASSWORD@db.internal:5432/prod
JWT_SIGNING_SECRET=placeholder-signing-secret-server-only
STRIPE_SECRET_KEY=sk_test_PLACEHOLDER
Quy tắc: nếu tên biến có thể tới code client, coi như nó sẽ tới. CI nên fail build khi grep thấy SECRET, PRIVATE_KEY, hay PASSWORD trong tên VITE_ / NEXT_PUBLIC_.
Rò rỉ dữ liệu ngoài API key
Secret là một loại rò; lộ quá mức là loại khác.
| Vector | Lộ gì | Giảm thiểu |
|---|---|---|
| Verbose errors | Stack trace, SQL, hostname nội bộ | Lỗi chung cho client; log chi tiết chỉ server |
| PII in URLs | Email, token trong query → Referer khi link ra ngoài (Phần 8) | POST bodies, path tokens, Referrer-Policy: strict-origin-when-cross-origin |
| Logging | console.log(user), Log user, token trong breadcrumb Sentry | Redact in client; never log secrets; sample PII in production |
| Source maps in prod | Path TS đầy đủ, comment, đôi khi snippet env | Disable public maps; upload to private symbol server only |
| Debug endpoints | /api/debug, ?debug=true, Endpoint debug, key staging trong build prod | Strip via env; block at gateway |
| Over-fetching APIs | API trả full user; UI chỉ hiện tên | Response DTOs per client; field-level authz on server |
// ❌ Leaks internal detail to every user
catch (err) {
showToast(`Payment failed: ${err.message}`); // might be "connection to pay-db.internal refused"
}
// ✅ Safe client message; rich context only server-side
catch (err) {
reportError(err); // server/logging pipeline
showToast('Payment could not be processed. Try again or contact support.');
}
Script bên thứ ba: toàn quyền origin
Như Phần 1 giải thích, <script src="https://analytics.vendor.com/tag.js"> chạy với quyền của origin bạn — cùng DOM, cùng fetch tới API, đọc được localStorage nếu có script lưu token.
Thẩm định vendor (chính sách privacy, SOC2, danh sách subprocessors, lịch sử sự cố). Giảm tag — mỗi script tag manager là khe thực thi code từ xa. Cô lập khi có thể: Partytown/worker đưa việc ra khỏi main thread (không phải biên giới bảo mật đơn lẻ, nhưng giảm phạm vi).
Part 3 — CSP limits what can run and where data can go:
Content-Security-Policy: script-src 'self' https://cdn.example.com; connect-src 'self' https://api.example.com; object-src 'none'; base-uri 'self'
connect-src chặt chặn script analytics bị compromise gửi dữ liệu tới https://evil-collector.example dù nó đã chạy. Ghép script-src nonce (Phần 3) để thẻ exfil inline ngẫu nhiên không chạy.
Thực tế supply-chain: app = npm × dependency bắc cầu
Repo frontend điển hình liệt kê hàng chục dependency trực tiếp và kéo hàng trăm dependency bắc cầu.
- Chạy script
postinstall/preinstalltrên laptop dev và CI - Ship code vào bundle hoặc server SSR
- Gọi về nhà lúc build (ít gặp, nhưng org mature vẫn audit)
Mối đe dọa cần nhận ra:
- Package độc hại — backdoor, trộm credential, miner
- Gõ nhầm tên —
reakt,lodashh, … - Nhầm dependency — tên package private bị publish public không scope, npm ưu tiên bản public
- Maintainer bị compromise — token npm, release bị chiếm
- Protestware — package phổ biến thêm hành vi phá hoại trong bản patch
Phòng thủ thực tế
Lockfile và cài đặt tái lập
Commit lockfile. Dùng npm ci trên CI — cài đúng lockfile, fail nếu package.json lệch.
# Reproducible CI install (not npm install)
npm ci
# Audit production tree only (faster signal for what ships)
npm audit --omit=dev
# See why a package is installed
npm ls suspicious-package-name
Audit, ghim, tối thiểu hóa
- Chạy
npm audit; coi critical/high trên đường production là chặn release trừ khi có ngoại lệ ghi nhận - Dependabot / Renovate gom PR — đọc changelog, không merge mù
- Ít dependency hơn — copy util 20 dòng thay vì thêm package cho một hàm
- Ghim version chính xác cho tooling rủi ro cao khi policy yêu cầu
Script cài đặt
# Safer CI when you have audited deps and no native compile need
npm ci --ignore-scripts
# Review what a package runs on install
npm view some-package scripts --json
Chỉ dùng --ignore-scripts khi hiểu hậu quả (native module, postinstall esbuild). Với package lạ, đọc scripts trong package.json trước lần cài đầu.
SRI cho script CDN (Phần 8)
SRI đảm bảo CDN bị hijack không đổi nội dung file mà trình duyệt vẫn chạy.
# SHA-384 hash for integrity attribute (openssl example)
curl -sL https://cdn.example.com/lib/analytics@3.2.1/analytics.min.js | openssl dgst -sha384 -binary | openssl base64 -A
<script
src="https://cdn.example.com/lib/analytics@3.2.1/analytics.min.js"
integrity="sha384-PLACEHOLDER_BASE64_HASH_FROM_COMMAND_ABOVE"
crossorigin="anonymous"
></script>
Cập nhật cả URL và integrity khi nâng version CDN.
Xác minh provenance và sức khỏe package
- Ưu tiên package có npm provenance / publish có chữ ký
- Xem download/tuần, ngày publish cuối, số maintainer, chuyển quyền đột ngột
- Tránh dep không maintain còn CVE critical — fork, thay, hoặc patch bằng
overrides/resolutionscẩn thận
Kiểm soát org: registry, scope, allowlist
- Registry/proxy private với allowlist — chỉ package được duyệt cài trên CI
- Scope package nội bộ:
@yourco/internal-utils— chặn dependency confusion trên tên không scope - CODEOWNERS trên
package.json/ lockfile để team security review khi bump dependency
Checklist ứng phó sự cố
Khi cảnh báo package bị compromise tới feed:
- Xác định —
npm ls, grep lockfile, SBOM nếu có - Kìm — ghim bản good cuối hoặc gỡ dep; deploy lại không cache độc
- Đổi — secret có thể bị script install trên máy dev đọc (token CI, auth
.npmrc) - Thông báo — status page, khách hàng nếu dữ liệu user bị chạm
Vị trí trong series
| Layer | Part | Vai trò |
|---|---|---|
| Who can read your page | Part 1 — SOP | Script bên thứ ba = origin bạn |
| Limit exfil / loads | Part 3 — CSP | script-src, connect-src Giới hạn exfil/nạp |
| Token storage | Part 5 — Auth tokens | Không secret trong localStorage |
| Headers & SRI | Part 8 — Headers & TLS | Referrer-Policy, Header & SRI |
| Secrets & deps | Part 9 (this post) | Secret & dependency |
| Trust model | Part 10 — Threat model | Mô hình tin cậy |
Bài tập / Exercises
1. Đồng nghiệp thêm VITE_STRIPE_SECRET_KEY=sk_live_PLACEHOLDER vào .env và dùng trong component checkout. Liệt kê ba cách kẻ tấn công lấy được mà không cần hack server.
Lời giải
- Tải bundle JS — tìm
sk_livehoặc chuỗi env; Vite nhúngimport.meta.env.VITE_*lúc build. - Source map — nếu public, map về file và dòng chứa key.
- DevTools — Network/runtime, extension, hoặc script bên thứ ba đọc global.
Sửa: chỉ tạo charge server-side; key publishable (pk_) trên client nếu thật cần.
2. Chạy npm audit --omit=dev trên project bạn maintain. Chọn một finding: nó có trong cây dependency production không? npm ci --ignore-scripts có giảm rủi ro lúc install không?
Lời giải
Dùng npm ls <package> xem package lỗi có reachable từ prod deps không (không chỉ devDependencies). --ignore-scripts chặn script lúc install nhưng không gỡ code runtime lỗi trong package — vẫn cần upgrade/patch hoặc override.
3. Viết snippet HTML cho script CDN có SRI và crossorigin, và lệnh bash khi nâng từ v1 lên v2.
Lời giải
curl -sL https://cdn.example.com/lib/widget@2.0.0/widget.min.js | openssl dgst -sha384 -binary | openssl base64 -A<script
src="https://cdn.example.com/lib/widget@2.0.0/widget.min.js"
integrity="sha384-NEW_HASH_FROM_COMMAND"
crossorigin="anonymous"
></script>Lệch sau upgrade → trình duyệt từ chối chạy (đúng ý) cho đến khi cập nhật URL và hash.
Nâng cao:Audit một script analytics/tag manager trên site bạn dùng: liệt kê mọi origin nó có thể chạm (host script + connect-src từ code minify hoặc tab Network). Đề xuất allowlist CSP connect-src và một vendor bạn sẽ gỡ.
Điểm chính
- Không secret trên trình duyệt — bundle, prefix env public, source map đọc được hết; proxy qua backend.
- Rò dữ liệu gồm lỗi, PII trong URL (Phần 8), log, API trả thừa, artifact debug — không chỉ API key.
- Script bên thứ ba chạy như bạn (Phần 1); thẩm định vendor và siết CSP Phần 3.
- Supply-chain: lockfile +
npm ci,npm audit, cẩn thận install script, SRI trên CDN (Phần 8), scope package private, ít dep.
Tiếp theo
Phần 10 — Input Validation, Open Redirects & Frontend Threat Model: coi mọi input client là thù địch, đóng redirect gadget, và map phòng thủ lên threat model thực tế cho team frontend.