From a7092eff3d7fc60d0c44104c99dbcdec152ccd17 Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Tue, 13 Oct 2026 20:04:48 +0200 Subject: [PATCH 1/3] Windows: Follow off-site redirects in the tab, not the window WooCommerce.com's Connect account answers with a redirect to woocommerce.com/oauth/authorize. Inside a window the iframe followed it, woocommerce.com refused to be framed (X-Frame-Options), and the window showed a browser error with the flow dead. A chromeless navigation that redirects to another host now gets a small hand-off page instead of the redirect. It posts os-iframe-offsite-redirect to the shell, which drops the window and takes the tab to the destination, as classic admin does. The service's return to an admin URL comes back into the shell through the portal. The page also carries a target="_top" link for a parent that does not answer. A shell the user has not interacted with yet only drops the window, so a session that restores a window on such a URL does not send the user off the desktop at boot. --- docs/bridge-protocol.md | 1 + includes/core/routing.php | 99 +++++++++++++++++++ src/window/iframe-bridge.ts | 45 +++++++++ .../openStationChromelessPreserveRedirect.php | 31 ++++++ 4 files changed, 176 insertions(+) diff --git a/docs/bridge-protocol.md b/docs/bridge-protocol.md index bf7e30c51..cbd999068 100644 --- a/docs/bridge-protocol.md +++ b/docs/bridge-protocol.md @@ -306,6 +306,7 @@ The chromeless bridge intercepts every same-origin `` cl | Type | Direction | Carries | Purpose | |---|---|---|---| | `os-iframe-other-admin-link` | iframe → parent | `{ url }` | Posted for a same-origin click that leaves this ADMIN — another site's `wp-admin`, or the network admin, detected by the bridge's `adminScope()` rule. Deliberately its own message rather than a flag on `os-iframe-admin-link`: the same-admin path consults remaps and dock entries that mean nothing in another admin. The shell hops to that admin's own shell, since on a network every site is its own OpenStation (see [multisite.md](./multisite.md#site-instances)); the flag `openstation_chromeless` is stripped before posting. Modifier and middle clicks never produce it — the interceptor yields those to the browser. | +| `os-iframe-offsite-redirect` | iframe → parent | `{ url }` | Posted by the page the server answers with when a window's navigation is redirected to another host (`openstation_chromeless_hand_off_offsite_redirect()`, a `wp_redirect` filter): a connect flow's sign-in, such as WooCommerce.com's **Connect account**, whose destination refuses to be framed. The shell drops the window without the unsaved-changes query (its page already left, and a window kept in the session would reopen on the URL that redirects) and takes the tab to `url`, as classic admin would; the service's return to an admin URL comes back into the shell through the portal. A shell the user has not interacted with yet (a window restored at boot) only drops the window. The page also carries a `target="_top"` link to `url` for a parent that does not answer. Only http(s) URLs on another origin are followed. | | `os-iframe-admin-link` | iframe → parent | `{ url, label, newContext }` | Posted from the chromeless bridge's link interceptor for every admin-internal click that survived the modifier-key / target / download filters. `label` is the clicked link's visible text (falling back to its `title` / `aria-label`, truncated to 80 chars). `newContext` is true when the link named another browsing context, and bars the parent from driving the source window with it, closing it on a native remap included. The bridge `preventDefault`s the click first; the parent owns the navigation. The plugins screen posts one too, with `newContext: true`, for a screen a plugin redirected a window to (see `openstation_chromeless_hand_off_plugins_redirect()`). | Parent dispatch (in `src/window/iframe-bridge.ts`, wired by `bindAdminLinkDispatch` in `desktop.ts`): diff --git a/includes/core/routing.php b/includes/core/routing.php index 1f9afabd9..646da9497 100644 --- a/includes/core/routing.php +++ b/includes/core/routing.php @@ -719,6 +719,105 @@ function openstation_classic_preserve_redirect( $location ) { } add_filter( 'wp_redirect', 'openstation_classic_preserve_redirect', 999 ); +/** + * Hands an off-site redirect from a window to the shell instead of + * following it inside the iframe. + * + * Connect flows send the browser to another site to sign in: + * WooCommerce.com's "Connect account" answers with a redirect to + * `woocommerce.com/oauth/authorize`. Inside a window that redirect + * lands in the iframe, and the destination refuses to be framed + * (`X-Frame-Options`), so the window shows a browser error and the + * flow is dead. Classic admin follows the redirect in the tab, and the + * service sends the user back to an admin URL, which the portal routes + * into the shell with that URL as its target. + * + * So the response becomes a small page that asks the shell to do + * exactly that: close the window and take the tab to the destination + * (`os-iframe-offsite-redirect`, see docs/bridge-protocol.md). It + * carries a link to the destination for a parent that does not answer. + * + * Only navigations are answered this way. A script's request that + * redirects off-site keeps the plain redirect, which it can handle. + * + * @param string $location The redirect URL. + * @return string The redirect URL, unchanged when the request continues. + */ +function openstation_chromeless_hand_off_offsite_redirect( $location ) { + if ( empty( $location ) || ! openstation_is_chromeless_request() || openstation_is_subresource_request() ) { + return $location; + } + if ( ! openstation_is_offsite_redirect_target( $location ) ) { + return $location; + } + + if ( ! headers_sent() ) { + status_header( 200 ); + nocache_headers(); + header( 'Content-Type: text/html; charset=utf-8' ); + } + echo openstation_offsite_redirect_document( $location ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the document escapes its own parts. + exit; +} +add_filter( 'wp_redirect', 'openstation_chromeless_hand_off_offsite_redirect', 1000 ); + +/** + * Whether `$location` leaves this site: an http(s) URL on a host that + * is neither the admin's nor the front end's. + * + * @internal + * + * @param string $location Raw redirect URL handed to `wp_redirect`. + * @return bool + */ +function openstation_is_offsite_redirect_target( $location ) { + $parts = wp_parse_url( (string) $location ); + if ( false === $parts || empty( $parts['host'] ) ) { + return false; + } + if ( isset( $parts['scheme'] ) && ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) { + return false; + } + foreach ( array( site_url(), home_url() ) as $own ) { + $own_host = wp_parse_url( $own, PHP_URL_HOST ); + if ( $own_host && 0 === strcasecmp( (string) $parts['host'], (string) $own_host ) ) { + return false; + } + } + return true; +} + +/** + * The page {@see openstation_chromeless_hand_off_offsite_redirect()} + * answers with. + * + * @internal + * + * @param string $location The off-site redirect URL. + * @return string A complete HTML document. + */ +function openstation_offsite_redirect_document( $location ) { + $host = (string) wp_parse_url( $location, PHP_URL_HOST ); + $message = wp_json_encode( + array( + 'type' => 'os-iframe-offsite-redirect', + 'url' => $location, + ), + JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_SLASHES + ); + $script = 'try{window.parent.postMessage(' . $message . ',window.location.origin);}catch(e){}'; + + /* translators: %s: Host name of the site the link opens, e.g. woocommerce.com. */ + $label = sprintf( __( 'Continue to %s', 'desktop-mode' ), $host ); + + return '' + . '' . esc_html( $label ) . '' + . '' + . '

