CVE-2023-2531 (published 2023-05-05T01:15Z)

Improper Restriction of Excessive Authentication Attempts in GitHub repository azuracast/azuracast prior to 0.18.3.

CVSS v3 base score: 9.8, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-307
cwe_name: Improper Restriction of Excessive Authentication Attempts
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.
extended_description: 
            
            
            
         
         

Fix information:
repo_url: https://github.com/azuracast/azuracast
commit_details: repo_url: https://github.com/azuracast/azuracast
author: Buster Neece
author_date: 2023-04-28 01:47:44-05:00
msg: Tighten allowed IPs to avoid brute-force workarounds.
repository: repo_url: https://github.com/azuracast/azuracast
repo_name: AzuraCast/AzuraCast
description: A self-hosted web radio management suite, including turnkey installer tools for the full radio software stack and a modern, easy-to-use web app to manage your stations.
repo_language: PHP
owner: azuracast
file_changes: filename: CHANGELOG.md
change_type: ModificationType.MODIFY
diff: @@ -7,6 +7,10 @@ release channel, you can take advantage of these new features and fixes.
 
 ## 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
 
 ---

programming_language: Markdown
filename: Settings.vue
change_type: ModificationType.MODIFY
diff: @@ -133,6 +133,7 @@ const {form, v$, ifValid} = useVuelidateOnForm(
 
         always_use_ssl: {},
         api_access_control: {},
+        ip_source: {},
 
         check_for_updates: {},
         acme_email: {},
@@ -150,13 +151,14 @@ const {form, v$, ifValid} = useVuelidateOnForm(
         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',
@@ -179,6 +181,7 @@ const {form, v$, ifValid} = useVuelidateOnForm(
         enable_advanced_features: true,
         analytics: null,
         always_use_ssl: false,
+        ip_source: 'local',
         api_access_control: '*',
         check_for_updates: 1,
         acme_email: '',

programming_language: TypeScript
filename: SecurityPrivacyTab.vue
change_type: ModificationType.MODIFY
diff: @@ -69,9 +69,32 @@
                 </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>
@@ -98,6 +121,8 @@
 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: {
@@ -105,4 +130,23 @@ const props = defineProps({
         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>

programming_language: HTML
filename: RelaysController.php
change_type: ModificationType.MODIFY
diff: @@ -38,7 +38,8 @@ final class RelaysController
 {
     public function __construct(
         private readonly EntityManagerInterface $em,
-        private readonly Adapters $adapters
+        private readonly Adapters $adapters,
+        private readonly Entity\Repository\SettingsRepository $settingsRepo
     ) {
     }
 
@@ -126,7 +127,7 @@ final class RelaysController
             $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]);

programming_language: PHP
filename: SubmitAction.php
change_type: ModificationType.MODIFY
diff: @@ -6,6 +6,7 @@ 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;
@@ -43,7 +44,8 @@ use Psr\Http\Message\ResponseInterface;
 final class SubmitAction
 {
     public function __construct(
-        private readonly StationRequestRepository $requestRepo
+        private readonly StationRequestRepository $requestRepo,
+        private readonly SettingsRepository $settingsRepo
     ) {
     }
 
@@ -64,11 +66,13 @@ 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')
             );
 

programming_language: PHP
filename: IpSources.php
change_type: ModificationType.ADD
diff: @@ -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);
+    }
+}

programming_language: PHP
filename: Version20230428062001.php
change_type: ModificationType.ADD
diff: @@ -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');
+    }
+}

programming_language: PHP
filename: Settings.php
change_type: ModificationType.MODIFY
diff: @@ -14,6 +14,7 @@ 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;
@@ -1059,6 +1060,37 @@ 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';

programming_language: PHP
filename: ServerRequest.php
change_type: ModificationType.MODIFY
diff: @@ -14,7 +14,6 @@ use App\RateLimit;
 use App\Session;
 use App\View;
 use Mezzio\Session\SessionInterface;
-use RuntimeException;
 
 final class ServerRequest extends \Slim\Http\ServerRequest
 {
@@ -122,30 +121,4 @@ 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);
-    }
 }

programming_language: PHP
filename: RateLimit.php
change_type: ModificationType.MODIFY
diff: @@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace App;
 
+use App\Entity\Repository\SettingsRepository;
 use App\Http\ServerRequest;
 use App\Lock\LockFactory;
 use Psr\Cache\CacheItemPoolInterface;
@@ -18,6 +19,7 @@ final class RateLimit
     public function __construct(
         private readonly LockFactory $lockFactory,
         private readonly Environment $environment,
+        private readonly SettingsRepository $settingsRepo,
         CacheItemPoolInterface $cacheItemPool
     ) {
         $this->psr6Cache = new ProxyAdapter($cacheItemPool, 'ratelimit.');
@@ -41,7 +43,9 @@ final class RateLimit
             return;
         }
 
-        $ipKey = str_replace([':', '.'], '_', $request->getIp());
+        $ip = $this->settingsRepo->readSettings()->getIp($request);
+
+        $ipKey = str_replace([':', '.'], '_', $ip);
         $this->checkRateLimit($groupName, $ipKey, $interval, $limit);
     }
 

programming_language: PHP