Summary
The patch introduced for GHSA-cpgw-wgf3-xc6v (SSRF via Photo::fromUrl) contains an incomplete IP validation check that fails to block loopback addresses (127.0.0.0/8) and link-local addresses (169.254.0.0/16, including the AWS EC2 instance metadata endpoint 169.254.169.254).
An authenticated user can still reach internal services using direct IP addresses, bypassing all four protection configuration settings even when they are set to their secure defaults.
Vulnerable Code
app/Rules/PhotoUrlRule.php lines 85–101:
// Uses FILTER_FLAG_NO_PRIV_RANGE — only blocks RFC-1918 (10.x, 172.16.x, 192.168.x)
// Does NOT block 127.0.0.0/8 (loopback) or 169.254.0.0/16 (link-local)!
if (
$this->config_manager->getValueAsBool('import_via_url_forbidden_local_ip') &&
filter_var($host, FILTER_VALIDATE_IP) !== false &&
filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) === false
) {
$fail($attribute . ' must not be a private IP address.');
return;
}
// Only checks the string 'localhost' — does not block direct IP 127.0.0.1!
if (
$this->config_manager->getValueAsBool('import_via_url_forbidden_localhost') &&
$host === 'localhost'
) {
$fail($attribute . ' must not be localhost.');
return;
}
Root Cause
PHP's FILTER_FLAG_NO_PRIV_RANGE blocks only RFC-1918 private ranges (10.x, 172.16.x, 192.168.x). It does NOT block reserved ranges covered by FILTER_FLAG_NO_RES_RANGE: 127.0.0.0/8 (loopback), 169.254.0.0/16 (link-local / AWS metadata), 0.0.0.0/8.
The localhost string check ($host === 'localhost') only matches the hostname string, not the loopback IP 127.0.0.1.
Proof of Concept
With all 4 protection configs at their secure defaults, the following requests succeed:
Bypass 1 — Loopback via direct IP (bypasses forbidden_localhost string-only check):
POST /api/v2/Photo::fromUrl
Authorization: Bearer <token>
{"urls": ["http://127.0.0.1/"], "album_id": null}
Bypass 2 — AWS EC2 metadata endpoint (bypasses forbidden_local_ip range check):
POST /api/v2/Photo::fromUrl
Authorization: Bearer <token>
{"urls": ["http://169.254.169.254/latest/meta-data/iam/security-credentials/"], "album_id": null}
169.254.169.254 passes FILTER_FLAG_NO_PRIV_RANGE because link-local is a reserved range, not RFC-1918 private. It is only blocked by FILTER_FLAG_NO_RES_RANGE.
Impact
- Authenticated attacker can reach HTTP services on
127.0.0.1 even with forbidden_localhost=1 and forbidden_local_ip=1
- On cloud deployments (AWS/GCP/Azure),
169.254.169.254 is reachable, potentially exposing IAM credentials and instance metadata
- Users who patched GHSA-cpgw-wgf3-xc6v believe they are protected but remain vulnerable
Fix
// Use BOTH flags to cover private AND reserved ranges
if (
$this->config_manager->getValueAsBool('import_via_url_forbidden_local_ip') &&
filter_var($host, FILTER_VALIDATE_IP) !== false &&
filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
) {
$fail($attribute . ' must not be a private or reserved IP address.');
return;
}
// Cover loopback IPs, not just the hostname string
if (
$this->config_manager->getValueAsBool('import_via_url_forbidden_localhost') &&
in_array($host, ['localhost', '127.0.0.1', '::1'], true)
) {
$fail($attribute . ' must not be localhost.');
return;
}
Reporter
Assaf (@offensiveee)
Fix
4138667
Summary
The patch introduced for GHSA-cpgw-wgf3-xc6v (SSRF via
Photo::fromUrl) contains an incomplete IP validation check that fails to block loopback addresses (127.0.0.0/8) and link-local addresses (169.254.0.0/16, including the AWS EC2 instance metadata endpoint169.254.169.254).An authenticated user can still reach internal services using direct IP addresses, bypassing all four protection configuration settings even when they are set to their secure defaults.
Vulnerable Code
app/Rules/PhotoUrlRule.phplines 85–101:Root Cause
PHP's
FILTER_FLAG_NO_PRIV_RANGEblocks only RFC-1918 private ranges (10.x, 172.16.x, 192.168.x). It does NOT block reserved ranges covered byFILTER_FLAG_NO_RES_RANGE:127.0.0.0/8(loopback),169.254.0.0/16(link-local / AWS metadata),0.0.0.0/8.The
localhoststring check ($host === 'localhost') only matches the hostname string, not the loopback IP127.0.0.1.Proof of Concept
With all 4 protection configs at their secure defaults, the following requests succeed:
Bypass 1 — Loopback via direct IP (bypasses
forbidden_localhoststring-only check):Bypass 2 — AWS EC2 metadata endpoint (bypasses
forbidden_local_iprange check):169.254.169.254passesFILTER_FLAG_NO_PRIV_RANGEbecause link-local is a reserved range, not RFC-1918 private. It is only blocked byFILTER_FLAG_NO_RES_RANGE.Impact
127.0.0.1even withforbidden_localhost=1andforbidden_local_ip=1169.254.169.254is reachable, potentially exposing IAM credentials and instance metadataFix
Reporter
Assaf (@offensiveee)
Fix
4138667