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 +109 -1 2.0.1 → 2.0.4 View file →
@@ -47,8 +47,13 @@
47 47 */
48 48 const UA_WOFF2 = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0';
49 49
50 50 /**
51 + * Transient value marking a recently-failed CSS fetch (negative cache).
52 + */
53 + const FAIL_MARKER = '__easyfonts_fetch_failed__';
54 +
55 + /**
51 56 * Old UA that makes the API return TTF (used for metric extraction only).
52 57 */
53 58 const UA_TTF = 'Mozilla/5.0 (Windows NT 6.1; rv:27.0) Gecko/20100101 Firefox/27.0';
54 59
@@ -69,8 +74,80 @@
69 74 'application/vnd.ms-fontobject' => 'eot',
70 75 );
71 76
72 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 + /**
73 150 * Fetch CSS text from a font-provider URL.
74 151 *
75 152 * @param string $url Stylesheet URL.
76 153 * @return string|null
@@ -83,21 +160,46 @@
83 160 // (which bumps the buster) transparently refreshes it.
84 161 $cache_key = 'easyfonts_css_' . md5( $url . '|' . \EasyFonts\Settings::buster() );
85 162 $cached = get_transient( $cache_key );
86 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 +
87 170 if ( is_string( $cached ) && '' !== $cached ) {
88 171 return $cached;
89 172 }
90 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 +
91 187 $response = wp_remote_get(
92 188 $url,
93 189 array(
94 - 'timeout' => 10,
190 + /**
191 + * Timeout for stylesheet fetches (seconds).
192 + *
193 + * @param int $timeout
194 + */
195 + 'timeout' => (int) apply_filters( 'easyfonts_css_timeout', 10 ),
95 196 'user-agent' => apply_filters( 'easyfonts_css_user_agent', self::UA_WOFF2 ),
96 197 )
97 198 );
98 199
99 200 if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
201 + set_transient( $cache_key, self::FAIL_MARKER, $fail_ttl );
100 202 return null;
101 203 }
102 204
103 205 $body = wp_remote_retrieve_body( $response );
@@ -102,8 +204,9 @@
102 204
103 205 $body = wp_remote_retrieve_body( $response );
104 206
105 207 if ( '' === $body ) {
208 + set_transient( $cache_key, self::FAIL_MARKER, $fail_ttl );
106 209 return null;
107 210 }
108 211
109 212 set_transient( $cache_key, $body, 7 * DAY_IN_SECONDS );
@@ -200,8 +303,13 @@
200 303
201 304 $css = wp_remote_retrieve_body( $response );
202 305
203 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] ) ) {
204 312 return null;
205 313 }
206 314
207 315 $font = wp_remote_get( $m[1], array( 'timeout' => 20 ) );