PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.4
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.4
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
← All changes | src/Fonts/Downloader.php +163 -3 2.0.0 → 2.0.4 View file →
@@ -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 ) );