Skip to content

Vendor ESP Web Tools into the installer instead of loading it from a CDN - #78

Merged
marklynch merged 1 commit into
mainfrom
claude/package-repo-options-bc13ra
Jul 26, 2026
Merged

Vendor ESP Web Tools into the installer instead of loading it from a CDN#78
marklynch merged 1 commit into
mainfrom
claude/package-repo-options-bc13ra

Conversation

@marklynch

Copy link
Copy Markdown
Owner

Follow-up to #77. Addresses the one finding from a security review of the installer.

The problem

The page loaded the flasher from unpkg with a floating major range:

<script type="module" src="https://unpkg.com/esp-web-tools@10/dist/web/install-button.js"></script>

@10 resolves to whatever the newest 10.x.y is at page load time, so the published installer executed code that did not exist at review time and that nobody here had seen. No deploy, merge, or maintainer action gated it — a new 10.x on npm reached every visitor on their next page load.

That matters here because of what this specific page does: it is the only page in the project that holds a live Web Serial port and writes a full flash image. The loaded script decides which bytes reach the device. A compromised publish could have flashed arbitrary firmware onto a board that is then given the user's WiFi credentials, sits on their LAN, bridges to the pool control bus, and has its own OTA path — a persistent foothold, with the flash appearing to succeed normally.

Changes

Vendored the flasher. build-site.sh downloads the exact EWT_VERSION tarball from the npm registry, verifies it against the registry's published dist.integrity hash pinned in EWT_INTEGRITY, and unpacks it to ewt/. Published npm versions are immutable, so a mismatch means tampering in transit or a registry compromise — either way the build aborts rather than shipping it. Upgrading is a deliberate two-line change, with the command to obtain the new hash documented in the script and README.

Subresource Integrity would not have been sufficient. The bundle lazily imports sibling chunks at runtime (install-dialog-*.js, index-*.js, and a per-chip esp32c6-*.js), which an integrity attribute on the entry point does not cover. Vendoring removes the third-party origin entirely instead of trying to constrain it.

Added a Content-Security-Policy. With every asset same-origin this is now meaningful: default-src 'none' with script-src 'self' and no external origins. The page script moved out of the HTML into installer/app.js so it needs no 'unsafe-inline'.

Two deliberate relaxations, both documented inline:

  • style-src allows inline — the page styles are a <style> block, and ESP Web Tools falls back to injecting <style> where adoptedStyleSheets is unavailable.
  • connect-src allows blob: — the flasher's dialog creates object URLs.

frame-ancestors is omitted on purpose: it is ignored in a meta CSP and needs a real header, which GitHub Pages cannot set.

Confirmed against the bundle that it uses no Worker, importScripts, WebAssembly, eval, or new Function, so script-src 'self' does not need loosening.

Verification

On an assembled build served under the enforced CSP:

  • the custom element upgrades, so the vendored entry point loads and executes under script-src 'self'
  • the picker lists all five versions and still re-points the button when switched
  • both lazily imported chunks (install-dialog, esp32c6) load without violation — the key test, since they are dynamic imports
  • zero off-origin requests; the CDN dependency is genuinely gone
  • no CSP violations or console errors

Also confirmed the integrity check fails closed by building against a corrupted expected hash: non-zero exit, no ewt/ written, no partial site.

Not verified: flashing to real hardware under the CSP — that needs a device. Worth one real flash before relying on it.

Adds ~536 KB to the site, against ~7.5 MB of firmware binaries.

🤖 Generated with Claude Code

https://claude.ai/code/session_015Kx7qyAqUvkwnvEJJcSW7N


Generated by Claude Code

The page loaded the flasher from unpkg with a floating major range
(esp-web-tools@10), so it executed whatever had most recently been published
to npm — code that did not exist at review time, on the one page that holds a
live Web Serial port and writes a full flash image to the device. A compromised
publish would have reached every visitor immediately, with no deploy step in
between, and could have flashed arbitrary firmware onto a board that then holds
the user's WiFi credentials and sits on their LAN.

build-site.sh now downloads the exact pinned version from the npm registry,
verifies the tarball against the registry's published dist.integrity hash, and
unpacks it into ewt/. Published npm versions are immutable, so a mismatch means
tampering in transit or a registry compromise; either way the build aborts
rather than shipping it. Upgrading is a deliberate two-line change with a
documented way to obtain the new hash.

Subresource Integrity was not sufficient here: the bundle lazily imports
sibling chunks at runtime (install-dialog, index, and a per-chip module), which
an integrity attribute on the entry point would not cover. Vendoring removes
the third-party origin entirely instead.

With every asset now same-origin, the page carries a default-src 'none' CSP
permitting script-src 'self' and no external origins. The page script moved out
of the HTML into app.js so that needs no 'unsafe-inline'. style-src still allows
inline: the page styles are a <style> block and ESP Web Tools falls back to
injecting <style> where adoptedStyleSheets is unavailable. connect-src allows
blob: because the flasher's dialog creates object URLs. frame-ancestors is
omitted deliberately — it is ignored in a meta CSP and needs a real header,
which GitHub Pages cannot set.

Confirmed against the bundle that it uses no Worker, importScripts,
WebAssembly, eval or new Function, so script-src 'self' does not need loosening.

Verified on an assembled build served under the enforced CSP: the custom
element upgrades, the picker lists all five versions and still re-points the
button, and both lazily imported chunks (install-dialog and esp32c6) load
without violation. The page makes zero off-origin requests, and the console is
clean. Also confirmed the integrity check fails closed by building against a
corrupted expected hash: non-zero exit, no ewt/ written.

Not verified: flashing to real hardware under the CSP, which needs a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Kx7qyAqUvkwnvEJJcSW7N
@marklynch
marklynch merged commit 4c935ec into main Jul 26, 2026
3 checks passed
@marklynch
marklynch deleted the claude/package-repo-options-bc13ra branch July 26, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants