What CSP and script hash reports would have shown during the polyfill.io attack
In 2024 a CDN embedded on 100,000+ sites changed owners and began serving malicious code to mobile visitors. What a violation report would have shown, what a hash report would not, and why.
Michael Cameron7 min readUpdated
For years, a <script> tag pointing at cdn.polyfill.io was the standard way to
give old browsers modern JavaScript features. In February 2024 the domain and
the project’s GitHub account were sold, and by June the CDN was serving code
that sent some visitors to a sports betting site. The URL on every one of those
pages had not changed. The file behind it had.
Script hash reporting exists for incidents like this one, and this is also where its limits matter most.
What happened
Sansec published its findings on 25 June 2024. More than 100,000 sites embedded the service through cdn.polyfill.io, JSTOR, Intuit and the World Economic Forum among them. In February 2024 a Chinese company had bought the domain and the GitHub account, and since then the domain had been injecting malware into pages on mobile devices. Complaints on the project’s GitHub were deleted.
The sale had been flagged at the time. Cloudflare wrote in February that the project’s original author had warned people to stop using polyfill.io, because modern browsers no longer needed it and a new owner could inject code into it. Cloudflare put a mirror on cdnjs the same month.
Within days of Sansec’s post, Google began blocking ads for e-commerce sites using the domain, Cloudflare started rewriting cdn.polyfill.io links to its mirror for sites behind its proxy, and the registrar, Namecheap, put the domain on hold. Censys counted 384,773 hosts still referencing the domain on 2 July.
How the payload worked
The polyfill service was never a static file. Its own README described it as reading the User-Agent header of each request and returning the polyfills that browser needed. So the malicious version could be, in Sansec’s words, dynamically generated based on the HTTP headers.
The variant Sansec decoded checked for a mobile user agent, ran only during
certain hours, stood down when it detected an admin user, and waited when it
found a web analytics service on the page, presumably to stay out of the
statistics. When it fired, it loaded a second script from
www.googie-anaiytics.com, a lookalike of Google Analytics, then set
window.location.href to a redirect that landed on a betting site. It carried
protection against reverse engineering.
The CSP report this would have produced
cdn.polyfill.io was on the allowlist of every site that used it. It had to be. So the polyfill file itself, malicious or not, loaded without a report. The report comes from the second stage.
{
"csp-report": {
"document-uri": "https://www.example.com/pricing",
"violated-directive": "script-src-elem",
"effective-directive": "script-src-elem",
"original-policy": "default-src 'self'; script-src 'self' https://cdn.polyfill.io; report-uri https://k3x9mp2nq7ab.ingest.us.headerhawk.com/r",
"disposition": "report",
"blocked-uri": "https://www.googie-anaiytics.com/html/checkcachehw.js",
"source-file": "https://cdn.polyfill.io/v3/polyfill.min.js",
"line-number": 1,
"status-code": 200
}
}
The page and policy are illustrative. blocked-uri names a host that does
not appear in any legitimate polyfill, source-file names the CDN script that
tried to load it, and the browser in every report would be a mobile one,
because the payload checked for that before doing anything.
Where it surfaces
HeaderHawk groups reports by effective directive and blocked host, so the issue
reads script-src-elem blocking www.googie-anaiytics.com, with a report count
and first- and last-seen timestamps. Each report names its page (every page
that included polyfill, which on most sites is all of them) and the visitor’s
browser and operating system. Mobile only, at certain hours: that would be the
shape of the payload showing through.
Alerting is on browser agreement. Reports for an issue accumulate in 15-minute windows, and a window reported by two or more browser families, or carrying 20 or more reports from one, goes to Slack, email or a webhook, then repeats no more often than the rule’s window, which defaults to a day. Mobile Chrome and mobile Safari are two families.
The redirect itself produces no report. CSP has no directive for a navigation
the page starts. navigate-to was proposed and then
dropped from the specification
before any browser shipped it. Had the payload redirected without loading a
second script first, a violation report would have had nothing to say.
What a script hash report would have shown
Script hash reporting is aimed at this exact case. With 'report-sha256' in
script-src, Chrome and Edge send a report carrying a SHA-256 hash of each
script they fetch, so a script whose contents change under a stable URL shows
up as a new hash against a URL you already know. The
script hash reporting post
covers how it works. Applied to polyfill.io, the limits arrive in order.
In June 2024 no browser sent hash reports at all. The keyword shipped in Chrome 133, in February 2025. Firefox does not send them, and WebKit’s support is still in development. A hash report for this incident is a description of what a Chrome or Edge visitor would produce today, not of anything anyone could have seen at the time.
The hash for cdn.polyfill.io was never one value. The service returned a different bundle for each browser by design, so a site with any variety of visitors already had many hashes for that URL, and one more was routine. The malicious variant went only to mobile visitors at certain hours: one new hash among several, with nothing marking it as the wrong one.
A cross-origin script only gets a hash if it was fetched with CORS. That means
crossorigin="anonymous" on the tag and an Access-Control-Allow-Origin
header from the CDN. The typical polyfill tag had no crossorigin attribute, in which case the browser
reports the script with an empty hash. HeaderHawk records that as an unhashed
sighting, so the script still appears in the Scripts view, marked as not
hashed, and it is the mark rather than a hash that tells you which tag to fix.
Nothing about its contents is recorded, so there is nothing to compare from
one visit to the next.
Hash reports are sampled. The first sighting of a hash for a site bypasses sampling, subject to the tenant’s quota and a per-site daily ceiling, and repeat sightings are sampled at the site’s rate. A cached script is not fetched and so never hashed, and a script that runs inline is never hashed either. The inventory is a sample of what Chrome and Edge visitors fetched, not a record of what every visitor ran.
A new hash alerts only on a payment page. On the Team plan and above, the Scripts view lists hashes by origin and by script for inspection, and on the Professional plan and above by page too. On those plans, a script authorized with pinned hashes alerts when a hash you did not pin runs on a page you marked as a payment page, once browsers on two different networks have reported it, or once that page’s daily scan fetches the script and hashes a version you did not pin. The scan fetches the file itself, so it hashes it even when the tag has no CORS and every visitor has it cached. A script authorized for any content, which is how a CDN-served polyfill that updates in place would usually be authorized, alerts nobody when it changes. Anywhere else, a changed script is something you notice in the Scripts view, not something that pages you.
For a site with Chrome and Edge visitors and CORS on the tag, the hash view
would have recorded that cdn.polyfill.io produced hashes it had not produced
before, with the first-seen time of each. Read alongside the script-src-elem
issue, that is a timeline. Read alone, it is a list of hashes for a URL that
was always going to have several.
Where the report stops
The host was trusted, and a host allowlist cannot say that a trusted host
changed. Subresource integrity is the browser’s answer to that, and it was
unusable here for the same reason the hashes vary: a per-browser bundle has no
single hash to pin. Polyfill.io tags carried no integrity attribute, and the
author’s advice was to remove the tag, not secure it.
A payload with no second stage reports nothing. The variant Sansec decoded loaded a script from a lookalike domain, and that load is the report above. A variant carrying its redirect inline makes no request a policy could refuse.
Enforcement would have refused the second-stage script, and the first stage had already run from an allowed host, so whether the redirect still fired depends on how the payload was written. The remedy was removing the tag, which Cloudflare did by rewriting it and Namecheap by taking the domain down. What a report adds is the first mobile visit on which the page loaded something from googie-anaiytics.com, with the CDN script named as the source.
Sources
- Sansec, Polyfill supply chain attack hits 100K+ sites, 25 June 2024, with updates through 28 June.
- Cloudflare, polyfill.io now available on cdnjs: reduce your supply chain risk, February 2024, and Automatically replacing polyfill.io links with Cloudflare’s mirror for a safer Internet, 26 June 2024.
- Censys, Polyfill.io Supply Chain Attack - Digging into the Web of Compromised Domains, 2 July 2024.
- Financial Times, polyfill-service README, as archived in March 2023, before the sale.
- Chrome for Developers, Chrome 133 release notes, February 2025.
- W3C, Content Security Policy Level 3.