CVE-2023-2531: Improper Restriction of Excessive Authentication Attempts (CWE-307)
value: Improper Restriction of Excessive Authentication Attempts in GitHub repository azuracast/azuracast prior to 0.18.3.
CWE description: The product does not implement sufficient measures to prevent multiple failed authentication attempts within a short time frame, making it more susceptible to brute force attacks.
CVSS v3 base score: 9.8
Published: 2023-05-05T01:15Z
Repository: https://github.com/azuracast/azuracast
Language: PHP
Files changed: CHANGELOG.md, frontend/vue/components/Admin/Settings.vue, frontend/vue/components/Admin/Settings/SecurityPrivacyTab.vue, src/Controller/Api/Admin/RelaysController.php, src/Controller/Api/Stations/Requests/SubmitAction.php, src/Entity/Enums/IpSources.php, src/Entity/Migration/Version20230428062001.php, src/Entity/Settings.php, src/Http/ServerRequest.php, src/RateLimit.php
Commit date: 2023-04-28 01:47:44 -0500
Commit message: Tighten allowed IPs to avoid brute-force workarounds.

Diff: diff --git a/CHANGELOG.md b/CHANGELOG.md
index ee1619108..abac36d98 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,10 +5,14 @@ release channel, you can take advantage of these new features and fixes.
 
 ## New Features/Changes
 
 ## Code Quality/Technical Changes
 
+- In sections of our application that depend on IP addresses, we've tightened our allowed IP addresses significantly to
+  improve security and prevent brute-force flooding. If you're using a reverse proxy or CloudFlare, you should update
+  your "IP Address Source" under the "System Settings" page.
+
 ## Bug Fixes
 
 ---
 
 # AzuraCast 0.18.1 (Apr 21, 2023)
