PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.2
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.2
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.2, at src/Fonts/Downloader.php

392 lines 10.5 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 * Old UA that makes the API return TTF (used for metric extraction only).
52 */
53 const UA_TTF = 'Mozilla/5.0 (Windows NT 6.1; rv:27.0) Gecko/20100101 Firefox/27.0';
54
55 /**
56 * Content-Type => extension.
57 */
58 const MIME = array(
59 'font/woff2' => 'woff2',
60 'application/font-woff2' => 'woff2',
61 'font/woff' => 'woff',
62 'application/font-woff' => 'woff',
63 'font/ttf' => 'ttf',
64 'application/x-font-ttf' => 'ttf',
65 'font/sfnt' => 'ttf',
66 'application/font-sfnt' => 'ttf',
67 'font/otf' => 'otf',
68 'application/x-font-opentype' => 'otf',
69 'application/vnd.ms-fontobject' => 'eot',
70 );
71
72 /**
73 * SSRF guard for stylesheet fetches. Unlike font binaries (locked to a
74 * provider allow-list), CSS may legitimately be fetched from ANY public host
75 * — that's how fonts hidden in third-party/CDN stylesheets are detected. So
76 * here we don't allow-list hosts; we deny-list internal targets: non-HTTP(S)
77 * schemes and any host that resolves to a private, reserved, loopback or
78 * link-local address (e.g. 127.0.0.1, 10/8, 192.168/16, 169.254.169.254).
79 * This blocks pointing the fetcher at internal services without affecting
80 * any real public stylesheet. Filterable for intranet installs.
81 *
82 * @param string $url Absolute URL.
83 * @return bool
84 */
85 public static function is_public_url( string $url ): bool {
86 /**
87 * Allow fetching from private/internal hosts (intranet installs).
88 *
89 * @param bool $allow Default false.
90 * @param string $url The URL under test.
91 */
92 if ( apply_filters( 'easyfonts_allow_private_fetch', false, $url ) ) {
93 return true;
94 }
95
96 $parts = wp_parse_url( $url );
97 $scheme = strtolower( (string) ( $parts['scheme'] ?? '' ) );
98 $host = (string) ( $parts['host'] ?? '' );
99
100 if ( ( 'http' !== $scheme && 'https' !== $scheme ) || '' === $host ) {
101 return false;
102 }
103
104 // Collect candidate IPs: literal host, or resolved A/AAAA records.
105 $ips = array();
106
107 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
108 $ips[] = $host;
109 } else {
110 $v4 = gethostbynamel( $host );
111
112 if ( is_array( $v4 ) ) {
113 $ips = array_merge( $ips, $v4 );
114 }
115
116 if ( function_exists( 'dns_get_record' ) ) {
117 $v6 = @dns_get_record( $host, DNS_AAAA ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
118
119 if ( is_array( $v6 ) ) {
120 foreach ( $v6 as $rec ) {
121 if ( ! empty( $rec['ipv6'] ) ) {
122 $ips[] = $rec['ipv6'];
123 }
124 }
125 }
126 }
127 }
128
129 // Couldn't resolve → not a known-internal target; let the HTTP layer
130 // deal with it (it simply won't connect).
131 if ( empty( $ips ) ) {
132 return true;
133 }
134
135 foreach ( $ips as $ip ) {
136 if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
137 return false; // private / reserved / loopback / link-local.
138 }
139 }
140
141 return true;
142 }
143
144 /**
145 * Fetch CSS text from a font-provider URL.
146 *
147 * @param string $url Stylesheet URL.
148 * @return string|null
149 */
150 public function fetch_css( string $url ): ?string {
151 $url = \EasyFonts\Detect\Providers::normalize_url( $url );
152
153 // Cache the fetched CSS so font detection doesn't hit the network on every
154 // page render. Keyed by URL + cache-buster, so any settings/font change
155 // (which bumps the buster) transparently refreshes it.
156 $cache_key = 'easyfonts_css_' . md5( $url . '|' . \EasyFonts\Settings::buster() );
157 $cached = get_transient( $cache_key );
158
159 if ( is_string( $cached ) && '' !== $cached ) {
160 return $cached;
161 }
162
163 // SSRF guard: never fetch a stylesheet from an internal/private address.
164 if ( ! self::is_public_url( $url ) ) {
165 return null;
166 }
167
168 $response = wp_remote_get(
169 $url,
170 array(
171 'timeout' => 10,
172 'user-agent' => apply_filters( 'easyfonts_css_user_agent', self::UA_WOFF2 ),
173 )
174 );
175
176 if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
177 return null;
178 }
179
180 $body = wp_remote_retrieve_body( $response );
181
182 if ( '' === $body ) {
183 return null;
184 }
185
186 set_transient( $cache_key, $body, 7 * DAY_IN_SECONDS );
187
188 return $body;
189 }
190
191 /**
192 * Download a font binary into the cache and return [filename, ext, size].
193 *
194 * @param string $url Font file URL.
195 * @param string $basename Cache basename (without extension).
196 * @param Storage $storage Storage.
197 * @return array{file:string,ext:string,size:int}|null
198 */
199 public function fetch_font( string $url, string $basename, Storage $storage ): ?array {
200 // Never download a font binary during a normal visitor page render — that
201 // is what could stall the request and white-screen the site. On the render
202 // path we serve only already-cached fonts; missing ones are fetched by a
203 // background warm (probe) request. Admin/CLI/warm requests set this true.
204 if ( ! self::$allow_fonts ) {
205 return null;
206 }
207
208 $url = \EasyFonts\Detect\Providers::normalize_url( $url );
209
210 // SSRF guard: only fetch font binaries from recognised provider hosts
211 // (or the site's own origin). A crafted @font-face can otherwise point
212 // src at an internal address; this blocks that without affecting any
213 // legitimate Google/Bunny/wp.com font. Filterable for niche providers.
214 if ( ! self::host_allowed( $url ) ) {
215 return null;
216 }
217
218 $response = wp_remote_get(
219 $url,
220 array(
221 'timeout' => 10,
222 'user-agent' => self::UA_WOFF2,
223 )
224 );
225
226 if ( is_wp_error( $response ) ) {
227 return null;
228 }
229
230 $code = (int) wp_remote_retrieve_response_code( $response );
231
232 if ( $code < 200 || $code >= 300 ) {
233 return null;
234 }
235
236 $body = wp_remote_retrieve_body( $response );
237
238 if ( '' === $body ) {
239 return null;
240 }
241
242 $ct = strtolower( trim( explode( ';', (string) wp_remote_retrieve_header( $response, 'content-type' ) )[0] ) );
243 $ext = self::MIME[ $ct ] ?? $this->ext_from_url( $url );
244
245 $filename = $basename . '.' . $ext;
246
247 if ( ! $storage->write( $filename, $body ) ) {
248 return null;
249 }
250
251 return array(
252 'file' => $filename,
253 'ext' => $ext,
254 'size' => strlen( $body ),
255 );
256 }
257
258 /**
259 * Fetch the raw bytes of a TTF copy of a Google Fonts family for metrics.
260 * Returns the binary, never writes it to disk.
261 *
262 * @param string $css_url Original CSS URL.
263 * @return string|null
264 */
265 public function fetch_ttf_bytes( string $css_url ): ?string {
266 $response = wp_remote_get(
267 $css_url,
268 array(
269 'timeout' => 20,
270 'user-agent' => self::UA_TTF,
271 )
272 );
273
274 if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
275 return null;
276 }
277
278 $css = wp_remote_retrieve_body( $response );
279
280 if ( ! preg_match( '/src\s*:\s*[^;]*?url\(\s*[\'"]?([^\'")]+\.(?:ttf|otf))[\'"]?\s*\)/i', $css, $m ) ) {
281 return null;
282 }
283
284 // The font URL comes out of fetched CSS — guard it the same way.
285 if ( ! self::is_public_url( $m[1] ) ) {
286 return null;
287 }
288
289 $font = wp_remote_get( $m[1], array( 'timeout' => 20 ) );
290
291 if ( is_wp_error( $font ) || 200 !== (int) wp_remote_retrieve_response_code( $font ) ) {
292 return null;
293 }
294
295 $bytes = wp_remote_retrieve_body( $font );
296
297 return '' === $bytes ? null : $bytes;
298 }
299
300 /**
301 * Fetch TTF bytes for a specific Google family/weight purely for metric
302 * extraction. Source-independent: we construct a css2 request from the
303 * family name, so it works whether the font came from a <link>, @import, or
304 * an inline @font-face. Returns binary, never written to disk.
305 *
306 * @param string $family Family name (e.g. "Open Sans").
307 * @param string $weight Representative numeric weight.
308 * @return string|null
309 */
310 public function fetch_family_ttf( string $family, string $weight = '400' ): ?string {
311 $weight = (string) absint( $weight );
312
313 if ( '' === $weight || '0' === $weight ) {
314 $weight = '400';
315 }
316
317 $url = 'https://fonts.googleapis.com/css2?family='
318 . rawurlencode( $family ) . ':wght@' . $weight . '&display=swap';
319 $url = str_replace( '%20', '+', $url );
320
321 return $this->fetch_ttf_bytes( $url );
322 }
323
324 /**
325 * Is this URL host allowed for font-binary downloads? Provider font hosts
326 * plus the site's own origin. Filterable via `easyfonts_font_src_hosts`.
327 *
328 * @param string $url Absolute URL.
329 * @return bool
330 */
331 private static function host_allowed( string $url ): bool {
332 $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
333
334 if ( '' === $host ) {
335 return false;
336 }
337
338 $own = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
339
340 /**
341 * Allowed host suffixes for downloading font binaries.
342 *
343 * @param string[] $hosts
344 */
345 $allowed = apply_filters(
346 'easyfonts_font_src_hosts',
347 array(
348 'fonts.gstatic.com',
349 'fonts.googleapis.com',
350 'fonts.bunny.net',
351 'fonts.wp.com',
352 'fonts-api.wp.com',
353 's.w.org',
354 'i0.wp.com',
355 'i1.wp.com',
356 'i2.wp.com',
357 )
358 );
359
360 if ( '' !== $own ) {
361 $allowed[] = $own;
362 }
363
364 foreach ( $allowed as $candidate ) {
365 $candidate = strtolower( trim( (string) $candidate ) );
366
367 if ( '' === $candidate ) {
368 continue;
369 }
370
371 // Exact host or a subdomain of an allowed host.
372 if ( $host === $candidate || substr( $host, -strlen( '.' . $candidate ) ) === '.' . $candidate ) {
373 return true;
374 }
375 }
376
377 return false;
378 }
379
380 /**
381 * Guess an extension from a URL path.
382 *
383 * @param string $url URL.
384 * @return string
385 */
386 private function ext_from_url( string $url ): string {
387 $ext = strtolower( (string) pathinfo( (string) wp_parse_url( $url, PHP_URL_PATH ), PATHINFO_EXTENSION ) );
388
389 return in_array( $ext, array( 'woff2', 'woff', 'ttf', 'otf', 'eot' ), true ) ? $ext : 'woff2';
390 }
391 }
392