Plugin Directory

Changeset 3707539


Ignore:
Timestamp:
09/22/2026 02:15:39 PM (5 weeks ago)
Author:
DaanvandenBergh
Message:

Update to version 6.3.11 from GitHub

Location:
host-webfonts-local
Files:
22 edited
1 copied

Legend:

Unmodified
Added
Removed
  • host-webfonts-local/tags/6.3.11/host-webfonts-local.php

    r3641590 r3707539  
    44 * Plugin URI: https://daan.dev/wordpress/omgf/
    55 * Description: Increase GDPR/DSGVO compliance and leverage browser cache by automatically self-hosting Google Fonts.
    6  * Version: 6.3.10
     6 * Version: 6.3.11
    77 * Author: Daan from Daan.dev
    88 * Author URI: https://daan.dev
  • host-webfonts-local/tags/6.3.11/readme.txt

    r3641590 r3707539  
    44Requires at least: 5.9
    55Tested up to: 7.0
    6 Stable tag: 6.3.10
     6Stable tag: 6.3.11
    77Requires PHP: 7.3
    88License: GPLv2 or later
    … …  
    8787
    8888== Changelog ==
     89
     90= 6.3.11 =
     91* Security: hardened the detection of Google Fonts stylesheets and the handling of downloaded files. Thanks to Wordfence for responsibly disclosing this issue.
     92* Security: Save & Optimize can now only be started by an authorized request. Thanks to Ángel Santana (via WPScan) for responsibly disclosing this issue.
     93* Fixed: stylesheets generated by the WPTT Webfont Loader were removed from the page instead of being optimized because the compatibility fix's validation was inverted.
     94* Fixed: a font family whose name contains a character with a special meaning in a regular expression (e.g., a dot or an asterisk) claimed the @font-face statements — and the downloads — of every
     95other font in the same stylesheet. The name is now escaped before it's matched.
     96* Improved: values which can't be the name of a font are no longer stored as font families, e.g. 'Arial, Helvetica, sans-serif !important', 'var(--tve-font-family,Mulish)', 'inherit' and 'sans-serif.'
     97  A stylesheet which isn't a Google Fonts API response (e.g., a theme's stylesheet, which is only processed by OMGF Pro) contains font-family declarations outside its @font-face statements, and each
     98  of those was stored as a font family. They can't be downloaded, so they only cluttered the Optimize Fonts screen. Enable Debug Mode to log which values were skipped.
    8999
    90100= 6.3.10 =
  • host-webfonts-local/tags/6.3.11/src/Admin/Actions.php

    r3641590 r3707539  
    248248                set_transient( 'settings_errors', get_settings_errors(), 30 );
    249249
    250                 // Redirect back to the settings page that was submitted.
    251                 $goback = add_query_arg( 'settings-updated', 'true', wp_get_referer() );
     250                // Redirect back to the settings page that was submitted. The nonce authorizes the Save & Optimize run.
     251                $goback = add_query_arg(
     252                        [
     253                                'settings-updated' => 'true',
     254                                '_wpnonce'         => wp_create_nonce( Optimize::NONCE_ACTION ),
     255                        ],
     256                        wp_get_referer()
     257                );
    252258
    253259                if ( ! defined( 'DAAN_DOING_TESTS' ) ) {
  • host-webfonts-local/tags/6.3.11/src/Admin/Optimize.php

    r3494741 r3707539  
    2121
    2222class Optimize {
     23        /**
     24         * Nonce action which authorizes a Save & Optimize run. It's added to the redirect URL after
     25         * the settings are saved, so it's only present on a request that follows a form submit.
     26         */
     27        const NONCE_ACTION = 'omgf-optimize';
     28
    2329        /** @var string */
    2430        private $settings_page = '';
    … …  
    3036        private $settings_updated = false;
    3137
     38        /** @var string */
     39        private $nonce = '';
     40
    3241        /**
    3342         * OMGF\Admin\Optimize constructor.
    34          *
    35          * @codeCoverageIgnore
    3643         */
    3744        public function __construct() {
    … …  
    3946                $this->settings_tab     = isset( $_GET['tab'] ) ? sanitize_text_field( $_GET['tab'] ) : Settings::OMGF_SETTINGS_FIELD_OPTIMIZE;
    4047                $this->settings_updated = isset( $_GET['settings-updated'] );
     48                $this->nonce            = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( $_GET['_wpnonce'] ) : '';
    4149
    4250                $this->init();
    … …  
    5967                if ( ! $this->settings_updated ) {
    6068                        return; // @codeCoverageIgnore
     69                }
     70
     71                /**
     72                 * A run has to be authorized by a capable user and the nonce that's issued when the settings are saved.
     73                 *
     74                 * @see Actions::update_settings()
     75                 */
     76                if ( ! current_user_can( 'manage_options' ) ) {
     77                        return;
     78                }
     79
     80                if ( wp_verify_nonce( $this->nonce, self::NONCE_ACTION ) < 1 ) {
     81                        return;
    6182                }
    6283
  • host-webfonts-local/tags/6.3.11/src/Compatibility/CachingPlugins.php

    r3494951 r3707539  
    3737                 * - The `admin_init` action is triggered,
    3838                 * - This is OMGF's settings page,
    39                  * - Settings are updated.
     39                 * - Settings are updated by an administrator.
    4040                 *
    41                  * So, finally, we need to check if this is an administrator.
     41                 * The capability is checked again here, because the action can be triggered from elsewhere.
    4242                 *
    4343                 * @see \OMGF\Admin\Optimize::init()
  • host-webfonts-local/tags/6.3.11/src/Download.php

    r3628684 r3707539  
    7676                }
    7777
    78                 $temp_filename = $this->path . '/' . $this->filename . '.tmp';
     78                /**
     79                 * @since v6.3.11 Use an unguessable name for the temporary file, because $this->path is inside the
     80                 *                uploads directory, i.e. it's publicly accessible.
     81                 */
     82                $temp_filename = $this->path . '/' . $this->filename . '-' . wp_generate_password( 12, false ) . '.tmp';
    7983
    8084                $response = wp_safe_remote_get(
    … …  
    8892
    8993                if ( is_wp_error( $response ) ) {
    90                         if ( file_exists( $temp_filename ) ) {
    91                                 unlink( $temp_filename );
    92                         }
     94                        $this->delete_temp_file( $temp_filename );
    9395
    9496                        Notice::set_notice(
    … …  
    106108                // Handle non-success HTTP status codes.
    107109                if ( $code < 200 || $code >= 300 ) {
    108                         if ( file_exists( $temp_filename ) ) {
    109                                 unlink( $temp_filename );
    110                         }
     110                        $this->delete_temp_file( $temp_filename );
    111111
    112112                        Notice::set_notice(
    … …  
    123123
    124124                if ( ! $content_type ) {
     125                        $this->delete_temp_file( $temp_filename );
     126
    125127                        Notice::set_notice(
    126128                                __( 'OMGF couldn\'t determine the mime-type for the downloaded font file', 'host-webfonts-local' ) . ': ' . $this->filename,
    … …  
    138140
    139141                if ( ! $extension ) {
     142                        $this->delete_temp_file( $temp_filename );
     143
    140144                        OMGF::debug(
    141145                                sprintf(
    … …  
    169173
    170174                                // Clean up the temp file
    171                                 if ( file_exists( $temp_filename ) ) {
    172                                         unlink( $temp_filename );
    173                                 }
     175                                $this->delete_temp_file( $temp_filename );
    174176
    175177                                return '';
    … …  
    179181                return OMGF_UPLOAD_URL . str_replace( OMGF_UPLOAD_DIR, '', $this->path ) . '/' . $this->filename . '.' . $extension;
    180182        }
     183
     184        /**
     185         * Removes the temporary file the response was streamed to.
     186         *
     187         * @since v6.3.11 The temporary file lives inside the uploads directory, which is publicly accessible, so
     188         *                it should never be left behind, no matter why the download was aborted.
     189         *
     190         * @param string $temp_filename
     191         *
     192         * @return void
     193         */
     194        private function delete_temp_file( $temp_filename ) {
     195                if ( file_exists( $temp_filename ) ) {
     196                        unlink( $temp_filename );
     197                }
     198        }
    181199}
  • host-webfonts-local/tags/6.3.11/src/Frontend/Compatibility/Elementor.php

    r3503345 r3707539  
    3838        public function init() {
    3939                add_filter( 'omgf_frontend_process_fonts_set', [ $this, 'maybe_modify_id' ], 10, 2 );
    40                 add_filter( 'omgf_frontend_process_parse_links', [ $this, 'validate_link_element' ], 10, 2 );
     40                add_filter( 'omgf_frontend_process_parse_links', [ $this, 'validate_link_element' ], 10, 3 );
    4141                add_filter( 'omgf_frontend_process_invalid_request', [ $this, 'validate_request' ], 10, 2 );
    4242        }
    … …  
    6868         * @see    \OMGF\Frontend\Process::process()
    6969         *
     70         * @since  v6.3.11 Validate the element's attribute values, instead of matching its markup, so only
     71         *                 elements which actually point to the stylesheet are included.
     72         *
    7073         * @param $is_valid
    7174         * @param $link
     75         * @param $urls     The element's attribute values, sanitized by @see \OMGF\Frontend\Process::get_element_urls()
    7276         *
    7377         * @return bool
    7478         */
    75         public function validate_link_element( $is_valid, $link ) {
    76                 return $is_valid || str_contains( $link, '/uploads/elementor/google-fonts' );
     79        public function validate_link_element( $is_valid, $link, $urls = [] ) {
     80                if ( $is_valid ) {
     81                        return true;
     82                }
     83
     84                foreach ( (array) $urls as $url ) {
     85                        if ( $this->is_google_fonts_stylesheet( $url ) ) {
     86                                return true;
     87                        }
     88                }
     89
     90                return false;
     91        }
     92
     93        /**
     94         * Does $url point to a Google Fonts stylesheet Elementor stored locally?
     95         *
     96         * @since v6.3.11
     97         *
     98         * @param string $url
     99         *
     100         * @return bool
     101         */
     102        private function is_google_fonts_stylesheet( $url ) {
     103                $path = wp_parse_url( html_entity_decode( (string) $url ), PHP_URL_PATH );
     104
     105                return is_string( $path ) && str_contains( $path, '/uploads/elementor/google-fonts' );
    77106        }
    78107
    … …  
    89118         */
    90119        public function validate_request( $is_invalid, $url ) {
    91                 return $is_invalid && ! str_contains( $url, '/uploads/elementor/google-fonts' );
     120                return $is_invalid && ! $this->is_google_fonts_stylesheet( $url );
    92121        }
    93122}
  • host-webfonts-local/tags/6.3.11/src/Frontend/Compatibility/WPTT.php

    r3503345 r3707539  
    3232         */
    3333        private function init() {
    34                 add_filter( 'omgf_frontend_process_parse_links', [ $this, 'validate_link_element' ], 10, 2 );
     34                add_filter( 'omgf_frontend_process_parse_links', [ $this, 'validate_link_element' ], 10, 3 );
    3535                add_filter( 'omgf_frontend_process_invalid_request', [ $this, 'validate_request' ], 10, 2 );
    3636        }
    … …  
    4242         * @see    \OMGF\Frontend\Process::process()
    4343         *
     44         * @since  v6.3.11 Validate the element's attribute values, instead of matching its markup, so only
     45         *                 elements which actually point to the stylesheet are included.
     46         *
    4447         * @param $is_valid
    4548         * @param $link
     49         * @param $urls     The element's attribute values, sanitized by @see \OMGF\Frontend\Process::get_element_urls()
    4650         *
    4751         * @return bool
    4852         */
    49         public function validate_link_element( $is_valid, $link ) {
    50                 return $is_valid || preg_match( '/wp-content\/fonts\/[a-zA-Z0-9]{32}\.css/', $link );
     53        public function validate_link_element( $is_valid, $link, $urls = [] ) {
     54                if ( $is_valid ) {
     55                        return true;
     56                }
     57
     58                foreach ( (array) $urls as $url ) {
     59                        if ( $this->is_webfont_loader_stylesheet( $url ) ) {
     60                                return true;
     61                        }
     62                }
     63
     64                return false;
     65        }
     66
     67        /**
     68         * Does $url point to a stylesheet stored by the WPTT Webfont Loader?
     69         *
     70         * @since v6.3.11
     71         *
     72         * @param string $url
     73         *
     74         * @return bool
     75         */
     76        private function is_webfont_loader_stylesheet( $url ) {
     77                $path = wp_parse_url( html_entity_decode( (string) $url ), PHP_URL_PATH );
     78
     79                return is_string( $path ) && (bool) preg_match( '/wp-content\/fonts\/[a-zA-Z0-9]{32}\.css$/', $path );
    5180        }
    5281
    … …  
    5786         * @see    \OMGF\Frontend\Process::build_search_replace()
    5887         *
     88         * @since  v6.3.11 The condition was inverted: a WPTT stylesheet (which has no family parameter) was marked
     89         *                 as invalid and removed from the page, while any other request without a family parameter
     90         *                 was no longer marked as invalid, and requested instead of removed.
     91         *
    5992         * @param $is_invalid
    6093         * @param $url
    … …  
    6396         */
    6497        public function validate_request( $is_invalid, $url ) {
    65                 return $is_invalid && preg_match( '/wp-content\/fonts\/[a-zA-Z0-9]{32}\.css/', $url );
     98                return $is_invalid && ! $this->is_webfont_loader_stylesheet( $url );
    6699        }
    67100}
  • host-webfonts-local/tags/6.3.11/src/Frontend/Process.php

    r3641590 r3707539  
    4444
    4545        /**
     46         * The hosts of the Google Fonts API (and its GDPR compliant alternatives) OMGF processes, along with
     47         * the path(s) a stylesheet request to each of them starts with.
     48         *
     49         * @see   self::is_font_api_url()
     50         * @since v6.3.11
     51         */
     52        const FONT_API_HOSTS = [
     53                'fonts.googleapis.com' => [ '/css' ],
     54                'fonts.bunny.net'      => [ '/css' ],
     55                'fonts-api.wp.com'     => [ '/css' ],
     56        ];
     57
     58        /**
    4659         * Post types that still trigger template_redirect.
    4760         *
    … …  
    432445                                 *                $link itself is left untouched, because it's passed to the filter below and
    433446                                 *                used for search/replace later on.
     447                                 * @since v6.3.11 Detect the API by parsing the element's attribute values, instead of running a
     448                                 *                substring match against the serialized element. Only elements which actually
     449                                 *                point at the API should be processed, not elements which merely mention it.
    434450                                 */
    435                                 $sanitized = $this->sanitize_url( $link );
    436 
    437                                 return apply_filters(
    438                                         'omgf_frontend_process_parse_links',
    439                                         str_contains( $sanitized, 'fonts.googleapis.com/css' ) || str_contains( $sanitized, 'fonts.bunny.net/css' ) || str_contains( $sanitized, 'fonts-api.wp.com/css' ),
    440                                         $link
    441                                 );
     451                                $urls  = $this->get_element_urls( $link );
     452                                $found = false;
     453
     454                                foreach ( $urls as $url ) {
     455                                        if ( $this->is_font_api_url( $url ) ) {
     456                                                $found = true;
     457
     458                                                break;
     459                                        }
     460                                }
     461
     462                                /**
     463                                 * @since v6.3.11 The element's (sanitized) attribute values are passed along, so 3rd parties can
     464                                 *                validate the URLs themselves, instead of matching the element's markup.
     465                                 */
     466                                return apply_filters( 'omgf_frontend_process_parse_links', $found, $link, $urls );
    442467                        }
    443468                );
    … …  
    475500        private function is_amp() {
    476501                return ( function_exists( 'is_amp_endpoint' ) && is_amp_endpoint() ) || ( function_exists( 'ampforwp_is_amp_endpoint' ) && ampforwp_is_amp_endpoint() );
     502        }
     503
     504        /**
     505         * Returns the (sanitized, non-empty) values of all quoted attributes in a HTML element.
     506         *
     507         * Which attribute holds the stylesheet's URL isn't set in stone: themes and plugins are known to
     508         * park it in e.g. a data-href attribute and fill the href attribute in later on.
     509         *
     510         * @see   self::sanitize_url()
     511         * @since v6.3.11
     512         *
     513         * @param string $element A serialized HTML element, e.g. <link href="..." rel="stylesheet" />.
     514         *
     515         * @return array
     516         */
     517        public function get_element_urls( $element ) {
     518                preg_match_all( '/[a-zA-Z0-9_:.-]+=([\'"])(?P<value>[^\'"]*)\1/', (string) $element, $attributes );
     519
     520                if ( empty( $attributes['value'] ) ) {
     521                        return []; // @codeCoverageIgnore
     522                }
     523
     524                return array_values( array_filter( array_map( [ $this, 'sanitize_url' ], $attributes['value'] ) ) );
     525        }
     526
     527        /**
     528         * Is $url a request to one of the Google Fonts API (compatible) endpoints OMGF processes?
     529         *
     530         * The host is matched in full (never as a substring) and the path has to be the endpoint serving
     531         * the stylesheets.
     532         *
     533         * @see   self::FONT_API_HOSTS
     534         * @since v6.3.11
     535         *
     536         * @param string $url
     537         *
     538         * @return bool
     539         */
     540        public function is_font_api_url( $url ) {
     541                if ( ! is_string( $url ) || $url === '' ) {
     542                        return false;
     543                }
     544
     545                /**
     546                 * Validate the URL in the same shape it's requested in later on.
     547                 *
     548                 * @see \OMGF\Frontend\Filters::decode_url()
     549                 */
     550                $url = $this->sanitize_url( html_entity_decode( $url ) );
     551
     552                /**
     553                 * A scheme less URL (e.g. fonts.googleapis.com/css?family=Roboto) isn't parsed into a host by
     554                 * wp_parse_url(), while a scheme relative URL (//fonts.googleapis.com/css?family=Roboto) is.
     555                 */
     556                if ( ! preg_match( '~^(https?:)?//~i', $url ) ) {
     557                        $url = '//' . ltrim( $url, '/' );
     558                }
     559
     560                $parts = wp_parse_url( $url );
     561
     562                if ( empty( $parts['host'] ) ) {
     563                        return false; // @codeCoverageIgnore
     564                }
     565
     566                /**
     567                 * @filter omgf_font_api_hosts Allows add-ons to process additional endpoints, e.g. Material Icons.
     568                 */
     569                $hosts = apply_filters( 'omgf_font_api_hosts', self::FONT_API_HOSTS );
     570                $host  = strtolower( rtrim( $parts['host'], '.' ) );
     571
     572                if ( ! isset( $hosts[ $host ] ) ) {
     573                        return false;
     574                }
     575
     576                $path = strtolower( $parts['path'] ?? '' );
     577
     578                foreach ( (array) $hosts[ $host ] as $prefix ) {
     579                        if ( str_starts_with( $path, $prefix ) ) {
     580                                return true;
     581                        }
     582                }
     583
     584                return false;
     585        }
     586
     587        /**
     588         * Strips characters from a URL which browsers ignore when requesting it.
     589         *
     590         * HTML allows line breaks (and tabs) inside attribute values, e.g., to keep a long Google Fonts API request
     591         * readable in the editor. Browsers remove those characters before requesting the URL, which means the
     592         * stylesheet loads just fine, while the same URL would be rejected when used as-is in a HTTP request.
     593         *
     594         * @see   https://url.spec.whatwg.org/#concept-basic-url-parser (tab/newline removal)
     595         * @since v6.3.10
     596         *
     597         * @param string $url
     598         *
     599         * @return string
     600         */
     601        private function sanitize_url( $url ) {
     602                return trim( str_replace( [ "\r", "\n", "\t" ], '', $url ) );
    477603        }
    478604
    … …  
    572698
    573699                return $handle;
    574         }
    575 
    576         /**
    577          * Strips characters from a URL which browsers ignore when requesting it.
    578          *
    579          * HTML allows line breaks (and tabs) inside attribute values, e.g., to keep a long Google Fonts API request
    580          * readable in the editor. Browsers remove those characters before requesting the URL, which means the
    581          * stylesheet loads just fine, while the same URL would be rejected when used as-is in a HTTP request.
    582          *
    583          * @see   https://url.spec.whatwg.org/#concept-basic-url-parser (tab/newline removal)
    584          * @since v6.3.10
    585          *
    586          * @param string $url
    587          *
    588          * @return string
    589          */
    590         private function sanitize_url( $url ) {
    591                 return trim( str_replace( [ "\r", "\n", "\t" ], '', $url ) );
    592700        }
    593701
  • host-webfonts-local/tags/6.3.11/src/Helper.php

    r3586383 r3707539  
    2020
    2121class Helper {
     22        /**
     23         * CSS-wide keywords and generic font families. They're valid values for a font-family
     24         * declaration, but they're never the name of an actual font.
     25         *
     26         * @see   self::is_valid_font_family()
     27         * @since v6.3.11
     28         */
     29        const OMGF_INVALID_FONT_FAMILIES = [
     30                'cursive',
     31                'default',
     32                'emoji',
     33                'fangsong',
     34                'fantasy',
     35                'inherit',
     36                'initial',
     37                'math',
     38                'monospace',
     39                'none',
     40                'revert',
     41                'revert-layer',
     42                'sans-serif',
     43                'serif',
     44                'system-ui',
     45                'ui-monospace',
     46                'ui-rounded',
     47                'ui-sans-serif',
     48                'ui-serif',
     49                'unset',
     50        ];
     51
    2252        /**
    2353         * Property to hold all settings.
    … …  
    227257
    228258        /**
     259         * Check if $family is (or could be) the name of an actual font.
     260         *
     261         * A font-family name is a single name. Any value which is a complete declaration value
     262         * (e.g., 'Arial,Helvetica,sans-serif !important' or 'var(--wp--preset--font-family--foo)'),
     263         * a CSS-wide keyword (e.g., 'inherit') or a generic family (e.g., 'sans-serif') is never a
     264         * font we can download, and should never be stored as one.
     265         *
     266         * This can't be an allow list of characters, because font family names are free-form and
     267         * can contain (non-Latin) letters, digits, spaces, dots and dashes.
     268         *
     269         * @since v6.3.11
     270         *
     271         * @param string $family
     272         *
     273         * @return bool
     274         */
     275        public static function is_valid_font_family( $family ) {
     276                if ( ! is_string( $family ) ) {
     277                        return false;
     278                }
     279
     280                $family = trim( $family );
     281
     282                if ( $family === '' ) {
     283                        return false;
     284                }
     285
     286                /**
     287                 * Any of these characters means this isn't a name, but (a leftover of) a complete
     288                 * declaration value: a font stack, a CSS function, an !important flag, or CSS syntax.
     289                 */
     290                if ( preg_match( '/[,!(){};:"\']/', $family ) ) {
     291                        return false;
     292                }
     293
     294                // A CSS custom property, e.g. --wp--preset--font-family--foo.
     295                if ( str_starts_with( $family, '--' ) ) {
     296                        return false;
     297                }
     298
     299                return ! in_array( strtolower( $family ), self::OMGF_INVALID_FONT_FAMILIES, true );
     300        }
     301
     302        /**
    229303         * Delete a file or directory from the filesystem.
    230304         *
  • host-webfonts-local/tags/6.3.11/src/Optimize.php

    r3494741 r3707539  
    315315                }
    316316
     317                /**
     318                 * @since v6.3.11 Make sure the response is a stylesheet before it's parsed as one. The Google Fonts
     319                 *                API (and any locally hosted stylesheet) is served as text/css, so anything else
     320                 *                shouldn't be parsed.
     321                 */
     322                if ( ! $this->is_stylesheet_response( $response ) ) {
     323                        return ''; // @codeCoverageIgnore
     324                }
     325
    317326                return wp_remote_retrieve_body( $response );
     327        }
     328
     329        /**
     330         * Does $response contain a stylesheet?
     331         *
     332         * A response without a Content-Type is accepted, because not every (proxy) server sets one.
     333         *
     334         * @since v6.3.11
     335         *
     336         * @param array $response A response as returned by wp_remote_get().
     337         *
     338         * @return bool
     339         */
     340        private function is_stylesheet_response( $response ) {
     341                $content_type = wp_remote_retrieve_header( $response, 'content-type' );
     342
     343                if ( is_array( $content_type ) ) {
     344                        $content_type = reset( $content_type ); // @codeCoverageIgnore
     345                }
     346
     347                if ( ! $content_type ) {
     348                        return true;
     349                }
     350
     351                // Normalize before lookup, i.e. strip the parameters (e.g. charset) and lowercase it.
     352                $content_type = strtolower( trim( explode( ';', (string) $content_type )[0] ) );
     353
     354                /**
     355                 * @filter omgf_optimize_stylesheet_content_types Allows adding Content-Types used by servers which
     356                 *                                               don't serve stylesheets as text/css.
     357                 */
     358                $allowed = apply_filters(
     359                        'omgf_optimize_stylesheet_content_types',
     360                        [ 'text/css', 'text/x-css', 'application/css', 'application/x-css' ]
     361                );
     362
     363                if ( in_array( $content_type, $allowed, true ) ) {
     364                        return true;
     365                }
     366
     367                // @codeCoverageIgnoreStart
     368                OMGF::debug(
     369                        sprintf(
     370                                __( 'The response for %1$s was ignored, because it isn\'t a stylesheet: %2$s.', 'host-webfonts-local' ),
     371                                $this->url,
     372                                $content_type
     373                        )
     374                );
     375
     376                return false;
     377                // @codeCoverageIgnoreEnd
    318378        }
    319379
    … …  
    335395
    336396                $font_families = array_unique( $font_families[1] );
    337                 $object        = [];
     397
     398                /**
     399                 * @since v6.3.11 Only keep values which are (or could be) the name of an actual font.
     400                 *                A stylesheet which isn't a Google Fonts API response contains font-family
     401                 *                declarations outside @font-face statements, e.g. 'Arial,Helvetica,sans-serif
     402                 *                !important' or 'inherit'. Those can't be downloaded, and there's no font to
     403                 *                match them to, but they were stored as font families all the same. They were
     404                 *                also interpolated (unescaped) into a pattern in self::parse_variants(), where
     405                 *                any character with a special meaning in a regular expression is interpreted
     406                 *                as such.
     407                 */
     408                $valid_families   = array_filter( $font_families, [ OMGF::class, 'is_valid_font_family' ] );
     409                $invalid_families = array_diff( $font_families, $valid_families );
     410
     411                if ( ! empty( $invalid_families ) ) {
     412                        OMGF::debug_array( __( 'Skipped invalid font-families', 'host-webfonts-local' ), $invalid_families );
     413                }
     414
     415                $font_families = $valid_families;
     416
     417                if ( empty( $font_families ) ) {
     418                        return [];
     419                }
     420
     421                $object = [];
    338422
    339423                OMGF::debug_array( __( 'Font-families found', 'host-webfonts-local' ), $font_families );
    … …  
    398482                foreach ( $font_faces[0] as $font_face ) {
    399483                        /**
    400                          * @since v5.3.3 Exact match for font-family attribute, to prevent similar font names from falling through, e.g., Roboto and Roboto Slab.
     484                         * @since v5.3.3  Exact match for font-family attribute, to prevent similar font names from falling through, e.g., Roboto and Roboto Slab.
     485                         * @since v6.3.11 Escape the font family, so a name containing a character with a special meaning in a
     486                         *                regular expression (e.g., '.*') is matched literally, instead of matching (and claiming
     487                         *                the variants of) every other @font-face statement in the stylesheet.
    401488                         */
    402                         if ( ! preg_match( '/font-family:[\s\'"]*?' . $font_family . '[\'"]?;/', $font_face ) ) {
     489                        if ( ! preg_match( '/font-family:[\s\'"]*?' . preg_quote( $font_family, '/' ) . '[\'"]?;/', $font_face ) ) {
    403490                                continue; // @codeCoverageIgnore
    404491                        }
  • host-webfonts-local/trunk/host-webfonts-local.php

    r3641590 r3707539  
    44 * Plugin URI: https://daan.dev/wordpress/omgf/
    55 * Description: Increase GDPR/DSGVO compliance and leverage browser cache by automatically self-hosting Google Fonts.
    6  * Version: 6.3.10
     6 * Version: 6.3.11
    77 * Author: Daan from Daan.dev
    88 * Author URI: https://daan.dev
  • host-webfonts-local/trunk/readme.txt

    r3641590 r3707539  
    44Requires at least: 5.9
    55Tested up to: 7.0
    6 Stable tag: 6.3.10
     6Stable tag: 6.3.11
    77Requires PHP: 7.3
    88License: GPLv2 or later
    … …  
    8787
    8888== Changelog ==
     89
     90= 6.3.11 =
     91* Security: hardened the detection of Google Fonts stylesheets and the handling of downloaded files. Thanks to Wordfence for responsibly disclosing this issue.
     92* Security: Save & Optimize can now only be started by an authorized request. Thanks to Ángel Santana (via WPScan) for responsibly disclosing this issue.
     93* Fixed: stylesheets generated by the WPTT Webfont Loader were removed from the page instead of being optimized because the compatibility fix's validation was inverted.
     94* Fixed: a font family whose name contains a character with a special meaning in a regular expression (e.g., a dot or an asterisk) claimed the @font-face statements — and the downloads — of every
     95other font in the same stylesheet. The name is now escaped before it's matched.
     96* Improved: values which can't be the name of a font are no longer stored as font families, e.g. 'Arial, Helvetica, sans-serif !important', 'var(--tve-font-family,Mulish)', 'inherit' and 'sans-serif.'
     97  A stylesheet which isn't a Google Fonts API response (e.g., a theme's stylesheet, which is only processed by OMGF Pro) contains font-family declarations outside its @font-face statements, and each
     98  of those was stored as a font family. They can't be downloaded, so they only cluttered the Optimize Fonts screen. Enable Debug Mode to log which values were skipped.
    8999
    90100= 6.3.10 =
  • host-webfonts-local/trunk/src/Admin/Actions.php

    r3641590 r3707539  
    248248                set_transient( 'settings_errors', get_settings_errors(), 30 );
    249249
    250                 // Redirect back to the settings page that was submitted.
    251                 $goback = add_query_arg( 'settings-updated', 'true', wp_get_referer() );
     250                // Redirect back to the settings page that was submitted. The nonce authorizes the Save & Optimize run.
     251                $goback = add_query_arg(
     252                        [
     253                                'settings-updated' => 'true',
     254                                '_wpnonce'         => wp_create_nonce( Optimize::NONCE_ACTION ),
     255                        ],
     256                        wp_get_referer()
     257                );
    252258
    253259                if ( ! defined( 'DAAN_DOING_TESTS' ) ) {
  • host-webfonts-local/trunk/src/Admin/Optimize.php

    r3494741 r3707539  
    2121
    2222class Optimize {
     23        /**
     24         * Nonce action which authorizes a Save & Optimize run. It's added to the redirect URL after
     25         * the settings are saved, so it's only present on a request that follows a form submit.
     26         */
     27        const NONCE_ACTION = 'omgf-optimize';
     28
    2329        /** @var string */
    2430        private $settings_page = '';
    … …  
    3036        private $settings_updated = false;
    3137
     38        /** @var string */
     39        private $nonce = '';
     40
    3241        /**
    3342         * OMGF\Admin\Optimize constructor.
    34          *
    35          * @codeCoverageIgnore
    3643         */
    3744        public function __construct() {
    … …  
    3946                $this->settings_tab     = isset( $_GET['tab'] ) ? sanitize_text_field( $_GET['tab'] ) : Settings::OMGF_SETTINGS_FIELD_OPTIMIZE;
    4047                $this->settings_updated = isset( $_GET['settings-updated'] );
     48                $this->nonce            = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( $_GET['_wpnonce'] ) : '';
    4149
    4250                $this->init();
    … …  
    5967                if ( ! $this->settings_updated ) {
    6068                        return; // @codeCoverageIgnore
     69                }
     70
     71                /**
     72                 * A run has to be authorized by a capable user and the nonce that's issued when the settings are saved.
     73                 *
     74                 * @see Actions::update_settings()
     75                 */
     76                if ( ! current_user_can( 'manage_options' ) ) {
     77                        return;
     78                }
     79
     80                if ( wp_verify_nonce( $this->nonce, self::NONCE_ACTION ) < 1 ) {
     81                        return;
    6182                }
    6283
  • host-webfonts-local/trunk/src/Compatibility/CachingPlugins.php

    r3494951 r3707539  
    3737                 * - The `admin_init` action is triggered,
    3838                 * - This is OMGF's settings page,
    39                  * - Settings are updated.
     39                 * - Settings are updated by an administrator.
    4040                 *
    41                  * So, finally, we need to check if this is an administrator.
     41                 * The capability is checked again here, because the action can be triggered from elsewhere.
    4242                 *
    4343                 * @see \OMGF\Admin\Optimize::init()
  • host-webfonts-local/trunk/src/Download.php

    r3628684 r3707539  
    7676                }
    7777
    78                 $temp_filename = $this->path . '/' . $this->filename . '.tmp';
     78                /**
     79                 * @since v6.3.11 Use an unguessable name for the temporary file, because $this->path is inside the
     80                 *                uploads directory, i.e. it's publicly accessible.
     81                 */
     82                $temp_filename = $this->path . '/' . $this->filename . '-' . wp_generate_password( 12, false ) . '.tmp';
    7983
    8084                $response = wp_safe_remote_get(
    … …  
    8892
    8993                if ( is_wp_error( $response ) ) {
    90                         if ( file_exists( $temp_filename ) ) {
    91                                 unlink( $temp_filename );
    92                         }
     94                        $this->delete_temp_file( $temp_filename );
    9395
    9496                        Notice::set_notice(
    … …  
    106108                // Handle non-success HTTP status codes.
    107109                if ( $code < 200 || $code >= 300 ) {
    108                         if ( file_exists( $temp_filename ) ) {
    109                                 unlink( $temp_filename );
    110                         }
     110                        $this->delete_temp_file( $temp_filename );
    111111
    112112                        Notice::set_notice(
    … …  
    123123
    124124                if ( ! $content_type ) {
     125                        $this->delete_temp_file( $temp_filename );
     126
    125127                        Notice::set_notice(
    126128                                __( 'OMGF couldn\'t determine the mime-type for the downloaded font file', 'host-webfonts-local' ) . ': ' . $this->filename,
    … …  
    138140
    139141                if ( ! $extension ) {
     142                        $this->delete_temp_file( $temp_filename );
     143
    140144                        OMGF::debug(
    141145                                sprintf(
    … …  
    169173
    170174                                // Clean up the temp file
    171                                 if ( file_exists( $temp_filename ) ) {
    172                                         unlink( $temp_filename );
    173                                 }
     175                                $this->delete_temp_file( $temp_filename );
    174176
    175177                                return '';
    … …  
    179181                return OMGF_UPLOAD_URL . str_replace( OMGF_UPLOAD_DIR, '', $this->path ) . '/' . $this->filename . '.' . $extension;
    180182        }
     183
     184        /**
     185         * Removes the temporary file the response was streamed to.
     186         *
     187         * @since v6.3.11 The temporary file lives inside the uploads directory, which is publicly accessible, so
     188         *                it should never be left behind, no matter why the download was aborted.
     189         *
     190         * @param string $temp_filename
     191         *
     192         * @return void
     193         */
     194        private function delete_temp_file( $temp_filename ) {
     195                if ( file_exists( $temp_filename ) ) {
     196                        unlink( $temp_filename );
     197                }
     198        }
    181199}
  • host-webfonts-local/trunk/src/Frontend/Compatibility/Elementor.php

    r3503345 r3707539  
    3838        public function init() {
    3939                add_filter( 'omgf_frontend_process_fonts_set', [ $this, 'maybe_modify_id' ], 10, 2 );
    40                 add_filter( 'omgf_frontend_process_parse_links', [ $this, 'validate_link_element' ], 10, 2 );
     40                add_filter( 'omgf_frontend_process_parse_links', [ $this, 'validate_link_element' ], 10, 3 );
    4141                add_filter( 'omgf_frontend_process_invalid_request', [ $this, 'validate_request' ], 10, 2 );
    4242        }
    … …  
    6868         * @see    \OMGF\Frontend\Process::process()
    6969         *
     70         * @since  v6.3.11 Validate the element's attribute values, instead of matching its markup, so only
     71         *                 elements which actually point to the stylesheet are included.
     72         *
    7073         * @param $is_valid
    7174         * @param $link
     75         * @param $urls     The element's attribute values, sanitized by @see \OMGF\Frontend\Process::get_element_urls()
    7276         *
    7377         * @return bool
    7478         */
    75         public function validate_link_element( $is_valid, $link ) {
    76                 return $is_valid || str_contains( $link, '/uploads/elementor/google-fonts' );
     79        public function validate_link_element( $is_valid, $link, $urls = [] ) {
     80                if ( $is_valid ) {
     81                        return true;
     82                }
     83
     84                foreach ( (array) $urls as $url ) {
     85                        if ( $this->is_google_fonts_stylesheet( $url ) ) {
     86                                return true;
     87                        }
     88                }
     89
     90                return false;
     91        }
     92
     93        /**
     94         * Does $url point to a Google Fonts stylesheet Elementor stored locally?
     95         *
     96         * @since v6.3.11
     97         *
     98         * @param string $url
     99         *
     100         * @return bool
     101         */
     102        private function is_google_fonts_stylesheet( $url ) {
     103                $path = wp_parse_url( html_entity_decode( (string) $url ), PHP_URL_PATH );
     104
     105                return is_string( $path ) && str_contains( $path, '/uploads/elementor/google-fonts' );
    77106        }
    78107
    … …  
    89118         */
    90119        public function validate_request( $is_invalid, $url ) {
    91                 return $is_invalid && ! str_contains( $url, '/uploads/elementor/google-fonts' );
     120                return $is_invalid && ! $this->is_google_fonts_stylesheet( $url );
    92121        }
    93122}
  • host-webfonts-local/trunk/src/Frontend/Compatibility/WPTT.php

    r3503345 r3707539  
    3232         */
    3333        private function init() {
    34                 add_filter( 'omgf_frontend_process_parse_links', [ $this, 'validate_link_element' ], 10, 2 );
     34                add_filter( 'omgf_frontend_process_parse_links', [ $this, 'validate_link_element' ], 10, 3 );
    3535                add_filter( 'omgf_frontend_process_invalid_request', [ $this, 'validate_request' ], 10, 2 );
    3636        }
    … …  
    4242         * @see    \OMGF\Frontend\Process::process()
    4343         *
     44         * @since  v6.3.11 Validate the element's attribute values, instead of matching its markup, so only
     45         *                 elements which actually point to the stylesheet are included.
     46         *
    4447         * @param $is_valid
    4548         * @param $link
     49         * @param $urls     The element's attribute values, sanitized by @see \OMGF\Frontend\Process::get_element_urls()
    4650         *
    4751         * @return bool
    4852         */
    49         public function validate_link_element( $is_valid, $link ) {
    50                 return $is_valid || preg_match( '/wp-content\/fonts\/[a-zA-Z0-9]{32}\.css/', $link );
     53        public function validate_link_element( $is_valid, $link, $urls = [] ) {
     54                if ( $is_valid ) {
     55                        return true;
     56                }
     57
     58                foreach ( (array) $urls as $url ) {
     59                        if ( $this->is_webfont_loader_stylesheet( $url ) ) {
     60                                return true;
     61                        }
     62                }
     63
     64                return false;
     65        }
     66
     67        /**
     68         * Does $url point to a stylesheet stored by the WPTT Webfont Loader?
     69         *
     70         * @since v6.3.11
     71         *
     72         * @param string $url
     73         *
     74         * @return bool
     75         */
     76        private function is_webfont_loader_stylesheet( $url ) {
     77                $path = wp_parse_url( html_entity_decode( (string) $url ), PHP_URL_PATH );
     78
     79                return is_string( $path ) && (bool) preg_match( '/wp-content\/fonts\/[a-zA-Z0-9]{32}\.css$/', $path );
    5180        }
    5281
    … …  
    5786         * @see    \OMGF\Frontend\Process::build_search_replace()
    5887         *
     88         * @since  v6.3.11 The condition was inverted: a WPTT stylesheet (which has no family parameter) was marked
     89         *                 as invalid and removed from the page, while any other request without a family parameter
     90         *                 was no longer marked as invalid, and requested instead of removed.
     91         *
    5992         * @param $is_invalid
    6093         * @param $url
    … …  
    6396         */
    6497        public function validate_request( $is_invalid, $url ) {
    65                 return $is_invalid && preg_match( '/wp-content\/fonts\/[a-zA-Z0-9]{32}\.css/', $url );
     98                return $is_invalid && ! $this->is_webfont_loader_stylesheet( $url );
    6699        }
    67100}
  • host-webfonts-local/trunk/src/Frontend/Process.php

    r3641590 r3707539  
    4444
    4545        /**
     46         * The hosts of the Google Fonts API (and its GDPR compliant alternatives) OMGF processes, along with
     47         * the path(s) a stylesheet request to each of them starts with.
     48         *
     49         * @see   self::is_font_api_url()
     50         * @since v6.3.11
     51         */
     52        const FONT_API_HOSTS = [
     53                'fonts.googleapis.com' => [ '/css' ],
     54                'fonts.bunny.net'      => [ '/css' ],
     55                'fonts-api.wp.com'     => [ '/css' ],
     56        ];
     57
     58        /**
    4659         * Post types that still trigger template_redirect.
    4760         *
    … …  
    432445                                 *                $link itself is left untouched, because it's passed to the filter below and
    433446                                 *                used for search/replace later on.
     447                                 * @since v6.3.11 Detect the API by parsing the element's attribute values, instead of running a
     448                                 *                substring match against the serialized element. Only elements which actually
     449                                 *                point at the API should be processed, not elements which merely mention it.
    434450                                 */
    435                                 $sanitized = $this->sanitize_url( $link );
    436 
    437                                 return apply_filters(
    438                                         'omgf_frontend_process_parse_links',
    439                                         str_contains( $sanitized, 'fonts.googleapis.com/css' ) || str_contains( $sanitized, 'fonts.bunny.net/css' ) || str_contains( $sanitized, 'fonts-api.wp.com/css' ),
    440                                         $link
    441                                 );
     451                                $urls  = $this->get_element_urls( $link );
     452                                $found = false;
     453
     454                                foreach ( $urls as $url ) {
     455                                        if ( $this->is_font_api_url( $url ) ) {
     456                                                $found = true;
     457
     458                                                break;
     459                                        }
     460                                }
     461
     462                                /**
     463                                 * @since v6.3.11 The element's (sanitized) attribute values are passed along, so 3rd parties can
     464                                 *                validate the URLs themselves, instead of matching the element's markup.
     465                                 */
     466                                return apply_filters( 'omgf_frontend_process_parse_links', $found, $link, $urls );
    442467                        }
    443468                );
    … …  
    475500        private function is_amp() {
    476501                return ( function_exists( 'is_amp_endpoint' ) && is_amp_endpoint() ) || ( function_exists( 'ampforwp_is_amp_endpoint' ) && ampforwp_is_amp_endpoint() );
     502        }
     503
     504        /**
     505         * Returns the (sanitized, non-empty) values of all quoted attributes in a HTML element.
     506         *
     507         * Which attribute holds the stylesheet's URL isn't set in stone: themes and plugins are known to
     508         * park it in e.g. a data-href attribute and fill the href attribute in later on.
     509         *
     510         * @see   self::sanitize_url()
     511         * @since v6.3.11
     512         *
     513         * @param string $element A serialized HTML element, e.g. <link href="..." rel="stylesheet" />.
     514         *
     515         * @return array
     516         */
     517        public function get_element_urls( $element ) {
     518                preg_match_all( '/[a-zA-Z0-9_:.-]+=([\'"])(?P<value>[^\'"]*)\1/', (string) $element, $attributes );
     519
     520                if ( empty( $attributes['value'] ) ) {
     521                        return []; // @codeCoverageIgnore
     522                }
     523
     524                return array_values( array_filter( array_map( [ $this, 'sanitize_url' ], $attributes['value'] ) ) );
     525        }
     526
     527        /**
     528         * Is $url a request to one of the Google Fonts API (compatible) endpoints OMGF processes?
     529         *
     530         * The host is matched in full (never as a substring) and the path has to be the endpoint serving
     531         * the stylesheets.
     532         *
     533         * @see   self::FONT_API_HOSTS
     534         * @since v6.3.11
     535         *
     536         * @param string $url
     537         *
     538         * @return bool
     539         */
     540        public function is_font_api_url( $url ) {
     541                if ( ! is_string( $url ) || $url === '' ) {
     542                        return false;
     543                }
     544
     545                /**
     546                 * Validate the URL in the same shape it's requested in later on.
     547                 *
     548                 * @see \OMGF\Frontend\Filters::decode_url()
     549                 */
     550                $url = $this->sanitize_url( html_entity_decode( $url ) );
     551
     552                /**
     553                 * A scheme less URL (e.g. fonts.googleapis.com/css?family=Roboto) isn't parsed into a host by
     554                 * wp_parse_url(), while a scheme relative URL (//fonts.googleapis.com/css?family=Roboto) is.
     555                 */
     556                if ( ! preg_match( '~^(https?:)?//~i', $url ) ) {
     557                        $url = '//' . ltrim( $url, '/' );
     558                }
     559
     560                $parts = wp_parse_url( $url );
     561
     562                if ( empty( $parts['host'] ) ) {
     563                        return false; // @codeCoverageIgnore
     564                }
     565
     566                /**
     567                 * @filter omgf_font_api_hosts Allows add-ons to process additional endpoints, e.g. Material Icons.
     568                 */
     569                $hosts = apply_filters( 'omgf_font_api_hosts', self::FONT_API_HOSTS );
     570                $host  = strtolower( rtrim( $parts['host'], '.' ) );
     571
     572                if ( ! isset( $hosts[ $host ] ) ) {
     573                        return false;
     574                }
     575
     576                $path = strtolower( $parts['path'] ?? '' );
     577
     578                foreach ( (array) $hosts[ $host ] as $prefix ) {
     579                        if ( str_starts_with( $path, $prefix ) ) {
     580                                return true;
     581                        }
     582                }
     583
     584                return false;
     585        }
     586
     587        /**
     588         * Strips characters from a URL which browsers ignore when requesting it.
     589         *
     590         * HTML allows line breaks (and tabs) inside attribute values, e.g., to keep a long Google Fonts API request
     591         * readable in the editor. Browsers remove those characters before requesting the URL, which means the
     592         * stylesheet loads just fine, while the same URL would be rejected when used as-is in a HTTP request.
     593         *
     594         * @see   https://url.spec.whatwg.org/#concept-basic-url-parser (tab/newline removal)
     595         * @since v6.3.10
     596         *
     597         * @param string $url
     598         *
     599         * @return string
     600         */
     601        private function sanitize_url( $url ) {
     602                return trim( str_replace( [ "\r", "\n", "\t" ], '', $url ) );
    477603        }
    478604
    … …  
    572698
    573699                return $handle;
    574         }
    575 
    576         /**
    577          * Strips characters from a URL which browsers ignore when requesting it.
    578          *
    579          * HTML allows line breaks (and tabs) inside attribute values, e.g., to keep a long Google Fonts API request
    580          * readable in the editor. Browsers remove those characters before requesting the URL, which means the
    581          * stylesheet loads just fine, while the same URL would be rejected when used as-is in a HTTP request.
    582          *
    583          * @see   https://url.spec.whatwg.org/#concept-basic-url-parser (tab/newline removal)
    584          * @since v6.3.10
    585          *
    586          * @param string $url
    587          *
    588          * @return string
    589          */
    590         private function sanitize_url( $url ) {
    591                 return trim( str_replace( [ "\r", "\n", "\t" ], '', $url ) );
    592700        }
    593701
  • host-webfonts-local/trunk/src/Helper.php

    r3586383 r3707539  
    2020
    2121class Helper {
     22        /**
     23         * CSS-wide keywords and generic font families. They're valid values for a font-family
     24         * declaration, but they're never the name of an actual font.
     25         *
     26         * @see   self::is_valid_font_family()
     27         * @since v6.3.11
     28         */
     29        const OMGF_INVALID_FONT_FAMILIES = [
     30                'cursive',
     31                'default',
     32                'emoji',
     33                'fangsong',
     34                'fantasy',
     35                'inherit',
     36                'initial',
     37                'math',
     38                'monospace',
     39                'none',
     40                'revert',
     41                'revert-layer',
     42                'sans-serif',
     43                'serif',
     44                'system-ui',
     45                'ui-monospace',
     46                'ui-rounded',
     47                'ui-sans-serif',
     48                'ui-serif',
     49                'unset',
     50        ];
     51
    2252        /**
    2353         * Property to hold all settings.
    … …  
    227257
    228258        /**
     259         * Check if $family is (or could be) the name of an actual font.
     260         *
     261         * A font-family name is a single name. Any value which is a complete declaration value
     262         * (e.g., 'Arial,Helvetica,sans-serif !important' or 'var(--wp--preset--font-family--foo)'),
     263         * a CSS-wide keyword (e.g., 'inherit') or a generic family (e.g., 'sans-serif') is never a
     264         * font we can download, and should never be stored as one.
     265         *
     266         * This can't be an allow list of characters, because font family names are free-form and
     267         * can contain (non-Latin) letters, digits, spaces, dots and dashes.
     268         *
     269         * @since v6.3.11
     270         *
     271         * @param string $family
     272         *
     273         * @return bool
     274         */
     275        public static function is_valid_font_family( $family ) {
     276                if ( ! is_string( $family ) ) {
     277                        return false;
     278                }
     279
     280                $family = trim( $family );
     281
     282                if ( $family === '' ) {
     283                        return false;
     284                }
     285
     286                /**
     287                 * Any of these characters means this isn't a name, but (a leftover of) a complete
     288                 * declaration value: a font stack, a CSS function, an !important flag, or CSS syntax.
     289                 */
     290                if ( preg_match( '/[,!(){};:"\']/', $family ) ) {
     291                        return false;
     292                }
     293
     294                // A CSS custom property, e.g. --wp--preset--font-family--foo.
     295                if ( str_starts_with( $family, '--' ) ) {
     296                        return false;
     297                }
     298
     299                return ! in_array( strtolower( $family ), self::OMGF_INVALID_FONT_FAMILIES, true );
     300        }
     301
     302        /**
    229303         * Delete a file or directory from the filesystem.
    230304         *
  • host-webfonts-local/trunk/src/Optimize.php

    r3494741 r3707539  
    315315                }
    316316
     317                /**
     318                 * @since v6.3.11 Make sure the response is a stylesheet before it's parsed as one. The Google Fonts
     319                 *                API (and any locally hosted stylesheet) is served as text/css, so anything else
     320                 *                shouldn't be parsed.
     321                 */
     322                if ( ! $this->is_stylesheet_response( $response ) ) {
     323                        return ''; // @codeCoverageIgnore
     324                }
     325
    317326                return wp_remote_retrieve_body( $response );
     327        }
     328
     329        /**
     330         * Does $response contain a stylesheet?
     331         *
     332         * A response without a Content-Type is accepted, because not every (proxy) server sets one.
     333         *
     334         * @since v6.3.11
     335         *
     336         * @param array $response A response as returned by wp_remote_get().
     337         *
     338         * @return bool
     339         */
     340        private function is_stylesheet_response( $response ) {
     341                $content_type = wp_remote_retrieve_header( $response, 'content-type' );
     342
     343                if ( is_array( $content_type ) ) {
     344                        $content_type = reset( $content_type ); // @codeCoverageIgnore
     345                }
     346
     347                if ( ! $content_type ) {
     348                        return true;
     349                }
     350
     351                // Normalize before lookup, i.e. strip the parameters (e.g. charset) and lowercase it.
     352                $content_type = strtolower( trim( explode( ';', (string) $content_type )[0] ) );
     353
     354                /**
     355                 * @filter omgf_optimize_stylesheet_content_types Allows adding Content-Types used by servers which
     356                 *                                               don't serve stylesheets as text/css.
     357                 */
     358                $allowed = apply_filters(
     359                        'omgf_optimize_stylesheet_content_types',
     360                        [ 'text/css', 'text/x-css', 'application/css', 'application/x-css' ]
     361                );
     362
     363                if ( in_array( $content_type, $allowed, true ) ) {
     364                        return true;
     365                }
     366
     367                // @codeCoverageIgnoreStart
     368                OMGF::debug(
     369                        sprintf(
     370                                __( 'The response for %1$s was ignored, because it isn\'t a stylesheet: %2$s.', 'host-webfonts-local' ),
     371                                $this->url,
     372                                $content_type
     373                        )
     374                );
     375
     376                return false;
     377                // @codeCoverageIgnoreEnd
    318378        }
    319379
    … …  
    335395
    336396                $font_families = array_unique( $font_families[1] );
    337                 $object        = [];
     397
     398                /**
     399                 * @since v6.3.11 Only keep values which are (or could be) the name of an actual font.
     400                 *                A stylesheet which isn't a Google Fonts API response contains font-family
     401                 *                declarations outside @font-face statements, e.g. 'Arial,Helvetica,sans-serif
     402                 *                !important' or 'inherit'. Those can't be downloaded, and there's no font to
     403                 *                match them to, but they were stored as font families all the same. They were
     404                 *                also interpolated (unescaped) into a pattern in self::parse_variants(), where
     405                 *                any character with a special meaning in a regular expression is interpreted
     406                 *                as such.
     407                 */
     408                $valid_families   = array_filter( $font_families, [ OMGF::class, 'is_valid_font_family' ] );
     409                $invalid_families = array_diff( $font_families, $valid_families );
     410
     411                if ( ! empty( $invalid_families ) ) {
     412                        OMGF::debug_array( __( 'Skipped invalid font-families', 'host-webfonts-local' ), $invalid_families );
     413                }
     414
     415                $font_families = $valid_families;
     416
     417                if ( empty( $font_families ) ) {
     418                        return [];
     419                }
     420
     421                $object = [];
    338422
    339423                OMGF::debug_array( __( 'Font-families found', 'host-webfonts-local' ), $font_families );
    … …  
    398482                foreach ( $font_faces[0] as $font_face ) {
    399483                        /**
    400                          * @since v5.3.3 Exact match for font-family attribute, to prevent similar font names from falling through, e.g., Roboto and Roboto Slab.
     484                         * @since v5.3.3  Exact match for font-family attribute, to prevent similar font names from falling through, e.g., Roboto and Roboto Slab.
     485                         * @since v6.3.11 Escape the font family, so a name containing a character with a special meaning in a
     486                         *                regular expression (e.g., '.*') is matched literally, instead of matching (and claiming
     487                         *                the variants of) every other @font-face statement in the stylesheet.
    401488                         */
    402                         if ( ! preg_match( '/font-family:[\s\'"]*?' . $font_family . '[\'"]?;/', $font_face ) ) {
     489                        if ( ! preg_match( '/font-family:[\s\'"]*?' . preg_quote( $font_family, '/' ) . '[\'"]?;/', $font_face ) ) {
    403490                                continue; // @codeCoverageIgnore
    404491                        }
Note: See TracChangeset for help on using the changeset viewer.