jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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-httpOnly cookies (see Part 5 — Auth tokens) {Mọi thứ trong localStorage, sessionStorage, hay cookie không httpOnly (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}.

VectorWhat leaks {Lộ gì}Mitigation {Giảm thiểu}
Verbose errorsStack 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 URLsEmails, 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
Loggingconsole.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 prodFull 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 APIsAPI 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 / preinstall scripts on every developer laptop and CI machine {Chạy script postinstall / preinstall trê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)}
Your app package.json direct deps 12 packages transitive deps × 900 1 malicious / typosquat runs in YOUR build & page lockfiles · npm audit · pin versions · SRI for CDNs
A few direct dependencies fan out to hundreds of transitive packages — one malicious or typosquatted package runs in your build and ships to users

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}
  • Typosquattingreakt, lodashh, @types/nodee instead of the real name {Gõ nhầm tên — reakt, lodashh, …}
  • Dependency confusion — private package name internal-utils published 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ạ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 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 / resolutions with care {Tránh dep không maintain còn CVE critical — fork, thay, hoặc patch bằng overrides / resolutions cẩ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ên package.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}:

  1. Identifynpm ls <package>, lockfile grep, SBOM if you generate one {Xác địnhnpm ls, grep lockfile, SBOM nếu có}
  2. 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}
  3. Rotate — any secret that could have been read by install scripts on developer machines (CI tokens, .npmrc auth) {Đổi — secret có thể bị script install trên máy dev đọc (token CI, auth .npmrc)}
  4. 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}

LayerPartRole {Vai trò}
Who can read your pagePart 1 — SOPThird-party scripts = your origin {Script bên thứ ba = origin bạn}
Limit exfil / loadsPart 3 — CSPscript-src, connect-src {Giới hạn exfil/nạp}
Token storagePart 5 — Auth tokensNo secrets in localStorage {Không secret trong localStorage}
Headers & SRIPart 8 — Headers & TLSReferrer-Policy, SRI, HSTS {Header & SRI}
Secrets & depsPart 9 (this post)Client is public; lockfile + audit + vet vendors {Secret & dependency}
Trust modelPart 10 — Threat modelValidate 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}
  1. Download the JS bundle — search for sk_live or the env string; Vite inlines import.meta.env.VITE_* at build time {Tải bundle JS — tìm sk_live hoặc chuỗi env; Vite nhúng import.meta.env.VITE_* lúc build}.
  2. 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}.
  3. 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ó SRIcrossorigin, 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.