PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.5
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.5
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.5, at includes/portal.php

637 lines 24.8 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 }
154
155 // Pick the page the shell opens first. An explicit `target` query
156 // arg — a same-origin wp-admin URL — is how
157 // `openstation_redirect_plain_admin_to_portal` preserves the user's
158 // navigation intent when they follow a link to a specific admin
159 // page (e.g. profile.php). Without one the shell screen resolves
160 // the entry itself: the last-focused window from the saved
161 // session, else the default window, else the Dashboard — see
162 // `openstation_shell_boot_target()`. The bare screen URL is the
163 // canonical address, and a reload of it re-resolves against the
164 // live session rather than against the window that was focused
165 // when the redirect happened.
166 $target = '';
167 $has_intent = false;
168 if ( ! empty( $_GET['target'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
169 // `esc_url_raw`, NOT `sanitize_text_field`: the latter strips
170 // every `%XX` percent-encoded sequence from its input as an XSS
171 // safeguard, which mangles request URIs that legitimately carry
172 // encoded slashes (e.g. `plugin=dir%2Ffile.php`). The downstream
173 // `openstation_sanitize_portal_target` validates the URL
174 // rigorously (scheme rejection, traversal rejection, and a
175 // hardcoded allowlist of canonical wp-admin filenames — see
176 // `openstation_admin_target_allowlist()`) so we don't lose
177 // any real safety by skipping `sanitize_text_field` here.
178 $target = openstation_sanitize_portal_target( esc_url_raw( wp_unslash( $_GET['target'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
179 if ( '' !== $target ) {
180 $has_intent = true;
181 }
182 }
183 // `intent=1` rides along with an explicit target so the shell treats
184 // the resulting `currentPage` as user intent and opens it on top of
185 // the restored session. Without it, a bare `/openstation/` visit and
186 // a portal-redirected admin-bar click would be indistinguishable
187 // downstream.
188 wp_safe_redirect( openstation_shell_url( $target, $has_intent ) );
189 exit;
190 }
191 add_action( 'parse_request', 'openstation_handle_portal_request' );
192
193 /**
194 * Decides whether the current request to the portal can mutate
195 * user-meta safely (same-origin) or should only redirect (cross-
196 * origin, possibly CSRF).
197 *
198 * Logic mirrors the `Sec-Fetch-Site` heuristic browsers use:
199 *
200 * - `Sec-Fetch-Site: same-origin | same-site | none` → trusted
201 * (the request originated from this site, or from a typed URL
202 * / bookmark with no referrer info).
203 * - `Sec-Fetch-Site: cross-site` → untrusted (a third-party page
204 * pointed the user at the portal — could be an `<img>` tag).
205 * - Header missing (older browsers): fall back to `Referer` —
206 * same host or empty referrer is trusted, anything else isn't.
207 *
208 * @return bool
209 */
210 function openstation_portal_is_same_origin_navigation() {
211 if ( ! empty( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) {
212 $site = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) );
213 return in_array( $site, array( 'same-origin', 'same-site', 'none' ), true );
214 }
215
216 if ( empty( $_SERVER['HTTP_REFERER'] ) ) {
217 return true;
218 }
219
220 $referer_host = wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ), PHP_URL_HOST );
221 $home_host = wp_parse_url( home_url(), PHP_URL_HOST );
222
223 if ( ! is_string( $referer_host ) || '' === $referer_host ) {
224 return true;
225 }
226
227 return is_string( $home_host ) && strtolower( $referer_host ) === strtolower( $home_host );
228 }
229
230 /**
231 * Detects whether the current request is for the portal URL.
232 *
233 * Strips any query string and trailing slash and compares against
234 * `/openstation` relative to the site's home path. The pre-rebrand
235 * `/desktop-mode` path is accepted too, so bookmarks made before the
236 * rename still land in the shell.
237 *
238 * @return bool
239 */
240 function openstation_is_portal_request() {
241 if ( empty( $_SERVER['REQUEST_URI'] ) ) {
242 return false;
243 }
244
245 // `esc_url_raw` instead of `sanitize_text_field` so percent-encoded
246 // chars in the URI (notably `%2F` from query-arg slashes) survive
247 // long enough for `wp_parse_url` to split path / query correctly.
248 $uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
249 $path = wp_parse_url( $uri, PHP_URL_PATH );
250 if ( ! is_string( $path ) ) {
251 return false;
252 }
253
254 $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
255 $home_path = is_string( $home_path ) ? rtrim( $home_path, '/' ) : '';
256
257 $path = '/' . ltrim( rtrim( $path, '/' ), '/' );
258
259 return in_array(
260 $path,
261 array(
262 $home_path . '/' . OPENSTATION_PORTAL_PATH,
263 $home_path . '/' . OPENSTATION_PORTAL_PATH_LEGACY,
264 ),
265 true
266 );
267 }
268
269 /**
270 * Sends plain `/wp-admin/...` requests into the desktop.
271 *
272 * The shell is served by its own screen (`includes/shell-screen.php`),
273 * so a plain admin page is never where the desktop renders: a user who
274 * typed or bookmarked `/wp-admin/edit.php` is forwarded to the shell
275 * screen with that URL as the page it opens first. Three routes out of
276 * here, cheapest first:
277 *
278 * 1. **Straight to the shell screen** when the portal would only hand
279 * this URL back — an allowlisted wp-admin file that is also the
280 * page being served, carrying no query arg the portal would strip
281 * ({@see openstation_portal_forward_is_redundant()}). One
282 * redirect; the portal hop would have cost a WordPress bootstrap
283 * to learn what is already known. `openstation_skip_redundant_portal_forward`
284 * (return false) forces the hop back on for a plugin that hooks
285 * the portal handler for side effects.
286 * 2. **Through `/openstation/?target=…`** otherwise — a network-admin
287 * URL, a path outside the wp-admin allowlist — so the portal can
288 * fall back to the saved session's focused window, which is a real
289 * change of destination the shell can't make from here.
290 * 3. **The frozen-flag alias.** A URL carrying `desktop_mode_portal=1`
291 * is the desktop's pre-screen address: the portal used to forward
292 * to a real admin page tagged with it, and bookmarks, the PWA start
293 * URL and plugin-built links still say so. It goes to the shell
294 * screen with that URL as the target, and `intent=1` when the
295 * intent flag was present. The flags stay frozen (see AGENTS.md);
296 * only what they resolve to moved.
297 *
298 * Narrowly scoped to bail on every automated or sub-request entry point
299 * — AJAX, REST, cron, admin-post.php, non-GET methods — so the hook
300 * can't corrupt a form submission or break an API call. The shell
301 * screen itself, chromeless loads, solo boots and classic-flagged
302 * requests pass through.
303 *
304 * Disable via the `openstation_admin_redirect_to_portal` filter (return
305 * false); plain admin pages then render as classic admin and the
306 * desktop lives at `/openstation/` only. The alias route runs before
307 * the filter: a URL that names the desktop is not a plain admin page.
308 */
309 function openstation_redirect_plain_admin_to_portal() {
310 if ( ! openstation_is_enabled() ) {
311 return;
312 }
313 // The screen the redirects land on. First in the chain: every other
314 // branch below ends in a redirect here, and the screen is a plain
315 // admin GET like any other.
316 if ( openstation_is_shell_screen_request() ) {
317 return;
318 }
319 if ( openstation_is_chromeless_request() ) {
320 return;
321 }
322 // A solo boot renders one window in place, wherever it landed.
323 if ( function_exists( 'openstation_is_solo_request' ) && openstation_is_solo_request() ) {
324 return;
325 }
326 if ( wp_doing_ajax() || wp_doing_cron() ) {
327 return;
328 }
329 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
330 return;
331 }
332 if ( ! empty( $_SERVER['REQUEST_METHOD'] ) && 'GET' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) ) {
333 return;
334 }
335
336 // The "Detach to new tab" button tags its URL with this flag so the
337 // user can view one admin page classically without disabling desktop
338 // mode account-wide. Only affects the single request — subsequent
339 // navigations inside the tab lose the flag and follow normal rules.
340 if ( ! empty( $_GET[ OPENSTATION_CLASSIC_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
341 return;
342 }
343
344 // admin-post.php and admin-ajax.php handle form submissions and JSON
345 // endpoints; redirecting them would break the call.
346 global $pagenow;
347 if ( in_array( $pagenow, array( 'admin-post.php', 'admin-ajax.php' ), true ) ) {
348 return;
349 }
350
351 // `esc_url_raw` instead of `sanitize_text_field`: the latter strips
352 // every `%XX` percent-encoded sequence, which corrupts URIs whose
353 // query string legitimately carries an encoded slash — e.g. WP's
354 // own `plugins.php?action=activate&plugin=dir%2Ffile.php` activate
355 // link. The shell screen validates the target on read.
356 $target = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
357 $target = is_string( $target ) ? $target : '';
358
359 // Route 3: the frozen-flag alias. The sanitiser strips both flags
360 // from the target; an unresolvable one leaves the screen to pick
361 // the entry, exactly as the portal did for an invalid `target`.
362 if ( ! empty( $_GET[ OPENSTATION_PORTAL_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
363 $clean = openstation_sanitize_portal_target( $target );
364 $intent = '' !== $clean && ! empty( $_GET[ OPENSTATION_PORTAL_INTENT_FLAG ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
365 wp_safe_redirect( openstation_shell_url( $clean, $intent ) );
366 exit;
367 }
368
369 /**
370 * Filters whether plain admin URLs should redirect into the desktop
371 * when OpenStation is active.
372 *
373 * @param bool $redirect Whether to redirect. Default true.
374 * @param int $user_id The current user's ID.
375 */
376 $redirect = apply_filters( 'openstation_admin_redirect_to_portal', true, get_current_user_id() );
377 if ( ! $redirect ) {
378 return;
379 }
380
381 // Route 1: straight to the shell screen.
382 if ( openstation_portal_forward_is_redundant( $target ) ) {
383 /**
384 * Filters whether to skip the portal hop for a URL the portal
385 * would only hand straight back.
386 *
387 * Default: true — the request goes straight to the shell screen
388 * with this URL as its target. Return false to route through
389 * `/openstation/` anyway, e.g. for a plugin that hooks
390 * `openstation_handle_portal_request` for its own side effects
391 * and needs it to run on every admin entry.
392 *
393 * @param bool $skip Whether to skip the portal hop.
394 * @param string $request_uri The current request URI.
395 */
396 if ( apply_filters( 'openstation_skip_redundant_portal_forward', true, $target ) ) {
397 wp_safe_redirect( openstation_shell_url( openstation_sanitize_portal_target( $target ), true ) );
398 exit;
399 }
400 }
401
402 // Route 2: through the portal, target preserved. Without it,
403 // navigating to a specific admin page (profile.php, plugins.php, any
404 // deep link) loses the user's intent — the portal would forward them
405 // to whichever window was last focused instead of the page they asked
406 // for. The portal handler reads `target`, validates it's same-origin
407 // wp-admin, and passes it on to the shell screen.
408 $portal_url = openstation_portal_url();
409 if ( '' !== $target ) {
410 $portal_url = add_query_arg( 'target', rawurlencode( $target ), $portal_url );
411 }
412
413 wp_safe_redirect( $portal_url );
414 exit;
415 }
416 add_action( 'admin_init', 'openstation_redirect_plain_admin_to_portal' );
417
418 /**
419 * Whether forwarding this request through `/openstation/` would only
420 * hand the URL already being served back as the shell's target.
421 *
422 * Answers locally, and without the HTTP round trip, the same question
423 * {@see openstation_handle_portal_request()} answers after another
424 * WordPress bootstrap. True means the hop is pure overhead and the
425 * caller can send the user straight to the shell screen with this URL
426 * as its target.
427 *
428 * Deliberately conservative: every "don't know" answers false, so the
429 * forward survives wherever the portal might genuinely choose a
430 * different destination.
431 *
432 * 1. The path must resolve through the same wp-admin allowlist the
433 * portal validates `?target=` against. Anything that list rejects
434 * — a `network/` or `user/` sub-path on multisite, a filename that
435 * isn't canonical wp-admin — makes the portal fall back to the
436 * session's focused window, which is a real change of destination.
437 * 2. The resolved filename must be the file this request is actually
438 * serving. If `$pagenow` disagrees with the URL path then a
439 * rewrite is in play and we can't claim to know what renders here.
440 * 3. The query must survive intact. The portal drops
441 * `openstation_chromeless`, both portal flags and `target` from
442 * the URL it rebuilds, so a request carrying any of them comes
443 * back as a different URL.
444 *
445 * @param string $request_uri The current request URI, unslashed.
446 * @return bool True when the portal would resolve this URL to itself.
447 */
448 function openstation_portal_forward_is_redundant( $request_uri ) {
449 global $pagenow;
450
451 if ( ! is_string( $request_uri ) || '' === $request_uri ) {
452 return false;
453 }
454
455 $path = wp_parse_url( $request_uri, PHP_URL_PATH );
456 if ( ! is_string( $path ) || '' === $path ) {
457 return false;
458 }
459
460 $admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
461 $admin_path = is_string( $admin_path ) ? $admin_path : '/wp-admin/';
462 if ( 0 !== strpos( $path, $admin_path ) ) {
463 return false;
464 }
465
466 $file = ltrim( (string) substr( $path, strlen( $admin_path ) ), '/' );
467 if ( '' === $file ) {
468 $file = 'index.php';
469 }
470
471 // 1. The portal's allowlist has to accept it.
472 if ( is_wp_error( openstation_resolve_admin_target( $file ) ) ) {
473 return false;
474 }
475
476 // 2. …and it has to be the page we are actually serving.
477 if ( ! is_string( $pagenow ) || strtolower( $file ) !== strtolower( $pagenow ) ) {
478 return false;
479 }
480
481 // 3. …carrying a query the portal would hand back unchanged.
482 $rewritten = array(
483 'openstation_chromeless',
484 OPENSTATION_PORTAL_FLAG,
485 OPENSTATION_PORTAL_INTENT_FLAG,
486 'target',
487 );
488 foreach ( $rewritten as $key ) {
489 if ( isset( $_GET[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
490 return false;
491 }
492 }
493
494 return true;
495 }
496
497 /**
498 * Resolves the admin URL the portal should forward to for a given user.
499 *
500 * Looks up the user's session and returns the URL of the window flagged
501 * as `focused`. If the session is empty, has no focused window, or the
502 * focused window's URL isn't same-origin admin, falls back to the
503 * dashboard.
504 *
505 * The portal navigates the TOP window, not an iframe, so any chromeless
506 * `openstation_chromeless=1` flag baked into the stored URL is stripped — a leftover
507 * flag would land the user in a standalone chromeless page (no admin
508 * bar, no toggle, no way out) instead of the shell.
509 *
510 * @param int $user_id The user whose session to consult.
511 * @return string The admin URL to redirect to.
512 */
513 function openstation_portal_entry_url( $user_id ) {
514 $session = openstation_get_session( $user_id );
515
516 // User's configured default-window preference. When disabled, we
517 // still have to forward SOMEWHERE (the portal is an HTTP redirect),
518 // so we land on the Dashboard URL — but the shell detects the
519 // `enabled=false` state via the config and skips the auto-open,
520 // leaving the user with an empty desktop as they chose.
521 $default_window = openstation_get_default_window( $user_id );
522 $fallback = $default_window['url'];
523
524 // Native marker (e.g. "native:os-settings") is not a
525 // redirectable URL. The portal MUST forward somewhere — the
526 // redirect happens at HTTP level — so we land on the admin home
527 // and let the shell pick up `defaultWindow.url` from the config
528 // after init and call nativeWindows.openById( <slug> ).
529 if ( is_string( $fallback ) && 0 === strpos( $fallback, 'native:' ) ) {
530 $fallback = admin_url();
531 }
532
533 if ( empty( $session['focused'] ) || empty( $session['windows'] ) ) {
534 return $fallback;
535 }
536
537 foreach ( $session['windows'] as $win ) {
538 if ( ! isset( $win['id'], $win['url'] ) ) {
539 continue;
540 }
541 if ( $win['id'] !== $session['focused'] ) {
542 continue;
543 }
544 if ( ! openstation_url_is_same_admin( $win['url'] ) ) {
545 return $fallback;
546 }
547 // The shell must never open itself. A saved window pointing at
548 // the shell screen cannot be produced by the shell, but a
549 // hand-edited session could say so; treat it as nothing focused.
550 if ( openstation_url_is_shell_screen( $win['url'] ) ) {
551 return $fallback;
552 }
553 return remove_query_arg( array( 'openstation_chromeless', OPENSTATION_PORTAL_FLAG ), $win['url'] );
554 }
555
556 return $fallback;
557 }
558
559 /**
560 * Validates and normalizes a `target` query arg on the portal URL.
561 *
562 * Accepts a raw request-URI-shaped string (path + optional query, e.g.
563 * `/wp-admin/profile.php?foo=bar`) and returns a fully-qualified admin
564 * URL if — and only if — it resolves to a same-origin `wp-admin/` path.
565 * Everything else returns an empty string so the caller falls back to
566 * the saved-session entry URL.
567 *
568 * Strips `openstation_chromeless` and the portal flag from the query so the target
569 * doesn't chain us into a chromeless standalone load or an infinite
570 * redirect loop.
571 *
572 * @param string $raw Raw value from `$_GET['target']` (already unslashed).
573 * @return string A safe absolute admin URL, or '' if the input is invalid.
574 */
575 function openstation_sanitize_portal_target( $raw ) {
576 if ( ! is_string( $raw ) || '' === $raw ) {
577 return '';
578 }
579
580 // Reject URIs with a scheme or protocol-relative prefix — we only
581 // accept relative paths so there's no way to redirect off-site.
582 if ( preg_match( '#^([a-z][a-z0-9+.-]*:|//)#i', $raw ) ) {
583 return '';
584 }
585
586 // Must be an absolute path starting with /.
587 if ( '/' !== $raw[0] ) {
588 return '';
589 }
590
591 $path = wp_parse_url( $raw, PHP_URL_PATH );
592 $query = wp_parse_url( $raw, PHP_URL_QUERY );
593 if ( ! is_string( $path ) || '' === $path ) {
594 return '';
595 }
596
597 $admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
598 $admin_path = is_string( $admin_path ) ? $admin_path : '/wp-admin/';
599 if ( 0 !== strpos( $path, $admin_path ) ) {
600 return '';
601 }
602
603 $file = substr( $path, strlen( $admin_path ) );
604 $file = ltrim( (string) $file, '/' );
605 if ( '' === $file ) {
606 $file = 'index.php';
607 }
608
609 // Resolve against the hardcoded allowlist of canonical wp-admin
610 // filenames (see `openstation_admin_target_allowlist()`). A
611 // regex alone would accept a plausible-looking filename that
612 // isn't a real core admin page (e.g. `custom_admin_page.php`)
613 // and effectively become an open redirect to a 404 page served
614 // under the admin path; the explicit allowlist closes that.
615 $target = openstation_resolve_admin_target( $file );
616 if ( is_wp_error( $target ) ) {
617 return '';
618 }
619
620 if ( is_string( $query ) && '' !== $query ) {
621 parse_str( $query, $args );
622 unset( $args['openstation_chromeless'], $args[ OPENSTATION_PORTAL_FLAG ], $args[ OPENSTATION_PORTAL_INTENT_FLAG ], $args['target'] );
623 if ( ! empty( $args ) ) {
624 $target = add_query_arg( $args, $target );
625 }
626 }
627
628 // The shell screen is where a target is opened, never a target: the
629 // shell would open itself in a window, and a redirect chain built
630 // from it would loop. Fall back to the entry resolver instead.
631 if ( openstation_url_is_shell_screen( $target ) ) {
632 return '';
633 }
634
635 return $target;
636 }
637