VYPR
Medium severity6.3NVD Advisory· Published Jun 12, 2026

CVE-2026-50552

CVE-2026-50552

Description

Koel prior to 9.7.1 SSRF in radio station creation due to missing 'bail' validation, allowing authenticated non-admin users to probe internal networks.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Koel prior to 9.7.1 SSRF in radio station creation due to missing 'bail' validation, allowing authenticated non-admin users to probe internal networks.

Vulnerability

Koel versions prior to 9.7.1 contain a Server-Side Request Forgery (SSRF) vulnerability in the radio station creation endpoint (POST /api/radio/stations). The validation rules for the url field are listed without the bail keyword, causing the HasAudioContentType rule—which issues HTTP requests to the supplied URL—to execute even after the SafeUrl rule has rejected a private or reserved address. Any authenticated, non-admin user can exploit this by providing a URL pointing to an internal host [1][2].

Exploitation

An attacker needs a valid authenticated session with a non-admin account. They craft a POST request to /api/radio/stations with a url parameter pointing to a private IP address (e.g., http://169.254.169.254, http://127.0.0.1:6379, or http://192.168.0.1). The SafeUrl rule fails, but without bail, the HasAudioContentType rule still executes and makes an HTTP HEAD or GET request to the specified internal address. The response body is not returned to the attacker, but the distinct validation error messages (from SafeUrl vs. HasAudioContentType) allow the attacker to determine whether the internal host is reachable—forming a reliable internal-network reachability oracle [2].

Impact

This is a blind SSRF vulnerability. The attacker can probe internal hosts and services, potentially discovering exposed internal endpoints (e.g., cloud metadata services, databases, internal APIs). While the response body is not exfiltrated, the reachability oracle enables internal network mapping and may allow interaction with state-changing internal endpoints that respond to GET or HEAD requests. The confidentiality impact is low (information about reachable hosts) and integrity impact is low (possible action on internal services) [2].

Mitigation

The vulnerability is fixed in Koel version 9.7.1. The fix adds the bail keyword to the validation rules, ensuring that once SafeUrl fails, subsequent rules (including HasAudioContentType) are not executed. The commit [1] includes the necessary changes. Users should upgrade to Koel 9.7.1 or later. No known workarounds are available [1][2].

AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Koel/Koelreferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: <9.7.1

Patches

1
5f6ce2cefd08

fix: close remaining SSRF advisories (DNS rebinding, IPv6 transition, bail) (#2549)

https://github.com/koel/koelPhan AnJun 4, 2026via nvd-ref
28 files changed · +557 360
  • app/Ai/Tools/AddRadioStation.php+1 0 modified
    @@ -43,6 +43,7 @@ public function handle(Request $request): Stringable|string
     
             $validator = Validator::make(['url' => $request['url']], [
                 'url' => [
    +                'bail',
                     'required',
                     'url',
                     Rule::unique('radio_stations')->where(static fn (Builder $query) => $query->where('user_id', $userId)),
    
  • app/Helpers/Network.php+0 69 removed
    @@ -1,69 +0,0 @@
    -<?php
    -
    -namespace App\Helpers;
    -
    -use Illuminate\Support\Uri;
    -use Throwable;
    -
    -class Network
    -{
    -    private const array SAFE_URL_SCHEMES = ['http', 'https'];
    -
    -    /**
    -     * Check if a URL is safe to reach: HTTP/HTTPS scheme + a public host.
    -     * Does NOT perform any network calls beyond DNS resolution.
    -     * For full validation including effective-URL-after-redirect, use the SafeUrl validation rule.
    -     */
    -    public function isSafeUrl(string $url): bool
    -    {
    -        try {
    -            $uri = Uri::of($url);
    -        } catch (Throwable) {
    -            return false;
    -        }
    -
    -        if (!in_array($uri->scheme(), self::SAFE_URL_SCHEMES, true)) {
    -            return false;
    -        }
    -
    -        $host = $uri->host();
    -
    -        return $host !== '' && $this->isPublicHost($host);
    -    }
    -
    -    /**
    -     * Check if a host resolves only to public (non-private, non-reserved) IP addresses.
    -     * Validates both A (IPv4) and AAAA (IPv6) records. All resolved IPs must be public.
    -     */
    -    public function isPublicHost(string $host): bool
    -    {
    -        if (filter_var($host, FILTER_VALIDATE_IP)) {
    -            return (
    -                filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false
    -            );
    -        }
    -
    -        try {
    -            $records = array_merge(dns_get_record($host, DNS_A) ?: [], dns_get_record($host, DNS_AAAA) ?: []);
    -        } catch (Throwable) {
    -            return false;
    -        }
    -
    -        if ($records === []) {
    -            return false;
    -        }
    -
    -        foreach ($records as $record) {
    -            $ip = $record['ip'] ?? $record['ipv6'] ?? null;
    -
    -            if (
    -                !$ip
    -                || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
    -            ) {
    -                return false;
    -            }
    -        }
    -
    -        return true;
    -    }
    -}
    
  • app/Helpers/SafeHttp.php+0 80 removed
    @@ -1,80 +0,0 @@
    -<?php
    -
    -namespace App\Helpers;
    -
    -use App\Exceptions\UnsafeUrlException;
    -use GuzzleHttp\Client;
    -use GuzzleHttp\HandlerStack;
    -use GuzzleHttp\Middleware;
    -use Psr\Http\Message\RequestInterface;
    -use Psr\Http\Message\ResponseInterface;
    -use Psr\Http\Message\UriInterface;
    -
    -/**
    - * Defenses for SSRF on outbound HTTP. Every call site that follows redirects
    - * needs per-hop validation: an attacker-controlled URL can pass the initial
    - * SafeUrl check, then 302 to 127.0.0.1, 169.254.169.254, etc.
    - *
    - * Consumers using Laravel Http facade: `Http::withOptions(SafeHttp::redirectOptions())`.
    - * Consumers needing a PSR-18 client (e.g. Poddle): `SafeHttp::guzzleClient()`.
    - *
    - * Note: this addresses redirect-SSRF only. DNS rebinding via TOCTOU between
    - * the validator's DNS lookup and the HTTP client's connect-time lookup is a
    - * separate concern that requires IP pinning (e.g. curl's CURLOPT_RESOLVE) and
    - * is tracked separately.
    - */
    -class SafeHttp
    -{
    -    public function __construct(
    -        private readonly Network $network,
    -    ) {}
    -
    -    /**
    -     * Returns the `allow_redirects` options that re-validate every redirect
    -     * target via Network::isSafeUrl, throwing UnsafeUrlException on a private
    -     * or reserved target.
    -     *
    -     * @return array<string, mixed>
    -     */
    -    public function redirectOptions(int $max = 5): array
    -    {
    -        return [
    -            'allow_redirects' => [
    -                'max' => $max,
    -                'on_redirect' => function (
    -                    RequestInterface $request,
    -                    ResponseInterface $response,
    -                    UriInterface $uri,
    -                ): void {
    -                    if (!$this->network->isSafeUrl((string) $uri)) {
    -                        throw UnsafeUrlException::forUrl((string) $uri);
    -                    }
    -                },
    -            ],
    -        ];
    -    }
    -
    -    /**
    -     * Build a PSR-18 client that applies the same per-redirect-hop validation
    -     * via Guzzle middleware. Use for libraries that take an injected client
    -     * (e.g. PhanAn\Poddle\Poddle::fromUrl()).
    -     */
    -    public function guzzleClient(int $timeoutInSeconds = 30): Client
    -    {
    -        $stack = HandlerStack::create();
    -
    -        $stack->push(Middleware::mapRequest(function (RequestInterface $request): RequestInterface {
    -            if (!$this->network->isSafeUrl((string) $request->getUri())) {
    -                throw UnsafeUrlException::forUrl((string) $request->getUri());
    -            }
    -
    -            return $request;
    -        }));
    -
    -        return new Client([
    -            'handler' => $stack,
    -            'timeout' => $timeoutInSeconds,
    -            ...$this->redirectOptions(),
    -        ]);
    -    }
    -}
    
  • app/Http/Requests/API/Radio/RadioStationStoreRequest.php+1 0 modified
    @@ -24,6 +24,7 @@ public function rules(): array
         {
             return [
                 'url' => [
    +                'bail',
                     'required',
                     'url',
                     Rule::unique('radio_stations')->where(function ($query) {
    
  • app/Http/Requests/API/Radio/RadioStationUpdateRequest.php+1 0 modified
    @@ -24,6 +24,7 @@ public function rules(): array
         {
             return [
                 'url' => [
    +                'bail',
                     'required',
                     'url',
                     Rule::unique('radio_stations')
    
  • app/Http/Requests/Subsonic/CreateInternetRadioStationRequest.php+1 1 modified
    @@ -17,7 +17,7 @@ class CreateInternetRadioStationRequest extends Request
         public function rules(): array
         {
             return [
    -            'streamUrl' => ['required', 'url', new SafeUrl(), new HasAudioContentType()],
    +            'streamUrl' => ['bail', 'required', 'url', new SafeUrl(), new HasAudioContentType()],
                 'name' => ['required', 'string'],
                 'homepageUrl' => ['nullable', 'string'],
             ];
    
  • app/Http/Requests/Subsonic/UpdateInternetRadioStationRequest.php+1 1 modified
    @@ -19,7 +19,7 @@ public function rules(): array
         {
             return [
                 'id' => ['required', 'string'],
    -            'streamUrl' => ['required', 'url', new SafeUrl(), new HasAudioContentType()],
    +            'streamUrl' => ['bail', 'required', 'url', new SafeUrl(), new HasAudioContentType()],
                 'name' => ['required', 'string'],
                 'homepageUrl' => ['nullable', 'string'],
             ];
    
  • app/Providers/AppServiceProvider.php+0 7 modified
    @@ -3,7 +3,6 @@
     namespace App\Providers;
     
     use App\Enums\Acl\Role;
    -use App\Helpers\SafeHttp;
     use App\Models\Album;
     use App\Models\Artist;
     use App\Models\Genre;
    @@ -34,7 +33,6 @@
     use Illuminate\Support\Facades\Gate;
     use Illuminate\Support\Facades\Route;
     use Illuminate\Support\ServiceProvider;
    -use Psr\Http\Client\ClientInterface;
     use SpotifyWebAPI\Session as SpotifySession;
     
     class AppServiceProvider extends ServiceProvider
    @@ -77,11 +75,6 @@ public function boot(Builder $schema, DatabaseManager $db): void
     
             $this->app->bind(LicenseServiceInterface::class, LicenseService::class);
     
    -        // PSR-18 client used by libraries that fetch external URLs (e.g. Poddle).
    -        // Backed by SafeHttp so every redirect hop is re-validated against the
    -        // SafeUrl rules — closes the redirect-SSRF leg of GHSA-6qvr-wjmv-v8mm.
    -        $this->app->bind(ClientInterface::class, static fn (): ClientInterface => app(SafeHttp::class)->guzzleClient());
    -
             $this->app->bind(ScannerCacheStrategyContract::class, static function () {
                 // Use a no-cache strategy for unit tests to ensure consistent results
                 return app()->runningUnitTests() ? app(ScannerNoCacheStrategy::class) : app(ScannerCacheStrategy::class);
    
  • app/Rules/HasAudioContentType.php+22 13 modified
    @@ -2,27 +2,37 @@
     
     namespace App\Rules;
     
    -use App\Helpers\SafeHttp;
    +use App\Exceptions\UnsafeUrlException;
    +use App\Services\Network\SafeHttp;
     use Closure;
     use Illuminate\Contracts\Validation\ValidationRule;
    -use Illuminate\Support\Facades\Http;
     use Illuminate\Support\Str;
     use Illuminate\Translation\PotentiallyTranslatedString;
     use Throwable;
     
     /**
    - * Validates that a URL serves audio content.
    - * Should be used after SafeUrl to ensure the URL is safe to reach.
    + * Validates that a URL serves audio content. Pair with SafeUrl + 'bail' on the
    + * field so this rule never runs on a URL the safety check has already rejected.
      */
     class HasAudioContentType implements ValidationRule
     {
    +    public function __construct(
    +        private ?SafeHttp $http = null,
    +    ) {
    +        $this->http ??= app(SafeHttp::class);
    +    }
    +
         /** @param Closure(string, ?string=): PotentiallyTranslatedString $fail */
         public function validate(string $attribute, mixed $value, Closure $fail): void
         {
             $url = (string) $value;
     
             try {
                 $contentType = $this->resolveContentType($url);
    +        } catch (UnsafeUrlException) {
    +            $fail('The :attribute must point to a public URL.');
    +
    +            return;
             } catch (Throwable) {
                 $fail("The $attribute couldn't be reached.");
     
    @@ -41,23 +51,22 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
          */
         private function resolveContentType(string $url): string
         {
    -        $redirectOptions = app(SafeHttp::class)->redirectOptions();
    -
    -        // Try HEAD first — fast and lightweight
    +        // Try HEAD first — fast and lightweight. Let UnsafeUrlException propagate
    +        // (don't fall back to GET on safety failures — the URL is blocked, not
    +        // just HEAD-incompatible).
             try {
    -            $response = Http::withOptions($redirectOptions)->head($url);
    +            $response = $this->http->head($url);
     
                 if ($response->successful()) {
                     return $response->header('Content-Type');
                 }
    +        } catch (UnsafeUrlException $e) {
    +            throw $e;
             } catch (Throwable) { // @mago-expect lint:no-empty-catch-clause -- HEAD may time out or fail on streaming servers; fall through to GET below.
             }
     
    -        // Fall back to GET with ICY headers — streaming servers often only respond to GET
    -        $response = Http::withHeaders(['Icy-MetaData' => '1'])->withOptions([
    -            ...$redirectOptions,
    -            'stream' => true,
    -        ])->get($url);
    +        // Fall back to GET as a stream with ICY headers — Shoutcast/Icecast only respond to GET
    +        $response = $this->http->getAsStream($url, ['Icy-MetaData' => '1']);
     
             return $response->header('Content-Type');
         }
    
  • app/Rules/SafeUrl.php+10 31 modified
    @@ -3,28 +3,27 @@
     namespace App\Rules;
     
     use App\Exceptions\UnsafeUrlException;
    -use App\Helpers\Network;
    -use App\Helpers\SafeHttp;
    +use App\Services\Network\SafeHttp;
     use Closure;
     use Illuminate\Contracts\Validation\ValidationRule;
    -use Illuminate\Support\Facades\Http;
     use Illuminate\Support\Uri;
     use Illuminate\Translation\PotentiallyTranslatedString;
     use Throwable;
     
     /**
    - * Validates that a URL does not resolve to a private or reserved IP address,
    - * preventing SSRF attacks against internal services.
    - * Also follows redirects and validates the effective URL.
    + * Validates that a URL is safe to fetch — its host (and every redirect target's
    + * host) resolves only to public IP addresses. Delegates the SSRF guarantees to
    + * SafeHttp; the rule itself just translates UnsafeUrlException into the
    + * appropriate validation failure message.
      */
     class SafeUrl implements ValidationRule
     {
         private const array ALLOWED_SCHEMES = ['http', 'https'];
     
         public function __construct(
    -        private ?Network $network = null,
    +        private ?SafeHttp $safeHttp = null,
         ) {
    -        $this->network ??= app(Network::class);
    +        $this->safeHttp ??= app(SafeHttp::class);
         }
     
         /** @param Closure(string, ?string=): PotentiallyTranslatedString $fail */
    @@ -44,39 +43,19 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
                 return;
             }
     
    -        if (!$this->network->isPublicHost($uri->host())) {
    -            $fail('The :attribute must point to a public URL.');
    -
    -            return;
    -        }
    -
    -        $redirectOptions = app(SafeHttp::class)->redirectOptions();
    -
             try {
    -            $response = Http::withOptions($redirectOptions)->head((string) $value);
    +            $this->safeHttp->head((string) $value);
             } catch (UnsafeUrlException) {
                 $fail('The :attribute must point to a public URL.');
    -
    -            return;
             } catch (Throwable) {
    -            // Some streaming servers don't support HEAD — try GET
    +            // Some streaming servers don't support HEAD — try GET as a stream.
                 try {
    -                $response = Http::withOptions([...$redirectOptions, 'stream' => true])->get((string) $value);
    +                $this->safeHttp->getAsStream((string) $value);
                 } catch (UnsafeUrlException) {
                     $fail('The :attribute must point to a public URL.');
    -
    -                return;
                 } catch (Throwable) {
                     $fail("The $attribute couldn't be reached.");
    -
    -                return;
                 }
             }
    -
    -        $effectiveHost = $response->effectiveUri()?->getHost();
    -
    -        if ($effectiveHost && !$this->network->isPublicHost($effectiveHost)) {
    -            $fail('The :attribute must point to a public URL.');
    -        }
         }
     }
    
  • app/Services/DownloadService.php+3 1 modified
    @@ -7,6 +7,7 @@
     use App\Models\Song;
     use App\Models\User;
     use App\Repositories\SongRepository;
    +use App\Services\Network\SafeHttp;
     use App\Services\SongStorages\CloudStorage;
     use App\Services\SongStorages\SongStorageFactory;
     use App\Values\Downloadable;
    @@ -20,6 +21,7 @@ class DownloadService
     {
         public function __construct(
             private readonly SongRepository $songRepository,
    +        private readonly SafeHttp $http,
             #[Config('koel.download.limit')]
             private readonly int $downloadLimit = 0,
         ) {}
    @@ -102,7 +104,7 @@ public function getLocalPath(Song $song): ?string
             }
     
             if ($song->isEpisode()) {
    -            return EpisodePlayable::getForEpisode($song)->path;
    +            return EpisodePlayable::getForEpisode($song, $this->http)->path;
             }
     
             $localPath = SongStorageFactory::make($song->storage)->getLocalPath($song->path);
    
  • app/Services/Network/Network.php+96 0 added
    @@ -0,0 +1,96 @@
    +<?php
    +
    +namespace App\Services\Network;
    +
    +use Illuminate\Support\Arr;
    +use Illuminate\Support\Uri;
    +use IPLib\Factory;
    +use IPLib\Range\Type as RangeType;
    +use Throwable;
    +
    +class Network
    +{
    +    /**
    +     * Check if a URL is safe to reach: HTTP/HTTPS scheme + a public host.
    +     * Does NOT perform any network calls beyond DNS resolution.
    +     * For full validation including effective-URL-after-redirect, use the SafeUrl validation rule.
    +     */
    +    public function isSafeUrl(string $url): bool
    +    {
    +        try {
    +            $uri = Uri::of($url);
    +        } catch (Throwable) {
    +            return false;
    +        }
    +
    +        if (!in_array($uri->scheme(), ['http', 'https'], true)) {
    +            return false;
    +        }
    +
    +        return $this->isPublicHost($uri->host());
    +    }
    +
    +    /**
    +     * Check if a host resolves only to public (non-private, non-reserved) IP addresses.
    +     * Validates both A (IPv4) and AAAA (IPv6) records. All resolved IPs must be public.
    +     */
    +    public function isPublicHost(?string $host): bool
    +    {
    +        return $host && $this->resolveToPublicIps($host);
    +    }
    +
    +    /**
    +     * Resolve a host to its public IP addresses. Returns an empty list if the
    +     * host can't be resolved, has no records, or has *any* non-public record —
    +     * fail-closed semantics: a mixed public/private answer rejects the whole
    +     * host. The strict behavior is needed by `isPublicHost()` consumers that
    +     * don't go through curl (e.g. RadioStreamProxy uses fopen, which does its
    +     * own DNS lookup and could land on the private record).
    +     *
    +     * Callers that DO go through curl additionally feed the returned list to
    +     * CURLOPT_RESOLVE to pin connect-time DNS onto the validated IPs, closing
    +     * the DNS-rebinding TOCTOU window between validation and connect.
    +     *
    +     * "Public" means ip-lib's RangeType::T_PUBLIC — excludes private, loopback,
    +     * link-local, multicast, broadcast, reserved, documentation, NAT64
    +     * (T_RESERVED), 6to4 wrappers of private IPv4 (classified by embedded v4),
    +     * Teredo, CGNAT.
    +     *
    +     * @return list<string>
    +     */
    +    public function resolveToPublicIps(string $host): array
    +    {
    +        $literal = Factory::parseAddressString($host);
    +
    +        if ($literal) {
    +            return $literal->getRangeType() === RangeType::T_PUBLIC ? [$host] : [];
    +        }
    +
    +        try {
    +            $a = dns_get_record($host, DNS_A);
    +            $aaaa = dns_get_record($host, DNS_AAAA);
    +        } catch (Throwable) {
    +            return [];
    +        }
    +
    +        if ($a === false || $aaaa === false) {
    +            // dns_get_record returning false is a resolver failure — fail closed
    +            // rather than treating it as "host has no records".
    +            return [];
    +        }
    +
    +        $ips = [];
    +
    +        foreach (array_merge($a, $aaaa) as $record) {
    +            $ip = Arr::get($record, 'ip') ?? Arr::get($record, 'ipv6');
    +
    +            if (!$ip || Factory::parseAddressString($ip)?->getRangeType() !== RangeType::T_PUBLIC) {
    +                return [];
    +            }
    +
    +            $ips[] = $ip;
    +        }
    +
    +        return $ips;
    +    }
    +}
    
  • app/Services/Network/SafeHttp.php+152 0 added
    @@ -0,0 +1,152 @@
    +<?php
    +
    +namespace App\Services\Network;
    +
    +use App\Exceptions\UnsafeUrlException;
    +use Closure;
    +use GuzzleHttp\Client;
    +use GuzzleHttp\HandlerStack;
    +use Illuminate\Http\Client\PendingRequest;
    +use Illuminate\Http\Client\Response;
    +use Illuminate\Support\Facades\Http;
    +use Psr\Http\Message\RequestInterface;
    +
    +/**
    + * SSRF-hardened wrapper around Laravel's Http facade. Use it anywhere you fetch
    + * a URL that came from outside your trust boundary. Both legs of the SSRF threat
    + * model are closed for every request including each redirect hop:
    + *
    + *  - Redirect SSRF: every redirect target's host is re-resolved and re-validated.
    + *  - DNS rebinding (TOCTOU): the resolved IPs are pinned into curl via
    + *    CURLOPT_RESOLVE before each connect, so curl can't ask DNS again and get a
    + *    different (private) IP.
    + *
    + * Both protections live in a single Guzzle middleware. Guzzle re-traverses the
    + * handler stack for each redirect hop, so the middleware fires on the initial
    + * request AND every redirect target — no manual redirect loop needed.
    + *
    + * Throws UnsafeUrlException whenever any request URL's host fails to resolve to
    + * public IPs (initial URL or any redirect target).
    + *
    + * For PSR-18 consumers (e.g. PhanAn\Poddle\Poddle::fromUrl()), use
    + * SafeHttp::getPinnedGuzzleClient($url) instead.
    + */
    +class SafeHttp
    +{
    +    public function __construct(
    +        private readonly Network $network,
    +    ) {}
    +
    +    /** Issue a HEAD request against the URL with full SSRF protection. */
    +    public function head(string $url, array $headers = []): Response
    +    {
    +        return $this->buildRequest($headers)->head($url);
    +    }
    +
    +    /** Issue a GET request against the URL with full SSRF protection. */
    +    public function get(string $url, array $headers = []): Response
    +    {
    +        return $this->buildRequest($headers)->get($url);
    +    }
    +
    +    /**
    +     * GET the URL as a stream (response body lazily read). Use for arbitrarily
    +     * large or unbounded payloads — e.g. live radio streams whose content type
    +     * we want to inspect without buffering the body.
    +     */
    +    public function getAsStream(string $url, array $headers = []): Response
    +    {
    +        return $this->buildRequest($headers, ['stream' => true])->get($url);
    +    }
    +
    +    /** Download the URL into a file path on disk, with full SSRF protection. */
    +    public function download(string $url, string $sinkPath, array $headers = []): Response
    +    {
    +        return $this->buildRequest($headers)->sink($sinkPath)->get($url);
    +    }
    +
    +    /**
    +     * Build a PSR-18 client whose handler stack applies the same hop-by-hop
    +     * pinning + validation as the Laravel Http methods above. Use for libraries
    +     * that take an injected client (e.g. PhanAn\Poddle\Poddle::fromUrl()).
    +     */
    +    public function getPinnedGuzzleClient(string $url, bool $trackRedirects = false, int $timeoutInSeconds = 30): Client
    +    {
    +        $allowRedirects = ['max' => 5];
    +
    +        if ($trackRedirects) {
    +            $allowRedirects['track_redirects'] = true;
    +        }
    +
    +        return new Client([
    +            'handler' => $this->buildHandlerStack(),
    +            'timeout' => $timeoutInSeconds,
    +            'allow_redirects' => $allowRedirects,
    +        ]);
    +    }
    +
    +    /**
    +     * @param array<string, string> $headers
    +     * @param array<string, mixed> $extraOptions
    +     */
    +    private function buildRequest(array $headers, array $extraOptions = []): PendingRequest
    +    {
    +        // withMiddleware pushes onto Laravel's existing handler stack — required
    +        // so Http::fake() in tests still intercepts. Setting `handler` via
    +        // withOptions would *replace* Laravel's stack and break the fake.
    +        $request = Http::withMiddleware($this->pinAndValidateMiddleware());
    +
    +        if ($extraOptions !== []) {
    +            $request = $request->withOptions($extraOptions);
    +        }
    +
    +        return $headers === [] ? $request : $request->withHeaders($headers);
    +    }
    +
    +    /**
    +     * Guzzle handler stack with the pin-and-validate middleware. Used only by
    +     * getPinnedGuzzleClient (no Laravel Http::fake involvement there).
    +     */
    +    private function buildHandlerStack(): HandlerStack
    +    {
    +        $stack = HandlerStack::create();
    +        $stack->push($this->pinAndValidateMiddleware(), 'safe_http_pin');
    +
    +        return $stack;
    +    }
    +
    +    /**
    +     * Middleware that, for every outbound request: resolves the host's public
    +     * IPs, throws UnsafeUrlException if any are private/reserved or DNS fails,
    +     * and appends CURLOPT_RESOLVE entries to the transfer options so curl uses
    +     * the validated IPs instead of doing a fresh (rebindable) DNS lookup.
    +     */
    +    private function pinAndValidateMiddleware(): Closure
    +    {
    +        $network = $this->network;
    +
    +        return static function (callable $next) use ($network): Closure {
    +            return static function (RequestInterface $request, array $options) use ($next, $network) {
    +                $uri = $request->getUri();
    +                $host = $uri->getHost();
    +
    +                $ips = $network->resolveToPublicIps($host);
    +
    +                throw_unless($ips, UnsafeUrlException::forUrl((string) $uri));
    +
    +                // IP literals don't trigger a DNS lookup, so nothing to pin.
    +                if (!filter_var($host, FILTER_VALIDATE_IP)) {
    +                    $port = $uri->getPort() ?? ($uri->getScheme() === 'https' ? 443 : 80);
    +
    +                    $options['curl'] ??= [];
    +                    $options['curl'][CURLOPT_RESOLVE] = [
    +                        ...($options['curl'][CURLOPT_RESOLVE] ?? []),
    +                        ...array_map(static fn (string $ip): string => "{$host}:{$port}:{$ip}", $ips),
    +                    ];
    +                }
    +
    +                return $next($request, $options);
    +            };
    +        };
    +    }
    +}
    
  • app/Services/Podcast/PodcastService.php+5 12 modified
    @@ -6,22 +6,21 @@
     use App\Exceptions\FailedToParsePodcastFeedException;
     use App\Exceptions\UnsafePodcastFeedUrlException;
     use App\Exceptions\UserAlreadySubscribedToPodcastException;
    -use App\Helpers\Network;
    -use App\Helpers\SafeHttp;
     use App\Helpers\Uuid;
     use App\Models\Podcast;
     use App\Models\PodcastUserPivot;
     use App\Models\Song as Episode;
     use App\Models\User;
     use App\Repositories\PodcastRepository;
     use App\Repositories\SongRepository;
    +use App\Services\Network\Network;
    +use App\Services\Network\SafeHttp;
     use Carbon\Carbon;
     use GuzzleHttp\Client;
     use GuzzleHttp\RedirectMiddleware;
     use GuzzleHttp\RequestOptions;
     use Illuminate\Support\Arr;
     use Illuminate\Support\Facades\DB;
    -use Illuminate\Support\Facades\Http;
     use Illuminate\Support\Facades\Log;
     use PhanAn\Poddle\Poddle;
     use PhanAn\Poddle\Values\Episode as EpisodeValue;
    @@ -225,9 +224,7 @@ public function isPodcastObsolete(Podcast $podcast): bool
             }
     
             try {
    -            $lastModified = Http::withOptions($this->safeHttp->redirectOptions())
    -                ->head($podcast->url)
    -                ->header('Last-Modified');
    +            $lastModified = $this->safeHttp->head($podcast->url)->header('Last-Modified');
     
                 if (!$lastModified) {
                     return true;
    @@ -254,7 +251,7 @@ public function getStreamableUrl(string|Episode $url, ?Client $client = null, st
                 return null;
             }
     
    -        $client ??= $this->safeHttp->guzzleClient();
    +        $client ??= $this->safeHttp->getPinnedGuzzleClient($url, trackRedirects: true);
     
             try {
                 $response = $client->request($method, $url, [
    @@ -263,10 +260,6 @@ public function getStreamableUrl(string|Episode $url, ?Client $client = null, st
                         'Origin' => '*',
                     ],
                     RequestOptions::HTTP_ERRORS => false,
    -                RequestOptions::ALLOW_REDIRECTS => [
    -                    ...$this->safeHttp->redirectOptions()['allow_redirects'],
    -                    'track_redirects' => true,
    -                ],
                 ]);
     
                 $redirects = Arr::wrap($response->getHeader(RedirectMiddleware::HISTORY_HEADER));
    @@ -307,6 +300,6 @@ private function createParser(string $url): Poddle
                 throw UnsafePodcastFeedUrlException::create($url);
             }
     
    -        return Poddle::fromUrl($url, 5 * 60, $this->client);
    +        return Poddle::fromUrl($url, 5 * 60, $this->client ?? $this->safeHttp->getPinnedGuzzleClient($url));
         }
     }
    
  • app/Services/Radio/RadioStreamProxy.php+1 1 modified
    @@ -2,8 +2,8 @@
     
     namespace App\Services\Radio;
     
    -use App\Helpers\Network;
     use App\Models\RadioStation;
    +use App\Services\Network\Network;
     
     class RadioStreamProxy
     {
    
  • app/Services/Streamer/Adapters/PodcastStreamerAdapter.php+3 1 modified
    @@ -3,6 +3,7 @@
     namespace App\Services\Streamer\Adapters;
     
     use App\Models\Song as Episode;
    +use App\Services\Network\SafeHttp;
     use App\Services\Podcast\PodcastService;
     use App\Services\Streamer\Adapters\Concerns\StreamsLocalPath;
     use App\Values\Podcast\EpisodePlayable;
    @@ -15,6 +16,7 @@ class PodcastStreamerAdapter implements StreamerAdapter
     
         public function __construct(
             private readonly PodcastService $podcastService,
    +        private readonly SafeHttp $http,
         ) {}
     
         /** @inheritDoc */
    @@ -28,6 +30,6 @@ public function stream(Episode $song, ?RequestedStreamingConfig $config = null)
                 return response()->redirectTo($streamableUrl);
             }
     
    -        $this->streamLocalPath(EpisodePlayable::getForEpisode($song)->path);
    +        $this->streamLocalPath(EpisodePlayable::getForEpisode($song, $this->http)->path);
         }
     }
    
  • app/Values/Podcast/EpisodePlayable.php+5 33 modified
    @@ -2,17 +2,12 @@
     
     namespace App\Values\Podcast;
     
    -use App\Exceptions\UnsafeUrlException;
    -use App\Helpers\Network;
     use App\Models\Song as Episode;
    +use App\Services\Network\SafeHttp;
     use Illuminate\Contracts\Support\Arrayable;
     use Illuminate\Contracts\Support\Jsonable;
     use Illuminate\Support\Facades\Cache;
     use Illuminate\Support\Facades\File;
    -use Illuminate\Support\Facades\Http;
    -use Psr\Http\Message\RequestInterface;
    -use Psr\Http\Message\ResponseInterface;
    -use Psr\Http\Message\UriInterface;
     
     final class EpisodePlayable implements Arrayable, Jsonable
     {
    @@ -31,43 +26,20 @@ public function valid(): bool
             return File::isReadable($this->path) && $this->checksum === File::hash($this->path);
         }
     
    -    public static function getForEpisode(Episode $episode): self
    +    public static function getForEpisode(Episode $episode, SafeHttp $safeHttp): self
         {
             /** @var self|null $cached */
             $cached = Cache::get("episode-playable.{$episode->id}");
     
    -        return $cached?->valid() ? $cached : self::createForEpisode($episode);
    +        return $cached?->valid() ? $cached : self::createForEpisode($episode, $safeHttp);
         }
     
    -    private static function createForEpisode(Episode $episode): self
    +    private static function createForEpisode(Episode $episode, SafeHttp $safeHttp): self
         {
             $file = artifact_path("episodes/{$episode->id}.mp3");
     
             if (!File::exists($file)) {
    -            $network = app(Network::class);
    -            $url = (string) $episode->path;
    -
    -            if (!$network->isSafeUrl($url)) {
    -                throw UnsafeUrlException::forUrl($url);
    -            }
    -
    -            Http::sink($file)
    -                ->withOptions([
    -                    'allow_redirects' => [
    -                        'max' => 5,
    -                        'on_redirect' => static function (
    -                            RequestInterface $request,
    -                            ResponseInterface $response,
    -                            UriInterface $uri,
    -                        ) use ($network): void {
    -                            if (!$network->isSafeUrl((string) $uri)) {
    -                                throw UnsafeUrlException::forUrl((string) $uri);
    -                            }
    -                        },
    -                    ],
    -                ])
    -                ->get($url)
    -                ->throw();
    +            $safeHttp->download((string) $episode->path, $file)->throw();
             }
     
             $playable = new self($file, File::hash($file));
    
  • composer.json+2 1 modified
    @@ -53,7 +53,8 @@
         "enshrined/svg-sanitize": "^0.22.0",
         "phanan/poddle": "^1.2.4",
         "laravel/ai": "^0.7.0",
    -    "league/flysystem-webdav": "^3.0"
    +    "league/flysystem-webdav": "^3.0",
    +    "mlocati/ip-lib": "^1.22"
       },
       "require-dev": {
         "roave/security-advisories": "dev-latest",
    
  • composer.lock+72 1 modified
    @@ -4,7 +4,7 @@
             "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
             "This file is @generated automatically"
         ],
    -    "content-hash": "d1437db343f05b61a0d00a522e577eee",
    +    "content-hash": "ebc7649de19d849bafc5c7861481228b",
         "packages": [
             {
                 "name": "algolia/algoliasearch-client-php",
    @@ -4039,6 +4039,77 @@
                 },
                 "time": "2022-08-16T15:18:17+00:00"
             },
    +        {
    +            "name": "mlocati/ip-lib",
    +            "version": "1.22.0",
    +            "source": {
    +                "type": "git",
    +                "url": "https://github.com/mlocati/ip-lib.git",
    +                "reference": "4e40ffd3bf9989db19403d89c4d8be44b87b8a91"
    +            },
    +            "dist": {
    +                "type": "zip",
    +                "url": "https://api.github.com/repos/mlocati/ip-lib/zipball/4e40ffd3bf9989db19403d89c4d8be44b87b8a91",
    +                "reference": "4e40ffd3bf9989db19403d89c4d8be44b87b8a91",
    +                "shasum": ""
    +            },
    +            "require": {
    +                "php": ">=5.3.3"
    +            },
    +            "require-dev": {
    +                "ext-pdo_sqlite": "*",
    +                "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5 || ^8.5 || ^9.5"
    +            },
    +            "type": "library",
    +            "autoload": {
    +                "psr-4": {
    +                    "IPLib\\": "src/"
    +                }
    +            },
    +            "notification-url": "https://packagist.org/downloads/",
    +            "license": [
    +                "MIT"
    +            ],
    +            "authors": [
    +                {
    +                    "name": "Michele Locati",
    +                    "email": "mlocati@gmail.com",
    +                    "homepage": "https://github.com/mlocati",
    +                    "role": "Author"
    +                }
    +            ],
    +            "description": "Handle IPv4, IPv6 addresses and ranges",
    +            "homepage": "https://github.com/mlocati/ip-lib",
    +            "keywords": [
    +                "IP",
    +                "address",
    +                "addresses",
    +                "ipv4",
    +                "ipv6",
    +                "manage",
    +                "managing",
    +                "matching",
    +                "network",
    +                "networking",
    +                "range",
    +                "subnet"
    +            ],
    +            "support": {
    +                "issues": "https://github.com/mlocati/ip-lib/issues",
    +                "source": "https://github.com/mlocati/ip-lib/tree/1.22.0"
    +            },
    +            "funding": [
    +                {
    +                    "url": "https://github.com/sponsors/mlocati",
    +                    "type": "github"
    +                },
    +                {
    +                    "url": "https://paypal.me/mlocati",
    +                    "type": "other"
    +                }
    +            ],
    +            "time": "2025-10-15T12:35:09+00:00"
    +        },
             {
                 "name": "monolog/monolog",
                 "version": "3.10.0",
    
  • tests/Fakes/FakeNetwork.php+16 23 modified
    @@ -2,9 +2,7 @@
     
     namespace Tests\Fakes;
     
    -use App\Helpers\Network;
    -use Illuminate\Support\Uri;
    -use Throwable;
    +use App\Services\Network\Network;
     
     /**
      * Skips DNS resolution so tests don't need real internet connectivity.
    @@ -13,33 +11,28 @@
      */
     class FakeNetwork extends Network
     {
    -    private const array SAFE_SCHEMES = ['http', 'https'];
    -
    -    public function isSafeUrl(string $url): bool
    +    /**
    +     * Return a synthetic public IP for any non-IP host that would otherwise
    +     * require DNS, and apply the literal-IP privacy check inline (no recursion
    +     * back into isPublicHost). The pinned IP is never actually contacted in
    +     * tests because Http::fake intercepts before curl runs.
    +     *
    +     * @return list<string>
    +     */
    +    public function resolveToPublicIps(string $host): array
         {
    -        try {
    -            $uri = Uri::of($url);
    -        } catch (Throwable) {
    -            return false;
    -        }
    -
    -        if (!in_array($uri->scheme(), self::SAFE_SCHEMES, true)) {
    -            return false;
    +        if ($host === '') {
    +            return [];
             }
     
    -        $host = $uri->host();
    -
    -        return $host !== '' && $this->isPublicHost($host);
    -    }
    -
    -    public function isPublicHost(string $host): bool
    -    {
             if (filter_var($host, FILTER_VALIDATE_IP)) {
                 return (
    -                filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false
    +                filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)
    +                    ? [$host]
    +                    : []
                 );
             }
     
    -        return $host !== '';
    +        return ['203.0.113.1'];
         }
     }
    
  • tests/Feature/RadioStationTest.php+21 0 modified
    @@ -6,6 +6,7 @@
     use App\Http\Resources\RadioStationResource;
     use App\Models\Organization;
     use App\Models\RadioStation;
    +use Illuminate\Http\Client\Request;
     use Illuminate\Support\Facades\Http;
     use PHPUnit\Framework\Attributes\Test;
     use Tests\TestCase;
    @@ -23,6 +24,26 @@ public function setUp(): void
             Http::fake(['*' => Http::response('', 200, ['Content-Type' => 'audio/mpeg'])]);
         }
     
    +    #[Test]
    +    public function createWithPrivateUrlDoesNotProbeIt(): void
    +    {
    +        // bail on the validation rules must stop HasAudioContentType from probing
    +        // a URL that SafeUrl has already rejected. Without bail, the HEAD/GET
    +        // probe lands on the private host — SSRF (GHSA-jr4p-4xjh-fwvw).
    +        $user = create_user();
    +
    +        $this->postAs(
    +            '/api/radio/stations',
    +            [
    +                'url' => 'http://127.0.0.1/stream',
    +                'name' => 'Internal',
    +            ],
    +            $user,
    +        )->assertUnprocessable();
    +
    +        Http::assertNotSent(static fn (Request $request): bool => str_contains($request->url(), '127.0.0.1'));
    +    }
    +
         #[Test]
         public function create(): void
         {
    
  • tests/Integration/Services/Podcast/PodcastServiceTest.php+4 1 modified
    @@ -277,7 +277,10 @@ public function getStreamableUrlFollowsRedirects(): void
             ]);
     
             $handlerStack = HandlerStack::create($mock);
    -        $client = new Client(['handler' => $handlerStack]);
    +        $client = new Client([
    +            'handler' => $handlerStack,
    +            'allow_redirects' => ['track_redirects' => true],
    +        ]);
     
             self::assertSame('https://assets.example.com/episode.mp3', $this->service->getStreamableUrl(
                 'https://example.com/episode.mp3',
    
  • tests/Integration/Values/EpisodePlayableTest.php+14 4 modified
    @@ -4,6 +4,7 @@
     
     use App\Exceptions\UnsafeUrlException;
     use App\Models\Song;
    +use App\Services\Network\SafeHttp;
     use App\Values\Podcast\EpisodePlayable;
     use Illuminate\Support\Facades\Cache;
     use Illuminate\Support\Facades\Http;
    @@ -12,6 +13,15 @@
     
     class EpisodePlayableTest extends TestCase
     {
    +    private SafeHttp $safeHttp;
    +
    +    public function setUp(): void
    +    {
    +        parent::setUp();
    +
    +        $this->safeHttp = app(SafeHttp::class);
    +    }
    +
         #[Test]
         public function createAndRetrieved(): void
         {
    @@ -24,14 +34,14 @@ public function createAndRetrieved(): void
                     'path' => 'https://example.com/episode.mp3',
                 ]);
     
    -        $playable = EpisodePlayable::getForEpisode($episode);
    +        $playable = EpisodePlayable::getForEpisode($episode, $this->safeHttp);
     
             Http::assertSentCount(1);
             self::assertSame('acbd18db4cc2f85cedef654fccc4a4d8', $playable->checksum);
     
             self::assertTrue(Cache::has("episode-playable.{$episode->id}"));
     
    -        $retrieved = EpisodePlayable::getForEpisode($episode);
    +        $retrieved = EpisodePlayable::getForEpisode($episode, $this->safeHttp);
     
             // No extra HTTP request should be made.
             Http::assertSentCount(1);
    @@ -56,7 +66,7 @@ public function refusesToFetchUnsafeUrl(): void
             self::expectException(UnsafeUrlException::class);
     
             try {
    -            EpisodePlayable::getForEpisode($episode);
    +            EpisodePlayable::getForEpisode($episode, $this->safeHttp);
             } finally {
                 Http::assertNothingSent();
             }
    @@ -77,7 +87,7 @@ public function refusesToFollowRedirectToUnsafeUrl(): void
             self::expectException(UnsafeUrlException::class);
     
             try {
    -            EpisodePlayable::getForEpisode($episode);
    +            EpisodePlayable::getForEpisode($episode, $this->safeHttp);
             } finally {
                 // The redirect target must never be requested.
                 Http::assertNotSent(static fn ($request) => str_contains($request->url(), '169.254.169.254'));
    
  • tests/TestCase.php+1 1 modified
    @@ -3,13 +3,13 @@
     namespace Tests;
     
     use App\Facades\License;
    -use App\Helpers\Network;
     use App\Helpers\Ulid;
     use App\Helpers\Uuid;
     use App\Models\Album;
     use App\Observers\AlbumObserver;
     use App\Services\License\CommunityLicenseService;
     use App\Services\MediaBrowser;
    +use App\Services\Network\Network;
     use Illuminate\Filesystem\Filesystem;
     use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
     use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
    
  • tests/Unit/Helpers/SafeHttpTest.php+0 76 removed
    @@ -1,76 +0,0 @@
    -<?php
    -
    -namespace Tests\Unit\Helpers;
    -
    -use App\Exceptions\UnsafeUrlException;
    -use App\Helpers\SafeHttp;
    -use GuzzleHttp\Client;
    -use GuzzleHttp\Exception\GuzzleException;
    -use GuzzleHttp\Handler\MockHandler;
    -use GuzzleHttp\HandlerStack;
    -use GuzzleHttp\Psr7\Response;
    -use PHPUnit\Framework\Attributes\Test;
    -use Tests\TestCase;
    -
    -class SafeHttpTest extends TestCase
    -{
    -    private SafeHttp $safeHttp;
    -
    -    public function setUp(): void
    -    {
    -        parent::setUp();
    -
    -        $this->safeHttp = app(SafeHttp::class);
    -    }
    -
    -    #[Test]
    -    public function guzzleClientRejectsPreflightUnsafeUrl(): void
    -    {
    -        $this->expectException(UnsafeUrlException::class);
    -
    -        $this->safeHttp->guzzleClient()->request('GET', 'http://127.0.0.1/admin');
    -    }
    -
    -    #[Test]
    -    public function redirectOptionsRejectRedirectToPrivateHost(): void
    -    {
    -        // Build a Guzzle client that emits a 302 to a private host, then would
    -        // emit a 200 (which we must never reach). Apply only the redirect options
    -        // — no pre-flight middleware — to isolate the on_redirect leg.
    -        $mock = new MockHandler([
    -            new Response(302, ['Location' => 'http://127.0.0.1/admin']),
    -            new Response(200, [], 'should be unreachable'),
    -        ]);
    -
    -        $client = new Client(['handler' => HandlerStack::create($mock)]);
    -
    -        $this->expectException(UnsafeUrlException::class);
    -
    -        try {
    -            $client->request('GET', 'https://public.example.com/feed', $this->safeHttp->redirectOptions());
    -        } catch (GuzzleException $e) {
    -            // Guzzle wraps middleware exceptions; unwrap to surface the real cause.
    -            if ($e->getPrevious() instanceof UnsafeUrlException) {
    -                throw $e->getPrevious();
    -            }
    -
    -            throw $e;
    -        }
    -    }
    -
    -    #[Test]
    -    public function redirectOptionsAllowRedirectToPublicHost(): void
    -    {
    -        $mock = new MockHandler([
    -            new Response(302, ['Location' => 'https://other.example.com/feed']),
    -            new Response(200, [], 'ok'),
    -        ]);
    -
    -        $client = new Client(['handler' => HandlerStack::create($mock)]);
    -
    -        $response = $client->request('GET', 'https://public.example.com/feed', $this->safeHttp->redirectOptions());
    -
    -        self::assertSame(200, $response->getStatusCode());
    -        self::assertSame('ok', (string) $response->getBody());
    -    }
    -}
    
  • tests/Unit/Services/Network/NetworkTest.php+52 2 renamed
    @@ -1,8 +1,8 @@
     <?php
     
    -namespace Tests\Unit\Helpers;
    +namespace Tests\Unit\Services\Network;
     
    -use App\Helpers\Network;
    +use App\Services\Network\Network;
     use PHPUnit\Framework\Attributes\DataProvider;
     use PHPUnit\Framework\Attributes\Test;
     use Tests\TestCase;
    @@ -80,4 +80,54 @@ public function isSafeUrlRejectsUnsafeUrl(string $url): void
         {
             self::assertFalse($this->network->isSafeUrl($url));
         }
    +
    +    /** @return array<string, array{string}> */
    +    public static function provideIpv6TransitionWrappers(): array
    +    {
    +        return [
    +            'NAT64 well-known of loopback' => ['64:ff9b::7f00:1'],
    +            'NAT64 well-known of AWS metadata' => ['64:ff9b::a9fe:a9fe'],
    +            'NAT64 well-known of private 10.x' => ['64:ff9b::a00:1'],
    +            '6to4 of private 10.0.0.1' => ['2002:a00:1::'],
    +            '6to4 of loopback' => ['2002:7f00:1::'],
    +            '6to4 of AWS metadata' => ['2002:a9fe:a9fe::'],
    +        ];
    +    }
    +
    +    #[Test, DataProvider('provideIpv6TransitionWrappers')]
    +    public function rejectsIpv6TransitionWrappersOfPrivateIps(string $wrapper): void
    +    {
    +        self::assertFalse($this->network->isPublicHost($wrapper));
    +        self::assertSame([], $this->network->resolveToPublicIps($wrapper));
    +    }
    +
    +    #[Test]
    +    public function resolveToPublicIpsReturnsIpsForPublicIpLiteral(): void
    +    {
    +        self::assertSame(['8.8.8.8'], $this->network->resolveToPublicIps('8.8.8.8'));
    +    }
    +
    +    #[Test]
    +    public function resolveToPublicIpsReturnsEmptyForPrivateIpLiteral(): void
    +    {
    +        self::assertSame([], $this->network->resolveToPublicIps('127.0.0.1'));
    +    }
    +
    +    #[Test]
    +    public function resolveToPublicIpsReturnsIpsForPublicHost(): void
    +    {
    +        $ips = $this->network->resolveToPublicIps('example.com');
    +
    +        self::assertNotEmpty($ips);
    +
    +        foreach ($ips as $ip) {
    +            self::assertNotFalse(filter_var($ip, FILTER_VALIDATE_IP));
    +        }
    +    }
    +
    +    #[Test]
    +    public function resolveToPublicIpsReturnsEmptyForUnresolvableHost(): void
    +    {
    +        self::assertSame([], $this->network->resolveToPublicIps('this-host-does-not-exist.invalid'));
    +    }
     }
    
  • tests/Unit/Services/Network/SafeHttpTest.php+72 0 added
    @@ -0,0 +1,72 @@
    +<?php
    +
    +namespace Tests\Unit\Services\Network;
    +
    +use App\Exceptions\UnsafeUrlException;
    +use App\Services\Network\SafeHttp;
    +use Illuminate\Support\Facades\Http;
    +use PHPUnit\Framework\Attributes\Test;
    +use Tests\TestCase;
    +
    +class SafeHttpTest extends TestCase
    +{
    +    private SafeHttp $safeHttp;
    +
    +    public function setUp(): void
    +    {
    +        parent::setUp();
    +
    +        $this->safeHttp = app(SafeHttp::class);
    +    }
    +
    +    #[Test]
    +    public function headRejectsPrivateIpLiteral(): void
    +    {
    +        $this->expectException(UnsafeUrlException::class);
    +
    +        $this->safeHttp->head('http://127.0.0.1/admin');
    +    }
    +
    +    #[Test]
    +    public function getRejectsPrivateIpLiteral(): void
    +    {
    +        $this->expectException(UnsafeUrlException::class);
    +
    +        $this->safeHttp->get('http://169.254.169.254/latest/meta-data/');
    +    }
    +
    +    #[Test]
    +    public function headRejectsRedirectToPrivateHost(): void
    +    {
    +        Http::fake([
    +            '8.8.8.8/*' => Http::response('', 302, ['Location' => 'http://127.0.0.1/admin']),
    +            '*' => Http::response('', 200),
    +        ]);
    +
    +        $this->expectException(UnsafeUrlException::class);
    +
    +        $this->safeHttp->head('https://8.8.8.8/feed');
    +    }
    +
    +    #[Test]
    +    public function headFollowsRedirectsAcrossPublicHosts(): void
    +    {
    +        Http::fake([
    +            '8.8.8.8/*' => Http::response('', 302, ['Location' => 'https://1.1.1.1/feed']),
    +            '1.1.1.1/*' => Http::response('', 200),
    +        ]);
    +
    +        self::assertTrue($this->safeHttp->head('https://8.8.8.8/feed')->successful());
    +    }
    +
    +    #[Test]
    +    public function pinnedGuzzleClientRejectsPrivateIp(): void
    +    {
    +        // The pinned Guzzle client (used by Poddle, getStreamableUrl) shares
    +        // the same pin-and-validate middleware. Verify the standalone branch
    +        // throws on a private target, not just the Laravel Http path.
    +        $this->expectException(UnsafeUrlException::class);
    +
    +        $this->safeHttp->getPinnedGuzzleClient('http://127.0.0.1/feed')->request('GET', 'http://127.0.0.1/feed');
    +    }
    +}
    
  • tests/Unit/Services/Radio/RadioStreamProxyTest.php+1 1 modified
    @@ -2,7 +2,7 @@
     
     namespace Tests\Unit\Services\Radio;
     
    -use App\Helpers\Network;
    +use App\Services\Network\Network;
     use App\Services\Radio\RadioStreamProxy;
     use Mockery;
     use PHPUnit\Framework\Attributes\Test;
    

Vulnerability mechanics

Root cause

"Missing 'bail' keyword in Laravel validation rules allows HasAudioContentType to make HTTP requests to internal addresses after SafeUrl has already rejected the URL."

Attack vector

An authenticated, non-admin attacker sends a POST request to `/api/radio/stations` with a `url` payload pointing to an internal address (e.g. `http://127.0.0.1:6379` or `http://169.254.169.254`). Because the validation array lacks `bail`, Laravel continues executing `HasAudioContentType` after `SafeUrl` has already called `$fail()`. `HasAudioContentType` then issues an HTTP HEAD (and, if that fails, a streaming GET) to the attacker-supplied URL [ref_id=2]. The response body is never returned, but the server makes the request regardless. Two distinct error messages from `HasAudioContentType` form an internal-network reachability oracle: 'The url couldn't be reached.' vs 'The url doesn't look like a valid radio station URL.' [ref_id=2].

Affected code

The vulnerability lies in the radio station creation endpoint (POST /api/radio/stations). The `url` field's validation rules are defined in `app/Http/Requests/API/Radio/RadioStationStoreRequest.php` without the `bail` keyword, so `HasAudioContentType` (which makes HTTP HEAD/GET requests to the supplied URL) still executes even after `SafeUrl` has rejected the URL. The same missing-`bail` pattern affected the station-update endpoint in `CreateInternetRadioStationRequest.php` and the podcast stream-update endpoint in `UpdatePodcastStreamRequest.php`.

What the fix does

The patch inserts `'bail'` as the first rule in the `url` and `streamUrl` validation arrays of three FormRequest classes (`RadioStationStoreRequest`, `CreateInternetRadioStationRequest`, `UpdatePodcastStreamRequest`), ensuring that `HasAudioContentType` only runs when `SafeUrl` has already validated the host as public [patch_id=5749249]. Additionally, `HasAudioContentType` and `SafeUrl` are refactored to inject `SafeHttp` instead of raw `Http` calls — `SafeHttp` provides a Guzzle middleware that resolves every request's host (including each redirect hop) to public IPs and pins those IPs with `CURLOPT_RESOLVE` to prevent DNS rebinding TOCTOU attacks [patch_id=5749249]. The `Network` class was upgraded from `FILTER_VALIDATE_IP` flags to the `ip-lib` library so that IPv6 transition wrappers (NAT64 `64:ff9b::/96`, 6to4 `2002::/16`) embedding private IPv4 addresses are also rejected [patch_id=5749249].

Preconditions

  • authAttacker must have a valid authentication token for any non-admin user account.
  • networkThe radio station creation endpoint (POST /api/radio/stations) must be exposed on the network.
  • networkThe target internal host/port must be reachable from the Koel server.
  • inputThe url field must be a HTTP/HTTPS URI pointing to an internal or reserved address that SafeUrl rejects.

Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.