Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
53.33% covered (warning)
53.33%
24 / 45
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
CSS_Proxy
53.33% covered (warning)
53.33%
24 / 45
25.00% covered (danger)
25.00%
1 / 4
76.76
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 handle_css_proxy
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
90
 serve_proxied_css
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_proxied_css
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
10.08
1<?php
2
3namespace Automattic\Jetpack_Boost\Modules\Optimizations\Critical_CSS;
4
5use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_State;
6use Automattic\Jetpack_Boost\Lib\Critical_CSS\Display_Critical_CSS;
7
8/**
9 * Add an ajax endpoint to proxy external CSS files.
10 */
11class CSS_Proxy {
12    const NONCE_ACTION = 'jb-generate-proxy-nonce';
13
14    public static function init() {
15        $instance = new self();
16
17        if ( is_admin() ) {
18            add_action( 'wp_ajax_boost_proxy_css', array( $instance, 'handle_css_proxy' ) );
19        }
20    }
21
22    /**
23     * AJAX handler to handle proxying of external CSS resources.
24     *
25     * @return void
26     */
27    public function handle_css_proxy() {
28
29        // Verify valid nonce.
30        if ( empty( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['nonce'] ), self::NONCE_ACTION ) ) {
31            wp_die( '', 400 );
32        }
33
34        // Make sure currently logged in as admin.
35        if ( ! current_user_can( 'manage_options' ) ) {
36            wp_die( '', 400 );
37        }
38
39        // Reject any request made when not generating.
40        if ( ! ( new Critical_CSS_State() )->is_requesting() ) {
41            wp_die( '', 400 );
42        }
43
44        // Validate URL and fetch.
45        $proxy_url = filter_var( wp_unslash( $_POST['proxy_url'] ?? '' ), FILTER_VALIDATE_URL );
46        if ( ! wp_http_validate_url( $proxy_url ) ) {
47            die( 'Invalid URL' );
48        }
49
50        $url_path = wp_parse_url( $proxy_url, PHP_URL_PATH );
51        if ( ! $url_path || substr( strtolower( $url_path ), -4 ) !== '.css' ) {
52            wp_die( 'Invalid CSS file URL', 400 );
53        }
54
55        $css = $this->get_proxied_css( $proxy_url );
56
57        if ( $css ) {
58            $this->serve_proxied_css( $css );
59            die( 0 );
60        }
61    }
62
63    /**
64     * Output the proxied CSS body with the appropriate headers.
65     *
66     * Separated from handle_css_proxy() so the output (and its </style
67     * neutralization) is testable without the request teardown (die()).
68     *
69     * @param string $css CSS body to serve.
70     */
71    protected function serve_proxied_css( $css ) {
72        if ( ! headers_sent() ) {
73            header( 'Content-type: text/css' );
74            header( 'X-Content-Type-Options: nosniff' );
75        }
76
77        /*
78         * Outputting proxied CSS contents unescaped. Do not strip tags here;
79         * valid CSS values may contain markup (e.g. inline SVGs in data: URIs),
80         * and stripping them corrupts the CSS fed to the generator. The
81         * text/css + nosniff headers stop a browser from sniffing this body as
82         * HTML; neutralizing </style is defense-in-depth in case the body is
83         * ever embedded inside a <style> element downstream.
84         */
85        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
86        echo Display_Critical_CSS::neutralize_style_closing_tags( $css );
87    }
88
89    /**
90     * Resolve the CSS for a proxied URL from cache, or by fetching and caching it.
91     *
92     * Separated from handle_css_proxy() so the cache/fetch logic is unit-testable
93     * without the surrounding request teardown (die()).
94     *
95     * @param string $proxy_url Validated external CSS URL.
96     * @return string The CSS body, or '' when there is none to serve.
97     */
98    protected function get_proxied_css( $proxy_url ) {
99        $cache_key = 'jb_css_proxy_' . md5( $proxy_url );
100        $response  = get_transient( $cache_key );
101
102        if ( is_array( $response ) && isset( $response['error'] ) ) {
103            wp_die( esc_html( $response['error'] ), 400 );
104        }
105
106        if ( is_string( $response ) ) {
107            // Cache hit: the transient stores the CSS body from a previous
108            // successful fetch. Without this branch a cached body was ignored
109            // (the handler saw no CSS), so it returned nothing to the Critical
110            // CSS generator.
111            return $response;
112        }
113
114        // Anything other than a missing entry (false) has been handled above.
115        if ( false !== $response ) {
116            return '';
117        }
118
119        $response = wp_safe_remote_get( $proxy_url );
120
121        // A transport failure must fail loudly with a 5xx. Falling through would
122        // either mis-cache it as a bad content type or (via die()) emit an HTTP
123        // 200 the Critical CSS generator would happily consume as a stylesheet,
124        // silently corrupting that provider's Critical CSS.
125        if ( is_wp_error( $response ) ) {
126            wp_die( '', 502 );
127        }
128
129        // Likewise, only a 2xx response is usable: an upstream 404/500 served with
130        // a text/css content type must not be cached and served as valid CSS.
131        $status_code = (int) wp_remote_retrieve_response_code( $response );
132        if ( $status_code < 200 || $status_code >= 300 ) {
133            wp_die( '', 502 );
134        }
135
136        $content_type = wp_remote_retrieve_header( $response, 'content-type' );
137        if ( strpos( $content_type, 'text/css' ) === false ) {
138            set_transient( $cache_key, array( 'error' => 'Invalid content type. Expected CSS.' ), HOUR_IN_SECONDS );
139            wp_die( 'Invalid content type. Expected CSS.', 400 );
140        }
141
142        $css = wp_remote_retrieve_body( $response );
143
144        // Only cache a non-empty body. Caching an empty string would make
145        // is_string() cache hits replay it for the full TTL, pinning empty
146        // CSS for an hour after a single transient empty/failed fetch.
147        if ( '' !== $css ) {
148            set_transient( $cache_key, $css, HOUR_IN_SECONDS );
149        }
150
151        return $css;
152    }
153}