|
17 | 17 |
|
18 | 18 | use SMF\Lang; |
19 | 19 | use SMF\Url; |
| 20 | +use SMF\IP; |
20 | 21 |
|
21 | 22 | /** |
22 | 23 | * Class SearchApi |
@@ -72,6 +73,13 @@ abstract class WebFetchApi implements WebFetchApiInterface |
72 | 73 | */ |
73 | 74 | private static array $still_alive = []; |
74 | 75 |
|
| 76 | + /** |
| 77 | + * @var array |
| 78 | + * |
| 79 | + * Cache for the results of self::isFetchSafe() |
| 80 | + */ |
| 81 | + private static array $safe_hosts = []; |
| 82 | + |
75 | 83 | /**************** |
76 | 84 | * Public methods |
77 | 85 | ****************/ |
@@ -122,7 +130,17 @@ public function resultRaw(?int $response_number = null): array |
122 | 130 | public static function fetch(Url|string $url, string|array $post_data = [], bool $keep_alive = false): string|false |
123 | 131 | { |
124 | 132 | if (!($url instanceof Url)) { |
125 | | - $url = Url::create($url, true)->validate()->toAscii(); |
| 133 | + $url = Url::create($url, true)->validate(); |
| 134 | + } |
| 135 | + |
| 136 | + $url->toAscii(); |
| 137 | + |
| 138 | + // SSRF guard: refuse loopback/private/link-local/reserved targets and |
| 139 | + // non-fetchable schemes before any connection is attempted. |
| 140 | + if (!self::isFetchSafe($url)) { |
| 141 | + trigger_error(Lang::getTxt('fetch_web_data_bad_url', [__METHOD__], file: 'Errors'), E_USER_NOTICE); |
| 142 | + |
| 143 | + return false; |
126 | 144 | } |
127 | 145 |
|
128 | 146 | // No scheme? No data for you! |
@@ -174,6 +192,81 @@ public static function fetch(Url|string $url, string|array $post_data = [], bool |
174 | 192 | return $fetcher->result('body'); |
175 | 193 | } |
176 | 194 |
|
| 195 | + /** |
| 196 | + * Decides whether a URL is safe to fetch from the server. |
| 197 | + * |
| 198 | + * Rejects URLs whose scheme is not in the fetchable set, and URLs whose |
| 199 | + * host resolves (or is) a non-global IP address: loopback, private, |
| 200 | + * link-local (incl. 169.254.0.0/16 cloud metadata), or other reserved |
| 201 | + * ranges. This is the single chokepoint that prevents the avatar, proxy, |
| 202 | + * getMimeType, and task fetchers from being used as SSRF primitives. It is |
| 203 | + * also re-applied to each redirect target by the fetchers. |
| 204 | + * |
| 205 | + * @param \SMF\Url $url The URL to check. |
| 206 | + * @param array $allowed_schemes Optional list of allowed URL schemes. |
| 207 | + * If empty, all schemes that have handlers are allowed. Otherwise, only |
| 208 | + * URLs using the one of the specified schemes will be allowed. |
| 209 | + * Default: [] |
| 210 | + * @return bool True if the URL is safe to fetch, false otherwise. |
| 211 | + */ |
| 212 | + public static function isFetchSafe(Url $url, array $allowed_schemes = []): bool |
| 213 | + { |
| 214 | + // Only known fetchable schemes. |
| 215 | + if ( |
| 216 | + empty($url->scheme) |
| 217 | + || !isset(self::$scheme_handlers[$url->scheme]) |
| 218 | + || (!empty($allowed_schemes) && !\in_array($url->scheme, $allowed_schemes)) |
| 219 | + ) { |
| 220 | + return false; |
| 221 | + } |
| 222 | + |
| 223 | + if ( |
| 224 | + // Must have a host. |
| 225 | + empty($url->host) |
| 226 | + // Reject reserved TLDs, since they are never in public DNS. |
| 227 | + || preg_match('/\b(?' . '>example|local(?' . '>host)?|onion|test|alt|in(?' . '>ternal|valid))$/', $url->host) |
| 228 | + ) { |
| 229 | + return false; |
| 230 | + } |
| 231 | + |
| 232 | + // Avoid unnecessary repetition. |
| 233 | + if (isset(self::$safe_hosts[$url->host])) { |
| 234 | + return self::$safe_hosts[$url->host]; |
| 235 | + } |
| 236 | + |
| 237 | + // Resolve the host to its address(es). A literal IP resolves to itself. |
| 238 | + $ips = []; |
| 239 | + |
| 240 | + if (filter_var($url->host, FILTER_VALIDATE_IP) !== false) { |
| 241 | + $ips[] = $url->host; |
| 242 | + } else { |
| 243 | + $records = @dns_get_record($url->host, DNS_A | DNS_AAAA); |
| 244 | + |
| 245 | + foreach ((array) $records as $record) { |
| 246 | + if (!empty($record['ip'])) { |
| 247 | + $ips[] = $record['ip']; |
| 248 | + } |
| 249 | + |
| 250 | + if (!empty($record['ipv6'])) { |
| 251 | + $ips[] = $record['ipv6']; |
| 252 | + } |
| 253 | + } |
| 254 | + } |
| 255 | + |
| 256 | + if ( |
| 257 | + // Couldn't resolve to anything: refuse rather than guess. |
| 258 | + $ips === [] |
| 259 | + // EVERY resolved address must be a global, public, unicast IP. |
| 260 | + || $ips !== array_filter($ips, fn($ip) => IP::create($ip)->isValid(FILTER_FLAG_GLOBAL_RANGE)), |
| 261 | + ) { |
| 262 | + self::$safe_hosts[$url->host] = false; |
| 263 | + } |
| 264 | + |
| 265 | + self::$safe_hosts[$url->host] ??= true; |
| 266 | + |
| 267 | + return self::$safe_hosts[$url->host]; |
| 268 | + } |
| 269 | + |
177 | 270 | /****************** |
178 | 271 | * Internal methods |
179 | 272 | ******************/ |
|
0 commit comments