Summary
The detector is currently loaded by importing it from a blob: URL. That choice causes two independent, silent failure modes for integrators. Serving it from a regular https:// URL and loading it with an injected <script type="module"> removes both, and leaves integrators with a stricter CSP than we ask for today.
Problem 1 — CSP scheme matching
A script-src wildcard matches neither blob: nor data:. Policies frequently permit one scheme and not the other, so on those origins the import is blocked outright.
Our only remedy today is to ask the integrator to add blob: or data: to script-src. Both are broad, and data: in particular is a well-known XSS amplifier — any injection on the page can then execute arbitrary script. A competent security review should push back on that request, and they do.
Problem 2 — bundler rewriting
A dynamic import() with a non-literal specifier is rewritten by webpack into a context module that cannot resolve a blob or data URL at runtime. Avoiding that requires both /* @vite-ignore */ and /* webpackIgnore: true */ on the import.
Those are comments, and removeComments in the shared build config strips them from dist/, so anyone bundling us from npm receives a bare import(url) regardless. Disabling removeComments is not an option — it would emit source comments into shipped output.
Impact
Both failures are silent. The load throws, the widget falls through to its no-detector path, and the request carries no detection result. The session is then served a challenge it cannot pass frictionlessly. The only client-side trace is a single console.error, and nothing on the wire records why.
Proposal
Serve the script from an https:// URL on the provider and load it with an injected <script type="module" src>.
- CSP becomes an origin allowlist. A named origin is an ordinary, easily-approved CSP entry and materially safer for the integrator than
blob: or data:. We stop asking people to weaken their policy.
- The bundler problem disappears permanently. A script tag contains no import expression, so there is nothing for webpack or Vite to rewrite, and
removeComments can stay on.
- We get a failure reason. A script tag has
onerror, so we can finally report why loading failed instead of silently proceeding without a detection result.
Work required
- New provider endpoint serving the script, keyed per session. Needs
Content-Type: text/javascript and CORS headers — module scripts and import() both require CORS cross-origin, a classic script would not.
- The script must self-register rather than rely on a module default export, since a script tag cannot return a value. The loader plants a resolver ahead of injection and the script calls it. Emit both shapes during a compatibility window so existing widget versions keep working.
- Add an optional URL field to the assign response alongside the existing inline script, and have the client prefer the URL and fall back to the current path. Old widgets ignore the unknown field, so no lockstep between widget and provider releases.
Tradeoffs / open questions
- One extra round trip, since the script is currently returned inline. No meaningful caching benefit to offset it.
- Wildcard vs single host. The script is served by whichever provider handled the session, so the allowlist would need to cover them all. Fronting it behind a single hostname would reduce that to one CSP entry — worth deciding as part of the design.
- Classic script + IIFE would avoid the CORS requirement entirely, but is a larger build change given the current module format.
- Exposure implications of a fetchable URL need to be worked through as part of refinement.
Related
PR #3191 adds a data: fallback to the existing path. It is a stopgap for policies that allow data: but not blob:, and does not address either root cause.
Summary
The detector is currently loaded by importing it from a
blob:URL. That choice causes two independent, silent failure modes for integrators. Serving it from a regularhttps://URL and loading it with an injected<script type="module">removes both, and leaves integrators with a stricter CSP than we ask for today.Problem 1 — CSP scheme matching
A
script-srcwildcard matches neitherblob:nordata:. Policies frequently permit one scheme and not the other, so on those origins the import is blocked outright.Our only remedy today is to ask the integrator to add
blob:ordata:toscript-src. Both are broad, anddata:in particular is a well-known XSS amplifier — any injection on the page can then execute arbitrary script. A competent security review should push back on that request, and they do.Problem 2 — bundler rewriting
A dynamic
import()with a non-literal specifier is rewritten by webpack into a context module that cannot resolve a blob or data URL at runtime. Avoiding that requires both/* @vite-ignore */and/* webpackIgnore: true */on the import.Those are comments, and
removeCommentsin the shared build config strips them fromdist/, so anyone bundling us from npm receives a bareimport(url)regardless. DisablingremoveCommentsis not an option — it would emit source comments into shipped output.Impact
Both failures are silent. The load throws, the widget falls through to its no-detector path, and the request carries no detection result. The session is then served a challenge it cannot pass frictionlessly. The only client-side trace is a single
console.error, and nothing on the wire records why.Proposal
Serve the script from an
https://URL on the provider and load it with an injected<script type="module" src>.blob:ordata:. We stop asking people to weaken their policy.removeCommentscan stay on.onerror, so we can finally report why loading failed instead of silently proceeding without a detection result.Work required
Content-Type: text/javascriptand CORS headers — module scripts andimport()both require CORS cross-origin, a classic script would not.Tradeoffs / open questions
Related
PR #3191 adds a
data:fallback to the existing path. It is a stopgap for policies that allowdata:but notblob:, and does not address either root cause.