Skip to content
Metric VaultHelp Center
Open app

Security headers and CSP

The complete response-header inventory for the platform: what the _headers file declares, which of those declarations survive to the browser, what the worker sets itself, and what is not set at all.

Last updated 2026-08-06

Summary#

This is the exhaustive inventory of response headers the platform emits, split into three groups: what _headers declares, what actually reaches the browser, and what the worker adds on its own responses. It is a reference, so it is complete rather than representative, and it records absences as explicitly as presences.

Two facts do most of the work on this page. _headers contains cache directives only; there is no security header in it. And most of those directives never reach the client, because HTML responses are rebuilt by the worker before they are returned.

Overview#

The platform is a Cloudflare Pages project in advanced mode. A single _worker.js answers every request that _routes.json does not exclude. Static assets are fetched through env.ASSETS.fetch(request) and then, for anything with a text/html content type, a brand-new Response is constructed carrying only Content-Type so the chat widget can be injected. Every other header on the asset response is discarded at that point, including the Cache-Control, Pragma and Expires values _headers set for that path.

That behavior is the single most important thing to know before you change anything here. Adding a header to _headers for an HTML route has no effect. Adding it to the response the worker builds does.

Non-HTML assets are returned unmodified, so their _headers entries do survive. /login and /login.html are unaffected for a different reason: the worker intercepts them earlier and returns the inlined LOGIN_HTML constant with its own explicit no-cache headers, so the file is never consulted.

The _headers file, in full#

Seven rules, all cache directives. Nothing else is in the file.

PathHeaders declaredReaches the browser?
/login.htmlCache-Control: no-cache, no-store, must-revalidate, Pragma: no-cache, Expires: 0Not from this file. The worker serves the inlined login page with equivalent headers of its own
/loginSame threeSame
/dashboard.htmlSame threeNo. The HTML response is rebuilt with Content-Type only
/dashboardSame threeNo, same reason
/sw.jsCache-Control: no-cache, no-store, must-revalidateYes. Non-HTML assets pass through untouched
/index.htmlCache-Control: no-cache, no-store, must-revalidateNo, same reason as /dashboard
/Cache-Control: no-cache, no-store, must-revalidateNo, same reason
Important

Important: if you need a header on /dashboard, /index.html or /, add it where the worker builds the HTML response. _headers will not deliver it.

Security headers the platform sets#

This is the complete list of security-relevant headers emitted by first-party responses.

HeaderValueWhere it is set
Referrer-Policyno-referrerOne branch of the responsive-preview proxy only
Cache-Controlno-storeDiagnostic 403 responses, the inlined login page, article and blog HTML, and every JSON response built through the shared no-cache helper
Cache-Controlno-storeThe API key creation response, so a generated key is never held by an intermediary

That is the entire list.

Security headers the platform does not set#

HeaderStatus on mainConsequence
Content-Security-PolicyNot set on any first-party responseNo browser-side containment of injected script. This is what makes the share-page surface below matter
Strict-Transport-SecurityNot set by the workerHTTPS is terminated at the edge; whether the zone adds HSTS is a Cloudflare zone setting and is not visible from the repository
X-Content-Type-OptionsNot setContent-type sniffing is not suppressed
X-Frame-OptionsNot setThe app and marketing pages can be framed
Permissions-PolicyNot setNo feature-policy restrictions are declared
Cross-Origin-Opener-Policy / Cross-Origin-Resource-PolicyNot setNo cross-origin isolation

Two things follow that are worth stating plainly rather than discovering later.

The published Security page at metricvaultai.com states that Content Security Policy headers are in use. They are not, on main. Treat closing that gap as a documentation-and-code task with both halves outstanding, not as a wording problem.

The product ships an HTTP Security Headers tool that grades third-party sites on exactly this header set. See Security Headers. Running it against metricvaultai.com is a fair test and will report what this table reports.

The shared-report page#

GET /share/<slug> is intentionally public: no session, no token, and a view_count increment on each view. The stored report body is inserted into the page unescaped, because escaping would destroy the legitimate HTML of a captured report. The response carries only Content-Type: text/html; charset=utf-8.

The consequence is a stored-script surface on the main origin. The fix that exists — on the unmerged security/deferred-hardening branch, not on main — is a per-request nonce CSP rather than escaping:

