You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
v0.2.8 — Security: JavaScript off while rendering, local SSRF proxy for Chrome, pinned Chrome, egress filtering recipe
This commit was created on GitHub.com and signed with GitHub’s verified signature.
[0.2.8] - 2026-10-02
Security
Page JavaScript is switched off again while Chrome renders a page, in the page itself and in cross-site frames. Penthouse switches it off by default, but the service passes blockJSRequests: false (so that one handler owns request interception) and that flag silently dropped the JavaScript-off half too, so since 0.2.0 the inline scripts of the page being rendered have run (external URLs ending in .js were always aborted). Everything a hostile page can do beyond markup - popups, workers, WebSocket, WebRTC, WebTransport, fetch() - needs JavaScript. The per-page switch alone does not reach cross-site <iframe> and <object> frames (separate out-of-process targets), so Chrome is also launched with --blink-settings=scriptEnabled=false. In a 456-vector test harness (attack pages driven through the real /generate against listeners on private networks) the vectors that still reached a listener went from 61 to 28 with this change, the remaining 28 being the three classes the next entry closes. Rendering also had a race that was already there: a page from browser.newPage() could be handed to penthouse before its request handler was installed (adding the JavaScript switch widened it until renders hung); every page's setup is now one shared promise that every caller waits for. See "Changed" for the effect on the CSS.
A local policy proxy is now Chrome's only way onto the network. Request interception sees requests and checks the destination with its own DNS lookup before Chrome resolves the name and connects by itself, which left DNS rebinding, *.localhost names (Chrome sends them to loopback whatever DNS says) and <link rel=preconnect> connections uncovered. Chrome now runs with --proxy-server pointing at a small proxy inside the service (service/ssrf-proxy.js, 127.0.0.1 only): for every http:// request and every https:///ws:///wss:// tunnel it resolves the name (concurrent requests for a name share one lookup, and the answer is reused for 10 seconds, during which every connection goes to an address that was validated), refuses it if any returned address is private or reserved (the same classifier as the rest of the service), refuses localhost and *.localhost by name, and connects to the exact address it validated, trying a name's addresses one after another (at most four, 10 seconds each, so one unreachable address, usually a broken IPv6 route, delays that host's assets). Chrome has no direct fallback: if the proxy stops, every request fails, and the service exits if the proxy itself errors. The proxy connects directly: Chrome no longer honours HTTP_PROXY/HTTPS_PROXY/NO_PROXY (the service's own fetches never did), and there is no upstream-proxy support. In the same harness the vectors that reached a listener went from 28 to 0 (also 0 with JavaScript forced back on). No configuration is needed. Two things you may notice: refusals are logged as [ssrf-proxy] refused "host":port (reason) (at most 50 lines a minute, then a count of the rest), and Chrome's own background requests (component updater, time sync) now pass through the proxy to public hosts like any other traffic. The image sets UV_THREADPOOL_SIZE=16: the proxy resolves names in Node's thread pool, which the rest of the service shares, and lets at most half of it be busy with that (further lookups queue; measured under the example compose's mem_limit, cpus and pids_limit: 256, a render peaks at 150-167 processes and threads on four heavy sites).
Two more destinations are refused like private addresses:168.63.129.16 (Azure's WireServer, which serves the VM agent and DNS and which Microsoft calls a "virtual public IP address", so no private range covered it) and 192.88.99.0/24 (the deprecated 6to4 relay block, RFC 7526). IPv4-mapped and NAT64 forms of both are covered too.
Chrome is pinned to a Stable build (154.0.8037.92) and the pin is checked every day. puppeteer ships the Chrome it was released with: 25.12.0 pins 154.0.8037.57, which was Stable when it was released, and six days later Stable was 154.0.8037.92, which Chrome's release notes put 32 security fixes ahead (one Critical); this service executes untrusted pages with --no-sandbox. service/Dockerfile now sets PUPPETEER_CHROME_VERSION, the build fails if the Chrome that ends up in the image is not that pin (puppeteer's installer exits 0 even when the download fails), ci.yml asserts that the Chrome puppeteer really launches is the pinned one, and chrome-freshness.yml fails when a Stable build newer than the pin has been out for more than 7 days. A trust change, stated plainly: before, the Chrome that actually ran was the base image's digest-pinned copy; now the executed Chrome is downloaded at build time from Chrome for Testing over TLS, which publishes no checksum (the build log prints the binary's sha256). This is a snapshot, not "always the newest Chrome": a Dependabot puppeteer bump no longer delivers a new Chrome, and a fix reaches you only with a release after the pin is moved.
The published image now carries the current Debian security fixes for the packages the Dockerfile upgrades in place. The apt-get ... --only-upgrade layer has constant text, so a cached copy of it never saw a newer package: 0.2.7 shipped libexpat1 2.5.0-1+deb12u3 although deb12u4 was out. publish-container.yml now passes a fresh APT_REFRESH value to both of its builds (the Dockerfile declares the matching ARG) and fails the run if the value did not reach the layer; the 0.2.8 image has libexpat1 2.5.0-1+deb12u4 or newer. A local Trivy scan of an image built from this release (Trivy 0.74.0, fresh database, 2026-10-02) reports 0 findings at CRITICAL/HIGH/MEDIUM that Debian already has a fix for (--ignore-unfixed, the setting CI uses); without that filter it reports 316 at those severities (502 counting LOW), 8 of them CRITICAL in glib, libxml2, perl-base, sqlite and zlib1g, none of which has a Debian fix yet, so a fix landing for one of those 8 can block a later publish.
Added
Optional network-level egress filtering for the container (docker-compose.egress.example.yml, documented in docs/DEPLOYMENT.md). A small guard container owns the service's network namespace and installs iptables/ip6tables rules in it that refuse the same private and reserved ranges as the code (IPv6 as an allow-list, the NAT64 prefix with its embedded private IPv4 ranges refused), after loopback, replies to existing connections and an explicit ADDRESS:PORT allow-list for your WordPress receiver. The service keeps cap_drop: ALL and cannot change the rules, it only starts once the rules are verified (its start command waits for them, so a restart by Docker itself cannot run it ahead of the rules), and it loses its network if the guard dies. It is a second line behind the code-level checks, not a replacement, and it is not enabled unless you use the override. Tested on Docker Desktop against the real image, not yet on a plain Linux host and not with an actual host reboot; on Azure the default DNS resolver is 168.63.129.16, so set EGRESS_ALLOW_DNS=168.63.129.16 there. Needs Docker Compose 2.24 or newer.
Operator documentation as a GitHub Wiki (github.com/solarssk/wp-critical-css/wiki): getting started, every service setting and plugin constant with its default, how a page is processed, a security overview, troubleshooting with the real status codes and log lines, upgrading and rolling back. Its source is docs/wiki/ and CI compares its settings tables with the code. Writing it showed that docs/DEPLOYMENT.md was wrong about one thing a deployer can hit, now corrected there: WordPress core's own sitemaps (wp-sitemap-posts-post-1.xml) are not followed by the daily sweep, which only matches post-sitemap*.xml and page-sitemap*.xml, so with them the sweep finds no URLs (saving a post still regenerates it).
Changed
The critical CSS is computed from the page as it is before any inline script runs. A rule that depends on a class a script adds (html.js, body.woocommerce-js, a slider's .active) can be missing from the inlined CSS, and .no-js / <noscript>-only rules can be present; because the plugin inlines only the critical CSS and defers the rest, JavaScript-driven UI (a collapsed menu, an accordion) can flash or shift until the full stylesheet arrives. On six real WordPress sites the CSS size differed by -0.1% to +5.3% from 0.2.7 (size understates it: a .js rule traded for a .no-js one is the same size). Stored CSS only changes when a page is regenerated: run /sweep (see section 6 of docs/DEPLOYMENT.md) or wait for the daily sweep.
Rendering an image-heavy page downloads more and can take longer than on 0.2.7. With JavaScript off Chrome loads every loading="lazy" image, and every <noscript> image fallback, immediately instead of as it scrolls into view, and a full /sweep takes proportionally longer.
Building the image needs network access to Chrome for Testing's storage, and a failed Chrome download now fails the build (before, it was silently ignored). A transient failure is fixed by re-running the build.
Deploy
Container image: ghcr.io/solarssk/wp-critical-css:0.2.8 (rolling :latest, :0.2), also published to docker.io/solarssk/wp-critical-css:0.2.8.
WordPress plugin: wp-critical-css-0.2.8.zip, attached to this release. The plugin itself is unchanged; its version moves only to stay in lockstep with the service.
No migration steps - fully backward compatible with 0.2.7's stored data, configuration, and REST contract, with no new setting you have to configure (the image sets UV_THREADPOOL_SIZE=16 itself; the optional egress override reads EGRESS_ALLOW and EGRESS_ALLOW_DNS). After upgrading, run one real render (POST /generate for a page of yours, see section 7 of docs/DEPLOYMENT.md): /health only shows that Node is up, not that Chrome can reach your site through the new proxy. To pick up the CSS change described above, regenerate (/sweep) rather than waiting for pages to be edited. The egress filtering is opt-in: nothing changes unless you add the override file.