Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,45 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]

### Added
- **`sourcemaps inject` refuses a build that pins its own script hashes** (Subresource Integrity),
exit 20. Injecting appends bytes to every `.js`, so a hash the HTML already carries stops matching
and the browser refuses to run the script: measured on a real webpack +
`webpack-subresource-integrity` build in Chromium 151, the page loaded and executed **nothing**
after injecting, where before it ran clean. Angular's `subresourceIntegrity: true` is the same
mechanism, and Angular/esbuild/Deno users drive this binary directly — the JS bundler plugins
carry their own copy of this guard, but they only cover vite and rollup.

Detected: `<script integrity src=…>` and `<link rel=modulepreload|preload integrity href=…>` in
any `.html`/`.htm`/`.xhtml` under the given paths. The URL is resolved literally first and then by
file name, so a `publicPath` — a CDN origin, `/static/`, `/_next/` — still resolves to the local
bytes it names; that is the canonical SRI deployment (hash locally, serve from a CDN) and treating
every absolute URL as somebody else's file would have missed it entirely.

The guard refuses only over a file this run would really REWRITE — so re-running `inject` stays a
no-op, including on a build stamped once with `--allow-sri` — and it shares one list with the walk
that does the stamping: an `--exclude`d file, a stale page pinning a deleted bundle, a pin on
something outside the output, and a file argument naming an unpinned bundle all proceed. Also not
flagged: a third-party CDN script (unless it shares a file name with one of your bundles, in
which case it is treated as yours — the safer error), a non-JS target, an empty `integrity`,
`data-integrity`/`data-src`, an inline script, a commented-out tag, and `rel=prefetch` (a failed
prefetch is discarded, not fatal).

Pages are read from anywhere under the given paths, plus any sitting directly in a given path's
parent (the usual layout is `dist/index.html` beside `dist/assets/*.js`). `--dry-run` refuses too:
the preview of a run that would refuse is a refusal, and it says why.

It cannot see SRI that never reaches the emitted HTML — a manifest consumed by a server template,
a page rendered at request time (Next.js `experimental.sri`), or HTML written outside the directory
it was pointed at.

