| @@ -14,13 +14,46 @@ | ||
| 14 | 14 | */ |
| 15 | 15 | class Downloader { |
| 16 | 16 | |
| 17 | 17 | /** |
| 18 | + * Whether remote font-binary downloads are allowed in the current request. | |
| 19 | + * Set to false during a normal visitor render (so the page is never blocked | |
| 20 | + * by synchronous downloads); true during admin/CLI/warm (probe) requests. | |
| 21 | + * | |
| 22 | + * @var bool | |
| 23 | + */ | |
| 24 | + private static bool $allow_fonts = true; | |
| 25 | + | |
| 26 | + /** | |
| 27 | + * Allow/disallow remote font-binary downloads for this request. | |
| 28 | + * | |
| 29 | + * @param bool $allow Allowed? | |
| 30 | + * @return void | |
| 31 | + */ | |
| 32 | + public static function set_allow_fonts( bool $allow ): void { | |
| 33 | + self::$allow_fonts = $allow; | |
| 34 | + } | |
| 35 | + | |
| 36 | + /** | |
| 37 | + * Are remote font-binary downloads currently allowed? | |
| 38 | + * | |
| 39 | + * @return bool | |
| 40 | + */ | |
| 41 | + public static function fonts_allowed(): bool { | |
| 42 | + return self::$allow_fonts; | |
| 43 | + } | |
| 44 | + | |
| 45 | + /** | |
| 18 | 46 | * Modern desktop UA so the API returns woff2. |
| 19 | 47 | */ |
| 20 | 48 | const UA_WOFF2 = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0'; |
| 21 | 49 | |
| 22 | 50 | /** |
| 51 | + * Transient value marking a recently-failed CSS fetch (negative cache). | |
| 52 | + */ | |
| 53 | + const FAIL_MARKER = '__easyfonts_fetch_failed__'; | |
| 54 | + | |
| 55 | + /** | |
| 23 | 56 | * Old UA that makes the API return TTF (used for metric extraction only). |
| 24 | 57 | */ |
| 25 | 58 | const UA_TTF = 'Mozilla/5.0 (Windows NT 6.1; rv:27.0) Gecko/20100101 Firefox/27.0'; |
| 26 | 59 | |
| @@ -41,8 +74,80 @@ | ||
| 41 | 74 | 'application/vnd.ms-fontobject' => 'eot', |
| 42 | 75 | ); |
| 43 | 76 | |
| 44 | 77 | /** |
| 78 | + * SSRF guard for stylesheet fetches. Unlike font binaries (locked to a | |
| 79 | + * provider allow-list), CSS may legitimately be fetched from ANY public host | |
| 80 | + * — that's how fonts hidden in third-party/CDN stylesheets are detected. So | |
| 81 | + * here we don't allow-list hosts; we deny-list internal targets: non-HTTP(S) | |
| 82 | + * schemes and any host that resolves to a private, reserved, loopback or | |
| 83 | + * link-local address (e.g. 127.0.0.1, 10/8, 192.168/16, 169.254.169.254). | |
| 84 | + * This blocks pointing the fetcher at internal services without affecting | |
| 85 | + * any real public stylesheet. Filterable for intranet installs. | |
| 86 | + * | |
| 87 | + * @param string $url Absolute URL. | |
| 88 | + * @return bool | |
| 89 | + */ | |
| 90 | + public static function is_public_url( string $url ): bool { | |
| 91 | + /** | |
| 92 | + * Allow fetching from private/internal hosts (intranet installs). | |
| 93 | + * | |
| 94 | + * @param bool $allow Default false. | |
| 95 | + * @param string $url The URL under test. | |
| 96 | + */ | |
| 97 | + if ( apply_filters( 'easyfonts_allow_private_fetch', false, $url ) ) { | |
| 98 | + return true; | |
| 99 | + } | |
| 100 | + | |
| 101 | + $parts = wp_parse_url( $url ); | |
| 102 | + $scheme = strtolower( (string) ( $parts['scheme'] ?? '' ) ); | |
| 103 | + $host = (string) ( $parts['host'] ?? '' ); | |
| 104 | + | |
| 105 | + if ( ( 'http' !== $scheme && 'https' !== $scheme ) || '' === $host ) { | |
| 106 | + return false; | |
| 107 | + } | |
| 108 | + | |
| 109 | + // Collect candidate IPs: literal host, or resolved A/AAAA records. | |
| 110 | + $ips = array(); | |
| 111 | + | |
| 112 | + if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { | |
| 113 | + $ips[] = $host; | |
| 114 | + } else { | |
| 115 | + $v4 = gethostbynamel( $host ); | |
| 116 | + | |
| 117 | + if ( is_array( $v4 ) ) { | |
| 118 | + $ips = array_merge( $ips, $v4 ); | |
| 119 | + } | |
| 120 | + | |
| 121 | + if ( function_exists( 'dns_get_record' ) ) { | |
| 122 | + $v6 = @dns_get_record( $host, DNS_AAAA ); // phpcs:ignore WordPress.PHP.NoSilencedErrors | |
| 123 | + | |
| 124 | + if ( is_array( $v6 ) ) { | |
| 125 | + foreach ( $v6 as $rec ) { | |
| 126 | + if ( ! empty( $rec['ipv6'] ) ) { | |
| 127 | + $ips[] = $rec['ipv6']; | |
| 128 | + } | |
| 129 | + } | |
| 130 | + } | |
| 131 | + } | |
| 132 | + } | |
| 133 | + | |
| 134 | + // Couldn't resolve → not a known-internal target; let the HTTP layer | |
| 135 | + // deal with it (it simply won't connect). | |
| 136 | + if ( empty( $ips ) ) { | |
| 137 | + return true; | |
| 138 | + } | |
| 139 | + | |
| 140 | + foreach ( $ips as $ip ) { | |
| 141 | + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { | |
| 142 | + return false; // private / reserved / loopback / link-local. | |
| 143 | + } | |
| 144 | + } | |
| 145 | + | |
| 146 | + return true; | |
| 147 | + } | |
| 148 | + | |
| 149 | + /** | |
| 45 | 150 | * Fetch CSS text from a font-provider URL. |
| 46 | 151 | * |
| 47 | 152 | * @param string $url Stylesheet URL. |
| 48 | 153 | * @return string|null |
| @@ -49,23 +154,65 @@ | ||
| 49 | 154 | */ |
| 50 | 155 | public function fetch_css( string $url ): ?string { |
| 51 | 156 | $url = \EasyFonts\Detect\Providers::normalize_url( $url ); |
| 52 | 157 | |
| 158 | + // Cache the fetched CSS so font detection doesn't hit the network on every | |
| 159 | + // page render. Keyed by URL + cache-buster, so any settings/font change | |
| 160 | + // (which bumps the buster) transparently refreshes it. | |
| 161 | + $cache_key = 'easyfonts_css_' . md5( $url . '|' . \EasyFonts\Settings::buster() ); | |
| 162 | + $cached = get_transient( $cache_key ); | |
| 163 | + | |
| 164 | + // Negative cache: a recently-failed fetch is not retried on every render, | |
| 165 | + // so a dead/blocked CSS host can never add its full timeout to each view. | |
| 166 | + if ( self::FAIL_MARKER === $cached ) { | |
| 167 | + return null; | |
| 168 | + } | |
| 169 | + | |
| 170 | + if ( is_string( $cached ) && '' !== $cached ) { | |
| 171 | + return $cached; | |
| 172 | + } | |
| 173 | + | |
| 174 | + /** | |
| 175 | + * How long a failed stylesheet fetch is remembered before retrying. | |
| 176 | + * | |
| 177 | + * @param int $ttl Seconds. | |
| 178 | + */ | |
| 179 | + $fail_ttl = (int) apply_filters( 'easyfonts_css_fail_ttl', HOUR_IN_SECONDS ); | |
| 180 | + | |
| 181 | + // SSRF guard: never fetch a stylesheet from an internal/private address. | |
| 182 | + if ( ! self::is_public_url( $url ) ) { | |
| 183 | + set_transient( $cache_key, self::FAIL_MARKER, $fail_ttl ); | |
| 184 | + return null; | |
| 185 | + } | |
| 186 | + | |
| 53 | 187 | $response = wp_remote_get( |
| 54 | 188 | $url, |
| 55 | 189 | array( |
| 56 | - 'timeout' => 20, | |
| 190 | + /** | |
| 191 | + * Timeout for stylesheet fetches (seconds). | |
| 192 | + * | |
| 193 | + * @param int $timeout | |
| 194 | + */ | |
| 195 | + 'timeout' => (int) apply_filters( 'easyfonts_css_timeout', 10 ), | |
| 57 | 196 | 'user-agent' => apply_filters( 'easyfonts_css_user_agent', self::UA_WOFF2 ), |
| 58 | 197 | ) |
| 59 | 198 | ); |
| 60 | 199 | |
| 61 | 200 | if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { |
| 201 | + set_transient( $cache_key, self::FAIL_MARKER, $fail_ttl ); | |
| 62 | 202 | return null; |
| 63 | 203 | } |
| 64 | 204 | |
| 65 | 205 | $body = wp_remote_retrieve_body( $response ); |
| 66 | 206 | |
| 67 | - return '' === $body ? null : $body; | |
| 207 | + if ( '' === $body ) { | |
| 208 | + set_transient( $cache_key, self::FAIL_MARKER, $fail_ttl ); | |
| 209 | + return null; | |
| 210 | + } | |
| 211 | + | |
| 212 | + set_transient( $cache_key, $body, 7 * DAY_IN_SECONDS ); | |
| 213 | + | |
| 214 | + return $body; | |
| 68 | 215 | } |
| 69 | 216 | |
| 70 | 217 | /** |
| 71 | 218 | * Download a font binary into the cache and return [filename, ext, size]. |
| @@ -75,8 +222,16 @@ | ||
| 75 | 222 | * @param Storage $storage Storage. |
| 76 | 223 | * @return array{file:string,ext:string,size:int}|null |
| 77 | 224 | */ |
| 78 | 225 | public function fetch_font( string $url, string $basename, Storage $storage ): ?array { |
| 226 | + // Never download a font binary during a normal visitor page render — that | |
| 227 | + // is what could stall the request and white-screen the site. On the render | |
| 228 | + // path we serve only already-cached fonts; missing ones are fetched by a | |
| 229 | + // background warm (probe) request. Admin/CLI/warm requests set this true. | |
| 230 | + if ( ! self::$allow_fonts ) { | |
| 231 | + return null; | |
| 232 | + } | |
| 233 | + | |
| 79 | 234 | $url = \EasyFonts\Detect\Providers::normalize_url( $url ); |
| 80 | 235 | |
| 81 | 236 | // SSRF guard: only fetch font binaries from recognised provider hosts |
| 82 | 237 | // (or the site's own origin). A crafted @font-face can otherwise point |
| @@ -88,9 +243,9 @@ | ||
| 88 | 243 | |
| 89 | 244 | $response = wp_remote_get( |
| 90 | 245 | $url, |
| 91 | 246 | array( |
| 92 | - 'timeout' => 30, | |
| 247 | + 'timeout' => 10, | |
| 93 | 248 | 'user-agent' => self::UA_WOFF2, |
| 94 | 249 | ) |
| 95 | 250 | ); |
| 96 | 251 | |
| @@ -148,8 +303,13 @@ | ||
| 148 | 303 | |
| 149 | 304 | $css = wp_remote_retrieve_body( $response ); |
| 150 | 305 | |
| 151 | 306 | if ( ! preg_match( '/src\s*:\s*[^;]*?url\(\s*[\'"]?([^\'")]+\.(?:ttf|otf))[\'"]?\s*\)/i', $css, $m ) ) { |
| 307 | + return null; | |
| 308 | + } | |
| 309 | + | |
| 310 | + // The font URL comes out of fetched CSS — guard it the same way. | |
| 311 | + if ( ! self::is_public_url( $m[1] ) ) { | |
| 152 | 312 | return null; |
| 153 | 313 | } |
| 154 | 314 | |
| 155 | 315 | $font = wp_remote_get( $m[1], array( 'timeout' => 20 ) ); |