Skip to content

[fix][core] keep server side rendering on the dashboard origin - #7926

Merged
shepardly[bot] merged 5 commits into
masterfrom
security/render-view-same-origin
Aug 25, 2026
Merged

[fix][core] keep server side rendering on the dashboard origin#7926
shepardly[bot] merged 5 commits into
masterfrom
security/render-view-same-origin

Conversation

@ar2rsawseen

Copy link
Copy Markdown
Member

What

api/utils/render.js builds the url it opens by concatenating the configured dashboard host with the requested view:

var host = (process.env.COUNTLY_CONFIG_PROTOCOL || "http") + "://" + (process.env.COUNTLY_CONFIG_HOSTNAME || "localhost") + countlyConfig.path;
...
await page.goto(host + view);

/o/render takes that view from the request (params.qstring.view). With the default countlyConfig.path of "" the prefix is exactly http://localhost, so a view that does not begin with / changes the host rather than the path:

view url actually opened
/#/dashboard http://localhost/#/dashboard, as intended
@169.254.169.254/latest/... host 169.254.169.254
:8500/v1/kv/?recurse host localhost:8500
.internal.example/x host localhost.internal.example

The headless browser then loads that from the server's own network position.

Change

  1. Parse the concatenated url and require its origin to match the dashboard's before navigating. What gets opened is the concatenation itself, unchanged, so a configured countlyConfig.path keeps behaving exactly as it did. Parsing settles the whole family of shapes at once, including the ones the url parser normalises away (tab, newline, carriage return), and it agrees with what Chromium does with the same string since both use the WHATWG parser.
  2. Restrict the renderer to the dashboard origin with request interception, the same control api/utils/pdf.js uses, so the page's subresources are bounded too and not just the navigation.

A private range denylist would be the wrong control here: the intended render target is loopback, which api/utils/ssrf-protection.js blocks by design.

Scope

Everything that drives a headless browser, with a verdict for each:

site verdict
api/utils/render.js, page.goto(host + view) fixed here
api/utils/render.js, login token navigation token is server generated and the host comes from config, and it is covered by the interception anyway
api/utils/pdf.js already limited to Countly's own origins
api/utils/requestProcessor.js, the /o/render caller passes params.qstring.view through, now checked at the sink
plugins/dashboards/api/api.js caller hardcodes "/dashboard?ssr=true#/custom/" + id, unaffected and covered by the tests below
countly-enterprise-plugins no render.js and no renderView caller

Neither caller sets options.host, so the base always stays the configured dashboard.

Verification

  • test/unit-tests/api.utils.render.js, 38 cases: every host rewriting shape is refused for each host form, and every view the product itself renders is allowed and returned byte identical. Swapping the check back for plain concatenation fails 13 of them, so the tests do pin the behaviour.
  • Real Chromium measurement of the interception, since the flag removal in 405b982 showed this file is easy to reason about wrongly: own origin image and stylesheet still load, the screenshot is still produced, the off origin request never reaches the other server, and no unhandled rejections are raised.
  • countly-platform's existing live renderView and renderPDF integration suite (test/2.api/99.api.utils.render.js) passes against the patched file, 7 of 7, including both real screenshot cases.
  • Grepped the dashboard shell for external subresources: there are none, the only CDN references in the tree are the populator demo templates, so nothing in a normal render is refused by the origin restriction.

api/utils/render.js builds the url it opens by concatenating the configured dashboard
host with the requested view, and /o/render lets the caller choose that view. With the
default countlyConfig.path of "" the prefix is exactly "http://localhost", so a view that
does not begin with "/" changes the host rather than the path, and the headless browser
opens that instead of the dashboard.

Parse the concatenated url and require its origin to match the dashboard's before
navigating. What gets navigated to is the concatenation itself, unchanged, so a configured
countlyConfig.path keeps behaving exactly as it did. Parsing settles the whole family of
shapes at once, including the ones the url parser normalises away, and it agrees with what
Chromium does with the same string since both use the WHATWG parser.

Also restrict the renderer to the dashboard origin with request interception, the same
control api/utils/pdf.js already uses. The dashboard serves all of its own assets, so
nothing in a normal render is refused: measured against real Chromium, own origin images
and stylesheets still load and the screenshot is still produced.

A private range denylist would be the wrong control here. The intended render target is
loopback, which api/utils/ssrf-protection.js blocks by design.
Comment thread api/utils/render.js Outdated
…y uses, not only its own

The subresource control was right in shape and too tight in practice. Both cases in
the review note check out against the tree:

  frontend/express/views/dashboard.html prefixes every core stylesheet and script
  with countlyConfig.cdn, so a deployment that sets it loses its bootstrap assets
  and the render times out waiting for the dashboard to initialize.

  frontend/express/public/javascripts/countly/vue/components/vis.js requests
  https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png and passes no subdomains
  option, so leaflet expands {s} to a, b and c. Map widgets render blank.

So the check is now against a small allowlist instead of a single origin: the
dashboard, the configured cdn, the tile provider, and whatever the operator adds in
countlyConfig.render.allowedOrigins. Entries may be a bare origin or any url on one.
Everything else is still aborted, and the navigation check on the view is untouched -
that is the control that stops the renderer reaching what the server can reach, and
allowing an asset origin does not weaken it.

The cdn lives in the dashboard's config rather than the api's. In the standard layout
both are in the same tree and it is picked up with no operator action; where the api
runs without the dashboard the file is absent, the require fails closed, and the
origin is named in the config array instead.

The tile origins are listed as four exact strings rather than matched by host suffix,
so the allowlist stays a set of strings to compare with no pattern to get wrong. A
look-alike host such as tile.openstreetmap.org.evil.example is a different origin and
stays out; there is a test for it.

An unparseable dashboard host now yields an empty allowlist, which refuses every
request rather than degrading into "allow anything".

Also corrected a comment that claimed this mirrors api/utils/pdf.js. It does not -
pdf.js renders supplied html and intercepts nothing.

Ten new tests, and the platform copy of the suite that was missing there entirely.
@shepardly
shepardly Bot enabled auto-merge August 25, 2026 20:10
@shepardly
shepardly Bot merged commit 447de52 into master Aug 25, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants