| @@ -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 ) ); |