PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 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 All 37 releases
desktop-mode / includes / portal.php

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

688 lines 27.2 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. Navigations inside that tab keep it (see the
360 // Referer check below).
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 // A navigation out of a classic tab that lost the flag stays classic,
403 // with the flag back in the URL for the next navigation's Referer.
404 if ( '' !== $target && openstation_is_classic_referer() ) {
405 wp_safe_redirect( add_query_arg( OPENSTATION_CLASSIC_FLAG, '1', $target ) );
406 exit;
407 }
408
409 // Route 1: straight to the shell screen.
410 if ( openstation_portal_forward_is_redundant( $target ) ) {
411 /**
412 * Filters whether to skip the portal hop for a URL the portal
413 * would only hand straight back.
414 *
415 * Default: true — the request goes straight to the shell screen
416 * with this URL as its target. Return false to route through
417 * `/openstation/` anyway, e.g. for a plugin that hooks
418 * `openstation_handle_portal_request` for its own side effects
419 * and needs it to run on every admin entry.
420 *
421 * @param bool $skip Whether to skip the portal hop.
422 * @param string $request_uri The current request URI.
423 */
424 if ( apply_filters( 'openstation_skip_redundant_portal_forward', true, $target ) ) {
425 wp_safe_redirect( openstation_shell_url( openstation_sanitize_portal_target( $target ), true ) );
426 exit;
427 }
428 }
429
430 // Route 2: through the portal, target preserved. Without it,
431 // navigating to a specific admin page (profile.php, plugins.php, any
432 // deep link) loses the user's intent — the portal would forward them
433 // to whichever window was last focused instead of the page they asked
434 // for. The portal handler reads `target`, validates it's same-origin
435 // wp-admin, and passes it on to the shell screen.
436 $portal_url = openstation_portal_url();
437 if ( '' !== $target ) {
438 $portal_url = add_query_arg( 'target', rawurlencode( $target ), $portal_url );
439 }
440
441 wp_safe_redirect( $portal_url );
442 exit;
443 }
444 add_action( 'admin_init', 'openstation_redirect_plain_admin_to_portal' );
445
446 /**
447 * Whether forwarding this request through `/openstation/` would only
448 * hand the URL already being served back as the shell's target.
449 *
450 * Answers locally, and without the HTTP round trip, the same question
451 * {@see openstation_handle_portal_request()} answers after another
452 * WordPress bootstrap. True means the hop is pure overhead and the
453 * caller can send the user straight to the shell screen with this URL
454 * as its target.
455 *
456 * Deliberately conservative: every "don't know" answers false, so the
457 * forward survives wherever the portal might genuinely choose a
458 * different destination.
459 *
460 * 1. The path must resolve through the same wp-admin allowlist the
461 * portal validates `?target=` against. Anything that list rejects
462 * — a `network/` or `user/` sub-path on multisite, a filename that
463 * isn't canonical wp-admin — makes the portal fall back to the
464 * session's focused window, which is a real change of destination.
465 * 2. The resolved filename must be the file this request is actually
466 * serving. If `$pagenow` disagrees with the URL path then a
467 * rewrite is in play and we can't claim to know what renders here.
468 * 3. The query must survive intact. The portal drops
469 * `openstation_chromeless`, both portal flags and `target` from
470 * the URL it rebuilds, so a request carrying any of them comes
471 * back as a different URL.
472 *
473 * @param string $request_uri The current request URI, unslashed.
474 * @return bool True when the portal would resolve this URL to itself.
475 */
476 function openstation_portal_forward_is_redundant( $request_uri ) {
477 global $pagenow;
478
479 if ( ! is_string( $request_uri ) || '' === $request_uri ) {
480 return false;
481 }
482
483 $path = wp_parse_url( $request_uri, PHP_URL_PATH );
484 if ( ! is_string( $path ) || '' === $path ) {
485 return false;
486 }
487
488 $admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
489 $admin_path = is_string( $admin_path ) ? $admin_path : '/wp-admin/';
490 if ( 0 !== strpos( $path, $admin_path ) ) {
491 return false;
492 }
493
494 $file = ltrim( (string) substr( $path, strlen( $admin_path ) ), '/' );
495 if ( '' === $file ) {
496 $file = 'index.php';
497 }
498
499 // 1. The portal's allowlist has to accept it.
500 if ( is_wp_error( openstation_resolve_admin_target( $file ) ) ) {
501 return false;
502 }
503
504 // 2. …and it has to be the page we are actually serving.
505 if ( ! is_string( $pagenow ) || strtolower( $file ) !== strtolower( $pagenow ) ) {
506 return false;
507 }
508
509 // 3. …carrying a query the portal would hand back unchanged.
510 $rewritten = array(
511 'openstation_chromeless',
512 OPENSTATION_PORTAL_FLAG,
513 OPENSTATION_PORTAL_INTENT_FLAG,
514 'target',
515 );
516 foreach ( $rewritten as $key ) {
517 if ( isset( $_GET[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
518 return false;
519 }
520 }
521
522 return true;
523 }
524
525 /**
526 * Resolves the admin URL the portal should forward to for a given user.
527 *
528 * Looks up the user's session and returns the URL of the window flagged
529 * as `focused`. If the session is empty, has no focused window, or the
530 * focused window's URL isn't same-origin admin, falls back to the
531 * dashboard.
532 *
533 * The portal navigates the TOP window, not an iframe, so any chromeless
534 * `openstation_chromeless=1` flag baked into the stored URL is stripped — a leftover
535 * flag would land the user in a standalone chromeless page (no admin
536 * bar, no toggle, no way out) instead of the shell.
537 *
538 * @param int $user_id The user whose session to consult.
539 * @return string The admin URL to redirect to.
540 */
541 function openstation_portal_entry_url( $user_id ) {
542 $session = openstation_get_session( $user_id );
543
544 // User's configured default-window preference. When disabled, we
545 // still have to forward SOMEWHERE (the portal is an HTTP redirect),
546 // so we land on the Dashboard URL — but the shell detects the
547 // `enabled=false` state via the config and skips the auto-open,
548 // leaving the user with an empty desktop as they chose.
549 $default_window = openstation_get_default_window( $user_id );
550 $fallback = $default_window['url'];
551
552 // Native marker (e.g. "native:os-settings") is not a
553 // redirectable URL. The portal MUST forward somewhere — the
554 // redirect happens at HTTP level — so we land on the admin home
555 // and let the shell pick up `defaultWindow.url` from the config
556 // after init and call nativeWindows.openById( <slug> ).
557 if ( is_string( $fallback ) && 0 === strpos( $fallback, 'native:' ) ) {
558 $fallback = admin_url();
559 }
560
561 if ( empty( $session['focused'] ) || empty( $session['windows'] ) ) {
562 return $fallback;
563 }
564
565 foreach ( $session['windows'] as $win ) {
566 if ( ! isset( $win['id'], $win['url'] ) ) {
567 continue;
568 }
569 if ( $win['id'] !== $session['focused'] ) {
570 continue;
571 }
572 if ( ! openstation_url_is_same_admin( $win['url'] ) ) {
573 return $fallback;
574 }
575 // The shell must never open itself. A saved window pointing at
576 // the shell screen cannot be produced by the shell, but a
577 // hand-edited session could say so; treat it as nothing focused.
578 if ( openstation_url_is_shell_screen( $win['url'] ) ) {
579 return $fallback;
580 }
581 return remove_query_arg( array( 'openstation_chromeless', OPENSTATION_PORTAL_FLAG ), $win['url'] );
582 }
583
584 return $fallback;
585 }
586
587 /**
588 * Validates and normalizes a `target` query arg on the portal URL.
589 *
590 * Accepts a raw request-URI-shaped string (path + optional query, e.g.
591 * `/wp-admin/profile.php?foo=bar`) and returns a fully-qualified admin
592 * URL if — and only if — it resolves to a same-origin `wp-admin/` path.
593 * Everything else returns an empty string so the caller falls back to
594 * the saved-session entry URL.
595 *
596 * Strips `openstation_chromeless` and the portal flag from the query so the target
597 * doesn't chain us into a chromeless standalone load or an infinite
598 * redirect loop.
599 *
600 * @param string $raw Raw value from `$_GET['target']` (already unslashed).
601 * @return string A safe absolute admin URL, or '' if the input is invalid.
602 */
603 function openstation_sanitize_portal_target( $raw ) {
604 if ( ! is_string( $raw ) || '' === $raw ) {
605 return '';
606 }
607
608 // Reject URIs with a scheme or protocol-relative prefix — we only
609 // accept relative paths so there's no way to redirect off-site.
610 if ( preg_match( '#^([a-z][a-z0-9+.-]*:|//)#i', $raw ) ) {
611 return '';
612 }
613
614 // Must be an absolute path starting with /.
615 if ( '/' !== $raw[0] ) {
616 return '';
617 }
618
619 $path = wp_parse_url( $raw, PHP_URL_PATH );
620 $query = wp_parse_url( $raw, PHP_URL_QUERY );
621 if ( ! is_string( $path ) || '' === $path ) {
622 return '';
623 }
624
625 $admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
626 $admin_path = is_string( $admin_path ) ? $admin_path : '/wp-admin/';
627 if ( 0 !== strpos( $path, $admin_path ) ) {
628 return '';
629 }
630
631 $file = substr( $path, strlen( $admin_path ) );
632 $file = ltrim( (string) $file, '/' );
633
634 // The network admin's own screens live one directory down and are
635 // resolved against their own list. Without this a network URL came
636 // back empty and the user was quietly forwarded to the site
637 // dashboard, which is a different admin.
638 $network = 0 === strpos( $file, 'network/' );
639 if ( $network ) {
640 $file = substr( $file, strlen( 'network/' ) );
641 }
642
643 if ( '' === $file ) {
644 $file = 'index.php';
645 }
646
647 // Resolve against the hardcoded allowlist of canonical wp-admin
648 // filenames (see `openstation_admin_target_allowlist()`). A
649 // regex alone would accept a plausible-looking filename that
650 // isn't a real core admin page (e.g. `custom_admin_page.php`)
651 // and effectively become an open redirect to a 404 page served
652 // under the admin path; the explicit allowlist closes that.
653 $target = openstation_resolve_admin_target( $file, $network );
654 if ( is_wp_error( $target ) ) {
655 return '';
656 }
657
658 if ( is_string( $query ) && '' !== $query ) {
659 parse_str( $query, $args );
660 unset( $args['openstation_chromeless'], $args[ OPENSTATION_PORTAL_FLAG ], $args[ OPENSTATION_PORTAL_INTENT_FLAG ], $args['target'] );
661 if ( ! empty( $args ) ) {
662 $target = add_query_arg( $args, $target );
663 }
664 }
665
666 // The shell screen is where a target is opened, never a target: the
667 // shell would open itself in a window, and a redirect chain built
668 // from it would loop. Fall back to the entry resolver instead.
669 if ( openstation_url_is_shell_screen( $target ) ) {
670 return '';
671 }
672
673 // `admin.php` is a bootstrap, not a page. Without a `page` arg core
674 // falls through the last `else` in `wp-admin/admin.php`, never
675 // requires `admin-header.php`, and answers 200 with an empty body —
676 // so the URL becomes a window showing nothing. The allowlist above
677 // matches filenames and cannot see the query, which is why the
678 // check belongs here, beside the shell-screen one: both are URLs
679 // that resolve but must not become a target. Returning '' hands the
680 // caller back to the entry resolver (session's focused window, else
681 // the default window, else the Dashboard).
682 if ( openstation_url_is_page_less_admin_php( $target ) ) {
683 return '';
684 }
685
686 return $target;
687 }
688