text
Content-Security-Policy: default-src 'self'; script-src 'nonce-<n>';
  style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:;
  font-src 'self' data:; connect-src 'self';
  object-src 'none'; base-uri 'none'; form-action 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer

Four properties of that design are worth carrying forward if you touch it:

  • The nonce is minted per view request, after the body was stored, so it cannot be known at the time content is injected.
  • Every script the page owns carries the nonce, including the external chart library tag. A nonce authorises an external script regardless of its host.
  • A public multi-tenant CDN host must never be allow-listed in script-src. An earlier revision allow-listed one and thereby defeated the nonce entirely, because such hosts serve arbitrary third-party code. Nonce the tag instead.
  • The policy blocks the inline on* handlers baked into captured reports. A nonced compatibility script restores the two behaviors that matter, drill-down reveal and broken-image hiding; inline hover effects stay inert.

The 404 and 410 responses on that route carry the same headers in that design.

CORS#

HelperHeaders
Standard JSON responsesContent-Type: application/json, Access-Control-Allow-Origin: *
Chrome-extension responsesThe above plus Access-Control-Allow-Methods: GET, POST, OPTIONS and Access-Control-Allow-Headers: Content-Type, X-Extension-Version
No-cache JSON responsesThe standard set plus Cache-Control: no-store, no-cache, must-revalidate, max-age=0, Pragma: no-cache, CDN-Cache-Control: no-store, Cloudflare-CDN-Cache-Control: no-store
Global preflight, every OPTIONS on any pathAccess-Control-Allow-Origin: *, Access-Control-Allow-Methods: GET, POST, OPTIONS, Access-Control-Allow-Headers: Content-Type, Authorization

Access-Control-Allow-Credentials is never set anywhere, and no cookie carries authentication. The session token travels in an Authorization header attached by client JavaScript.

The wildcard origin is a deliberate decision, not an oversight. The Chrome extension runs from a chrome-extension:// origin and the public API is called from arbitrary servers; both need *. Because no credentials ride along, a cross-origin request gains an attacker nothing that a direct request would not also give them. What makes a cross-origin request useful today is the body-trusted identity class described in Authorization model, not the CORS policy. Tightening CORS would break the extension and the public API for no security benefit; closing the identity gap is the fix.

Other response headers the platform sets#

Not security headers, but part of the same inventory and frequently asked about.

HeaderValueWhere
X-MV-CachehitA shared-cache hit on a premium tool response
X-MV-Cache-Age-DaysWhole days since the cached payload was fetchedSame responses
X-MV-Saved-IdThe Library row id for the runTool responses that were saved
Access-Control-Expose-HeadersX-MV-Cache, X-MV-Cache-Age-Days, X-MV-Saved-IdSame responses, so the browser can read them
X-CacheHIT or MISSThe extension quickview endpoint
X-Cache-DebugA small JSON blobSame endpoint
Cache-Controlpublic, max-age=31536000, immutableBlog media served from object storage
Access-Control-Allow-Origin*Blog media, and the responsive-preview asset proxy
Timing-Allow-Origin*The responsive-preview asset proxy
Cache-Controlpublic, max-age=300The responsive-preview asset proxy, and pages served through the edge cache from the GitHub raw fallback
Cache-Controlpublic, max-age=3600Brand logo images served through the same fallback

The responsive-preview proxy additionally strips in-document <meta http-equiv="content-security-policy"> tags from the pages it fetches. That is the point of the proxy: a page that forbids framing cannot otherwise be previewed. The preview iframe is sandboxed without allow-same-origin, specifically so a framed site cannot read the dashboard's session, which is why the proxy injects shims for storage, matchMedia, history and document.cookie.

If you are adding headers#

  1. Decide whether the response is built by the worker or passed through from env.ASSETS. Only the first can carry new headers today.
  2. For HTML routes, add them where the injected-chatbot response is constructed, not in _headers.
  3. Introduce a CSP in report-only mode first. The dashboard and the marketing pages carry a large amount of inline script and inline style attributes, and an enforcing policy will break them until each is nonced or moved.
  4. Never allow-list a public multi-tenant CDN host in script-src.
  5. Re-run Security Headers against the deployed origin afterwards. It is the fastest end-to-end check that the header actually shipped.

See also

Was this article helpful?