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.
Part 9 of 10 in the Web Security for Frontend Devs series {Phần 9/10 trong series Web Security for Frontend Devs}. Previous {Trước}: Part 8 — Secure Headers & HTTPS/TLS · Next {Tiếp}: Part 10 — Input Validation, Open Redirects & a Frontend Threat Model.
This is Part 9 of a 10-part series on the web-security essentials every frontend developer should know — and actively prevent {Đâ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}. Each part explains a real threat, shows vulnerable code, then the fix, and ends with exercises {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}.
Part 8 — Secure Headers & HTTPS/TLS hardened transport and response headers {Phần 8 cứng hóa transport và header phản hồi}. This post covers two failures that still ship in production every week: treating the client as a vault for secrets, and trusting the npm graph without inspection {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}. Anything in your JS bundle is public; anything in node_modules can run during install and end up in that bundle {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 đó}.
The hard truth: no secrets in frontend code {Sự thật khó: không có secret trong code frontend}
If it ships to the browser, it is not secret {Nếu nó được ship tới trình duyệt, nó không phải secret}. Users, extensions, and attackers can read:
- Your JavaScript bundle (minified or not) {bundle JavaScript (minify hay không)}
- Environment variables inlined at build time (
VITE_*,NEXT_PUBLIC_*,REACT_APP_*,NUXT_PUBLIC_*, etc.) {biến môi trường nhúng lúc build (VITE_*,NEXT_PUBLIC_*, …)} - Source maps left on the CDN in production {source map để trên CDN ở production}
- Anything in
localStorage,sessionStorage, or non-httpOnlycookies (see Part 5 — Auth tokens) {Mọi thứ tronglocalStorage,sessionStorage, hay cookie khônghttpOnly(xem Phần 5)}
An “API key” in client code is a public identifier at best — and often a full credential an attacker can abuse {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}. Move secrets server-side: a backend route, edge function, or BFF that holds the real key and proxies requests with rate limits and auth {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 }),
});
Publishable keys (Stripe publishable, Maps browser key with HTTP referrer restrictions) are designed for the client — but still need domain restrictions and monitoring, not blanket trust {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}.
Build-tool env gotcha: public prefix only {Bẫy env build tool: chỉ prefix public}
Bundlers only expose env vars you explicitly mark as client-safe {Bundler chỉ expose biến môi trường bạn đánh dấu rõ là an toàn cho client}. Everything else must stay on the server and never appear in import.meta.env / process.env replacements in client chunks {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
Rule: if the variable name can reach client code, assume it will {Quy tắc: nếu tên biến có thể tới code client, coi như nó sẽ tới}. CI should fail builds that grep for SECRET, PRIVATE_KEY, or PASSWORD inside VITE_ / NEXT_PUBLIC_ names {CI nên fail build khi grep thấy SECRET, PRIVATE_KEY, hay PASSWORD trong tên VITE_ / NEXT_PUBLIC_}.
Data leakage beyond API keys {Rò rỉ dữ liệu ngoài API key}
Secrets are one class of leak; over-disclosure is another {Secret là một loại rò; lộ quá mức là loại khác}.
| Vector | What leaks {Lộ gì} | Mitigation {Giảm thiểu} |
|---|---|---|
| Verbose errors | Stack traces, SQL fragments, internal hostnames {Stack trace, SQL, hostname nội bộ} | Generic errors to clients; log details server-side only {Lỗi chung cho client; log chi tiết chỉ server} |
| PII in URLs | Emails, tokens, reset codes in query strings → Referer on outbound links (Part 8 Referrer-Policy) {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), tokens in Sentry breadcrumbs {Log user, token trong breadcrumb Sentry} | Redact in client; never log secrets; sample PII in production |
| Source maps in prod | Full TypeScript paths, comments, sometimes env snippets {Path TS đầy đủ, comment, đôi khi snippet env} | Disable public maps; upload to private symbol server only |
| Debug endpoints | /api/debug, ?debug=true, staging keys in prod builds {Endpoint debug, key staging trong build prod} | Strip via env; block at gateway |
| Over-fetching APIs | API returns full user record; UI shows name only {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.');
}
Third-party scripts: full origin privileges {Script bên thứ ba: toàn quyền origin}
As Part 1 — Browser security & SOP explained, a <script src="https://analytics.vendor.com/tag.js"> runs with your origin’s privileges — same DOM, same fetch to your APIs, readable localStorage if any script stored tokens there {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}.
Vet vendors (privacy policy, SOC2, subprocessor list, incident history) {Thẩm định vendor (chính sách privacy, SOC2, danh sách subprocessors, lịch sử sự cố)}. Minimize tags — every tag manager script is a live remote code execution slot {Giảm tag — mỗi script tag manager là khe thực thi code từ xa}. Isolate where you can: Partytown/workers move some work off the main thread (not a security boundary alone, but reduces blast radius for perf-heavy scripts) {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'
Tight connect-src blocks a compromised analytics script from exfiltrating to https://evil-collector.example even if it already executed {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}. Pair with script-src nonces (Part 3) so random inline exfil tags do not run {Ghép script-src nonce (Phần 3) để thẻ exfil inline ngẫu nhiên không chạy}.
Supply-chain reality: your app is npm × transitive deps {Thực tế supply-chain: app = npm × dependency bắc cầu}
A typical frontend repo lists dozens of direct dependencies and pulls hundreds of transitive ones {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}. Each package can:
- Run
postinstall/preinstallscripts on every developer laptop and CI machine {Chạy scriptpostinstall/preinstalltrên laptop dev và CI} - Ship code that ends up in your bundle or your SSR server {Ship code vào bundle hoặc server SSR}
- Phone home during build (less common, but audited in mature orgs) {Gọi về nhà lúc build (ít gặp, nhưng org mature vẫn audit)}
Threats you should recognize {Mối đe dọa cần nhận ra}:
- Malicious packages — intentional backdoors, credential stealers, crypto miners {Package độc hại — backdoor, trộm credential, miner}
- Typosquatting —
reakt,lodashh,@types/nodeeinstead of the real name {Gõ nhầm tên —reakt,lodashh, …} - Dependency confusion — private package name
internal-utilspublished publicly without scope, npm resolves the public one first {Nhầm dependency — tên package private bị publish public không scope, npm ưu tiên bản public} - Compromised maintainer — stolen npm token, hijacked release {Maintainer bị compromise — token npm, release bị chiếm}
- Protestware / sabotage — popular package adds destructive behavior in a patch version {Protestware — package phổ biến thêm hành vi phá hoại trong bản patch}
Practical defenses {Phòng thủ thực tế}
Lockfiles and reproducible installs {Lockfile và cài đặt tái lập}
Commit package-lock.json (npm), pnpm-lock.yaml, or yarn.lock {Commit lockfile}. Use npm ci in CI — installs exactly what the lockfile says, fails if package.json disagrees {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, pin, and minimize {Audit, ghim, tối thiểu hóa}
- Run
npm audit(or OSV/Snyk in pipeline); treat critical/high on production paths as release blockers unless documented exception {Chạynpm audit; coi critical/high trên đường production là chặn release trừ khi có ngoại lệ ghi nhận} - Dependabot / Renovate with grouped PRs — review changelogs, not blind merge {Dependabot / Renovate gom PR — đọc changelog, không merge mù}
- Fewer dependencies — copy a 20-line util instead of adding a package for one function {Ít dependency hơn — copy util 20 dòng thay vì thêm package cho một hàm}
- Pin exact versions for high-risk tooling (
eslint,postcss, anything with install scripts) when your policy requires it {Ghim version chính xác cho tooling rủi ro cao khi policy yêu cầu}
Install scripts {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
Use --ignore-scripts only when you understand breakage (native modules, esbuild postinstall) {Chỉ dùng --ignore-scripts khi hiểu hậu quả (native module, postinstall esbuild)}. For unknown packages, read scripts in package.json before the first install {Với package lạ, đọc scripts trong package.json trước lần cài đầu}.
SRI for CDN scripts (Part 8) {SRI cho script CDN (Phần 8)}
Subresource Integrity ensures a hijacked CDN cannot swap file contents without the browser refusing to execute {SRI đảm bảo CDN bị hijack không đổi nội dung file mà trình duyệt vẫn chạy}. Generate hashes when you pin a version:
# 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>
Update both URL and integrity when upgrading CDN versions {Cập nhật cả URL và integrity khi nâng version CDN}. See Part 8 — Secure Headers & HTTPS/TLS for delivery alongside CSP.
Verify provenance and package health {Xác minh provenance và sức khỏe package}
- Prefer packages with npm provenance / signed publishes where available {Ưu tiên package có npm provenance / publish có chữ ký}
- Check weekly downloads, last publish date, maintainer count, sudden ownership transfers {Xem download/tuần, ngày publish cuối, số maintainer, chuyển quyền đột ngột}
- Avoid unmaintained deps with open critical CVEs — fork, replace, or patch via
overrides/resolutionswith care {Tránh dep không maintain còn CVE critical — fork, thay, hoặc patch bằngoverrides/resolutionscẩn thận}
Org controls: registry, scope, allowlist {Kiểm soát org: registry, scope, allowlist}
- Private registry / proxy (Verdaccio, Artifactory, npm Enterprise) with allowlist — only approved packages install in CI {Registry/proxy private với allowlist — chỉ package được duyệt cài trên CI}
- Scope internal packages:
@yourco/internal-utils— prevents public dependency confusion on unscoped names {Scope package nội bộ:@yourco/internal-utils— chặn dependency confusion trên tên không scope} - CODEOWNERS on
package.json/ lockfile for security team review on dependency bumps {CODEOWNERS trênpackage.json/ lockfile để team security review khi bump dependency}
Incident response checklist {Checklist ứng phó sự cố}
When a compromised package alert hits your feed {Khi cảnh báo package bị compromise tới feed}:
- Identify —
npm ls <package>, lockfile grep, SBOM if you generate one {Xác định —npm ls, grep lockfile, SBOM nếu có} - Contain — pin last known-good version or remove dep; redeploy without cache poison {Kìm — ghim bản good cuối hoặc gỡ dep; deploy lại không cache độc}
- Rotate — any secret that could have been read by install scripts on developer machines (CI tokens,
.npmrcauth) {Đổi — secret có thể bị script install trên máy dev đọc (token CI, auth.npmrc)} - Communicate — status page, customer notice if user data touched {Thông báo — status page, khách hàng nếu dữ liệu user bị chạm}
How this fits the series {Vị trí trong series}
| Layer | Part | Role {Vai trò} |
|---|---|---|
| Who can read your page | Part 1 — SOP | Third-party scripts = your origin {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 | No secrets in localStorage {Không secret trong localStorage} |
| Headers & SRI | Part 8 — Headers & TLS | Referrer-Policy, SRI, HSTS {Header & SRI} |
| Secrets & deps | Part 9 (this post) | Client is public; lockfile + audit + vet vendors {Secret & dependency} |
| Trust model | Part 10 — Threat model | Validate on server; client is hostile {Mô hình tin cậy} |
Bài tập / Exercises
1. Your teammate adds VITE_STRIPE_SECRET_KEY=sk_live_PLACEHOLDER to .env and uses it in a checkout component. List three ways an attacker obtains it without hacking your server {Đồ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}.
Solution {Lời giải}
- Download the JS bundle — search for
sk_liveor the env string; Vite inlinesimport.meta.env.VITE_*at build time {Tải bundle JS — tìmsk_livehoặc chuỗi env; Vite nhúngimport.meta.env.VITE_*lúc build}. - Source map — if published, maps back to the exact file and line referencing the key {Source map — nếu public, map về file và dòng chứa key}.
- Browser DevTools — inspect Network/runtime, extensions, or compromised third-party script reading globals {DevTools — Network/runtime, extension, hoặc script bên thứ ba đọc global}.
Fix: server-side charge creation only; publishable key (pk_) in client if needed at all {Sửa: chỉ tạo charge server-side; key publishable (pk_) trên client nếu thật cần}.
2. Run npm audit --omit=dev on a project you maintain. Pick one finding: is it in the production dependency tree? Would npm ci --ignore-scripts have reduced install-time risk? {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?}
Solution {Lời giải}
Use npm ls <package> to see if the vulnerable package is reachable from prod deps (not only devDependencies) {Dùng npm ls <package> xem package lỗi có reachable từ prod deps không (không chỉ devDependencies)}. --ignore-scripts blocks install-time script execution (supply-chain during npm ci) but does not remove vulnerable runtime code already in the package — you still need upgrade/patch or override {--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. Write an HTML snippet for a CDN script with SRI and crossorigin, and the bash command you would run when upgrading from v1 to v2 {Viết snippet HTML cho script CDN có SRI và crossorigin, và lệnh bash khi nâng từ v1 lên v2}.
Solution {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>Mismatch after upgrade → browser refuses to run the script (intended) until you update both URL and hash {Lệch sau upgrade → trình duyệt từ chối chạy (đúng ý) cho đến khi cập nhật URL và hash}.
Stretch {Nâng cao}: Audit one analytics or tag-manager script on a site you use: list every origin it can reach (script hosts + likely connect-src targets from minified code or Network tab). Propose a CSP connect-src allowlist and one vendor you would remove. {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ỡ.}
Key takeaways {Điểm chính}
- No secrets in the browser — bundles, public env prefixes, and source maps are fully readable; proxy through your backend {Không secret trên trình duyệt — bundle, prefix env public, source map đọc được hết; proxy qua backend}.
- Data leakage includes errors, PII in URLs (Part 8), logs, over-fetching APIs, and debug artifacts — not only API keys {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}.
- Third-party scripts run as you (Part 1); vet vendors and tighten Part 3 CSP
script-src/connect-src{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, cautious install scripts, SRI on CDNs (Part 8), scoped private packages, minimal deps {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}.
Next up {Tiếp theo}
Part 10 — Input Validation, Open Redirects & a Frontend Threat Model: treat every client input as hostile, close redirect gadgets, and map defenses onto a practical threat model for frontend teams {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}. Continue to Part 10 — Input Validation, Open Redirects & a Frontend Threat Model.