Skip to content

chore(security): standardise the default response headers - #7885

Open
ar2rsawseen wants to merge 5 commits into
masterfrom
chore/standardize-security-headers
Open

chore(security): standardise the default response headers#7885
ar2rsawseen wants to merge 5 commits into
masterfrom
chore/standardize-security-headers

Conversation

@ar2rsawseen

Copy link
Copy Markdown
Member

Standardises the shipped security response headers so the three repos agree, and drops the ones that should no longer be sent.

Removed

X-XSS-Protection. Deprecated and removed from every current browser, so it does nothing. While it existed it could be turned against the page: steered into disabling one of the page's own scripts, and with mode=block the aborted load is observable cross-origin, which makes it an oracle for reading page contents. It belongs on the remove list, and scanners flag its presence.

The preload token on HSTS. Preload requires submitting the domain to the browser preload list and is painful to reverse; with includeSubDomains on a shared parent domain it can affect a customer's other services. max-age and includeSubDomains are unchanged — this only stops us shipping the irreversible part as a default.

Added

Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy, and X-Content-Type-Options where it was missing.

Not added, and why

Content-Security-Policy. The classic dashboard inlines countlyGlobal per request via res.expose, so its hash is not stable and a static policy would need 'unsafe-inline' — which removes the protection CSP exists for. It needs that inline script dealt with, or per-request nonces, and a flat config string cannot express a nonce. This is why a CSP attempt here previously broke things rather than helping.

Cross-Origin-Resource-Policy on the api set. That set carries Access-Control-Allow-Origin: * because web SDKs post from arbitrary customer origins. The two work against each other.

The embeddable-route pattern

The isolation headers are applied globally, like the rest, and removed on the routes customers embed from their own origin — which is exactly what those routes already do for X-Frame-Options.

Cross-Origin-Opener-Policy matters there specifically: an SDK may open a widget as a window rather than an iframe, and same-origin would sever window.opener along with any callback the SDK expects.

Existing installs need the upgrade script

Config defaults only seed keys that are absentcheckConfigs uses getObjectDiff, which copies a default across only when the stored value is undefined. So an install that already has these keys keeps its original values forever, and a change here would reach new installs only. Without the migration this PR would be cosmetic for the existing fleet.

The script edits rather than overwrites, so anything an operator added by hand survives: it drops the deprecated header, strips only the preload token from HSTS while leaving max-age/includeSubDomains, and appends what is missing. It is idempotent.

12 unit tests cover the rewrite: what it removes, what it adds, what it preserves, ordering, CRLF input, blank lines, case-insensitive header matching, and non-string input. It edits customer configuration, so both directions are asserted.

🤖 Generated with Claude Code

ar2rsawseen and others added 5 commits August 4, 2026 18:47
Brings the shipped header set in line with current guidance, and makes the two
default strings in this repo agree with each other and with countly-platform.

Removed:

- X-XSS-Protection. Deprecated and removed from every current browser, so it does
  nothing, and while it existed it could be turned against the page: steered into
  disabling one of the page's own scripts, and with mode=block the aborted load is
  observable cross-origin, which makes it an oracle for reading page contents. It
  is on the list of headers to remove rather than to send.
- The `preload` token on Strict-Transport-Security. Preload needs the domain
  submitted to the browser preload list and is painful to reverse, and with
  includeSubDomains on a shared parent domain it can affect a customer's other
  services, so it should be opted into rather than shipped. max-age and
  includeSubDomains are unchanged.

Added: Referrer-Policy and Permissions-Policy (both already in countly-platform),
X-Content-Type-Options to the api set, which only the dashboard set had, and
Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy on the dashboard set.

Not added: Content-Security-Policy. The dashboard inlines countlyGlobal per
request through res.expose, so its hash is not stable and a static policy would
need 'unsafe-inline', which removes the protection CSP is there for. It needs
that inline script dealt with first, or per-request nonces, neither of which a
flat config string can express.

Cross-Origin-Resource-Policy is deliberately not on the api set: that set carries
Access-Control-Allow-Origin:* because web SDKs post from arbitrary customer
origins, and the two work against each other.

