PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / portal.php

portal.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.10, at includes/portal.php

681 lines 27.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — `/openstation` Portal Entry Point.
4 *
5 * Registers `/openstation` as a shareable URL that behaves like the
6 * front door of the desktop UI:
7 * 1. Logged-out users are bounced through `wp-login.php` with a
8 * redirect back to `/openstation/`.
9 * 2. Logged-in users with basic admin-read capability have the
10 * `desktop_mode_mode` user-meta toggle auto-enabled on first visit,
11 * then are forwarded to the shell screen
12 * (`admin.php?page=openstation`, see `includes/shell-screen.php`).
13 * An explicit `?target=` travels along as the page the shell opens
14 * first; without one the screen resolves the entry itself — the
15 * last-focused window of the saved session, else the default
16 * window, else the Dashboard.
17 *
18 * The URL is served virtually (no rewrite rules, no `.htaccess`
19 * surgery) by intercepting `parse_request` before WordPress routes the
20 * URL to 404. This keeps the plugin drop-in.
21 *
22 * @package OpenStation
23 */
24
25 defined( 'ABSPATH' ) || exit;
26
27 /** The URL path that triggers the portal handler. */
28 const OPENSTATION_PORTAL_PATH = 'openstation';
29
30 /**
31 * The pre-rebrand portal path, still accepted.
32 *
33 * The portal was reachable at `/desktop-mode/` before the rename, and
34 * that address is the kind of thing people bookmark or pin. It is not
35 * canonical: {@see openstation_portal_url()} always emits the current
36 * path, and a visit here forwards into wp-admin exactly as the canonical
37 * path does, so the address bar self-corrects on the next hop.
38 *
39 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
40 * persisted or externally-visible identifier, so renaming it would
41 * orphan data already written by live installs (or break a live
42 * URL). The mismatch between this constant's name and its value is
43 * deliberate — it is NOT a half-finished rename.
44 */
45 const OPENSTATION_PORTAL_PATH_LEGACY = 'desktop-mode';
46
47 /**
48 * Query var the admin shell reads to know it was entered via the portal.
49 *
50 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
51 * persisted or externally-visible identifier, so renaming it would
52 * orphan data already written by live installs (or break a live
53 * URL). The mismatch between this constant's name and its value is
54 * deliberate — it is NOT a half-finished rename.
55 */
56 const OPENSTATION_PORTAL_FLAG = 'desktop_mode_portal';
57
58 /**
59 * Query var set on portal redirects whose landing page came from an
60 * explicit `?target=…` URL the user (or a redirect chain originating
61 * from a click) provided — as opposed to the portal picking the
62 * session's focused window or the default-window fallback.
63 *
64 * The shell uses this to distinguish "user expressed navigation intent
65 * toward this URL" (open it) from "portal had to forward somewhere"
66 * (don't disturb the restored session).
67 *
68 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
69 * persisted or externally-visible identifier, so renaming it would
70 * orphan data already written by live installs (or break a live
71 * URL). The mismatch between this constant's name and its value is
72 * deliberate — it is NOT a half-finished rename.
73 */
74 const OPENSTATION_PORTAL_INTENT_FLAG = 'desktop_mode_portal_intent';
75
76 /**
77 * Query var set by the window-title-bar "Detach" action. Tells the
78 * admin_init redirect to skip portal forwarding for this request so the
79 * user can view the page as classic wp-admin in a new tab even when
80 * OpenStation is globally enabled for their account.
81 *
82 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
83 * persisted or externally-visible identifier, so renaming it would
84 * orphan data already written by live installs (or break a live
85 * URL). The mismatch between this constant's name and its value is
86 * deliberate — it is NOT a half-finished rename.
87 */
88 const OPENSTATION_CLASSIC_FLAG = 'desktop_mode_classic';
89
90 /**
91 * Returns the canonical portal URL, e.g. `https://example.com/openstation/`.
92 *
93 * @return string
94 */
95 function openstation_portal_url() {
96 return home_url( '/' . OPENSTATION_PORTAL_PATH . '/' );
97 }
98
99 /**
100 * Intercepts requests to `/openstation` and forwards them into the admin.
101 *
102 * Hooks on `parse_request` — early enough to pre-empt 404 handling but
103 * late enough that `is_user_logged_in()` is reliable.
104 *
105 * @param WP $wp Current WordPress environment instance.
106 */
107 function openstation_handle_portal_request( $wp ) {
108 unset( $wp );
109
110 if ( ! openstation_is_portal_request() ) {
111 return;
112 }
113
114 // Logged-out: bounce through login, returning to the portal URL.
115 if ( ! is_user_logged_in() ) {
116 wp_safe_redirect( wp_login_url( openstation_portal_url() ) );
117 exit;
118 }
119
120 // Require basic admin-read capability so subscribers of sites that
121 // blocked `read` from admin don't land in a broken window.
122 if ( ! current_user_can( 'read' ) ) {
123 wp_die(
124 esc_html__( 'Sorry, you are not allowed to access the WordPress desktop.', 'desktop-mode' ),
125 '',
126 array( 'response' => 403 )
127 );
128 }
129
130 $user_id = get_current_user_id();
131
132 /**
133 * Filters whether visiting the `/openstation` portal should auto-enable
134 * OpenStation for the current user.
135 *
136 * Default: true — the portal is an explicit opt-in action, so flipping
137 * the user meta mirrors the intent of visiting the URL.
138 *
139 * @param bool $auto_enable Whether to auto-enable OpenStation.
140 * @param int $user_id The current user's ID.
141 */
142 $auto_enable = apply_filters( 'openstation_portal_auto_enable', true, $user_id );
143
144 // CSRF guard: only flip user-meta when the request is a same-origin
145 // top-level navigation. The portal is a GET URL by design (users
146 // follow shared `/openstation/` links), so we can't require a nonce
147 // — but we can require that the navigation originated from the
148 // same site (or a typed/bookmarked URL with no Referer/Sec-Fetch-
149 // Site). Off-origin hits still redirect into admin so shared
150 // links keep working; they just don't silently mutate user-meta.
151 if ( $auto_enable && openstation_portal_is_same_origin_navigation() && '1' !== get_user_meta( $user_id, 'desktop_mode_mode', true ) ) {
152 update_user_meta( $user_id, 'desktop_mode_mode', '1' );
153 // Same stamps + action as the AJAX toggle; the portal is the
154 // second of the two paths that turn a user on.
155 openstation_record_user_enabled( $user_id );
156 }
157
158 // Pick the page the shell opens first. An explicit `target` query
159 // arg — a same-origin wp-admin URL — is how
160 // `openstation_redirect_plain_admin_to_portal` preserves the user's
161 // navigation intent when they follow a link to a specific admin
162 // page (e.g. profile.php). Without one the shell screen resolves
163 // the entry itself: the last-focused window from the saved
164 // session, else the default window, else the Dashboard — see
165 // `openstation_shell_boot_target()`. The bare screen URL is the
166 // canonical address, and a reload of it re-resolves against the
167 // live session rather than against the window that was focused
168 // when the redirect happened.
169 $target = '';
170 $has_intent = false;
171 if ( ! empty( $_GET['target'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
172 // `esc_url_raw`, NOT `sanitize_text_field`: the latter strips
173 // every `%XX` percent-encoded sequence from its input as an XSS
174 // safeguard, which mangles request URIs that legitimately carry
175 // encoded slashes (e.g. `plugin=dir%2Ffile.php`). The downstream
176 // `openstation_sanitize_portal_target` validates the URL
177 // rigorously (scheme rejection, traversal rejection, and a
178 // hardcoded allowlist of canonical wp-admin filenames — see
179 // `openstation_admin_target_allowlist()`) so we don't lose
180 // any real safety by skipping `sanitize_text_field` here.
181 $target = openstation_sanitize_portal_target( esc_url_raw( wp_unslash( $_GET['target'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
182 if ( '' !== $target ) {
183 $has_intent = true;
184 }
185 }
186 // `intent=1` rides along with an explicit target so the shell treats
187 // the resulting `currentPage` as user intent and opens it on top of
188 // the restored session. Without it, a bare `/openstation/` visit and
189 // a portal-redirected admin-bar click would be indistinguishable
190 // downstream.
191 wp_safe_redirect( openstation_shell_url( $target, $has_intent ) );
192 exit;
193 }
194 add_action( 'parse_request', 'openstation_handle_portal_request' );
195
196 /**
197 * Decides whether the current request to the portal can mutate
198 * user-meta safely (same-origin) or should only redirect (cross-
199 * origin, possibly CSRF).
200 *
201 * Logic mirrors the `Sec-Fetch-Site` heuristic browsers use:
202 *
203 * - `Sec-Fetch-Site: same-origin | same-site | none` → trusted
204 * (the request originated from this site, or from a typed URL
205 * / bookmark with no referrer info).
206 * - `Sec-Fetch-Site: cross-site` → untrusted (a third-party page
207 * pointed the user at the portal — could be an `<img>` tag).
208 * - Header missing (older browsers): fall back to `Referer` —
209 * same host or empty referrer is trusted, anything else isn't.
210 *
211 * @return bool
212 */
213 function openstation_portal_is_same_origin_navigation() {
214 if ( ! empty( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) {
215 $site = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) );
216 return in_array( $site, array( 'same-origin', 'same-site', 'none' ), true );
217 }
218
219 if ( empty( $_SERVER['HTTP_REFERER'] ) ) {
220 return true;
221 }
222
223 $referer_host = wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ), PHP_URL_HOST );
224 $home_host = wp_parse_url( home_url(), PHP_URL_HOST );
225
226 if ( ! is_string( $referer_host ) || '' === $referer_host ) {
227 return true;
228 }
229
230 return is_string( $home_host ) && strtolower( $referer_host ) === strtolower( $home_host );
231 }
232
233 /**
234 * Detects whether the current request is for the portal URL.
235 *
236 * Strips any query string and trailing slash and compares against
237 * `/openstation` relative to the site's home path. The pre-rebrand
238 * `/desktop-mode` path is accepted too, so bookmarks made before the
239 * rename still land in the shell.
240 *
241 * @return bool
242 */
243 function openstation_is_portal_request() {
244 if ( empty( $_SERVER['REQUEST_URI'] ) ) {
245 return false;
246 }
247
248 // `esc_url_raw` instead of `sanitize_text_field` so percent-encoded
249 // chars in the URI (notably `%2F` from query-arg slashes) survive
250 // long enough for `wp_parse_url` to split path / query correctly.
251 $uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
252 $path = wp_parse_url( $uri, PHP_URL_PATH );
253 if ( ! is_string( $path ) ) {
254 return false;
255 }
256
257 $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
258 $home_path = is_string( $home_path ) ? rtrim( $home_path, '/' ) : '';
259
260 $path = '/' . ltrim( rtrim( $path, '/' ), '/' );
261
262 return in_array(
263 $path,
264 array(
265 $home_path . '/' . OPENSTATION_PORTAL_PATH,
266 $home_path . '/' . OPENSTATION_PORTAL_PATH_LEGACY,
267 ),
268 true
269 );
270 }
271
272 /**
273 * Sends plain `/wp-admin/...` requests into the desktop.
274 *
275 * The shell is served by its own screen (`includes/shell-screen.php`),
276 * so a plain admin page is never where the desktop renders: a user who
277 * typed or bookmarked `/wp-admin/edit.php` is forwarded to the shell
278 * screen with that URL as the page it opens first. Three routes out of
279 * here, cheapest first:
280 *
281 * 1. **Straight to the shell screen** when the portal would only hand
282 * this URL back — an allowlisted wp-admin file that is also the
283 * page being served, carrying no query arg the portal would strip
284 * ({@see openstation_portal_forward_is_redundant()}). One
285 * redirect; the portal hop would have cost a WordPress bootstrap
286 * to learn what is already known. `openstation_skip_redundant_portal_forward`
287 * (return false) forces the hop back on for a plugin that hooks
288 * the portal handler for side effects.
289 * 2. **Through `/openstation/?target=…`** otherwise — a network-admin
290 * URL, a path outside the wp-admin allowlist — so the portal can
291 * fall back to the saved session's focused window, which is a real
292 * change of destination the shell can't make from here.
293 * 3. **The frozen-flag alias.** A URL carrying `desktop_mode_portal=1`
294 * is the desktop's pre-screen address: the portal used to forward
295 * to a real admin page tagged with it, and bookmarks, the PWA start
296 * URL and plugin-built links still say so. It goes to the shell
297 * screen with that URL as the target, and `intent=1` when the
298 * intent flag was present. The flags stay frozen (see AGENTS.md);
299 * only what they resolve to moved.
300 *
301 * Narrowly scoped to bail on every automated or sub-request entry point
302 * — AJAX, REST, cron, admin-post.php, non-GET methods, and sub-resource
303 * fetches (an `<img>`, a script or an XHR whose URL is an admin page,
304 * see {@see openstation_is_subresource_request()}) — so the hook can't
305 * corrupt a form submission, break an API call or hand an image tag an
306 * HTML document. The shell screen itself, chromeless loads, solo boots
307 * and classic-flagged requests pass through.
308 *
309 * Disable via the `openstation_admin_redirect_to_portal` filter (return
310 * false); plain admin pages then render as classic admin and the
311 * desktop lives at `/openstation/` only. The alias route runs before
312 * the filter: a URL that names the desktop is not a plain admin page.
313 */
314 function openstation_redirect_plain_admin_to_portal() {
315 if ( ! openstation_is_enabled() ) {
316 return;
317 }
318 // The screen the redirects land on. First in the chain: every other
319 // branch below ends in a redirect here, and the screen is a plain
320 // admin GET like any other.
321 if ( openstation_is_shell_screen_request() ) {
322 return;
323 }
324 if ( openstation_is_chromeless_request() ) {
325 return;
326 }
327 // A solo boot renders one window in place, wherever it landed.
328 if ( function_exists( 'openstation_is_solo_request' ) && openstation_is_solo_request() ) {
329 return;
330 }
331 // The user admin (`wp-admin/user/`, multisite's dashboard for users
332 // with no site role) renders classic. It has no shell screen of its
333 // own, and its URLs never survive the target allowlist — before
334 // this pass-through the redirect claimed the request anyway and
335 // silently forwarded the user to the site desktop's default entry.
336 if ( is_multisite() && is_user_admin() ) {
337 return;
338 }
339 if ( wp_doing_ajax() || wp_doing_cron() ) {
340 return;
341 }
342 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
343 return;
344 }
345 if ( ! empty( $_SERVER['REQUEST_METHOD'] ) && 'GET' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) ) {
346 return;
347 }
348 // The browser says what it is fetching for. An <img>, a script or
349 // an XHR aimed at an admin URL (Jetpack's admin-bar sparkline is
350 // admin.php?page=stats&noheader&proxy&chart=…) is not a user
351 // landing on a plain admin page, and forwarding it into the desktop
352 // only swaps the bytes it asked for with the shell's HTML.
353 if ( openstation_is_subresource_request() ) {
354 return;
355 }
356
357 // The "Detach to new tab" button tags its URL with this flag so the
358 // user can view one admin page classically without disabling desktop
359 // mode account-wide. Only affects the single request — subsequent
360 // navigations inside the tab lose the flag and follow normal rules.
361 if ( ! empty( $_GET[ OPENSTATION_CLASSIC_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
362 return;
363 }
364
365 // admin-post.php and admin-ajax.php handle form submissions and JSON
366 // endpoints; redirecting them would break the call.
367 global $pagenow;
368 if ( in_array( $pagenow, array( 'admin-post.php', 'admin-ajax.php' ), true ) ) {
369 return;
370 }
371
372 // `esc_url_raw` instead of `sanitize_text_field`: the latter strips
373 // every `%XX` percent-encoded sequence, which corrupts URIs whose
374 // query string legitimately carries an encoded slash — e.g. WP's
375 // own `plugins.php?action=activate&plugin=dir%2Ffile.php` activate
376 // link. The shell screen validates the target on read.
377 $target = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
378 $target = is_string( $target ) ? $target : '';
379
380 // Route 3: the frozen-flag alias. The sanitiser strips both flags
381 // from the target; an unresolvable one leaves the screen to pick
382 // the entry, exactly as the portal did for an invalid `target`.
383 if ( ! empty( $_GET[ OPENSTATION_PORTAL_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
384 $clean = openstation_sanitize_portal_target( $target );
385 $intent = '' !== $clean && ! empty( $_GET[ OPENSTATION_PORTAL_INTENT_FLAG ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
386 wp_safe_redirect( openstation_shell_url( $clean, $intent ) );
387 exit;
388 }
389
390 /**
391 * Filters whether plain admin URLs should redirect into the desktop
392 * when OpenStation is active.
393 *
394 * @param bool $redirect Whether to redirect. Default true.
395 * @param int $user_id The current user's ID.
396 */
397 $redirect = apply_filters( 'openstation_admin_redirect_to_portal', true, get_current_user_id() );
398 if ( ! $redirect ) {
399 return;
400 }
401
402 // Route 1: straight to the shell screen.
403 if ( openstation_portal_forward_is_redundant( $target ) ) {
404 /**
405 * Filters whether to skip the portal hop for a URL the portal
406 * would only hand straight back.
407 *
408 * Default: true — the request goes straight to the shell screen
409 * with this URL as its target. Return false to route through
410 * `/openstation/` anyway, e.g. for a plugin that hooks
411 * `openstation_handle_portal_request` for its own side effects
412 * and needs it to run on every admin entry.
413 *
414 * @param bool $skip Whether to skip the portal hop.
415 * @param string $request_uri The current request URI.
416 */
417 if ( apply_filters( 'openstation_skip_redundant_portal_forward', true, $target ) ) {
418 wp_safe_redirect( openstation_shell_url( openstation_sanitize_portal_target( $target ), true ) );
419 exit;
420 }
421 }
422
423 // Route 2: through the portal, target preserved. Without it,
424 // navigating to a specific admin page (profile.php, plugins.php, any
425 // deep link) loses the user's intent — the portal would forward them
426 // to whichever window was last focused instead of the page they asked
427 // for. The portal handler reads `target`, validates it's same-origin
428 // wp-admin, and passes it on to the shell screen.
429 $portal_url = openstation_portal_url();
430 if ( '' !== $target ) {
431 $portal_url = add_query_arg( 'target', rawurlencode( $target ), $portal_url );
432 }
433
434 wp_safe_redirect( $portal_url );
435 exit;
436 }
437 add_action( 'admin_init', 'openstation_redirect_plain_admin_to_portal' );
438
439 /**
440 * Whether forwarding this request through `/openstation/` would only
441 * hand the URL already being served back as the shell's target.
442 *
443 * Answers locally, and without the HTTP round trip, the same question
444 * {@see openstation_handle_portal_request()} answers after another
445 * WordPress bootstrap. True means the hop is pure overhead and the
446 * caller can send the user straight to the shell screen with this URL
447 * as its target.
448 *
449 * Deliberately conservative: every "don't know" answers false, so the
450 * forward survives wherever the portal might genuinely choose a
451 * different destination.
452 *
453 * 1. The path must resolve through the same wp-admin allowlist the
454 * portal validates `?target=` against. Anything that list rejects
455 * — a `network/` or `user/` sub-path on multisite, a filename that
456 * isn't canonical wp-admin — makes the portal fall back to the
457 * session's focused window, which is a real change of destination.
458 * 2. The resolved filename must be the file this request is actually
459 * serving. If `$pagenow` disagrees with the URL path then a
460 * rewrite is in play and we can't claim to know what renders here.
461 * 3. The query must survive intact. The portal drops
462 * `openstation_chromeless`, both portal flags and `target` from
463 * the URL it rebuilds, so a request carrying any of them comes
464 * back as a different URL.
465 *
466 * @param string $request_uri The current request URI, unslashed.
467 * @return bool True when the portal would resolve this URL to itself.
468 */
469 function openstation_portal_forward_is_redundant( $request_uri ) {
470 global $pagenow;
471
472 if ( ! is_string( $request_uri ) || '' === $request_uri ) {
473 return false;
474 }
475
476 $path = wp_parse_url( $request_uri, PHP_URL_PATH );
477 if ( ! is_string( $path ) || '' === $path ) {
478 return false;
479 }
480
481 $admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
482 $admin_path = is_string( $admin_path ) ? $admin_path : '/wp-admin/';
483 if ( 0 !== strpos( $path, $admin_path ) ) {
484 return false;
485 }
486
487 $file = ltrim( (string) substr( $path, strlen( $admin_path ) ), '/' );
488 if ( '' === $file ) {
489 $file = 'index.php';
490 }
491
492 // 1. The portal's allowlist has to accept it.
493 if ( is_wp_error( openstation_resolve_admin_target( $file ) ) ) {
494 return false;
495 }
496
497 // 2. …and it has to be the page we are actually serving.
498 if ( ! is_string( $pagenow ) || strtolower( $file ) !== strtolower( $pagenow ) ) {
499 return false;
500 }
501
502 // 3. …carrying a query the portal would hand back unchanged.
503 $rewritten = array(
504 'openstation_chromeless',
505 OPENSTATION_PORTAL_FLAG,
506 OPENSTATION_PORTAL_INTENT_FLAG,
507 'target',
508 );
509 foreach ( $rewritten as $key ) {
510 if ( isset( $_GET[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
511 return false;
512 }
513 }
514
515 return true;
516 }
517
518 /**
519 * Resolves the admin URL the portal should forward to for a given user.
520 *
521 * Looks up the user's session and returns the URL of the window flagged
522 * as `focused`. If the session is empty, has no focused window, or the
523 * focused window's URL isn't same-origin admin, falls back to the
524 * dashboard.
525 *
526 * The portal navigates the TOP window, not an iframe, so any chromeless
527 * `openstation_chromeless=1` flag baked into the stored URL is stripped — a leftover
528 * flag would land the user in a standalone chromeless page (no admin
529 * bar, no toggle, no way out) instead of the shell.
530 *
531 * @param int $user_id The user whose session to consult.
532 * @return string The admin URL to redirect to.
533 */
534 function openstation_portal_entry_url( $user_id ) {
535 $session = openstation_get_session( $user_id );
536
537 // User's configured default-window preference. When disabled, we
538 // still have to forward SOMEWHERE (the portal is an HTTP redirect),
539 // so we land on the Dashboard URL — but the shell detects the
540 // `enabled=false` state via the config and skips the auto-open,
541 // leaving the user with an empty desktop as they chose.
542 $default_window = openstation_get_default_window( $user_id );
543 $fallback = $default_window['url'];
544
545 // Native marker (e.g. "native:os-settings") is not a
546 // redirectable URL. The portal MUST forward somewhere — the
547 // redirect happens at HTTP level — so we land on the admin home
548 // and let the shell pick up `defaultWindow.url` from the config
549 // after init and call nativeWindows.openById( <slug> ).
550 if ( is_string( $fallback ) && 0 === strpos( $fallback, 'native:' ) ) {
551 $fallback = admin_url();
552 }
553
554 if ( empty( $session['focused'] ) || empty( $session['windows'] ) ) {
555 return $fallback;
556 }
557
558 foreach ( $session['windows'] as $win ) {
559 if ( ! isset( $win['id'], $win['url'] ) ) {
560 continue;
561 }
562 if ( $win['id'] !== $session['focused'] ) {
563 continue;
564 }
565 if ( ! openstation_url_is_same_admin( $win['url'] ) ) {
566 return $fallback;
567 }
568 // The shell must never open itself. A saved window pointing at
569 // the shell screen cannot be produced by the shell, but a
570 // hand-edited session could say so; treat it as nothing focused.
571 if ( openstation_url_is_shell_screen( $win['url'] ) ) {
572 return $fallback;
573 }
574 return remove_query_arg( array( 'openstation_chromeless', OPENSTATION_PORTAL_FLAG ), $win['url'] );
575 }
576
577 return $fallback;
578 }
579
580 /**
581 * Validates and normalizes a `target` query arg on the portal URL.
582 *
583 * Accepts a raw request-URI-shaped string (path + optional query, e.g.
584 * `/wp-admin/profile.php?foo=bar`) and returns a fully-qualified admin
585 * URL if — and only if — it resolves to a same-origin `wp-admin/` path.
586 * Everything else returns an empty string so the caller falls back to
587 * the saved-session entry URL.
588 *
589 * Strips `openstation_chromeless` and the portal flag from the query so the target
590 * doesn't chain us into a chromeless standalone load or an infinite
591 * redirect loop.
592 *
593 * @param string $raw Raw value from `$_GET['target']` (already unslashed).
594 * @return string A safe absolute admin URL, or '' if the input is invalid.
595 */
596 function openstation_sanitize_portal_target( $raw ) {
597 if ( ! is_string( $raw ) || '' === $raw ) {
598 return '';
599 }
600
601 // Reject URIs with a scheme or protocol-relative prefix — we only
602 // accept relative paths so there's no way to redirect off-site.
603 if ( preg_match( '#^([a-z][a-z0-9+.-]*:|//)#i', $raw ) ) {
604 return '';
605 }
606
607 // Must be an absolute path starting with /.
608 if ( '/' !== $raw[0] ) {
609 return '';
610 }
611
612 $path = wp_parse_url( $raw, PHP_URL_PATH );
613 $query = wp_parse_url( $raw, PHP_URL_QUERY );
614 if ( ! is_string( $path ) || '' === $path ) {
615 return '';
616 }
617
618 $admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
619 $admin_path = is_string( $admin_path ) ? $admin_path : '/wp-admin/';
620 if ( 0 !== strpos( $path, $admin_path ) ) {
621 return '';
622 }
623
624 $file = substr( $path, strlen( $admin_path ) );
625 $file = ltrim( (string) $file, '/' );
626
627 // The network admin's own screens live one directory down and are
628 // resolved against their own list. Without this a network URL came
629 // back empty and the user was quietly forwarded to the site
630 // dashboard, which is a different admin.
631 $network = 0 === strpos( $file, 'network/' );
632 if ( $network ) {
633 $file = substr( $file, strlen( 'network/' ) );
634 }
635
636 if ( '' === $file ) {
637 $file = 'index.php';
638 }
639
640 // Resolve against the hardcoded allowlist of canonical wp-admin
641 // filenames (see `openstation_admin_target_allowlist()`). A
642 // regex alone would accept a plausible-looking filename that
643 // isn't a real core admin page (e.g. `custom_admin_page.php`)
644 // and effectively become an open redirect to a 404 page served
645 // under the admin path; the explicit allowlist closes that.
646 $target = openstation_resolve_admin_target( $file, $network );
647 if ( is_wp_error( $target ) ) {
648 return '';
649 }
650
651 if ( is_string( $query ) && '' !== $query ) {
652 parse_str( $query, $args );
653 unset( $args['openstation_chromeless'], $args[ OPENSTATION_PORTAL_FLAG ], $args[ OPENSTATION_PORTAL_INTENT_FLAG ], $args['target'] );
654 if ( ! empty( $args ) ) {
655 $target = add_query_arg( $args, $target );
656 }
657 }
658
659 // The shell screen is where a target is opened, never a target: the
660 // shell would open itself in a window, and a redirect chain built
661 // from it would loop. Fall back to the entry resolver instead.
662 if ( openstation_url_is_shell_screen( $target ) ) {
663 return '';
664 }
665
666 // `admin.php` is a bootstrap, not a page. Without a `page` arg core
667 // falls through the last `else` in `wp-admin/admin.php`, never
668 // requires `admin-header.php`, and answers 200 with an empty body —
669 // so the URL becomes a window showing nothing. The allowlist above
670 // matches filenames and cannot see the query, which is why the
671 // check belongs here, beside the shell-screen one: both are URLs
672 // that resolve but must not become a target. Returning '' hands the
673 // caller back to the entry resolver (session's focused window, else
674 // the default window, else the Dashboard).
675 if ( openstation_url_is_page_less_admin_php( $target ) ) {
676 return '';
677 }
678
679 return $target;
680 }
681