fix: return client IPv6 address via cloudflared (#757)

* fix: return client IPv6 address via cloudflared

The cloudflared reverse proxy populates the X-Forwarded-For header for origin IPv4 addresses, however origin IPv6 addresses are added in a different header: Cf-Connecting-Ipv6. This updates the getIP.php mechanism to retrieve the value of this header and to prefer it over other client IP headers (in both cases only if the Cf-Connecting-Ipv6 header exists and is not empty).

* fix: Validate and normalise IP addresses from request headers

getClientIp() used HTTP_CF_CONNECTING_IPV6 and other headers verbatim, allowing malformed values to reach ISP lookups and the offline DB.

Add normalizeCandidateIp() helper that trims whitespace, extracts the first comma-separated token, and validates via filter_var(). Require FILTER_FLAG_IPV6 for the CF header and fall through to the next source on failure.

Written with assistance from OpenCode using Claude Opus 4.6.
This commit is contained in:
Matthew Kobayashi 2026-03-06 08:52:59 +10:00 committed by GitHub
parent 98f447c8db
commit f1f48ae53e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,19 +1,55 @@
<?php
/**
* Normalize and validate an IP address candidate from a request header.
*
* Trims whitespace, takes the first comma-separated token (for XFF-like
* headers that may contain a chain of addresses), and validates the result
* with filter_var().
*
* @param string $raw Raw header value.
* @param int $extraFlags Additional FILTER_FLAG_* flags (e.g. FILTER_FLAG_IPV6).
*
* @return string|false The validated IP string, or false on failure.
*/
function normalizeCandidateIp($raw, $extraFlags = 0)
{
$ip = trim($raw);
// For XFF-like values, take the first address before a comma.
if (($pos = strpos($ip, ',')) !== false) {
$ip = trim(substr($ip, 0, $pos));
}
if ($ip === '') {
return false;
}
return filter_var($ip, FILTER_VALIDATE_IP, $extraFlags);
}
/**
* @return string
*/
function getClientIp() {
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
$ip = $_SERVER['HTTP_X_REAL_IP'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
$ip = preg_replace('/,.*/', '', $ip); # hosts are comma-separated, client is first
} else {
$ip = $_SERVER['REMOTE_ADDR'];
function getClientIp()
{
// Cloudflare IPv6 header — must be a valid IPv6 address.
if (!empty($_SERVER['HTTP_CF_CONNECTING_IPV6'])) {
$ip = normalizeCandidateIp($_SERVER['HTTP_CF_CONNECTING_IPV6'], FILTER_FLAG_IPV6);
if ($ip !== false) {
return preg_replace('/^::ffff:/', '', $ip);
}
}
return preg_replace('/^::ffff:/', '', $ip);
// Other forwarding / proxy headers — accept any valid IP.
foreach (['HTTP_CLIENT_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR'] as $header) {
if (!empty($_SERVER[$header])) {
$ip = normalizeCandidateIp($_SERVER[$header]);
if ($ip !== false) {
return preg_replace('/^::ffff:/', '', $ip);
}
}
}
// Fallback: REMOTE_ADDR is set by the web server and is always a single IP.
$ip = normalizeCandidateIp($_SERVER['REMOTE_ADDR'] ?? '');
if ($ip !== false) {
return preg_replace('/^::ffff:/', '', $ip);
}
return $_SERVER['REMOTE_ADDR'] ?? '';
}