The isolation headers are applied globally, as the others are, and removed on the
routes that are embedded from customer origins, which is what those routes
already do for X-Frame-Options. Cross-Origin-Opener-Policy matters there because
the SDK may open the popup as a window rather than an iframe, where same-origin
would sever window.opener and any callback the SDK expects.

Existing installs need the upgrade script. Config defaults only seed keys that
are absent: checkConfigs uses getObjectDiff, which copies a default across only
when the stored value is undefined, so an install that already has these keys
would otherwise keep its original values forever and a change here would reach
new installs only. The script edits rather than overwrites, so anything an
operator added by hand survives, and it is idempotent.

12 unit tests over the rewrite, covering what it removes, what it adds, what it
preserves, ordering, CRLF input and non-string input. Full unit suite 185 passing
before, 197 after, same 2 pre-existing failures (Countly Request, network
dependent). eslint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections after walking the routes that would be affected.

Cross-Origin-Resource-Policy is dropped entirely. It blocks any cross-origin
subresource load, so every asset a customer site fetches from the Countly host
would need an exemption, and the exemptions form a denylist that has to be
complete to be safe. Walking it turned up more than the widget routes: /pixel.png
is the no-JS tracking pixel, embedded as an <img> from customer pages and emails,
and in countly-platform the surveys and content plugins each serve their own asset
routes. A missed exemption there fails silently, with no console error and no
failed request anyone would notice, only missing analytics data. That is the wrong
risk to take for a header whose benefit is narrow, so it is left out until it can
be applied to the dashboard routes as an allowlist rather than subtracted from
everything as a denylist.

Cross-Origin-Opener-Policy is relaxed from same-origin to
same-origin-allow-popups. The dashboard opens popups from several places,
including crashes-jira opening a cross-origin Jira URL, and plain same-origin
severs the opener for any popup that navigates cross-origin. The relaxed value
keeps the protection that matters, refusing an opener reference from a cross-origin
document that opens us, without touching popups we open ourselves.

The CORP removal on the star-rating routes goes with it, since nothing sets that
header now. The COOP removal on the popup stays: an SDK may open it as a window
rather than an iframe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing the CORP comment blocks left the two res.writeHead calls mis-indented.
Those routes are now byte-identical to master again, which is the intent: nothing
sets Cross-Origin-Resource-Policy any more, so they need no exemption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The feedback widget routes remove X-Frame-Options so the widget can be
embedded in customer pages, which leaves nothing describing where the
widget may be framed. X-Frame-Options cannot express a domain list, but
CSP frame-ancestors takes a source list, so the routes now send one built
from the app's own configuration.

The source list comes from app.plugins.allow_access_control_origin, the
per-app newline separated origin list that already drives
Access-Control-Allow-Origin in api/utils/common.js. One list now
configures both, so a customer names their domains once.

Behaviour:

- An app with no usable origin configured gets no header at all. Apps
  that never filled the list in keep embedding exactly as before, so
  frame-ancestors 'none' is never used as a fallback.
- X-Frame-Options is still removed. Browsers that see both apply the
  stricter one, which would block the embed regardless of the CSP.
- The value is computed per request from the app behind the widget id, so
  it cannot live in security.dashboard_additional_headers, which is a
  static string.
- Only well-formed origins (scheme, host, optional port) are emitted.
  Malformed entries are skipped individually, because a browser discards
  a whole directive containing one source it cannot parse.
- 'self' is always included, since the dashboard previews these routes in
  an iframe of its own and nobody lists their Countly origin in a list
  meant for customer facing sites.

app_domain is deliberately not used: it is optional, free text, never
validated as an origin, and only used for display, audit logging and
building view URLs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
allow_access_control_origin is labelled in the app management UI as "Allow
Access-Control-Origin by listing separate origin (including https://) per
line". Nothing tells a customer it governs framing, so an app that listed
only the origin its SDK makes XHR from would have a working embed on
another host blocked the moment the directive is enforced.

Emit it through Content-Security-Policy-Report-Only instead, which blocks
nothing and surfaces the violations, and add security.
widget_frame_ancestors_enforce (default false) so an operator can promote it
once the reports from a real deployment come back clean. Everything else is
unchanged: X-Frame-Options still comes off, an empty or unusable list still
means no header at all, and the enforced path is covered by tests so the
promotion is a config change rather than a code change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant