PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.3
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.3
2.0.4 2.0.3 2.0.2 2.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2 1.3 2.0.0
easyfonts / src / Fonts / Downloader.php

Downloader.php in EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant 2.0.3, at src/Fonts/Downloader.php

418 lines 11.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Remote fetcher for stylesheets and font binaries.
4 *
5 * @package EasyFonts
6 */
7
8 namespace EasyFonts\Fonts;
9
10 defined( 'ABSPATH' ) || exit;
11
12 /**
13 * Downloads Google/Bunny CSS and font files.
14 */
15 class Downloader {
16
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 /**
46 * Modern desktop UA so the API returns woff2.
47 */
48 const UA_WOFF2 = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0';
49
50 /**
51 * Transient value marking a recently-failed CSS fetch (negative cache).
52 */
53 const FAIL_MARKER = '__easyfonts_fetch_failed__';
54
55 /**
56 * Old UA that makes the API return TTF (used for metric extraction only).
57 */
58 const UA_TTF = 'Mozilla/5.0 (Windows NT 6.1; rv:27.0) Gecko/20100101 Firefox/27.0';
59
60 /**
61 * Content-Type => extension.
62 */
63 const MIME = array(
64 'font/woff2' => 'woff2',
65 'application/font-woff2' => 'woff2',
66 'font/woff' => 'woff',
67 'application/font-woff' => 'woff',
68 'font/ttf' => 'ttf',
69 'application/x-font-ttf' => 'ttf',
70 'font/sfnt' => 'ttf',
71 'application/font-sfnt' => 'ttf',
72 'font/otf' => 'otf',
73 'application/x-font-opentype' => 'otf',
74 'application/vnd.ms-fontobject' => 'eot',
75 );
76
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 /**
150 * Fetch CSS text from a font-provider URL.
151 *
152 * @param string $url Stylesheet URL.
153 * @return string|null
154 */
155 public function fetch_css( string $url ): ?string {
156 $url = \EasyFonts\Detect\Providers::normalize_url( $url );
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
187 $response = wp_remote_get(
188 $url,
189 array(
190 /**
191 * Timeout for stylesheet fetches (seconds).
192 *
193 * @param int $timeout
194 */
195 'timeout' => (int) apply_filters( 'easyfonts_css_timeout', 10 ),
196 'user-agent' => apply_filters( 'easyfonts_css_user_agent', self::UA_WOFF2 ),
197 )
198 );
199
200 if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
201 set_transient( $cache_key, self::FAIL_MARKER, $fail_ttl );
202 return null;
203 }
204
205 $body = wp_remote_retrieve_body( $response );
206
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;
215 }
216
217 /**
218 * Download a font binary into the cache and return [filename, ext, size].
219 *
220 * @param string $url Font file URL.
221 * @param string $basename Cache basename (without extension).
222 * @param Storage $storage Storage.
223 * @return array{file:string,ext:string,size:int}|null
224 */
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
234 $url = \EasyFonts\Detect\Providers::normalize_url( $url );
235
236 // SSRF guard: only fetch font binaries from recognised provider hosts
237 // (or the site's own origin). A crafted @font-face can otherwise point
238 // src at an internal address; this blocks that without affecting any
239 // legitimate Google/Bunny/wp.com font. Filterable for niche providers.
240 if ( ! self::host_allowed( $url ) ) {
241 return null;
242 }
243
244 $response = wp_remote_get(
245 $url,
246 array(
247 'timeout' => 10,
248 'user-agent' => self::UA_WOFF2,
249 )
250 );
251
252 if ( is_wp_error( $response ) ) {
253 return null;
254 }
255
256 $code = (int) wp_remote_retrieve_response_code( $response );
257
258 if ( $code < 200 || $code >= 300 ) {
259 return null;
260 }
261
262 $body = wp_remote_retrieve_body( $response );
263
264 if ( '' === $body ) {
265 return null;
266 }
267
268 $ct = strtolower( trim( explode( ';', (string) wp_remote_retrieve_header( $response, 'content-type' ) )[0] ) );
269 $ext = self::MIME[ $ct ] ?? $this->ext_from_url( $url );
270
271 $filename = $basename . '.' . $ext;
272
273 if ( ! $storage->write( $filename, $body ) ) {
274 return null;
275 }
276
277 return array(
278 'file' => $filename,
279 'ext' => $ext,
280 'size' => strlen( $body ),
281 );
282 }
283
284 /**
285 * Fetch the raw bytes of a TTF copy of a Google Fonts family for metrics.
286 * Returns the binary, never writes it to disk.
287 *
288 * @param string $css_url Original CSS URL.
289 * @return string|null
290 */
291 public function fetch_ttf_bytes( string $css_url ): ?string {
292 $response = wp_remote_get(
293 $css_url,
294 array(
295 'timeout' => 20,
296 'user-agent' => self::UA_TTF,
297 )
298 );
299
300 if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
301 return null;
302 }
303
304 $css = wp_remote_retrieve_body( $response );
305
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] ) ) {
312 return null;
313 }
314
315 $font = wp_remote_get( $m[1], array( 'timeout' => 20 ) );
316
317 if ( is_wp_error( $font ) || 200 !== (int) wp_remote_retrieve_response_code( $font ) ) {
318 return null;
319 }
320
321 $bytes = wp_remote_retrieve_body( $font );
322
323 return '' === $bytes ? null : $bytes;
324 }
325
326 /**
327 * Fetch TTF bytes for a specific Google family/weight purely for metric
328 * extraction. Source-independent: we construct a css2 request from the
329 * family name, so it works whether the font came from a <link>, @import, or
330 * an inline @font-face. Returns binary, never written to disk.
331 *
332 * @param string $family Family name (e.g. "Open Sans").
333 * @param string $weight Representative numeric weight.
334 * @return string|null
335 */
336 public function fetch_family_ttf( string $family, string $weight = '400' ): ?string {
337 $weight = (string) absint( $weight );
338
339 if ( '' === $weight || '0' === $weight ) {
340 $weight = '400';
341 }
342
343 $url = 'https://fonts.googleapis.com/css2?family='
344 . rawurlencode( $family ) . ':wght@' . $weight . '&display=swap';
345 $url = str_replace( '%20', '+', $url );
346
347 return $this->fetch_ttf_bytes( $url );
348 }
349
350 /**
351 * Is this URL host allowed for font-binary downloads? Provider font hosts
352 * plus the site's own origin. Filterable via `easyfonts_font_src_hosts`.
353 *
354 * @param string $url Absolute URL.
355 * @return bool
356 */
357 private static function host_allowed( string $url ): bool {
358 $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
359
360 if ( '' === $host ) {
361 return false;
362 }
363
364 $own = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
365
366 /**
367 * Allowed host suffixes for downloading font binaries.
368 *
369 * @param string[] $hosts
370 */
371 $allowed = apply_filters(
372 'easyfonts_font_src_hosts',
373 array(
374 'fonts.gstatic.com',
375 'fonts.googleapis.com',
376 'fonts.bunny.net',
377 'fonts.wp.com',
378 'fonts-api.wp.com',
379 's.w.org',
380 'i0.wp.com',
381 'i1.wp.com',
382 'i2.wp.com',
383 )
384 );
385
386 if ( '' !== $own ) {
387 $allowed[] = $own;
388 }
389
390 foreach ( $allowed as $candidate ) {
391 $candidate = strtolower( trim( (string) $candidate ) );
392
393 if ( '' === $candidate ) {
394 continue;
395 }
396
397 // Exact host or a subdomain of an allowed host.
398 if ( $host === $candidate || substr( $host, -strlen( '.' . $candidate ) ) === '.' . $candidate ) {
399 return true;
400 }
401 }
402
403 return false;
404 }
405
406 /**
407 * Guess an extension from a URL path.
408 *
409 * @param string $url URL.
410 * @return string
411 */
412 private function ext_from_url( string $url ): string {
413 $ext = strtolower( (string) pathinfo( (string) wp_parse_url( $url, PHP_URL_PATH ), PATHINFO_EXTENSION ) );
414
415 return in_array( $ext, array( 'woff2', 'woff', 'ttf', 'otf', 'eot' ), true ) ? $ext : 'woff2';
416 }
417 }
418