PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.2
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.2
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 / core / routing.php

routing.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.2, at includes/core/routing.php

572 lines 19.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — request routing helpers.
4 *
5 * Chromeless / classic admin-bar suppression and the
6 * `wp_redirect` filter pair that re-stamps the openstation
7 * flags onto server-built redirects. Extracted from the
8 * 1,609-LOC `helpers.php` during the architecture-0.8.1 PHP
9 * slicing (phase 6).
10 *
11 * Behaviour is unchanged. Plugins that registered against any of
12 * these filters keep working: PHP looks function references up by
13 * name at hook-fire time, and `desktop-mode.php` requires this
14 * file before `helpers.php`, so the function definitions are
15 * always present by the time WordPress wants them.
16 *
17 * Functions in this file:
18 * - {@see openstation_url_is_same_admin()} — same-origin admin URL predicate
19 * - {@see openstation_resolve_admin_target()} — admin filename → URL resolver
20 * - {@see openstation_admin_target_allowlist()} — wp-admin filename allowlist
21 * - {@see openstation_is_chromeless_request()} — chromeless request detection
22 * - {@see openstation_is_classic_request()} — classic-override request detection
23 * - {@see openstation_chromeless_hide_admin_bar()} — `show_admin_bar` filter
24 * - {@see openstation_chromeless_suppress_admin_bar()} — `admin_init` action
25 * - {@see openstation_chromeless_preserve_redirect()} — `wp_redirect` filter
26 * - {@see openstation_classic_preserve_redirect()} — `wp_redirect` filter
27 * - {@see openstation_is_admin_redirect_target()} — internal predicate
28 *
29 * @package OpenStation
30 */
31
32 defined( 'ABSPATH' ) || exit;
33
34 /**
35 * Returns true when `$url` is a same-origin admin URL.
36 *
37 * Uses parsed-URL host + path comparison rather than a prefix
38 * `strpos` check so `//evil.com/wp-admin/…` or a URL whose
39 * normalisation happens to share the admin-URL prefix can't
40 * sneak through.
41 *
42 * An empty string returns false — a missing URL is never
43 * "same-origin admin" for the purposes of any caller.
44 *
45 * @param string $url URL to test.
46 * @return bool
47 */
48 function openstation_url_is_same_admin( $url ) {
49 if ( ! is_string( $url ) || '' === $url ) {
50 return false;
51 }
52
53 $parts = wp_parse_url( $url );
54 $admin_parts = wp_parse_url( admin_url() );
55 if ( ! is_array( $parts ) || ! is_array( $admin_parts ) ) {
56 return false;
57 }
58
59 // Host comparison is case-insensitive per RFC 3986. Missing
60 // host on the tested URL (relative or scheme-only) is a
61 // reject — callers should only be handing us fully-qualified
62 // URLs.
63 $url_host = isset( $parts['host'] ) ? strtolower( $parts['host'] ) : '';
64 $admin_host = isset( $admin_parts['host'] ) ? strtolower( $admin_parts['host'] ) : '';
65 if ( '' === $url_host || $url_host !== $admin_host ) {
66 return false;
67 }
68
69 // Path comparison is case-sensitive. The admin path always
70 // ends in `/` (e.g. `/wp-admin/`), so a prefix test is
71 // accurate — nothing at `/wp-administrator/…` can match.
72 $url_path = isset( $parts['path'] ) ? $parts['path'] : '';
73 $admin_path = isset( $admin_parts['path'] ) ? $admin_parts['path'] : '/wp-admin/';
74 return 0 === strpos( $url_path, $admin_path );
75 }
76
77 /**
78 * Resolves an admin-page filename (e.g. `edit.php`) to its
79 * absolute admin URL, allowlisted against the canonical set of
80 * wp-admin top-level filenames.
81 *
82 * Returns a `WP_Error` when the input contains path traversal,
83 * isn't a bare `.php` filename, or points at a file that doesn't
84 * exist in the static allowlist. A regex-only check would accept
85 * `custom_admin_page.php` if a plugin named something that way;
86 * the explicit allowlist closes that.
87 *
88 * @param string $file Bare admin filename (no path, no query string).
89 * @return string|WP_Error Absolute admin URL on success, `WP_Error` otherwise.
90 */
91 function openstation_resolve_admin_target( $file ) {
92 $file = is_string( $file ) ? trim( $file ) : '';
93 if ( '' === $file ) {
94 return new WP_Error(
95 'openstation_empty_target',
96 __( 'Admin target cannot be empty.', 'desktop-mode' )
97 );
98 }
99
100 if ( false !== strpos( $file, '..' ) || false !== strpos( $file, '/' ) || false !== strpos( $file, '\\' ) ) {
101 return new WP_Error(
102 'openstation_invalid_target',
103 __( 'Admin target contains invalid path characters.', 'desktop-mode' )
104 );
105 }
106
107 // Lowercase match mirrors WP's filesystem assumptions on
108 // case-insensitive volumes (macOS, Windows). The allowlist
109 // below is the final arbiter; this regex just pre-filters
110 // clearly bad inputs cheaply.
111 if ( ! preg_match( '/^[a-z0-9_-]+\.php$/i', $file ) ) {
112 return new WP_Error(
113 'openstation_invalid_target',
114 __( 'Admin target must be a plain .php filename.', 'desktop-mode' )
115 );
116 }
117
118 if ( ! in_array( strtolower( $file ), openstation_admin_target_allowlist(), true ) ) {
119 return new WP_Error(
120 'openstation_unknown_target',
121 __( 'Admin target does not exist.', 'desktop-mode' )
122 );
123 }
124
125 return admin_url( $file );
126 }
127
128 /**
129 * Returns the allowlist of canonical wp-admin top-level
130 * filenames that {@see openstation_resolve_admin_target()}
131 * accepts.
132 *
133 * Hardcoded rather than read from disk so the plugin doesn't
134 * depend on a particular WordPress install layout (and doesn't
135 * reference `ABSPATH` to probe core files). Plugins that ship
136 * their own top-level admin pages (rare) can extend the list
137 * via the filter.
138 *
139 * @return string[] Lowercased filenames including extension.
140 */
141 function openstation_admin_target_allowlist() {
142 $files = array(
143 'about.php',
144 'admin-ajax.php',
145 'admin-footer.php',
146 'admin-header.php',
147 'admin-post.php',
148 'admin.php',
149 'async-upload.php',
150 'authorize-application.php',
151 'comment.php',
152 'credits.php',
153 'custom-background.php',
154 'custom-header.php',
155 'customize.php',
156 'edit-comments.php',
157 'edit-form-advanced.php',
158 'edit-form-blocks.php',
159 'edit-form-comment.php',
160 'edit-link-form.php',
161 'edit-tag-form.php',
162 'edit-tags.php',
163 'edit.php',
164 'erase-personal-data.php',
165 'export-personal-data.php',
166 'export.php',
167 'freedoms.php',
168 'import.php',
169 'index.php',
170 'install.php',
171 'link-add.php',
172 'link-manager.php',
173 'link.php',
174 'load-scripts.php',
175 'load-styles.php',
176 'media-new.php',
177 'media-upload.php',
178 'media.php',
179 'menu-header.php',
180 'menu.php',
181 'moderation.php',
182 'ms-admin.php',
183 'ms-delete-site.php',
184 'ms-edit.php',
185 'ms-options.php',
186 'ms-sites.php',
187 'ms-themes.php',
188 'ms-upgrade-network.php',
189 'ms-users.php',
190 'my-sites.php',
191 'nav-menus.php',
192 'network.php',
193 'options-discussion.php',
194 'options-general.php',
195 'options-head.php',
196 'options-media.php',
197 'options-permalink.php',
198 'options-privacy.php',
199 'options-reading.php',
200 'options-writing.php',
201 'options.php',
202 'plugin-editor.php',
203 'plugin-install.php',
204 'plugins.php',
205 'post-new.php',
206 'post.php',
207 'press-this.php',
208 'privacy-policy-guide.php',
209 'privacy.php',
210 'profile.php',
211 'revision.php',
212 'setup-config.php',
213 'site-editor.php',
214 'site-health-info.php',
215 'site-health.php',
216 'sidebar.php',
217 'term.php',
218 'theme-editor.php',
219 'theme-install.php',
220 'themes.php',
221 'tools.php',
222 'update-core.php',
223 'update.php',
224 'upgrade.php',
225 'upload.php',
226 'user-edit.php',
227 'user-new.php',
228 'users.php',
229 'widgets.php',
230 );
231
232 /**
233 * Filters the wp-admin filename allowlist used when resolving
234 * portal `target=` query args.
235 *
236 * @param string[] $files Default allowlist.
237 */
238 $files = (array) apply_filters( 'openstation_admin_target_allowlist', $files );
239
240 return array_values( array_unique( array_map( 'strtolower', array_filter( $files, 'is_string' ) ) ) );
241 }
242
243 /**
244 * Checks whether the current request is a chromeless request.
245 *
246 * Chromeless requests are admin pages loaded inside openstation
247 * windows (iframes). They render only the page content without
248 * the admin shell (sidebar, admin bar, footer).
249 *
250 * @return bool True if this is a chromeless (iframe) request.
251 */
252 function openstation_is_chromeless_request() {
253 if ( ! openstation_is_enabled() ) {
254 // Only allow chromeless mode if the user actually has
255 // OpenStation enabled. Prevents stripping admin chrome via
256 // a bare `?openstation_chromeless=1` parameter from a
257 // logged-out URL.
258 return false;
259 }
260
261 // Primary signal — the explicit query flag the parent shell
262 // adds when opening windows.
263 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag, no state change.
264 if ( ! empty( $_GET['openstation_chromeless'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['openstation_chromeless'] ) ) ) {
265 return true;
266 }
267
268 // Fallback signal — the request is a same-origin iframe load.
269 // Modern browsers (Chrome 80+, Firefox 90+, Safari 16.4+) send
270 // the `Sec-Fetch-*` headers reliably, and they are immune to
271 // JavaScript spoofing (the browser sets them itself).
272 //
273 // This catches the failure mode where an internal admin
274 // navigation drops the `?openstation_chromeless=1` query flag —
275 // Gutenberg's `window.location` assignments, meta-refresh
276 // redirects, or any link the inline rewriter missed. The user
277 // is in an iframe on the same origin, has OpenStation enabled,
278 // so render as chromeless.
279 //
280 // `Sec-Fetch-Site: same-origin` is the cross-origin guard so a
281 // foreign site that iframes the wp-admin page can't trick us
282 // into stripping the chrome — the user agent reports the
283 // embedding context honestly.
284 $fetch_dest = isset( $_SERVER['HTTP_SEC_FETCH_DEST'] )
285 ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_DEST'] ) )
286 : '';
287 $fetch_site = isset( $_SERVER['HTTP_SEC_FETCH_SITE'] )
288 ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) )
289 : '';
290 if ( 'iframe' === $fetch_dest && 'same-origin' === $fetch_site ) {
291 /**
292 * Filter the Sec-Fetch fallback. Return false to require an
293 * explicit `?openstation_chromeless=1` flag; useful for environments where a
294 * reverse proxy strips the `Sec-Fetch-*` headers and they
295 * can't be trusted.
296 *
297 * @param bool $allow Default true.
298 */
299 return (bool) apply_filters( 'openstation_chromeless_sec_fetch_fallback', true );
300 }
301
302 return false;
303 }
304
305 /**
306 * Checks whether the current request carries the "classic
307 * override" flag.
308 *
309 * The window-chrome "Detach" action opens an admin page in a new
310 * browser tab with `?desktop_mode_classic=1` so the user can view
311 * that one page outside the desktop shell without disabling
312 * OpenStation account-wide. The flag is a per-request override:
313 * `openstation_is_enabled()` still returns true (the user's
314 * preference hasn't changed), but the shell, shell assets, and
315 * body class are skipped for this request so the classic admin
316 * renders normally.
317 *
318 * Keep this separate from `openstation_is_enabled()` so the
319 * admin-bar toggle in the detached tab correctly reflects the
320 * account state — letting the user disable OpenStation entirely
321 * from the tab if they want to.
322 *
323 * @return bool True if the request carries `?desktop_mode_classic=1`.
324 */
325 function openstation_is_classic_request() {
326 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag.
327 if ( empty( $_GET[ OPENSTATION_CLASSIC_FLAG ] ) ) {
328 return false;
329 }
330 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag.
331 return '1' === sanitize_text_field( wp_unslash( $_GET[ OPENSTATION_CLASSIC_FLAG ] ) );
332 }
333
334 /**
335 * Disables the admin bar on chromeless (iframe) requests.
336 *
337 * Hooked on the `show_admin_bar` filter so the front-end bar path
338 * also sees a false return. In admin, `is_admin_bar_showing()`
339 * short-circuits to true for any `is_admin()` request regardless
340 * of this filter, so the actual render is stopped by
341 * {@see openstation_chromeless_suppress_admin_bar()} below; this
342 * filter is kept for completeness + tests.
343 *
344 * @param bool $show Whether the admin bar should be shown.
345 * @return bool
346 */
347 function openstation_chromeless_hide_admin_bar( $show ) {
348 if ( openstation_is_chromeless_request() ) {
349 return false;
350 }
351 return $show;
352 }
353 add_filter( 'show_admin_bar', 'openstation_chromeless_hide_admin_bar' );
354
355 /**
356 * Suppresses the admin bar render inside chromeless iframes.
357 *
358 * `is_admin_bar_showing()` unconditionally returns true in admin
359 * context, so the `show_admin_bar` filter alone can't stop
360 * `wp_admin_bar_render()` from firing on `in_admin_header`. We
361 * detach the render action instead and let chromeless.css hide
362 * the `wp-toolbar` padding on `<html>`.
363 */
364 function openstation_chromeless_suppress_admin_bar() {
365 if ( openstation_is_chromeless_request() ) {
366 remove_action( 'in_admin_header', 'wp_admin_bar_render', 0 );
367 remove_action( 'wp_body_open', 'wp_admin_bar_render', 0 );
368 }
369 }
370 add_action( 'admin_init', 'openstation_chromeless_suppress_admin_bar' );
371
372 /**
373 * Detaches core's update / maintenance nags inside chromeless iframes so
374 * they don't repeat in every window — the shell surfaces the update once
375 * instead.
376 */
377 function openstation_chromeless_suppress_update_nags() {
378 if ( ! openstation_is_chromeless_request() ) {
379 return;
380 }
381 remove_action( 'admin_notices', 'update_nag', 3 );
382 remove_action( 'network_admin_notices', 'update_nag', 3 );
383 remove_action( 'admin_notices', 'maintenance_nag', 10 );
384 remove_action( 'network_admin_notices', 'maintenance_nag', 10 );
385 }
386 add_action( 'admin_init', 'openstation_chromeless_suppress_update_nags' );
387
388 /**
389 * Detaches the remaining global core admin notices inside chromeless iframes
390 * so they don't repeat in every window — the shell re-derives and surfaces
391 * each once (see `openstation_get_core_notices()`). The update / maintenance
392 * nags are handled by `openstation_chromeless_suppress_update_nags()`.
393 */
394 function openstation_chromeless_suppress_core_notices() {
395 if ( ! openstation_is_chromeless_request() ) {
396 return;
397 }
398 remove_action( 'admin_notices', 'wp_recovery_mode_nag', 1 );
399 remove_action( 'admin_notices', 'default_password_nag' );
400 remove_action( 'admin_notices', 'deactivated_plugins_notice', 5 );
401 remove_action( 'admin_notices', 'paused_plugins_notice', 5 );
402 remove_action( 'admin_notices', 'paused_themes_notice', 5 );
403 }
404 add_action( 'admin_init', 'openstation_chromeless_suppress_core_notices' );
405
406 /**
407 * Keeps core's session-expired login modal (`wp-auth-check`) out of
408 * chromeless iframes so the parent shell owns the single prompt.
409 *
410 * Every chromeless iframe runs its own Heartbeat, and by default
411 * each one loads `wp-auth-check.js` + the `#wp-auth-check-wrap`
412 * markup. When the session expires, N open windows meant N stacked
413 * login modals — all asking for the same credentials. Returning
414 * false from `wp_auth_check_load` here stops the modal assets from
415 * ever loading inside iframes; the parent shell (a normal admin
416 * page) keeps its copy and surfaces the one prompt over the whole
417 * desktop.
418 *
419 * Detection is unaffected: the `wp-auth-check` heartbeat response
420 * field is attached server-side (core hooks `wp_auth_check()` on
421 * `heartbeat_send` / `heartbeat_nopriv_send`), so the bridge's
422 * stale-nonce recovery in `chromeless-bridge.php` still sees the
423 * logged-out → logged-in flip without the modal JS.
424 *
425 * @param bool $show Whether to load the authentication check.
426 * @return bool
427 */
428 function openstation_chromeless_suppress_auth_check( $show ) {
429 if ( openstation_is_chromeless_request() ) {
430 return false;
431 }
432 return $show;
433 }
434 add_filter( 'wp_auth_check_load', 'openstation_chromeless_suppress_auth_check' );
435
436 /**
437 * Preserves the `openstation_chromeless` flag through admin
438 * redirects.
439 *
440 * A chromeless iframe can be navigated away from chromeless mode
441 * by any redirect that drops the query string —
442 * `wp_redirect( admin_url( 'edit.php' ) )` after saving a
443 * classic-editor post is the canonical example. The client-side
444 * form interceptor handles the outgoing request, but the
445 * server-built redirect URL is what the browser follows.
446 * Re-append the flag here so the landing page stays chromeless
447 * and the window doesn't "break out" into a nested admin.
448 *
449 * Scope is intentionally narrow: only same-site admin URLs are
450 * touched, and only when the current request is itself
451 * chromeless. Anything else passes through unchanged.
452 *
453 * @param string $location The redirect URL.
454 * @return string The redirect URL, with `openstation_chromeless=1` appended when applicable.
455 */
456 function openstation_chromeless_preserve_redirect( $location ) {
457 if ( empty( $location ) || ! openstation_is_chromeless_request() ) {
458 return $location;
459 }
460
461 if ( ! openstation_is_admin_redirect_target( $location ) ) {
462 return $location;
463 }
464
465 // Don't double-append if the URL already carries the flag.
466 if ( false !== strpos( $location, 'openstation_chromeless=' ) ) {
467 return $location;
468 }
469
470 return add_query_arg( 'openstation_chromeless', '1', $location );
471 }
472 add_filter( 'wp_redirect', 'openstation_chromeless_preserve_redirect', 999 );
473
474 /**
475 * Preserves the `desktop_mode_classic` flag through admin
476 * redirects.
477 *
478 * The detached-tab workflow depends on the classic flag living
479 * on every same-tab navigation — otherwise a `wp_redirect()`
480 * after saving a post (for instance) would drop it and the very
481 * next page would fall back into the desktop shell. The JS
482 * interceptor stamps the flag onto every outbound link and form,
483 * but it can't touch server-built redirect URLs.
484 *
485 * Scope mirrors the chromeless preserver: only same-site
486 * wp-admin targets, only when the current request is itself a
487 * classic-override request, and the flag is never appended
488 * twice.
489 *
490 * @param string $location The redirect URL.
491 * @return string The redirect URL, with `desktop_mode_classic=1` appended when applicable.
492 */
493 function openstation_classic_preserve_redirect( $location ) {
494 if ( empty( $location ) || ! openstation_is_classic_request() ) {
495 return $location;
496 }
497
498 if ( ! openstation_is_admin_redirect_target( $location ) ) {
499 return $location;
500 }
501
502 if ( false !== strpos( $location, OPENSTATION_CLASSIC_FLAG . '=' ) ) {
503 return $location;
504 }
505
506 return add_query_arg( OPENSTATION_CLASSIC_FLAG, '1', $location );
507 }
508 add_filter( 'wp_redirect', 'openstation_classic_preserve_redirect', 999 );
509
510 /**
511 * Whether `$location` is a redirect target that lands inside
512 * wp-admin on the current site. Handles all four shapes WP core
513 * actually emits:
514 *
515 * - Absolute, same-host: `https://example.com/wp-admin/users.php?...`
516 * - Absolute path: `/wp-admin/users.php?...`
517 * - Relative to wp-admin: `users.php?update=add&id=42` (used by
518 * `user-new.php`, `edit-tags.php`, and
519 * quite a few other core admin scripts)
520 * - Same-host without path: `?paged=2`
521 *
522 * Off-site redirects (login → external SSO, e.g.) and frontend
523 * redirects (`/`, `/?p=42`) return false so we never paint our
524 * query flag on URLs that don't run our admin code.
525 *
526 * @internal
527 *
528 * @param string $location Raw redirect URL handed to `wp_redirect`.
529 * @return bool
530 */
531 function openstation_is_admin_redirect_target( $location ) {
532 $location = (string) $location;
533 if ( '' === $location ) {
534 return false;
535 }
536
537 $parts = wp_parse_url( $location );
538 if ( false === $parts ) {
539 return false;
540 }
541
542 // External host? Bail — we don't own that page.
543 if ( ! empty( $parts['host'] ) ) {
544 $site_host = wp_parse_url( site_url(), PHP_URL_HOST );
545 if ( $site_host && 0 !== strcasecmp( (string) $parts['host'], (string) $site_host ) ) {
546 return false;
547 }
548 }
549
550 $path = isset( $parts['path'] ) ? (string) $parts['path'] : '';
551
552 // Absolute path (URL-with-host or leading-slash variant).
553 if ( '' !== $path ) {
554 // `/wp-admin/foo.php` — definitive admin target.
555 if ( false !== strpos( $path, '/wp-admin/' ) ) {
556 return true;
557 }
558 // Absolute path NOT into wp-admin (e.g. `/`, `/wp-login.php`,
559 // `/wp-json/...`). Frontend or login flow — leave alone.
560 if ( '/' === $path[0] ) {
561 return false;
562 }
563 }
564
565 // Relative URL (or pure query string). Only safe to treat as
566 // an admin target when the redirect was issued from inside
567 // wp-admin — that's where wp_redirect( 'users.php?...' )
568 // actually resolves to /wp-admin/users.php?... at the
569 // browser. is_admin() is the canonical signal.
570 return is_admin();
571 }
572