SSRF protection bypass: bracketed IPv6 literals defeat isPrivateURL() (loopback, RFC1918, cloud metadata)
Summary
Client::isPrivateURL() can be bypassed with a bracketed IPv6 literal, letting any project-member make the server
fetch loopback / RFC1918 / cloud-metadata addresses through the task external-link feature — and read the response, since
WebLink::getTitle() reflects the fetched <title> back into the UI. EXTERNAL_LINK_ALLOW_PRIVATE_NETWORKS is false
by default, i.e. the guard is supposed to be active.
Affected code
app/Core/Http/Client.php:
protected function isPrivateIpAddress($ip)
{
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
return false; // unparseable => treated as NOT private (fail-open)
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
}
public function isPrivateURL($url)
{
$parsedUrl = parse_url($url);
$host = trim($parsedUrl['host']);
$ipv4Address = gethostbyname($host); // returns the INPUT UNCHANGED when it cannot resolve
if ($this->isPrivateIpAddress($ipv4Address)) { return true; }
...
return false; // default: allow
}Three behaviours combine:
parse_url()returns an IPv6 host with its brackets —[::ffff:127.0.0.1].gethostbyname()cannot resolve that string, so it returns it unchanged.filter_var()cannot parse a bracketed literal, soisPrivateIpAddress()takes the fail-open branch.
cURL then strips the brackets and connects. The validator and the HTTP client disagree about the host.
Steps to reproduce
Tested on the official kanboard/kanboard:v1.2.52 image, with a service bound to 127.0.0.1:9099 on the Kanboard host
serving <title>INTERNAL-ONLY-SERVICE</title>.
- As a project-member, open a task → Add an external link → type
weblink. - Submit
http://127.0.0.1:9099/— blocked; the log recordsBlocked attempt to fetch URL from private network: http://127.0.0.1:9099/and the title falls back to the URL. - Submit
http://[::ffff:127.0.0.1]:9099/— no log entry, the fetch executes, and the returned title field containsINTERNAL-ONLY-SERVICE.
Host forms evaluated against the guard (Alpine/musl)
| Host | Verdict |
|---|---|
127.0.0.1, localhost, 2130706433, 0x7f000001, 017700000001, 127.1, 0 |
correctly BLOCKED |
[::1], [::ffff:127.0.0.1], [0:0:0:0:0:ffff:127.0.0.1] |
ALLOWED |
[::ffff:169.254.169.254] (cloud metadata), [::ffff:10.0.0.1], [fd00::1] |
ALLOWED |
127.0.0.1. (trailing dot) |
ALLOWED |
Decimal/hex/octal forms are correctly blocked because musl's gethostbyname() resolves them — the bracketed forms are the gap.
Impact
A plain project-member reaches services bound to the Kanboard host's loopback interface and receives page content back.
The guard also accepts [::ffff:169.254.169.254], so on a cloud host with IMDSv1 this is a path to instance credentials
(I did not reach a real metadata service — stating this as the ceiling, not as a demonstrated result). The same guard
protects WebhookNotification, which is equally bypassable and yields an attacker-controlled server-side POST.
CVSS 3.1: 8.5 (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:L), CWE-918.
Fix
Strip brackets, normalise, validate all resolved addresses, and fail closed:
$host = trim($parsedUrl['host'], " \t\n\r\0\x0B[]");
$host = rtrim($host, '.');
if (filter_var($host, FILTER_VALIDATE_IP)) {
return $this->isPrivateIpAddress($host); // literal (v4 or v6): validate directly
}
$addresses = gethostbynamel($host) ?: [];
if (empty($addresses)) { return true; } // unresolvable => treat as private
foreach ($addresses as $addr) {
if ($this->isPrivateIpAddress($addr)) { return true; }
}FILTER_FLAG_NO_PRIV_RANGE does not cover the IPv6 equivalents, so also reject IPv4-mapped (::ffff:0:0/96),
IPv4-compatible, ULA (fc00::/7), link-local (fe80::/10) and loopback in both families. Pinning the validated address
for the actual request (CURLOPT_RESOLVE, or re-checking CURLINFO_PRIMARY_IP after connect) additionally closes the
DNS-rebinding window, since the guard and cURL currently perform independent lookups.
Verified against v1.2.52 and the current release v1.2.53 (2026-07-24) — file byte-identical in both. Reported as a regular bug per SECURITY.md.
Source: kanboard/kanboard