diff --git a/frontend/vue/components/Admin/Settings.vue b/frontend/vue/components/Admin/Settings.vue
index 580f62bef..d1d39639a 100644
--- a/frontend/vue/components/Admin/Settings.vue
+++ b/frontend/vue/components/Admin/Settings.vue
@@ -131,10 +131,11 @@ const {form, v$, ifValid} = useVuelidateOnForm(
 
         analytics: {required},
 
         always_use_ssl: {},
         api_access_control: {},
+        ip_source: {},
 
         check_for_updates: {},
         acme_email: {},
         acme_domains: {},
         mail_enabled: {},
@@ -148,17 +149,18 @@ const {form, v$, ifValid} = useVuelidateOnForm(
         avatar_service: {},
         avatar_default_url: {},
         use_external_album_art_in_apis: {},
         use_external_album_art_when_processing_media: {},
         last_fm_api_key: {},
+
         $validationGroups: {
             generalTab: [
                 'base_url', 'instance_name', 'prefer_browser_url', 'use_radio_proxy',
                 'history_keep_days', 'enable_static_nowplaying', 'enable_advanced_features'
             ],
             securityPrivacyTab: [
-                'analytics', 'always_use_ssl', 'api_access_control'
+                'analytics', 'always_use_ssl', 'ip_source', 'api_access_control'
             ],
             servicesTab: [
                 'check_for_updates',
                 'acme_email', 'acme_domains',
                 'mail_enabled', 'mail_sender_name', 'mail_sender_email',
@@ -177,10 +179,11 @@ const {form, v$, ifValid} = useVuelidateOnForm(
         history_keep_days: 7,
         enable_static_nowplaying: true,
         enable_advanced_features: true,
         analytics: null,
         always_use_ssl: false,
+        ip_source: 'local',
         api_access_control: '*',
         check_for_updates: 1,
         acme_email: '',
         acme_domains: '',
         mail_enabled: false,
diff --git a/frontend/vue/components/Admin/Settings/SecurityPrivacyTab.vue b/frontend/vue/components/Admin/Settings/SecurityPrivacyTab.vue
index ad5e6859c..5ced1c9a1 100644
--- a/frontend/vue/components/Admin/Settings/SecurityPrivacyTab.vue
+++ b/frontend/vue/components/Admin/Settings/SecurityPrivacyTab.vue
@@ -67,13 +67,36 @@
                         $gettext('Set to "Yes" to always use "https://" secure URLs, and to automatically redirect to the secure URL when an insecure URL is visited.')
                     }}
                 </template>
             </b-wrapped-form-checkbox>
 
+            <b-wrapped-form-group
+                id="edit_form_ip_source"
+                class="col-md-6"
+                :field="form.ip_source"
+            >
+                <template #label>
+                    {{ $gettext('IP Address Source') }}
+                </template>
+                <template #description>
+                    {{
+                        $gettext('Customize this setting to ensure you get the correct IP address for remote users. Only change this setting if you use a reverse proxy, either within Docker or a third-party service like CloudFlare.')
+                    }}
+                </template>
+                <template #default="slotProps">
+                    <b-form-radio-group
+                        :id="slotProps.id"
+                        v-model="slotProps.field.$model"
+                        stacked
+                        :options="ipSourceOptions"
+                    />
+                </template>
+            </b-wrapped-form-group>
+
             <b-wrapped-form-group
                 id="edit_form_api_access_control"
-                class="col-md-12"
+                class="col-md-6"
                 :field="form.api_access_control"
             >
                 <template #label>
                     {{ $gettext('API "Access-Control-Allow-Origin" Header') }}
                 </template>
@@ -96,13 +119,34 @@
 
 <script setup>
 import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup.vue";
 import BFormFieldset from "~/components/Form/BFormFieldset.vue";
 import BWrappedFormCheckbox from "~/components/Form/BWrappedFormCheckbox.vue";
+import {useTranslate} from "~/vendor/gettext";
+import {computed} from "vue";
 
 const props = defineProps({
     form: {
         type: Object,
         required: true
     }
 });
+
+const {$gettext} = useTranslate();
+
+const ipSourceOptions = computed(() => {
+    return [
+        {
+            value: 'local',
+            text: $gettext('Local IP (Default)')
+        },
+        {
+            value: 'cloudflare',
+            text: $gettext('CloudFlare (CF-Connecting-IP)')
+        },
+        {
+            value: 'xff',
+            text: $gettext('Reverse Proxy (X-Forwarded-For)')
+        }
+    ]
+});
 </script>
diff --git a/src/Controller/Api/Admin/RelaysController.php b/src/Controller/Api/Admin/RelaysController.php
index 3bc5b8ad7..5f28bb197 100644
--- a/src/Controller/Api/Admin/RelaysController.php
+++ b/src/Controller/Api/Admin/RelaysController.php
@@ -36,11 +36,12 @@ use Psr\Http\Message\ResponseInterface;
 ]
 final class RelaysController
 {
     public function __construct(
         private readonly EntityManagerInterface $em,
-        private readonly Adapters $adapters
+        private readonly Adapters $adapters,
+        private readonly Entity\Repository\SettingsRepository $settingsRepo
     ) {
     }
 
     public function __invoke(
         ServerRequest $request,
@@ -124,11 +125,11 @@ final class RelaysController
 
         if (!empty($body['base_url'])) {
             $base_url = $body['base_url'];
         } else {
             /** @noinspection HttpUrlsUsage */
-            $base_url = 'http://' . $request->getIp();
+            $base_url = 'http://' . $this->settingsRepo->readSettings()->getIp($request);
         }
 
         $relay = $relay_repo->findOneBy(['base_url' => $base_url]);
         if (!$relay instanceof Entity\Relay) {
             $relay = new Entity\Relay($base_url);
diff --git a/src/Controller/Api/Stations/Requests/SubmitAction.php b/src/Controller/Api/Stations/Requests/SubmitAction.php
index becef2072..9b82a1f50 100644
--- a/src/Controller/Api/Stations/Requests/SubmitAction.php
+++ b/src/Controller/Api/Stations/Requests/SubmitAction.php
@@ -4,10 +4,11 @@ declare(strict_types=1);
 
 namespace App\Controller\Api\Stations\Requests;
 
 use App\Entity\Api\Error;
 use App\Entity\Api\Status;
+use App\Entity\Repository\SettingsRepository;
 use App\Entity\Repository\StationRequestRepository;
 use App\Entity\User;
 use App\Exception\InvalidRequestAttribute;
 use App\Http\Response;
 use App\Http\ServerRequest;
@@ -41,11 +42,12 @@ use Psr\Http\Message\ResponseInterface;
     )
 ]
 final class SubmitAction
 {
     public function __construct(
-        private readonly StationRequestRepository $requestRepo
+        private readonly StationRequestRepository $requestRepo,
+        private readonly SettingsRepository $settingsRepo
     ) {
     }
 
     public function __invoke(
         ServerRequest $request,
@@ -62,15 +64,17 @@ final class SubmitAction
         }
 
         $isAuthenticated = ($user instanceof User);
 
         try {
+            $ip = $this->settingsRepo->readSettings()->getIp($request);
+
             $this->requestRepo->submit(
                 $station,
                 $media_id,
                 $isAuthenticated,
-                $request->getIp(),
+                $ip,
                 $request->getHeaderLine('User-Agent')
             );
 
             return $response->withJson(Status::success());
         } catch (Exception $e) {
diff --git a/src/Entity/Enums/IpSources.php b/src/Entity/Enums/IpSources.php
new file mode 100644
index 000000000..271390217
--- /dev/null
+++ b/src/Entity/Enums/IpSources.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Entity\Enums;
+
+use Psr\Http\Message\ServerRequestInterface;
+
+enum IpSources: string
+{
+    case Local = 'local';
+    case XForwardedFor = 'xff';
+    case Cloudflare = 'cloudflare';
+
+    public static function default(): self
+    {
+        return self::Local;
+    }
+
+    public function getIp(ServerRequestInterface $request): string
+    {
+        if (self::Cloudflare === $this) {
+            $ip = $request->getHeaderLine('CF-Connecting-IP');
+            if (!empty($ip)) {
+                return $this->parseIp($ip);
+            }
+        }
+
+        if (self::XForwardedFor === $this) {
+            $ip = $request->getHeaderLine('X-Forwarded-For');
+            if (!empty($ip)) {
+                return $this->parseIp($ip);
+            }
+        }
+
+        $serverParams = $request->getServerParams();
+        $ip = $serverParams['REMOTE_ADDR'] ?? null;
+
+        if (empty($ip)) {
+            throw new \RuntimeException('No IP address attached to this request.');
+        }
+
+        return $this->parseIp($ip);
+    }
+
+    private function parseIp(string $ip): string
+    {
+        // Handle the IP being separated by commas.
+        if (str_contains($ip, ',')) {
+            $ipParts = explode(',', $ip);
+            $ip = array_shift($ipParts);
+        }
+
+        return trim($ip);
+    }
+}
diff --git a/src/Entity/Migration/Version20230428062001.php b/src/Entity/Migration/Version20230428062001.php
new file mode 100644
index 000000000..7dd874488
--- /dev/null
+++ b/src/Entity/Migration/Version20230428062001.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Entity\Migration;
+
+use Doctrine\DBAL\Schema\Schema;
+use Doctrine\Migrations\AbstractMigration;
+
+final class Version20230428062001 extends AbstractMigration
+{
+    public function getDescription(): string
+    {
+        return 'Add "IP Source" setting.';
+    }
+
+    public function up(Schema $schema): void
+    {
+        $this->addSql('ALTER TABLE settings ADD ip_source VARCHAR(50) DEFAULT NULL');
+    }
+
+    public function down(Schema $schema): void
+    {
+        $this->addSql('ALTER TABLE settings DROP ip_source');
+    }
+}
diff --git a/src/Entity/Settings.php b/src/Entity/Settings.php
index 0ddb7c2d7..e44bb49b9 100644
--- a/src/Entity/Settings.php
+++ b/src/Entity/Settings.php
@@ -12,10 +12,11 @@ use App\Service\Avatar;
 use App\Utilities\Strings;
 use App\Utilities\Urls;
 use Doctrine\ORM\Mapping as ORM;
 use InvalidArgumentException;
 use OpenApi\Attributes as OA;
+use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Message\UriInterface;
 use RuntimeException;
 use Stringable;
 use Symfony\Component\Serializer\Annotation\Groups;
 use Symfony\Component\Validator\Constraints as Assert;
@@ -1057,10 +1058,41 @@ class Settings implements Stringable
         }
 
         $this->acme_domains = $acme_domains;
     }
 
+    #[
+        OA\Property(description: "IP Address Source"),
+        ORM\Column(length: 50, nullable: true),
+        Groups(self::GROUP_GENERAL)
+    ]
+    protected ?string $ip_source = null;
+
+    public function getIpSource(): string
+    {
+        return $this->ip_source ?? Entity\Enums\IpSources::default()->value;
+    }
+
+    public function getIpSourceEnum(): Entity\Enums\IpSources
+    {
+        return Entity\Enums\IpSources::tryFrom($this->ip_source ?? '') ?? Entity\Enums\IpSources::default();
+    }
+
+    public function getIp(ServerRequestInterface $request): string
+    {
+        return $this->getIpSourceEnum()->getIp($request);
+    }
+
+    public function setIpSource(?string $ipSource): void
+    {
+        if (null !== $ipSource && null === Entity\Enums\IpSources::tryFrom($ipSource)) {
+            throw new InvalidArgumentException('Invalid IP source.');
+        }
+
+        $this->ip_source = $ipSource;
+    }
+
     public function __toString(): string
     {
         return 'Settings';
     }
 }
diff --git a/src/Http/ServerRequest.php b/src/Http/ServerRequest.php
index e0265e0a0..acb33dc06 100644
--- a/src/Http/ServerRequest.php
+++ b/src/Http/ServerRequest.php
@@ -12,11 +12,10 @@ use App\Enums\SupportedLocales;
 use App\Exception;
 use App\RateLimit;
 use App\Session;
 use App\View;
 use Mezzio\Session\SessionInterface;
-use RuntimeException;
 
 final class ServerRequest extends \Slim\Http\ServerRequest
 {
     public const ATTR_VIEW = 'app_view';
     public const ATTR_SESSION = 'app_session';
@@ -120,32 +119,6 @@ final class ServerRequest extends \Slim\Http\ServerRequest
             );
         }
 
         return $object;
     }
