[Security] SSRF: isPrivateURL() bypassed by decimal/hex IPv4 and IPv6-literal hosts (external links & webhooks)
Bug Summary
HttpClient::isPrivateURL() (SSRF guard for external web-link fetching and webhooks) is bypassed by alternate IPv4 encodings (decimal/hex/octal) and IPv6-literal hosts, letting a Project Member make Kanboard request loopback/internal/cloud-metadata addresses.
Description
The private-network SSRF guard added in 1de29f6e9 (HttpClient::isPrivateURL() in app/Core/Http/Client.php), used by external web-link metadata fetching (WebLink::getTitle()) and by webhook delivery, only classifies a host as private via gethostbyname() (standard dotted-quad IPv4) plus an AAAA lookup.
For alternate encodings — decimal (2130706433), hex (0x7f000001), octal (0177.0.0.1) — and IPv6-literal hosts ([::1], [::ffff:127.0.0.1]), gethostbyname() returns the input unchanged and filter_var(FILTER_VALIDATE_IP) rejects it, so the guard concludes "not private" and allows the fetch. libcurl, which performs the actual request, DOES accept all of these and connects to the internal address. The check and the fetch therefore disagree.
As a result, an authenticated user with the Project Member role can make the server issue requests to loopback, internal-network, and cloud instance metadata endpoints (e.g. http://2852039166/ = 169.254.169.254), bypassing EXTERNAL_LINK_ALLOW_PRIVATE_NETWORKS=false (and WEBHOOK_ALLOW_PRIVATE_NETWORKS=false for the webhook sink).
It is also a check-then-fetch (TOCTOU) control — it resolves the host separately from libcurl — so DNS rebinding bypasses it even in canonical notation.
Steps to Reproduce
- Start an internal-only HTTP service bound to 127.0.0.1:9000 returning INTERNAL-ONLY-SECRET (simulating a service unreachable from outside, e.g. cloud metadata or an internal admin panel).
- As a Project Member, add a "Web link" to any task in your project (or call JSON-RPC createExternalTaskLink) with URL "http://2130706433:9000/" and an EMPTY title. 2130706433 is the decimal form of 127.0.0.1.
- Kanboard calls WebLink::getTitle(): isPrivateURL("http://2130706433:9000/") returns false, so it fetches the URL server-side.
- The stored link title becomes "INTERNAL-ONLY-SECRET" — the internal service's content was read through Kanboard.
- For contrast, repeat with "http://127.0.0.1:9000/": isPrivateURL() returns true and the fetch is correctly blocked.
Expected Behavior
isPrivateURL() should treat 127.0.0.1 / 169.254.169.254 / internal ranges as private regardless of how the address is written (decimal, hex, octal, IPv6 literal), and the fetch to those addresses should be blocked — the same as it already blocks the canonical http://127.0.0.1/.
Actual Behavior
isPrivateURL() returns false ("not private") for the encoded/IPv6-literal forms, so the server proceeds to fetch them and libcurl connects to the internal address. The guard only blocks the canonical dotted-quad form, so the protection is trivially bypassed.
Version
1.2.53 (latest; main branch at time of report)
Database
Any (tested with SQLite) — the vulnerability is independent of the database backend.
PHP Version
8.3 (not version-dependent; behavior of gethostbyname/filter_var is the same across supported PHP versions)
Browser
N/A — server-side / JSON-RPC
Operating System
Any (server-side)
Relevant Logs or Error Output
# Kanboard's real HttpClient driven exactly as WebLink::getTitle() does:
http://127.0.0.1:9000/ -> guard BLOCK -> not fetched (correct)
http://2130706433:9000/ -> guard ALLOW -> fetched, title="INTERNAL-ONLY-SECRET"
http://0x7f000001:9000/ -> guard ALLOW -> fetched, title="INTERNAL-ONLY-SECRET"
# libcurl connection targets (curl -v):
http://2130706433/ -> Trying 127.0.0.1:80
http://2852039166/ -> Trying 169.254.169.254:80 # cloud metadata
http://0x7f000001/ -> Trying 127.0.0.1:80
http://[::1]/ -> Trying [::1]:80Additional Context
Reachable path (Project Member): TaskExternalLinkProcedure::createExternalTaskLink() calls getTitle() (the fetch) BEFORE validation when the title parameter is empty. The web UI "Add web link" reaches the same sink. Internal responses containing an HTML are reflected back in the stored link title (read primitive); otherwise it is blind SSRF (host/port discovery).</p> <p>Suggested fix: resolve the host once, reject non-global-unicast results, and pin the connection to the validated IP (CURLOPT_RESOLVE / CURLOPT_CONNECT_TO) so the checked address is the fetched address — this closes both the alternate-encoding/IPv6-literal bypass and DNS rebinding. Canonicalise numeric hosts with inet_pton/ip2long before range checks; treat bracketed IPv6 and IPv4-mapped IPv6 (::ffff:a.b.c.d) as their embedded address.</p> <p>I have a standalone PoC script (uses the real Kanboard\Core\Http\Client) and can share it.</p> <h3>Checklist</h3> <ul> <li><input checked="" disabled="" type="checkbox"> I have searched existing issues to ensure this bug hasn't been reported before.</li> <li><input checked="" disabled="" type="checkbox"> I verified that the problem is not caused by a plugin. Please report the issue to the plugin author if applicable.</li> </ul>
Source: kanboard/kanboard