' . esc_html( $label ) . '

' + . wp_get_inline_script_tag( $script ) + . ''; +} + /** * Whether `$location` is a redirect target that lands inside * wp-admin on the current site. Handles all four shapes WP core diff --git a/src/window/iframe-bridge.ts b/src/window/iframe-bridge.ts index 1c1db9c16..5276aba25 100644 --- a/src/window/iframe-bridge.ts +++ b/src/window/iframe-bridge.ts @@ -400,6 +400,16 @@ export function handleWindowMessage( win: Window, event: MessageEvent ): void { adminLinkDepsStore.state.deps?.openOtherAdmin?.( data.url ); } + // The page asked the server for something that redirected off-site + // (a connect flow's sign-in), and the server answered with a hand-off + // page instead. See openstation_chromeless_hand_off_offsite_redirect(). + if ( + data.type === 'os-iframe-offsite-redirect' && + typeof data.url === 'string' + ) { + leaveForOffsiteRedirect( win, data.url ); + } + if ( data.type === 'os-iframe-admin-link' && typeof data.url === 'string' && @@ -620,6 +630,41 @@ function iframeActivityError( status: number ): string { return sprintf( __( 'Request failed (HTTP %d).' ), status ); } +/** + * Follow an off-site redirect the way classic admin does: in this tab. + * The destination will not be framed, so it cannot be a window, and the + * service sends the user back to an admin URL that the portal routes + * into the shell again. + * + * The window goes first, and without the unsaved-changes query: its + * page already left, and a window kept in the session would reopen on + * the URL that redirects, sending the user straight back out. + * + * Only a user who has interacted with the shell is taken anywhere. A + * window the session restores on such a URL redirects at boot, before + * any click, and leaving the desktop then is a surprise, not a + * continuation; that window is just dropped. + */ +function leaveForOffsiteRedirect( win: Window, rawUrl: string ): void { + let url: URL; + try { + url = new URL( rawUrl ); + } catch { + return; + } + if ( + url.origin === INITIAL_ORIGIN || + ( url.protocol !== 'https:' && url.protocol !== 'http:' ) + ) { + return; + } + win.destroy(); + if ( navigator.userActivation?.hasBeenActive === false ) { + return; + } + window.location.assign( url.toString() ); +} + function handleDesktopNavigate( win: Window, rawUrl: string, diff --git a/tests/phpunit/tests/openStationChromelessPreserveRedirect.php b/tests/phpunit/tests/openStationChromelessPreserveRedirect.php index 575153011..7cbc2f484 100644 --- a/tests/phpunit/tests/openStationChromelessPreserveRedirect.php +++ b/tests/phpunit/tests/openStationChromelessPreserveRedirect.php @@ -222,4 +222,35 @@ public function test_a_plugin_that_redirects_again_is_let_through() { $this->assertSame( $destination, openstation_chromeless_hand_off_plugins_redirect( $destination ) ); } + + /** + * Only a redirect that leaves the site is handed to the shell; every + * redirect a window follows today (a save landing back on its list, + * the front end, a relative admin URL) still is. + * + * @covers ::openstation_is_offsite_redirect_target + */ + public function test_only_a_redirect_to_another_host_is_offsite() { + $this->assertTrue( openstation_is_offsite_redirect_target( 'https://woocommerce.com/oauth/authorize?secret=x' ) ); + $this->assertTrue( openstation_is_offsite_redirect_target( '//accounts.example.org/signin' ) ); + + $this->assertFalse( openstation_is_offsite_redirect_target( admin_url( 'edit.php' ) ) ); + $this->assertFalse( openstation_is_offsite_redirect_target( home_url( '/hello-world/' ) ) ); + $this->assertFalse( openstation_is_offsite_redirect_target( 'edit.php?updated=1' ) ); + $this->assertFalse( openstation_is_offsite_redirect_target( 'javascript://example.org/%0Aalert(1)' ) ); + } + + /** + * The hand-off page names the destination to the shell, and the URL + * cannot close the script it rides in. + * + * @covers ::openstation_offsite_redirect_document + */ + public function test_the_hand_off_page_posts_the_destination_to_the_shell() { + $markup = openstation_offsite_redirect_document( 'https://woocommerce.com/oauth/authorize?a=&c=d' ); + + $this->assertStringContainsString( '"type":"os-iframe-offsite-redirect"', $markup ); + $this->assertStringContainsString( 'target="_top"', $markup ); + $this->assertStringNotContainsString( '', $markup ); + } } From 7209159f847b061eaaab9371e6b932cd554a0945 Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Wed, 14 Oct 2026 10:59:51 +0200 Subject: [PATCH 2/3] Windows: Hide the off-site hand-off link unless it is needed The link on the hand-off page only matters when the shell cannot read its message, such as a shell loaded before the plugin was updated. It now stays hidden for a second, so the normal path paints nothing, and uses wp-admin's colours instead of the browser defaults, which were unreadable on the window's dark background. --- includes/core/routing.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/includes/core/routing.php b/includes/core/routing.php index 646da9497..c20c192a6 100644 --- a/includes/core/routing.php +++ b/includes/core/routing.php @@ -791,6 +791,11 @@ function openstation_is_offsite_redirect_target( $location ) { * The page {@see openstation_chromeless_hand_off_offsite_redirect()} * answers with. * + * The shell normally takes the tab before anything paints. The link is + * for a shell that cannot read the message, one that loaded before the + * plugin was updated, so it stays hidden for a second and then shows + * on wp-admin's own colours: the palette never reaches a window's page. + * * @internal * * @param string $location The off-site redirect URL. @@ -811,8 +816,15 @@ function openstation_offsite_redirect_document( $location ) { $label = sprintf( __( 'Continue to %s', 'desktop-mode' ), $host ); return '' - . '' . esc_html( $label ) . '' - . '' + . '' . esc_html( $label ) . '' + . '' + . '' . '