`--allow-sri` proceeds anyway, for a build that recomputes its hashes afterwards.
- **`sourcemaps inject --exclude <glob>`** (repeatable) — leave part of a build output alone. A
stock `next build` with browser source maps on has 39 JS files and 12 maps, and a Nuxt
`.output/server/node_modules` holds 22 vendored `.mjs`; `--exclude '**/node_modules/**'` keeps
`inject` out of third-party code inside the build output. Matched against the path relative to
each walked root and against the full path. An unparseable pattern is a configuration error
`inject` out of third-party code inside the build output. Matched against the absolute path, the path
relative to the current directory, and the path relative to each walked root, so
`dist/vendor/**`, `vendor/**` and an absolute path all work whether the root is passed as `dist`,
`./dist` or absolute. `*` crosses `/` (globset's default). An unparseable OR EMPTY pattern is a configuration error
(exit 20), never a silent "matches nothing" that would rewrite the files you meant to protect.
`js_excluded` is reported in the completion log.

Expand Down
43 changes: 39 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,16 @@ other type rather than accepted and ignored.

`--exclude <glob>` (repeatable) keeps `inject` out of part of a build output — `--exclude
'**/node_modules/**'` leaves vendored third-party code inside a server bundle untouched, `--exclude
'polyfills*.js'` skips one file by name. Globs are matched against the path relative to each walked
root and against the full path; an unparseable pattern is a configuration error (exit 20) rather
than a silent "matches nothing", which would rewrite exactly the files you meant to protect.
'polyfills*.js'` skips one file by name.

A pattern is tried against the absolute path, the path relative to the current directory, and the
path relative to each walked root, so `dist/vendor/**`, `vendor/**` and an absolute path all work
whether you pass `dist`, `./dist` or the absolute directory. `*` crosses `/` (globset's default), so
`*.js` matches `vendor/v.js` too — anchor with a leading `/` or a directory prefix if you do not want
that. An unparseable or empty pattern is a configuration error (exit 20) rather than a silent
"matches nothing", which would rewrite exactly the files you meant to protect. Patterns select
BUNDLES: an excluded bundle's `.map` is left alone with it, but a pattern matching only `.map` files
excludes nothing.

**A bundle with no source map is still stamped, on purpose.** It looks like waste — the id cannot
resolve to a symbol — but a crash frame carrying a debug-id whose map was never uploaded marks the
Expand All @@ -239,8 +246,36 @@ unsymbolicated instead. Measured on a stock `next build` with browser source map
12 maps, so 27 bundles are stamped without one; that is the case that produces the prompt. Use
`--exclude` when you would rather those files were not touched at all.

**A build that pins its own script hashes is REFUSED** (exit 20). Injecting appends bytes to every
`.js`, so a Subresource Integrity hash the HTML already carries stops matching and the browser
refuses to run the script — measured on a real webpack + `webpack-subresource-integrity` build in
Chromium 151: before injecting the app ran, after it the entry script was blocked and the page
executed nothing. `inject` looks for `<script integrity src=…>` and
`<link rel=modulepreload|preload integrity href=…>` in the HTML under the paths it was given, and
stops before writing anything. Angular's `subresourceIntegrity: true` is the same mechanism.

Fix it by stamping BEFORE the hashes are computed, by `--exclude`-ing the pinned files, or — if your
build recomputes hashes after this runs — with `--allow-sri`.

The guard only ever refuses over a file this run would really REWRITE, so a re-run that changes
nothing is still a no-op — including on a build stamped once with `--allow-sri` — and an excluded
file, a stale page pinning a bundle that no longer exists, or a page pinning something outside the
output does not stop it either.

Pages are read from anywhere under the path you give it, plus any sitting directly in that path's
parent — the usual layout is `dist/index.html` beside `dist/assets/*.js`, so `inject dist/assets`
still sees the page that pins those bundles. `--dry-run` refuses too: the preview of a run that
would refuse is a refusal, and it tells you why. A URL is matched literally first and then by file name, so a `publicPath` — a CDN origin,
`/static/`, `/_next/` — still resolves to the local bytes it names; the cost of that fallback is that
a third-party script sharing a file name with one of your bundles would be treated as yours.

**It cannot see SRI that is not in the emitted HTML**: a manifest consumed by a server template
(`webpack-assets-manifest` with `integrity: true`), a page rendered at request time (Next.js
`experimental.sri`), or HTML your build writes outside the directory you point this at. Those builds
still break, so keep `--allow-sri` off and check a deploy before trusting it.

```
bugsee-cli sourcemaps inject <paths>... [--exclude <glob>]... [--dry-run]
bugsee-cli sourcemaps inject <paths>... [--exclude <glob>]... [--allow-sri] [--dry-run]
bugsee-cli debug-files upload --type sourcemaps <paths>... --version <v> --build <b> \
[--concurrency N] [--allow-empty]
```
Expand Down
19 changes: 16 additions & 3 deletions src/cli/sourcemaps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ pub enum SourcemapsCommand {
#[arg(required = true)]
paths: Vec<PathBuf>,

/// Glob of files NOT to touch; repeatable. Matched against the path relative to each
/// walked root and against the full path, so `--exclude '**/node_modules/**'` keeps
/// Glob of files NOT to touch; repeatable. Matched against the absolute path, the path
/// relative to the current directory, and the path relative to each walked root, so
/// `dist/vendor/**`, `vendor/**` and an absolute path all work whatever the root looks
/// like. `*` crosses `/`. So `--exclude '**/node_modules/**'` keeps
/// `inject` out of vendored code inside a build output (a Nuxt `.output/server` carries
/// 22 such `.mjs`), and `--exclude 'polyfills*.js'` skips one file by name.
///
Expand All @@ -31,6 +33,16 @@ pub enum SourcemapsCommand {
#[arg(long)]
exclude: Vec<String>,

/// Inject even when the build pins its own script hashes (Subresource Integrity).
///
/// Injecting appends bytes to every `.js`, so a hash the HTML already pins stops matching
/// and the browser refuses to run the script — the page loads and nothing executes
/// (measured in Chromium 151 on a real webpack + webpack-subresource-integrity build).
/// That is refused by default. Pass this only if your build recomputes the hashes AFTER
/// this runs; otherwise stamp earlier, or `--exclude` the pinned files.
#[arg(long)]
allow_sri: bool,

/// Dry-run — report what would change without writing.
#[arg(long)]
dry_run: bool,
Expand All @@ -46,9 +58,10 @@ pub async fn dispatch(
SourcemapsCommand::Inject {
paths,
exclude,
allow_sri,
dry_run,
} => {
let stats = inject::inject_paths(&paths, &exclude, dry_run)?;
let stats = inject::inject_paths(&paths, &exclude, allow_sri, dry_run)?;
tracing::info!(
js_injected = stats.js_injected,
js_already_injected = stats.js_already,
Expand Down
Loading
Loading