Vendor ESP Web Tools into the installer instead of loading it from a CDN - #78
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
@10resolves to whatever the newest10.x.yis 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.shdownloads the exactEWT_VERSIONtarball from the npm registry, verifies it against the registry's publisheddist.integrityhash pinned inEWT_INTEGRITY, and unpacks it toewt/. 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-chipesp32c6-*.js), which anintegrityattribute 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'withscript-src 'self'and no external origins. The page script moved out of the HTML intoinstaller/app.jsso it needs no'unsafe-inline'.Two deliberate relaxations, both documented inline:
style-srcallows inline — the page styles are a<style>block, and ESP Web Tools falls back to injecting<style>whereadoptedStyleSheetsis unavailable.connect-srcallowsblob:— the flasher's dialog creates object URLs.frame-ancestorsis 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, ornew Function, soscript-src 'self'does not need loosening.Verification
On an assembled build served under the enforced CSP:
script-src 'self'install-dialog,esp32c6) load without violation — the key test, since they are dynamic importsAlso 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