3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00
Files
snipe-it/app/Rules/ExternalUrl.php

110 lines
3.5 KiB
PHP

<?php
namespace App\Rules;
use App\Helpers\PublicIpCheck;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Reject URLs that point at loopback, link-local, private-network, or
* otherwise-non-public addresses so a super-admin can't be tricked (or
* accidentally) turn the outbound webhook path into an SSRF primitive
* against 169.254.169.254, 127.0.0.1, RFC-1918, [::1], and friends.
*
* The rule is intentionally scheme-strict (http/https only): the previous
* webhook validator accepted ftp:// and irc://, which have no legitimate
* webhook use and expand the attack surface.
*
* Between this check and the outbound request there is still a DNS
* rebinding window; that is acceptable given the super-admin threat
* model. If we ever need to close it, pin the resolved IP into Guzzle
* via CURLOPT_RESOLVE on the actual request.
*/
class ExternalUrl implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || $value === '') {
$fail(trans('validation.external_url'));
return;
}
$parts = parse_url($value);
if ($parts === false || empty($parts['scheme']) || empty($parts['host'])) {
$fail(trans('validation.external_url'));
return;
}
if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
$fail(trans('validation.external_url'));
return;
}
// Operators who legitimately need to point at an on-LAN webhook
// receiver flip WEBHOOK_ALLOW_INTERNAL_TARGETS in .env. Scheme
// restrictions above still apply; only the IP-range check is skipped.
if (config('app.webhook_allow_internal_targets')) {
return;
}
$host = $parts['host'];
if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
$host = substr($host, 1, -1);
}
$ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : $this->resolveHost($host);
if ($ips === []) {
$fail(trans('validation.external_url'));
return;
}
foreach ($ips as $ip) {
if (! PublicIpCheck::isPublic($ip)) {
$fail(trans('validation.external_url'));
return;
}
}
}
private function resolveHost(string $host): array
{
$ips = [];
// gethostbynamel goes through nsswitch (/etc/hosts, mDNS, DNS) —
// the same lookup path the outbound HTTP client will use. dns_get_record
// alone is a pure DNS query that ignores /etc/hosts and may apply
// search suffixes, which would let "localhost" resolve to a public IP
// under "localhost.<search-domain>" and slip past the check.
$v4 = @gethostbynamel($host);
if (is_array($v4)) {
foreach ($v4 as $ip) {
$ips[] = $ip;
}
}
// There is no stdlib nsswitch equivalent for IPv6, so this leg is
// best-effort DNS. The IPv4 leg above already catches the common
// "localhost" / hosts-file cases, so a missed AAAA record here
// can't silently pass a name we would have otherwise rejected.
$v6 = @dns_get_record($host, DNS_AAAA);
if (is_array($v6)) {
foreach ($v6 as $r) {
if (! empty($r['ipv6'])) {
$ips[] = $r['ipv6'];
}
}
}
return array_values(array_unique($ips));
}
}