-
-    /**
-     * Get the remote user's IP address as indicated by HTTP headers.
-     */
-    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);
-    }
 }
diff --git a/src/RateLimit.php b/src/RateLimit.php
index 5d6ea628d..e1c3d1d99 100644
--- a/src/RateLimit.php
+++ b/src/RateLimit.php
@@ -2,10 +2,11 @@
 
 declare(strict_types=1);
 
 namespace App;
 
+use App\Entity\Repository\SettingsRepository;
 use App\Http\ServerRequest;
 use App\Lock\LockFactory;
 use Psr\Cache\CacheItemPoolInterface;
 use Symfony\Component\Cache\Adapter\ProxyAdapter;
 use Symfony\Component\RateLimiter\RateLimiterFactory;
@@ -16,10 +17,11 @@ final class RateLimit
     private CacheItemPoolInterface $psr6Cache;
 
     public function __construct(
         private readonly LockFactory $lockFactory,
         private readonly Environment $environment,
+        private readonly SettingsRepository $settingsRepo,
         CacheItemPoolInterface $cacheItemPool
     ) {
         $this->psr6Cache = new ProxyAdapter($cacheItemPool, 'ratelimit.');
     }
 
@@ -39,11 +41,13 @@ final class RateLimit
     ): void {
         if ($this->environment->isTesting() || $this->environment->isCli()) {
             return;
         }
 
-        $ipKey = str_replace([':', '.'], '_', $request->getIp());
+        $ip = $this->settingsRepo->readSettings()->getIp($request);
+
+        $ipKey = str_replace([':', '.'], '_', $ip);
         $this->checkRateLimit($groupName, $ipKey, $interval, $limit);
     }
 
     /**
      * @param string $groupName

Vulnerable code:                 'analytics', 'always_use_ssl', 'api_access_control'
                class="col-md-12"
        private readonly Adapters $adapters
            $base_url = 'http://' . $request->getIp();
        private readonly StationRequestRepository $requestRepo
                $request->getIp(),
use RuntimeException;

    /**
     * Get the remote user's IP address as indicated by HTTP headers.
     */
    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);
    }
        $ipKey = str_replace([':', '.'], '_', $request->getIp());
