diff --git a/docs/bridge-protocol.md b/docs/bridge-protocol.md index bf7e30c5..b3482c52 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 }` | 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 1f9afabd..e0c01bb3 100644 --- a/includes/core/routing.php +++ b/includes/core/routing.php @@ -719,6 +719,122 @@ 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. + * + * 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`. + * @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. + * + * 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. + * @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 1c1db9c1..2911fa9d 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,42 @@ 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. A browser without + * `navigator.userActivation` cannot tell, and always follows. + */ +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 57515301..a78f798b 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(); } @@ -222,4 +222,51 @@ 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)' ) ); + } + + /** + * 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. + * + * @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 ); + } }