Skip to content

Portal redirect: leave sub-resource fetches alone, so Jetpack's admin-bar sparkline renders - #737

Merged
epeicher merged 1 commit into
trunkfrom
fix/portal-redirect-skips-subresource-fetches
Sep 2, 2026
Merged

Portal redirect: leave sub-resource fetches alone, so Jetpack's admin-bar sparkline renders#737
epeicher merged 1 commit into
trunkfrom
fix/portal-redirect-skips-subresource-fetches

Conversation

@epeicher

@epeicher epeicher commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

What it does

Jetpack's admin-bar Stats sparkline renders again inside the desktop. More generally, the admin_init redirect that sends plain /wp-admin/ URLs into the shell now leaves sub-resource fetches alone: an <img>, a <script> or an XHR whose URL is an admin page gets the bytes it asked for instead of the shell's HTML.

Rationale

Jetpack's admin-bar node is an <img> whose src is an admin URL:

/wp-admin/admin.php?page=stats&noheader&proxy&chart=admin-bar-hours-scale

Core's admin.php skips the header on noheader and Jetpack's page hook echoes PNG bytes it proxies from stats.wp.com. openstation_redirect_plain_admin_to_portal() treated that request as a user landing on a plain admin page and forwarded it to the shell screen, so the image element received an HTML document and the admin bar drew the broken-image glyph with the alt text, "Statistics", where the chart should be.

The same thing happens to any XHR a plugin aims at an admin.php?page=…&noheader endpoint. The Jetpack Stats screen loads its report body that way.

Implementation

A new predicate in includes/core/routing.php reads the browser-set Sec-Fetch-Mode header:

function openstation_is_subresource_request() {
	if ( empty( $_SERVER['HTTP_SEC_FETCH_MODE'] ) ) {
		return false;
	}
	$mode = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_MODE'] ) ) );
	return '' !== $mode && 'navigate' !== $mode;
}

navigate is a document or frame load, the only kind of request worth forwarding into the desktop. no-cors (an <img>, a <script>) and cors (fetch, XHR) are sub-resources. A missing header answers false, so old browsers and proxies that strip the header keep today's behaviour rather than gaining a new one.

The redirect bails on it next to its existing AJAX, REST, cron, admin-post.php and non-GET bail-outs, before the frozen-flag alias route and before the openstation_admin_redirect_to_portal filter run. Chromeless iframe loads were already handled through Sec-Fetch-Dest: iframe and are unaffected.

Sec-Fetch-Mode was chosen over Sec-Fetch-Dest because it answers the exact question ("is this a navigation?") with one value, where the destination header would need an allowlist of document, iframe and frame.

Docs: the request-lifecycle entry in docs/architecture.md and the openstation_admin_redirect_to_portal entry in docs/hooks-reference.md now say the redirect only applies to navigations.

Testing instructions

npm run test:php -- --filter='Tests_OpenStation_Portal|Tests_OpenStation_OpenStation'

New tests: three for the predicate (header missing, navigate, no-cors and cors) and two for the redirect (the sparkline URL fetched as an image is left alone; the same screen loaded as a navigation still forwards to the shell screen).

To see it over HTTP, log in on a wp-env instance with the desktop enabled for the user and compare a navigation with an image-style fetch of the same admin URL:

curl -s -o /dev/null -b jar.txt -w '%{http_code} -> %{redirect_url}\n' \
  'http://localhost:8890/wp-admin/edit.php'
# 302 -> …/admin.php?page=openstation&target=%2Fwp-admin%2Fedit.php&intent=1

curl -s -o /dev/null -b jar.txt -H 'Sec-Fetch-Mode: no-cors' -H 'Sec-Fetch-Dest: image' \
  -w '%{http_code} content-type=%{content_type}\n' 'http://localhost:8890/wp-admin/edit.php'
# 200 content-type=text/html; charset=UTF-8   (the Posts screen, no shell markup)

On a site with Jetpack Stats, load the desktop: the sparkline draws in the admin bar instead of the broken "Statistics" image.

Open WordPress Playground Preview

…-bar sparkline renders

Jetpack's admin-bar Stats node is an <img> whose src is
admin.php?page=stats&noheader&proxy&chart=admin-bar-hours-scale: core
skips the header on `noheader` and the page hook echoes PNG bytes.
The admin_init redirect in includes/portal.php treated that request
as a user landing on a plain admin page and forwarded it to the shell
screen, so the <img> received the shell's HTML and drew a broken image
with its alt text ("Statistics") where the chart should be. The same
happens to any XHR a plugin aims at an admin.php?page=…&noheader
endpoint, the Jetpack Stats screen's own report loader included.

The browser already says what it is fetching for. A new
openstation_is_subresource_request() reads Sec-Fetch-Mode: `navigate`
is a document or frame load, the only kind of request worth
forwarding into the desktop; `no-cors` (an <img>, a <script>) and
`cors` (fetch, XHR) are sub-resources, and the redirect now bails on
them next to its AJAX, REST, cron and non-GET bail-outs. A missing
header answers false, so old browsers and header-stripping proxies
keep today's behaviour.

Tests pin the predicate and both sides of the redirect: the sparkline
URL fetched as an image is left alone, and the same screen loaded as
a navigation still forwards to the shell screen. The architecture and
hooks docs say the redirect only applies to navigations.
@epeicher

epeicher commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

This works as expected now
CleanShot 2026-09-02 at 15 51 30@2x

@epeicher
epeicher merged commit d7596dd into trunk Sep 2, 2026
5 checks passed
@epeicher
epeicher deleted the fix/portal-redirect-skips-subresource-fetches branch September 2, 2026 13:54
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