Fixed code: - In sections of our application that depend on IP addresses, we've tightened our allowed IP addresses significantly to
  improve security and prevent brute-force flooding. If you're using a reverse proxy or CloudFlare, you should update
  your "IP Address Source" under the "System Settings" page.

        ip_source: {},

                'analytics', 'always_use_ssl', 'ip_source', 'api_access_control'
        ip_source: 'local',
            <b-wrapped-form-group
                id="edit_form_ip_source"
                class="col-md-6"
                :field="form.ip_source"
            >
                <template #label>
                    {{ $gettext('IP Address Source') }}
                </template>
                <template #description>
                    {{
                        $gettext('Customize this setting to ensure you get the correct IP address for remote users. Only change this setting if you use a reverse proxy, either within Docker or a third-party service like CloudFlare.')
                    }}
                </template>
                <template #default="slotProps">
                    <b-form-radio-group
                        :id="slotProps.id"
                        v-model="slotProps.field.$model"
                        stacked
                        :options="ipSourceOptions"
                    />
                </template>
            </b-wrapped-form-group>

                class="col-md-6"
import {useTranslate} from "~/vendor/gettext";
import {computed} from "vue";

const {$gettext} = useTranslate();

const ipSourceOptions = computed(() => {
    return [
        {
            value: 'local',
            text: $gettext('Local IP (Default)')
        },
        {
            value: 'cloudflare',
            text: $gettext('CloudFlare (CF-Connecting-IP)')
        },
        {
            value: 'xff',
            text: $gettext('Reverse Proxy (X-Forwarded-For)')
        }
    ]
});
        private readonly Adapters $adapters,
        private readonly Entity\Repository\SettingsRepository $settingsRepo
            $base_url = 'http://' . $this->settingsRepo->readSettings()->getIp($request);
