Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.52% covered (warning)
89.52%
111 / 124
62.50% covered (warning)
62.50%
10 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Generator
89.52% covered (warning)
89.52%
111 / 124
62.50% covered (warning)
62.50%
10 / 16
89.75
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 force_logged_out_render
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 prevent_login_redirect
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 block_login_redirect_header
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
5.67
 login_redirect_in_headers
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 is_login_location
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
9
 send_login_blocked_response
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 is_same_endpoint
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
8
 normalize_url
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
8.02
 path_from_url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 normalize_path
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
13
 is_generating_critical_css
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_front_end_page_request
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
9
 is_rest_request
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
11.31
 get_generation_metadata
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 display_generate_meta
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace Automattic\Jetpack_Boost\Lib\Critical_CSS;
4
5use Automattic\Jetpack_Boost\Modules\Optimizations\Critical_CSS\CSS_Proxy;
6
7class Generator {
8
9    const GENERATE_QUERY_ACTION = 'jb-generate-critical-css';
10
11    const BLOCKED_HEADER = 'X-Jetpack-Boost-Generation-Blocked';
12
13    public static function init() {
14        $generator = new static();
15        if ( static::is_generating_critical_css() ) {
16            add_action( 'wp_head', array( $generator, 'display_generate_meta' ), 0 );
17            add_filter( 'wp_redirect', array( $generator, 'prevent_login_redirect' ), PHP_INT_MAX );
18            add_action( 'shutdown', array( $generator, 'block_login_redirect_header' ), 0 );
19            $generator->force_logged_out_render();
20        }
21    }
22
23    /**
24     * Force the current page to render as viewed by a logged out user. Useful when generating
25     * Critical CSS.
26     */
27    private function force_logged_out_render() {
28        $current_user_id = get_current_user_id();
29
30        if ( 0 !== $current_user_id ) {
31            // Force current user to 0 to ensure page is rendered as a non-logged-in user.
32            wp_set_current_user( 0 );
33
34            // Turn off display of admin bar.
35            add_filter( 'show_admin_bar', '__return_false', PHP_INT_MAX );
36        }
37    }
38
39    /**
40     * Terminate generation requests that redirect to the login page.
41     *
42     * @since 4.8.0
43     *
44     * @param string|false $location The path or URL to redirect to.
45     * @return string|false
46     */
47    public function prevent_login_redirect( $location ) {
48        if ( is_string( $location ) && self::is_login_location( $location ) ) {
49            $this->send_login_blocked_response();
50        }
51
52        return $location;
53    }
54
55    /**
56     * Replace a login redirect that never passed through wp_redirect().
57     *
58     * A gate can set the header itself and exit, which no filter sees. PHP holds headers until output
59     * begins, so the redirect can still be replaced here. A gate that redirects before this guard is
60     * installed on plugins_loaded stays out of reach.
61     *
62     * @since 4.8.0
63     */
64    public function block_login_redirect_header() {
65        if ( headers_sent() || ! self::login_redirect_in_headers( headers_list() ) ) {
66            return;
67        }
68
69        header_remove( 'Location' );
70        header( self::BLOCKED_HEADER . ': login-required' );
71        status_header( 403 );
72
73        echo esc_html__( 'Critical CSS cannot be generated for a page that requires login.', 'jetpack-boost' );
74    }
75
76    /**
77     * Whether a list of response headers redirects to the login page.
78     *
79     * @param string[] $headers Headers as headers_list() returns them.
80     * @return bool
81     */
82    private static function login_redirect_in_headers( $headers ) {
83        foreach ( $headers as $header ) {
84            if ( ! is_string( $header ) || 0 !== stripos( $header, 'location:' ) ) {
85                continue;
86            }
87
88            $location = trim( substr( $header, strlen( 'location:' ) ) );
89
90            if ( '' !== $location && self::is_login_location( $location ) ) {
91                return true;
92            }
93        }
94
95        return false;
96    }
97
98    /**
99     * Whether a redirect target addresses the login page.
100     *
101     * @param string $location The path or URL being redirected to.
102     * @return bool
103     */
104    private static function is_login_location( $location ) {
105        $targets = array( self::normalize_url( $location ) );
106
107        // A location starting with `//` is protocol-relative, but treat `//wp-login.php` as the login
108        // page too: failing closed here only ever ends a generation request.
109        if ( 0 === strpos( $location, '//' ) ) {
110            $targets[] = self::normalize_url( '/' . ltrim( $location, '/' ) );
111        }
112
113        foreach ( array( wp_login_url(), site_url( 'wp-login.php', 'login' ) ) as $login_url ) {
114            $login = self::normalize_url( $login_url );
115
116            // A filter can return an empty or path-less login URL. Matching on "/" alone would block
117            // every root-bound redirect, but a root path carrying the login's own query is still usable.
118            if ( ! is_array( $login ) || ( '/' === $login['path'] && ! $login['query'] ) ) {
119                continue;
120            }
121
122            foreach ( $targets as $target ) {
123                if ( is_array( $target ) && self::is_same_endpoint( $target, $login ) ) {
124                    return true;
125                }
126            }
127        }
128
129        return false;
130    }
131
132    /**
133     * End a generation request that would otherwise be sent to the login page.
134     *
135     * The header lets the dashboard tell this apart from any other 403 and name the real cause.
136     */
137    private function send_login_blocked_response() {
138        if ( ! headers_sent() ) {
139            header( self::BLOCKED_HEADER . ': login-required' );
140        }
141
142        wp_die(
143            esc_html__( 'Critical CSS cannot be generated for a page that requires login.', 'jetpack-boost' ),
144            '',
145            array( 'response' => 403 )
146        );
147    }
148
149    /**
150     * Whether a redirect target and the login URL address the same endpoint.
151     *
152     * HTTP and HTTPS count as the same endpoint: a site behind a TLS-terminating proxy can redirect
153     * to either scheme. A relative target carries no host, and matches on path alone.
154     *
155     * @param array $target Normalized redirect target.
156     * @param array $login  Normalized login URL.
157     * @return bool
158     */
159    private static function is_same_endpoint( $target, $login ) {
160        if ( '' !== $target['host'] && ( $target['host'] !== $login['host'] || $target['port'] !== $login['port'] ) ) {
161            return false;
162        }
163
164        if ( $target['path'] !== $login['path'] ) {
165            return false;
166        }
167
168        // The login URL's own parameters must all be present: a custom login endpoint can be a query
169        // parameter on an ordinary page. Extra parameters such as reauth or redirect_to are fine.
170        foreach ( $login['query'] as $key => $value ) {
171            if ( ! array_key_exists( $key, $target['query'] ) || $target['query'][ $key ] !== $value ) {
172                return false;
173            }
174        }
175
176        return true;
177    }
178
179    /**
180     * Reduce a URL to the parts an endpoint comparison can trust.
181     *
182     * Redirects reach this filter in many shapes, so the path is percent-decoded, resolved against the
183     * current request when relative, and stripped of repeated slashes, `.`, `..` and case differences.
184     *
185     * @param string $url URL or path to normalize.
186     * @return array|false
187     */
188    private static function normalize_url( $url ) {
189        $parts = wp_parse_url( $url );
190        if ( ! is_array( $parts ) ) {
191            return false;
192        }
193
194        $scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : '';
195        $port   = $parts['port'] ?? null;
196        if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) {
197            $port = null;
198        }
199
200        wp_parse_str( $parts['query'] ?? '', $query );
201
202        return array(
203            'host'  => isset( $parts['host'] ) ? strtolower( $parts['host'] ) : '',
204            'port'  => $port,
205            'path'  => self::normalize_path( $parts['path'] ?? '', isset( $parts['host'] ) ),
206            'query' => $query,
207        );
208    }
209
210    /**
211     * The path part of a URL or path, without its query or fragment.
212     *
213     * A leading "//" makes wp_parse_url() read the first segment as a host, and both a REQUEST_URI and
214     * a root install's relative home URL can start that way.
215     *
216     * @param string $url URL or path.
217     * @return string
218     */
219    private static function path_from_url( $url ) {
220        $url = (string) strtok( (string) $url, '?#' );
221
222        if ( preg_match( '#^[a-z][a-z0-9+.\-]*://[^/]*#i', $url, $matches ) ) {
223            $url = substr( $url, strlen( $matches[0] ) );
224        }
225
226        return $url;
227    }
228
229    /**
230     * Resolve a URL path to a single canonical form.
231     *
232     * @param string $path         Path to normalize.
233     * @param bool   $has_host     Whether the URL the path came from named a host.
234     * @param bool   $resolve_dots Whether to resolve "." and ".." segments. Safe when matching a login
235     *                             URL, where over-matching only ends a generation request; unsafe when
236     *                             classifying a request, where "/wp-json/../x" must stay a REST path.
237     * @return string
238     */
239    private static function normalize_path( $path, $has_host, $resolve_dots = true ) {
240        $path = rawurldecode( $path );
241
242        if ( '' === $path ) {
243            return '/';
244        }
245
246        // A path with no leading slash is relative to the requested directory, the way a browser reads it.
247        if ( '/' !== $path[0] ) {
248            if ( $has_host ) {
249                $path = '/' . $path;
250            } else {
251                $request = self::path_from_url( isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/' );
252                $request = '' === $request ? '/' : $request;
253                $path    = substr( $request, 0, (int) strrpos( $request, '/' ) + 1 ) . $path;
254            }
255        }
256
257        $resolved = array();
258        foreach ( explode( '/', $path ) as $segment ) {
259            if ( '' === $segment ) {
260                continue;
261            }
262            if ( $resolve_dots && '.' === $segment ) {
263                continue;
264            }
265            if ( $resolve_dots && '..' === $segment ) {
266                array_pop( $resolved );
267                continue;
268            }
269            $resolved[] = $segment;
270        }
271
272        $path = strtolower( '/' . implode( '/', $resolved ) );
273
274        return untrailingslashit( $path ) ? untrailingslashit( $path ) : '/';
275    }
276
277    /**
278     * Return true if page is loaded to generate critical CSS
279     *
280     * phpcs:disable WordPress.Security.NonceVerification.Recommended
281     */
282    public static function is_generating_critical_css() {
283        return isset( $_GET[ self::GENERATE_QUERY_ACTION ] ) && self::is_front_end_page_request();
284    }
285
286    /**
287     * Whether this request renders a front-end page, the only place Critical CSS is generated from.
288     *
289     * Generation renders as a logged-out user, so a request carrying the query parameter anywhere else -
290     * wp-admin, wp-login.php, REST - could log an administrator out of a page they merely opened.
291     *
292     * @since 4.8.0
293     *
294     * @return bool
295     */
296    private static function is_front_end_page_request() {
297        if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
298            return false;
299        }
300
301        if ( self::is_rest_request() || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
302            return false;
303        }
304
305        $entry_points = array( 'wp-login.php', 'wp-register.php', 'wp-signup.php', 'wp-activate.php', 'wp-trackback.php', 'xmlrpc.php' );
306
307        return ! in_array( $GLOBALS['pagenow'] ?? '', $entry_points, true );
308    }
309
310    /**
311     * Whether this request is served by the REST API.
312     *
313     * REST_REQUEST is only defined on parse_request, long after this class is set up on
314     * plugins_loaded, so read the request itself rather than the constant.
315     *
316     * @since 4.8.0
317     *
318     * @return bool
319     */
320    private static function is_rest_request() {
321        if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
322            return true;
323        }
324
325        // A site on plain permalinks reaches the REST API through this parameter instead. WP::parse_request()
326        // reads it from the body before the query, and rest_api_loaded() ignores an empty value.
327        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read to classify the request, never to act on it.
328        if ( ! empty( $_GET['rest_route'] ) || ! empty( $_POST['rest_route'] ) ) {
329            return true;
330        }
331
332        if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
333            return false;
334        }
335
336        // Both sides go through the same extraction and normalization, so a repeated slash, an encoded
337        // slash or a capital letter cannot hide a REST path. A request counts when either form matches:
338        // resolving dot segments catches "/a/../wp-json", keeping them catches "/wp-json/../a", and
339        // WordPress routes both to the REST API.
340        $request_uri   = self::path_from_url( esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) );
341        $request_paths = array(
342            self::normalize_path( $request_uri, false ),
343            self::normalize_path( $request_uri, false, false ),
344        );
345        $prefix        = rest_get_url_prefix();
346
347        if ( '' === trim( $prefix, '/' ) ) {
348            return false;
349        }
350
351        // get_rest_url() builds REST URLs from home_url(), and puts the prefix behind index.php on
352        // index permalinks. $wp_rewrite does not exist yet, so accept either shape.
353        foreach ( array( $prefix, 'index.php/' . $prefix ) as $route ) {
354            $rest_path = self::normalize_path( self::path_from_url( home_url( $route, 'relative' ) ), false );
355
356            if ( '/' === $rest_path ) {
357                continue;
358            }
359
360            foreach ( $request_paths as $request_path ) {
361                if ( 0 === strpos( trailingslashit( $request_path ), trailingslashit( $rest_path ) ) ) {
362                    return true;
363                }
364            }
365        }
366
367        return false;
368    }
369
370    /**
371     * Get a Critical CSS status block, adding in local generation nonces (if applicable).
372     * i.e.: Call this method to supply enough Critical CSS status to kick off local generation,
373     * such as in response to a request-generate API call or during page initialization.
374     */
375    public function get_generation_metadata() {
376        $status = array();
377
378        // Add a user-bound nonce to use when proxying CSS for Critical CSS generation.
379        $status['proxy_nonce'] = wp_create_nonce( CSS_Proxy::NONCE_ACTION );
380
381        return $status;
382    }
383
384    /**
385     * Renders a <meta> tag used to verify this is a valid page to generate Critical CSS with.
386     */
387    public function display_generate_meta() {
388        ?>
389        <meta name="<?php echo esc_attr( self::GENERATE_QUERY_ACTION ); ?>" content="true"/>
390        <?php
391    }
392}