embed = $embed; } /** * Hooks the front-end handler — any request may be the editor's * cookie-less frame — and the heartbeat's nonce refresh. */ public function register(): void { add_filter( 'query_vars', array( $this, 'query_vars' ) ); // First in line: nothing else on parse_request acts on the request. add_action( 'parse_request', array( $this, 'maybe_handle' ), 0 ); add_filter( 'wp_refresh_nonces', array( $this, 'refresh_nonce' ) ); } /** * Registers the query var, so the request parser picks it up. * * @param array $vars Public query vars. * @return array */ public function query_vars( $vars ): array { $vars = (array) $vars; $vars[] = self::QUERY_VAR; return $vars; } /** * Whether the parsed request asks for a preview. * * @param \WP $wp The parsed request. */ public function is_preview_request( \WP $wp ): bool { return ! empty( $wp->query_vars[ self::QUERY_VAR ] ); } /** * Serves a preview request and ends it; leaves any other alone. * * @param \WP $wp The parsed request. */ public function maybe_handle( $wp ): void { if ( $wp instanceof \WP && $this->is_preview_request( $wp ) ) { $this->handle(); } } /** * Adds a freshly signed frame URL to core's heartbeat nonce refresh. * * An editor can stay open longer than the URL minted with it lives. * Core applies this filter once its heartbeat nonce is past its first * half-life: from the first nonce tick boundary after the page loaded * (anywhere up to half a nonce life, 12 hours by default), and then * again about every half-life as its own nonce is renewed. The URL * lives at least a whole nonce life (ttl()), so a refresh always lands * before it expires; the block editor script uses the new URL for * frames it builds from then on. Not keyed on the classic editor's * `wp-refresh-post-nonces` flag: the block editor never sends it. * * @param array $response Heartbeat response. * @return array */ public function refresh_nonce( $response ): array { $response = (array) $response; $url = self::url(); if ( '' !== $url ) { $response['mapsemble_preview_url'] = $url; } return $response; } /** * The frame URL, without the map; the editor appends `&map=` and `&filters=`. * * Empty for anyone who cannot edit posts: minting is the only place * the capability is checked, as the frame's request carries no user. */ public static function url(): string { if ( ! current_user_can( 'edit_posts' ) ) { return ''; } $exp = time() + self::ttl(); $locale = sanitize_locale_name( get_user_locale() ); return add_query_arg( array( self::QUERY_VAR => '1', 'exp' => $exp, 'locale' => $locale, 'sig' => self::signature( $exp, $locale ), ), // The editor's origin, which is wp-admin's: home_url() may be // another host (headless, domain mapping) or scheme (mixed // content under FORCE_SSL_ADMIN). Any URL WordPress answers // runs parse_request, so the core directory serves as well. site_url( '/', 'admin' ) ); } /** * The headers the frame is served with, refusals included: framed by * this site only, never cached, never indexed — and isolated exactly * when the editor framing it is (see the class docblock). * * @internal Public for tests; handle() sends them. * * @return array */ public static function headers(): array { $headers = array_merge( array_filter( wp_get_nocache_headers(), 'is_string' ), array( 'X-Frame-Options' => 'SAMEORIGIN', 'Content-Security-Policy' => "frame-ancestors 'self'", 'X-Robots-Tag' => 'noindex, nofollow', ) ); // Unsigned on purpose: it changes only how this document isolates // itself, which grants nothing — a wrong value just leaves the // asker's own frame blank. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above. if ( isset( $_GET['isolated'] ) && '1' === $_GET['isolated'] ) { $headers['Document-Isolation-Policy'] = 'isolate-and-credentialless'; } return $headers; } /** * How long a minted URL works: a day, or a nonce life if the site * stretches that — the heartbeat refresh follows nonce ticks. */ private static function ttl(): int { return max( DAY_IN_SECONDS, (int) apply_filters( 'nonce_life', DAY_IN_SECONDS ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's hook. } /** * The signature of an expiry and a locale: keyed with the site's nonce * salt, so only this site can mint one, and prefixed so it signs * nothing else. * * @param int $exp Unix time the URL stops working. * @param string $locale Locale the preview renders in. */ private static function signature( int $exp, string $locale ): string { return hash_hmac( 'sha256', self::QUERY_VAR . '|' . $exp . '|' . $locale, wp_salt( 'nonce' ) ); } /** * Serves the frame. */ public function handle(): void { foreach ( self::headers() as $name => $value ) { header( $name . ': ' . $value ); } $html = $this->respond(); header( 'Content-Type: text/html; charset=' . get_option( 'blog_charset' ) ); echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped embed markup and core's script printer. exit; } /** * Checks the request and builds the frame for it. * * Split from handle() so everything but the headers, the final echo and * exit runs under test; every refusal dies before a byte of the * document exists. None of them links anywhere: a link back to the * referer — the editor — would load the editor inside the frame. * * @internal Public for tests; handle() is the entry point. */ public function respond(): string { // Only as a frame. Browsers that send no Sec-Fetch-Dest get the // benefit of the doubt; the signature still holds for them. $dest = isset( $_SERVER['HTTP_SEC_FETCH_DEST'] ) ? sanitize_key( wp_unslash( $_SERVER['HTTP_SEC_FETCH_DEST'] ) ) : ''; if ( '' !== $dest && 'iframe' !== $dest ) { wp_die( esc_html__( 'This preview only works inside the editor.', 'mapsemble' ), '', array( 'response' => 403 ) ); } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- the signature below is this request's nonce: cookie nonces cannot work in a credentialless frame (see the class docblock). $exp = isset( $_GET['exp'] ) && is_string( $_GET['exp'] ) ? absint( $_GET['exp'] ) : 0; $locale = isset( $_GET['locale'] ) && is_string( $_GET['locale'] ) ? sanitize_locale_name( wp_unslash( $_GET['locale'] ) ) : ''; $sig = isset( $_GET['sig'] ) && is_string( $_GET['sig'] ) ? sanitize_text_field( wp_unslash( $_GET['sig'] ) ) : ''; // No capability check: the request carries no user by design. The // signature is the authority — only an editor could have minted it. if ( $exp <= time() || ! hash_equals( self::signature( $exp, $locale ), $sig ) ) { wp_die( esc_html__( 'This preview has expired. Reload the editor to see the map again.', 'mapsemble' ), '', array( 'response' => 403 ) ); } $map = isset( $_GET['map'] ) && is_string( $_GET['map'] ) ? sanitize_text_field( wp_unslash( $_GET['map'] ) ) : ''; // Raw on purpose: a text sanitizer strips tags and %XX sequences out // of filter values ("<5 km"). This string is never output — Embed // JSON-decodes it, keeps only slug keys with scalar values, and // re-encodes that for the inline script. Strings only: `filters[]=` // arrives as an array, which is junk here, not a warning. $filters = isset( $_GET['filters'] ) && is_string( $_GET['filters'] ) ? wp_unslash( $_GET['filters'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated by Embed::sanitize_filters(), see above. // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( '' === $map ) { // The editor only frames a chosen map, so this is a hand-typed // URL; say so rather than serve an empty frame. wp_die( esc_html__( 'No map to preview.', 'mapsemble' ), '', array( 'response' => 400 ) ); } // The editor's language, not the site's. A language uninstalled // since the URL was minted is no reason to refuse: the site's // language serves instead. $switched = false; if ( 'en_US' === $locale || in_array( $locale, get_available_languages(), true ) ) { $switched = switch_to_locale( $locale ); } try { return $this->document( $map, $filters ); } finally { if ( $switched ) { restore_previous_locale(); } } } /** * Builds the frame document. * * @internal Public for tests; handle() is the entry point. * * @param string $map Map id, or 'sample'. * @param string $filters Initial filters JSON, may be ''. */ public function document( string $map, string $filters ): string { // The frame's request is logged out, so the embed would boot with a // visitor's empty notices; but only an editor is handed this URL, // and a mistyped id in the block is where an editor most often // meets an unavailable map. Scoped to this one render, and run // early so a site's own settings filter still has the last word. // A leaked URL shows no more than these two generic sentences. $notices = static function ( array $settings ): array { return array_merge( $settings, Embed::editor_notices() ); }; add_filter( 'mapsemble_widget_settings', $notices, 0 ); try { $body = $this->embed->render_by_map_id( $map, array( // The frame is the block's box: the map fills it, and the // editor, not the widget, owns scrolling. 'height' => '100vh', 'fill_viewport' => 'false', 'scroll_capture' => 'none', 'filters' => $filters, ) ); } finally { remove_filter( 'mapsemble_widget_settings', $notices, 0 ); } // The two handles only, straight from the registry: wp_print_scripts() // would also fire its action, where any plugin may print into the frame. ob_start(); wp_scripts()->do_items( array( 'mapsemble-widget', 'mapsemble' ) ); $scripts = (string) ob_get_clean(); // The frame's reset, through the style registry the same way. wp_register_style( 'mapsemble-block-preview', false, array(), MAPSEMBLE_VERSION ); wp_add_inline_style( 'mapsemble-block-preview', 'html,body{margin:0;padding:0;background:#fff;}' ); ob_start(); wp_styles()->do_items( array( 'mapsemble-block-preview' ) ); $styles = (string) ob_get_clean(); return '' . '' . '' // This document's URL is signed. The origin still goes out — // tile providers match URL-restricted keys on it — but no path // or query, whatever Referrer-Policy the site itself sends. . '' . '' . esc_html__( 'Map preview', 'mapsemble' ) . '' . $styles . '' . $body . $scripts . ''; } }