#5858·kanboard

SSRF protection bypass: bracketed IPv6 literals defeat isPrivateURL() (loopback, RFC1918, cloud metadata)

Author: krishnextgencyberCreated Jul 26, 2026Updated Jul 26, 2026

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:

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:

  1. parse_url() returns an IPv6 host with its brackets[::ffff:127.0.0.1].
  2. gethostbyname() cannot resolve that string, so it returns it unchanged.
  3. filter_var() cannot parse a bracketed literal, so isPrivateIpAddress() 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>.

  1. As a project-member, open a task → Add an external link → type weblink.
  2. Submit http://127.0.0.1:9099/ — blocked; the log records Blocked attempt to fetch URL from private network: http://127.0.0.1:9099/ and the title falls back to the URL.
  3. Submit http://[::ffff:127.0.0.1]:9099/no log entry, the fetch executes, and the returned title field contains INTERNAL-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:

php
$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.