AbstractConnector::getValidUrl() , used by the Generic and Discord webhook connectors to validate a station-manager-supplied webhook URL before AzuraCast sends an outbound HTTP request to it, only rejects URLs whose host is a literal, link-local IP address (169.254.0.0/16 / fe80::/10 — e.g. the AWS/GCP/Azure metadata endpoint). It does not check loopback addresses, RFC1918 private ranges, or any hostname at all (since resolving a hostname to an IP object throws, and the exception path skips the check entirely). As a result, a user holding only the station-scoped WebHooks permission can make the server issue arbitrary outbound HTTP requests to internal/loopback/private-network targets — a Server-Side Request Forgery.
backend/src/Webhook/Connector/AbstractConnector.php :
Urls::tryParseUserUrl() ( backend/src/Utilities/Urls.php ) only validates that the scheme is http / https ; it performs no host/IP restriction of its own.
backend/src/Webhook/Connector/Generic.php uses this to validate config['webhook_url'] before calling $this->httpClient->request('POST', $webhookUrl, ...) with the station's live Now Playing data as the JSON body — and no re-validation happens at request time, so even if a hostname resolves differently between check-time and request-time (DNS rebinding), it would not matter here since the hostname case bypasses the check unconditionally regardless of what it resolves to.
Access to configure a webhook requires only the station-scoped StationPermissions::WebHooks permission ( backend/config/routes/api_station.php , Middleware\Permissions(StationPermissions::WebHooks, true) ), not full/global administrator rights. Additionally, Controller/Api/Stations/Webhooks/TestAction.php lets the same low-privileged user trigger an on-demand webhook dispatch immediately ( PUT /station/{id}/webhook/{id}/test ), without waiting for a real Now Playing event, making exploitation and result observation fast.
Verified by executing the exact pinned dependency AzuraCast uses ( rlanvin/php-ip , commit 7811f12256a5a610ddcb31ed9840179f4dd3784d , as locked in composer.lock ) against the same validation logic used in getValidUrl() , in an isolated local sandbox (no AzuraCast server or database was started; only the third-party IP library itself was exercised):
Only the literal-IP link-local case is actually blocked; every other internal/loopback/private target — and every hostname, regardless of what it resolves to — passes straight through.
A user with only the narrow, station-scoped WebHooks permission (not a full server administrator) can cause the AzuraCast server to make outbound HTTP requests to:
Loopback services on the host itself,
Other containers/services on the internal Docker/private network,
Any attacker-controlled hostname (trivially defeats the one existing protection),
potentially enabling internal network reconnaissance/port scanning, interaction with internal-only APIs, or (depending on deployment) reaching the cloud metadata service via a hostname that resolves to it (the literal-IP form is blocked, but a hostname pointing at the same IP is not).
Centralize outbound-URL SSRF protection in Urls (or a shared helper used by every consumer, see the companion advisory on StationRemote ) so that:
The check always resolves the final hostname to its IP(s) (handling DNS) before evaluating it — never skip the check just because the input was a hostname.
Block loopback, RFC1918 private ranges, and IPv6 unique-local/loopback in addition to link-local, not just link-local.
Re-validate at request time (or pin the resolved IP for the actual request) to avoid DNS-rebinding TOCTOU gaps.
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
