Skip to content

Commit b4d23df

Browse files
Merge commit from fork
[2.1] Rejects internal/loopback/link-local fetch targets in fetch_web_data()
2 parents 3c98cab + 10a998c commit b4d23df

3 files changed

Lines changed: 91 additions & 7 deletions

File tree

Sources/Class-CurlFetchWeb.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,16 @@ private function set_options()
340340
*/
341341
private function redirect($target_url, $referer_url)
342342
{
343+
// SSRF guard: re-validate the redirect target before following it, so a
344+
// 302 -> http://127.0.0.1/ (or link-local cloud metadata) is refused.
345+
if (!is_fetch_safe($target_url))
346+
{
347+
if (isset($this->response[$this->current_redirect - 1]))
348+
$this->response[$this->current_redirect - 1]['success'] = false;
349+
350+
return;
351+
}
352+
343353
// no no I last saw that over there ... really, 301, 302, 307
344354
$this->set_options();
345355
$this->options[CURLOPT_REFERER] = $referer_url;

Sources/Subs.php

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4030,8 +4030,15 @@ function get_proxied_url($url)
40304030
$parsedurl = parse_iri($url);
40314031

40324032
// Don't bother with HTTPS URLs, schemeless URLs, or obviously invalid URLs
4033-
// Don't proxy localhost or IP addresses, either
4034-
if (empty($parsedurl['scheme']) || empty($parsedurl['host']) || empty($parsedurl['path']) || $parsedurl['scheme'] === 'https' || $parsedurl['host'] === 'localhost' || filter_var($parsedurl['host'], FILTER_VALIDATE_IP) !== false)
4033+
if (empty($parsedurl['scheme']) || empty($parsedurl['host']) || empty($parsedurl['path']) || $parsedurl['scheme'] === 'https')
4034+
return $url;
4035+
4036+
// Don't proxy URLs whose hosts are private or reserved IP addresses
4037+
if (filter_var($parsedurl['host'], FILTER_VALIDATE_IP) !== false && filter_var($parsedurl['host'], FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE | FILTER_FLAG_NO_PRIV_RANGE) === false)
4038+
return $url;
4039+
4040+
// Don't proxy URLs with domains that aren't part of public DNS
4041+
if (preg_match('/\b(?' . '>example|local(?' . '>host)?|onion|test|alt|in(?' . '>ternal|valid))$/', $parsedurl['host']))
40354042
return $url;
40364043

40374044
// We don't need to proxy our own resources
@@ -6117,6 +6124,15 @@ function fetch_web_data($url, $post_data = '', $keep_alive = false, $redirection
61176124
global $webmaster_email, $sourcedir, $txt;
61186125
static $keep_alive_dom = null, $keep_alive_fp = null;
61196126

6127+
// SSRF guard: refuse loopback/private/link-local/reserved targets and
6128+
// non-fetchable schemes before any connection is attempted.
6129+
if (!is_fetch_safe($url))
6130+
{
6131+
loadLanguage('Errors');
6132+
trigger_error($txt['fetch_web_data_bad_url'], E_USER_NOTICE);
6133+
return false;
6134+
}
6135+
61206136
preg_match('~^(http|ftp)(s)?://([^/:]+)(:(\d+))?(.+)$~', iri_to_url($url), $match);
61216137

61226138
// No scheme? No data for you!
@@ -6285,6 +6301,64 @@ function fetch_web_data($url, $post_data = '', $keep_alive = false, $redirection
62856301
return $data;
62866302
}
62876303

6304+
/**
6305+
* Decides whether a URL is safe to fetch from the server.
6306+
*
6307+
* Rejects URLs whose scheme is not in the fetchable set, and URLs whose
6308+
* host resolves (or is) a non-global IP address: loopback, private,
6309+
* link-local (incl. 169.254.0.0/16 cloud metadata), or other reserved
6310+
* ranges. This is the single chokepoint that prevents the avatar, proxy,
6311+
* get_mime_type, and task fetchers from being used as SSRF primitives. It is
6312+
* also re-applied to each redirect target by the fetchers.
6313+
*
6314+
* @param string $url The URL to check.
6315+
* @return bool True if the URL is safe to fetch, false otherwise.
6316+
*/
6317+
function is_fetch_safe($url)
6318+
{
6319+
static $safe_hosts = array();
6320+
6321+
$parsedurl = parse_iri(iri_to_url($url));
6322+
6323+
if (empty($parsedurl['scheme']) || !in_array($parsedurl['scheme'], array('http', 'https', 'ftp', 'ftps')) || empty($parsedurl['host']) || preg_match('/\b(?' . '>example|local(?' . '>host)?|onion|test|alt|in(?' . '>ternal|valid))$/', $parsedurl['host']))
6324+
return false;
6325+
6326+
// Avoid unnecessary repetition.
6327+
if (isset($safe_hosts[$parsedurl['host']]))
6328+
return $safe_hosts[$parsedurl['host']];
6329+
6330+
// Resolve the host to its address(es). A literal IP resolves to itself.
6331+
$ips = array();
6332+
6333+
if (filter_var($parsedurl['host'], FILTER_VALIDATE_IP) !== false)
6334+
{
6335+
$ips[] = $parsedurl['host'];
6336+
}
6337+
else
6338+
{
6339+
$records = @dns_get_record($parsedurl['host'], DNS_A | DNS_AAAA);
6340+
6341+
foreach ((array) $records as $record)
6342+
{
6343+
if (!empty($record['ip']))
6344+
$ips[] = $record['ip'];
6345+
6346+
if (!empty($record['ipv6']))
6347+
$ips[] = $record['ipv6'];
6348+
}
6349+
}
6350+
6351+
$safe_hosts[$parsedurl['host']] = !empty($ips) && $ips === array_filter(
6352+
$ips,
6353+
function ($ip)
6354+
{
6355+
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE | FILTER_FLAG_NO_PRIV_RANGE);
6356+
}
6357+
);
6358+
6359+
return $safe_hosts[$parsedurl['host']];
6360+
}
6361+
62886362
/**
62896363
* Attempts to determine the MIME type of some data or a file.
62906364
*

proxy.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,6 @@ public function checkRequest()
125125
if (!mkdir($this->cache) || !copy(dirname($this->cache) . '/index.php', $this->cache . '/index.php'))
126126
return false;
127127

128-
// Basic sanity check
129-
$_GET['request'] = validate_iri($_GET['request']);
130-
131128
// We aren't going anywhere without these
132129
if (empty($_GET['hash']) || empty($_GET['request']))
133130
return false;
@@ -137,9 +134,12 @@ public function checkRequest()
137134

138135
// Just in case...
139136
if (
140-
filter_var(parse_url($request, PHP_URL_HOST), FILTER_VALIDATE_IP) !== false
141-
|| parse_url($request, PHP_URL_HOST) === 'localhost'
137+
// Basic sanity check.
138+
!validate_iri($request)
139+
// Don't proxy our own resources.
142140
|| parse_url($request, PHP_URL_HOST) === parse_url($boardurl, PHP_URL_HOST)
141+
// SSRF protection: don't proxy localhost, private or reserved IPs, etc.
142+
|| !is_fetch_safe($request)
143143
) {
144144
return false;
145145
}

0 commit comments

Comments
 (0)