' . esc_html( $label ) . '

' . wp_get_inline_script_tag( $script ) . ''; From 453712bef7dff1fb809a558fc17fafb63f755938 Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Wed, 14 Oct 2026 11:35:18 +0200 Subject: [PATCH 3/3] Windows: Pin the off-site hand-off's pass-through and document its edges A plain admin request and a script's request inside a window keep the plain redirect; a test now holds that. The docblocks say that network siblings and allowed_redirect_hosts count as off-site, and that a browser without navigator.userActivation always follows. The bridge protocol row is shorter. --- docs/bridge-protocol.md | 2 +- includes/core/routing.php | 5 +++++ src/window/iframe-bridge.ts | 3 ++- .../openStationChromelessPreserveRedirect.php | 18 +++++++++++++++++- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/bridge-protocol.md b/docs/bridge-protocol.md index cbd999068..b3482c525 100644 --- a/docs/bridge-protocol.md +++ b/docs/bridge-protocol.md @@ -306,7 +306,7 @@ The chromeless bridge intercepts every same-origin `` cl | Type | Direction | Carries | Purpose | |---|---|---|---| | `os-iframe-other-admin-link` | iframe → parent | `{ url }` | Posted for a same-origin click that leaves this ADMIN — another site's `wp-admin`, or the network admin, detected by the bridge's `adminScope()` rule. Deliberately its own message rather than a flag on `os-iframe-admin-link`: the same-admin path consults remaps and dock entries that mean nothing in another admin. The shell hops to that admin's own shell, since on a network every site is its own OpenStation (see [multisite.md](./multisite.md#site-instances)); the flag `openstation_chromeless` is stripped before posting. Modifier and middle clicks never produce it — the interceptor yields those to the browser. | -| `os-iframe-offsite-redirect` | iframe → parent | `{ url }` | Posted by the page the server answers with when a window's navigation is redirected to another host (`openstation_chromeless_hand_off_offsite_redirect()`, a `wp_redirect` filter): a connect flow's sign-in, such as WooCommerce.com's **Connect account**, whose destination refuses to be framed. The shell drops the window without the unsaved-changes query (its page already left, and a window kept in the session would reopen on the URL that redirects) and takes the tab to `url`, as classic admin would; the service's return to an admin URL comes back into the shell through the portal. A shell the user has not interacted with yet (a window restored at boot) only drops the window. The page also carries a `target="_top"` link to `url` for a parent that does not answer. Only http(s) URLs on another origin are followed. | +| `os-iframe-offsite-redirect` | iframe → parent | `{ url }` | Sent instead of a window's redirect to another host, such as WooCommerce.com's **Connect account** (`openstation_chromeless_hand_off_offsite_redirect()`). The shell drops the window and takes the tab to `url`, as classic admin would; the return to an admin URL comes back through the portal. Before any user interaction (a window restored at boot) it only drops the window. Only http(s) URLs on another origin are followed; a parent that does not answer leaves a `target="_top"` link to `url`. | | `os-iframe-admin-link` | iframe → parent | `{ url, label, newContext }` | Posted from the chromeless bridge's link interceptor for every admin-internal click that survived the modifier-key / target / download filters. `label` is the clicked link's visible text (falling back to its `title` / `aria-label`, truncated to 80 chars). `newContext` is true when the link named another browsing context, and bars the parent from driving the source window with it, closing it on a native remap included. The bridge `preventDefault`s the click first; the parent owns the navigation. The plugins screen posts one too, with `newContext: true`, for a screen a plugin redirected a window to (see `openstation_chromeless_hand_off_plugins_redirect()`). | Parent dispatch (in `src/window/iframe-bridge.ts`, wired by `bindAdminLinkDispatch` in `desktop.ts`): diff --git a/includes/core/routing.php b/includes/core/routing.php index c20c192a6..e0c01bb34 100644 --- a/includes/core/routing.php +++ b/includes/core/routing.php @@ -765,6 +765,11 @@ function openstation_chromeless_hand_off_offsite_redirect( $location ) { * Whether `$location` leaves this site: an http(s) URL on a host that * is neither the admin's nor the front end's. * + * Another site in the network (a sibling subdomain, the network admin + * on its own host) counts as off-site, and so does a host trusted + * through `allowed_redirect_hosts`. Both are other origins that cannot + * be windows (docs/multisite.md), so the tab follows them instead. + * * @internal * * @param string $location Raw redirect URL handed to `wp_redirect`. diff --git a/src/window/iframe-bridge.ts b/src/window/iframe-bridge.ts index 5276aba25..2911fa9db 100644 --- a/src/window/iframe-bridge.ts +++ b/src/window/iframe-bridge.ts @@ -643,7 +643,8 @@ function iframeActivityError( status: number ): string { * Only a user who has interacted with the shell is taken anywhere. A * window the session restores on such a URL redirects at boot, before * any click, and leaving the desktop then is a surprise, not a - * continuation; that window is just dropped. + * continuation; that window is just dropped. A browser without + * `navigator.userActivation` cannot tell, and always follows. */ function leaveForOffsiteRedirect( win: Window, rawUrl: string ): void { let url: URL; diff --git a/tests/phpunit/tests/openStationChromelessPreserveRedirect.php b/tests/phpunit/tests/openStationChromelessPreserveRedirect.php index 7cbc2f484..a78f798b1 100644 --- a/tests/phpunit/tests/openStationChromelessPreserveRedirect.php +++ b/tests/phpunit/tests/openStationChromelessPreserveRedirect.php @@ -34,7 +34,7 @@ public function set_up() { public function tear_down() { delete_user_meta( self::$admin_id, 'desktop_mode_mode' ); delete_transient( openstation_plugins_handoff_key( self::$admin_id ) ); - unset( $_GET['openstation_chromeless'], $_SERVER['REQUEST_URI'] ); + unset( $_GET['openstation_chromeless'], $_SERVER['REQUEST_URI'], $_SERVER['HTTP_SEC_FETCH_MODE'] ); $GLOBALS['pagenow'] = $this->pagenow; parent::tear_down(); } @@ -240,6 +240,22 @@ public function test_only_a_redirect_to_another_host_is_offsite() { $this->assertFalse( openstation_is_offsite_redirect_target( 'javascript://example.org/%0Aalert(1)' ) ); } + /** + * Outside a window, and for a script's request inside one, an + * off-site redirect stays a plain redirect. + * + * @covers ::openstation_chromeless_hand_off_offsite_redirect + */ + public function test_an_offsite_redirect_outside_a_window_navigation_is_left_alone() { + $location = 'https://woocommerce.com/oauth/authorize?secret=x'; + + $this->assertSame( $location, openstation_chromeless_hand_off_offsite_redirect( $location ) ); + + $this->enter_chromeless(); + $_SERVER['HTTP_SEC_FETCH_MODE'] = 'cors'; + $this->assertSame( $location, openstation_chromeless_hand_off_offsite_redirect( $location ) ); + } + /** * The hand-off page names the destination to the shell, and the URL * cannot close the script it rides in.