Topic 21.6
Dependencies, CSP and Supply Chain Hygiene
In one line
Most of your app's code was written by strangers, so keep dependencies few, locked and audited (npm audit, lockfiles, npm ci, pinning, controlled install scripts), and add browser-enforced walls: a Content Security Policy that only runs your own scripts, and Subresource Integrity for anything loaded from a CDN.
Think of it like this
Cooking for a party with ingredients from many shops. Your own recipe is careful, but if one supplier's flour is spoiled, everyone gets sick. So you buy from known suppliers, keep the receipts (exactly what you bought, from where), check recall notices, and taste-test anything new. Your node_modules folder is the pantry: a new Vite + React app already has dozens of packages, a real app hundreds, and each one runs with full access to your users' pages.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Supply chain
- All the code and tools your app depends on that you didn't write.
- Transitive dependency
- A package your dependencies depend on. You never installed it directly, but it ships with your app.
- Typosquatting
- Publishing a harmful package with a name very close to a popular one, hoping for typos.
- Lockfile
- A file that records the exact version and hash of every installed package, so installs are repeatable.
- npm ci
- Clean install exactly from the lockfile; fails if it doesn't match
package.json. - Install script
- A
preinstall/install/postinstallcommand a package runs on your machine when installed. - CSP
- Content Security Policy: a response header listing which sources of scripts, styles and other resources the page may use.
- Nonce
- A random, single-use value in the CSP header and on allowed script tags, so injected scripts without it are blocked.
- SRI
- Subresource Integrity: an
integrityhash on a script or style tag; the browser refuses the file if it doesn't match.
Step by step
01Audit what you have
Run npm audit and read each entry: package, vulnerable range, severity, advisory link and the path that pulls it in. Real output from a Next.js project is long; this is an excerpt.
$ npm audit
# npm audit report
source-map-js 1.0.0 - 1.2.1
Severity: high
source-map-js allows event-loop denial of service through indexed source-map section offsets - https://github.com/advisories/GHSA-68fv-2mgg-jv7q
fix available via `npm audit fix`
node_modules/source-map-js
...
18 vulnerabilities (1 low, 17 high)
To address issues that do not require attention, run:
npm audit fix
To address all issues (including breaking changes), run:
npm audit fix --force
$ npm audit --omit=dev # only what ships to production
...
16 vulnerabilities (1 low, 15 high)02Lock installs down
Commit the lockfile, pin new dependencies, install with npm ci everywhere but your own machine, and stop install scripts unless a package needs one.
save-exact=true # npm install x -> "x": "1.2.3", not "^1.2.3"
ignore-scripts=true # don't run dependencies' install scripts
audit-level=high # npm audit exits non-zero only for high and critical03Fix a transitive dependency with overrides
When a vulnerable package is deep in the tree and its parent hasn't released a fix, force a fixed version for everyone. Test afterwards: you're running a combination the parent didn't test.
{
"dependencies": { "react": "19.2.8", "react-dom": "19.2.8" },
"overrides": {
"source-map-js": "1.2.2"
}
}04Add a CSP, report-only first
Send the header from your host (Vercel/Netlify headers file, nginx, or Next.js headers()/proxy). Watch the console and your report endpoint for violations, fix them (move inline scripts into files, add real API origins), then switch to the enforcing header name.
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy-Report-Only",
"value": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https://images.shop.example; connect-src 'self' https://api.shop.example; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
}
]
}
]
}05Pin CDN files with SRI
Compute the hash of the exact file and put it on the tag. If you can, self-host instead.
$ printf "console.log('hi');\n" > widget.js
$ openssl dgst -sha384 -binary widget.js | openssl base64 -A
S/E9AczxJ45AfnRY5VLtw/IwaqqEyuZpg/47V2uDmGHkP2qdE/iuDb2BNHoj+IFy
<script src="https://cdn.example.com/widget@1.0.0/widget.js"
integrity="sha384-S/E9AczxJ45AfnRY5VLtw/IwaqqEyuZpg/47V2uDmGHkP2qdE/iuDb2BNHoj+IFy"
crossorigin="anonymous"></script>Try it yourself
- 1
Patch the vulnerable package
In the audit example, change
tiny-date's version to1.4.2. Predict the output. (One finding left,color-parse; the count says 1.) - 2
Model an override
Set the transitive
color-parseversion to1.0.1, as anoverridesentry would. Predict the report. (Onlytiny-dateremains, unless you also fixed it in the first task.)
Code & diagrams
The `onClick` never appears in the HTML: React listens at the root from JavaScript, so `script-src 'self'` without `'unsafe-inline'` doesn't affect it. The `style` attribute you see is how the browser reflects styles React set through `element.style`, which CSP doesn't restrict. Server-rendered `style="…"` attributes in HTML are different: a strict `style-src` without `'unsafe-inline'` blocks them, which is why some SSR apps allow inline styles or use nonces.
Preview
Console
Nothing logged yet.Expected console output
HTML React wrote: <button style="color: black;">Clicked 0</button>
inline onclick attribute: null
after a click: <button style="color: green;">Clicked 1</button>Each advisory here is a range `[from, fixed)`. `chart-kit@3.2.0` is past its vulnerable range, but it pulls in `color-parse@0.9.3`, a **transitive** dependency you never chose. That's why audits read the lockfile (the exact tree), not `package.json`, and why `overrides` exist. Real ranges use full semver syntax, including pre-releases.
Preview
Console
Nothing logged yet.Expected console output
high tiny-date@1.4.1 -> node_modules/tiny-date
moderate color-parse@0.9.3 -> node_modules/chart-kit/node_modules/color-parse
2 vulnerabilitiesimport { NextRequest, NextResponse } from "next/server";
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const isDev = process.env.NODE_ENV === "development";
const csp = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""};
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`.replace(/\s{2,}/g, " ").trim();
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", csp);
return response;
}
// Next.js reads the nonce from the header and adds it to its own scripts.
// Pages using nonces must be dynamically rendered (a new nonce per request).
// 'unsafe-eval' is only for development: React uses eval there for better error stacks.# Policy: script-src 'self'
# A missed sink rendered: <img src=x onerror="console.log('this would have run')">
Executing inline event handler violates the following Content Security Policy directive 'script-src 'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution. Note that hashes do not apply to event handlers, style attributes and javascript: navigations unless the 'unsafe-hashes' keyword is present. The action has been blocked.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Install without the lockfile in CI
Delete package-lock.json from the repo (or run npm install in CI) so every build resolves ranges fresh.
Break #2
Turn on CSP and break the app
Add an enforcing script-src 'self' header to a page that still has an inline <script> (for example an analytics snippet or inline config) in index.html.
Myth vs fact
Myth
npm audit saying 0 vulnerabilities means my dependencies are safe.
Fact
It only knows reported vulnerabilities. A freshly hijacked package or an unknown bug won't appear. Lockfiles, review and CSP limit what unknown problems can do.
Myth
Every npm audit finding is urgent.
Fact
Many are in build tools that never reach users, or in code paths you don't use. Triage by whether it ships, whether it's reachable, and severity, then fix promptly what matters.
Myth
CSP replaces sanitising and escaping.
Fact
CSP is a second wall. Policies have gaps (allowed hosts with JSONP or user uploads, 'unsafe-inline' left on), and older browsers ignore parts. Fix sinks first.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
'strict-dynamic'lets a script that was allowed by nonce or hash load further scripts, and makes browsers ignore host lists and'self'inscript-src. That's why nonce-based policies don't need to list CDNs, and why the Next.js example works with dynamic chunk loading. - ▸
CSP hashes (
'sha256-…') let specific inline scripts run without nonces, which suits static sites (the hash is computed at build time). Next.js's experimental SRI mode pairs build-time integrity hashes with a static CSP. - ▸
Lockfiles store an
integrityhash per package (sha512-…), and npm verifies downloaded tarballs against it, so a registry or mirror can't silently swap package contents for a locked version. - ▸
Third-party scripts you load with
<script src>(chat widgets, analytics, A/B testing) run with the same power as your own code, including readinglocalStorageand the DOM. Load as few as possible, load them in a sandboxed iframe or a web worker where the vendor supports it, and list their exact hosts in CSP.
Remember this
- 1
The software supply chain is everything that goes into your shipped app that you didn't write: npm packages (and their dependencies, called transitive dependencies), build tools, CDN scripts, analytics snippets. Supply chain attacks target that chain: a popular package's maintainer account is taken over and a bad version is published; a package with a name one letter off a popular one (typosquatting) waits for typos; an install script (
postinstall) runs code on your machine or CI the moment you install. Bugs in honest packages count too: a vulnerable version of a Markdown or sanitiser library is an XSS hole in your app. - 2
**
npm audit** compares the exact versions in your lockfile against the GitHub Advisory Database and lists known vulnerabilities, with severity and the dependency path.npm audit fixupgrades within your allowed ranges;npm audit fix --forcemay install breaking major versions, so read what it will do. Many findings are in dev-only tools that never reach users (npm audit --omit=devfocuses on what ships); decide case by case, and useoverridesinpackage.jsonto force a fixed version of a transitive dependency when its parent hasn't updated. Automate it: Dependabot or Renovate opens pull requests for updates, and CI can fail on high-severity findings in production dependencies. - 3
Lockfiles and pinning.
package.jsonusually holds ranges (^19.2.0means "any 19.x at least 19.2.0"), so two installs on different days can get different code. The lockfile (package-lock.json,pnpm-lock.yaml,yarn.lock) records the exact version and an integrity hash of every package, transitive ones included. Commit it, review changes to it, and install with **npm ci** in CI and deploys: it installs exactly the lockfile and fails if it doesn't matchpackage.json. Pinning exact versions (save-exact=truein.npmrc) makes upgrades deliberate. Treat install scripts as a risk:npm install --ignore-scripts(orignore-scripts=truein.npmrc) stops them, at the cost of packages that need a build step; pnpm 10 doesn't run dependencies' install scripts unless you allow them.npm audit signatureschecks the registry signatures and provenance attestations of what you installed. - 4
Content Security Policy (CSP) is an HTTP response header that tells the browser which sources of scripts, styles, images, frames and connections are allowed on your page. It is the second wall against XSS: even if a sink is missed, an injected inline
onerrorhandler or a script fromevil.exampleis blocked. A good policy for a built React SPA (Vite output loads one module script from your own origin and has no inline scripts) isdefault-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none', plus the API origins inconnect-srcand image hosts inimg-src. Avoid'unsafe-inline'and'unsafe-eval'for scripts. React suits CSP well: it attaches events from JavaScript (noonclick="..."attributes) and sets styles through the DOM's style object, which CSP allows. Server-rendered apps (Next.js) use a nonce (a random value per response, put in the header and on each script tag) with'strict-dynamic'. Start with **Content-Security-Policy-Report-Only** to see violations without breaking anything. - 5
Subresource Integrity (SRI) protects scripts and styles loaded from someone else's server. You add
integrity="sha384-…"(a cryptographic hash of the exact file) andcrossorigin="anonymous"to the<script>or<link>tag; if the CDN's file changes by one byte, the browser refuses to run it. It only works for files that never change (a specific version URL), not for scripts that update in place, like many analytics snippets. The best option is often to self-host: install the package and let your bundler include it, so it's covered by your lockfile and your CSP's'self'. Next.js has experimental SRI for its own bundles (experimental.sri). - 6
Hygiene habits: add dependencies deliberately (check maintenance, downloads, open issues, what it pulls in; sometimes 20 lines of your own code is better); remove unused ones; keep React, your router and your sanitiser current; never run
npx some-packagefrom a tutorial without checking the name; protect your own npm account with 2FA and publish with provenance; give CI tokens the least access. Security tooling likenpm auditonly knows known problems: lockfiles, review and CSP limit the damage of unknown ones.
Explain it without notes
What is a software supply chain attack, and give three ways one can reach a React app?
What do lockfiles and npm ci protect against, and why pin versions?
What is a Content Security Policy, and why does it suit React apps?
What is Subresource Integrity, and when can't you use it?
Practice
Write a CI job (GitHub Actions) that installs reproducibly, fails on high-severity production vulnerabilities, and builds.
Write a starting CSP for a Vite React SPA that calls https://api.shop.example and shows images from https://img.shop.example.
You must load a chart library from a CDN. Write the tag safely, and explain the better alternative.
Trade-offs
- ↔
Strict pinning and
ignore-scriptsmake builds predictable and safer but create more update work and break packages that need install steps; automate updates and allow-list the few packages that must build. - ↔
A strict CSP is one of the strongest XSS defences but takes effort to deploy (nonces need dynamic rendering, third-party tools need hosts, inline styles may need work); report-only mode makes the rollout safe.
Done when you can
I can explain supply chain attacks and transitive dependencies.
I can read
npm auditoutput and decide what to fix, including withoverrides.I commit lockfiles, use
npm ciin CI and control install scripts.I can write a strict CSP for a React app and roll it out with report-only first.
I can add SRI to a CDN tag and explain when self-hosting is better.