use App\Entity\Repository\SettingsRepository;
        private readonly StationRequestRepository $requestRepo,
        private readonly SettingsRepository $settingsRepo
            $ip = $this->settingsRepo->readSettings()->getIp($request);

                $ip,
<?php

declare(strict_types=1);

namespace App\Entity\Enums;

use Psr\Http\Message\ServerRequestInterface;

enum IpSources: string
{
    case Local = 'local';
    case XForwardedFor = 'xff';
    case Cloudflare = 'cloudflare';

    public static function default(): self
    {
        return self::Local;
    }

    public function getIp(ServerRequestInterface $request): string
    {
        if (self::Cloudflare === $this) {
            $ip = $request->getHeaderLine('CF-Connecting-IP');
            if (!empty($ip)) {
                return $this->parseIp($ip);
            }
        }

        if (self::XForwardedFor === $this) {
            $ip = $request->getHeaderLine('X-Forwarded-For');
            if (!empty($ip)) {
                return $this->parseIp($ip);
            }
        }

        $serverParams = $request->getServerParams();
        $ip = $serverParams['REMOTE_ADDR'] ?? null;

        if (empty($ip)) {
            throw new \RuntimeException('No IP address attached to this request.');
        }

        return $this->parseIp($ip);
    }

    private function parseIp(string $ip): string
    {
        // Handle the IP being separated by commas.
        if (str_contains($ip, ',')) {
            $ipParts = explode(',', $ip);
            $ip = array_shift($ipParts);
        }

        return trim($ip);
    }
}
<?php

declare(strict_types=1);

namespace App\Entity\Migration;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20230428062001 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Add "IP Source" setting.';
    }

    public function up(Schema $schema): void
    {
        $this->addSql('ALTER TABLE settings ADD ip_source VARCHAR(50) DEFAULT NULL');
    }

    public function down(Schema $schema): void
    {
        $this->addSql('ALTER TABLE settings DROP ip_source');
    }
}
use Psr\Http\Message\ServerRequestInterface;
    #[
        OA\Property(description: "IP Address Source"),
        ORM\Column(length: 50, nullable: true),
        Groups(self::GROUP_GENERAL)
    ]
    protected ?string $ip_source = null;

    public function getIpSource(): string
    {
        return $this->ip_source ?? Entity\Enums\IpSources::default()->value;
    }

    public function getIpSourceEnum(): Entity\Enums\IpSources
    {
        return Entity\Enums\IpSources::tryFrom($this->ip_source ?? '') ?? Entity\Enums\IpSources::default();
    }

    public function getIp(ServerRequestInterface $request): string
    {
        return $this->getIpSourceEnum()->getIp($request);
    }

    public function setIpSource(?string $ipSource): void
    {
        if (null !== $ipSource && null === Entity\Enums\IpSources::tryFrom($ipSource)) {
            throw new InvalidArgumentException('Invalid IP source.');
        }

        $this->ip_source = $ipSource;
    }

use App\Entity\Repository\SettingsRepository;
        private readonly SettingsRepository $settingsRepo,
        $ip = $this->settingsRepo->readSettings()->getIp($request);

        $ipKey = str_replace([':', '.'], '_', $ip);