mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-16 22:05:20 +00:00
fix(security): gate AI tools by user ability and block admin-URL SSRF
The AI chat assistant scoped tool queries by company but ignored the per-user Bouncer abilities the rest of the app enforces, so any `use ai` holder could read customers, invoices, payments, and company financials their role couldn't otherwise see. Each AiTool now declares a required ability (entity-aligned); the registry hides unauthorized tools from the model and refuses to execute them as a backstop. Separately, admin/owner-supplied URLs were fetched server-side with no guard against private/reserved targets (SSRF): the AI base URL, the CurrencyConverter "DEDICATED" exchange-rate URL, and S3/Spaces file-disk endpoints. A shared PrivateNetworkGuard now backs a PublicHttpUrl validation rule (save-time) and runtime guards in each driver. - AiTool::requiredAbility() + mapping across all 12 tools - AiToolRegistry filters schemas() by ability and re-checks in execute() - PrivateNetworkGuard / BlockedUrlException / PublicHttpUrl rule (new) - Rule wired into AI config (service + 3 controllers), exchange-rate, and file-disk endpoints; runtime guards in OpenRouterDriver, CurrencyConverterDriver, and FileDiskService - Tests for ability filtering, the guard, the rule, and 422 rejections
This commit is contained in:
246
app/Support/Net/PrivateNetworkGuard.php
Normal file
246
app/Support/Net/PrivateNetworkGuard.php
Normal file
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Net;
|
||||
|
||||
/**
|
||||
* Decides whether a URL is safe for the server to request — i.e. that it does
|
||||
* not target a private, loopback, link-local, or otherwise non-publicly-routable
|
||||
* address.
|
||||
*
|
||||
* This is the single source of truth behind two layers:
|
||||
* - the `PublicHttpUrl` validation rule (save-time UX gate), and
|
||||
* - runtime guards in outbound HTTP drivers (OpenRouter AI base URL, the
|
||||
* CurrencyConverter "DEDICATED" exchange-rate URL, and FileDisk S3/Spaces
|
||||
* endpoints) that re-check right before the request is made.
|
||||
*
|
||||
* Threat model: SSRF where an admin or per-company owner supplies a base URL or
|
||||
* endpoint that the server then fetches with credentials (bearer token, S3 keys)
|
||||
* attached. We block IP literals in unsafe ranges and hostnames that resolve into
|
||||
* them. A host that does NOT resolve is treated as "not provably unsafe"
|
||||
* (fail-open): a request to an unresolvable host cannot reach a private network,
|
||||
* and failing open keeps offline validation from rejecting legitimate public
|
||||
* hostnames. The authoritative enforcement is the runtime guard, which re-checks
|
||||
* at request time.
|
||||
*
|
||||
* Known limitation: this is not TOCTOU/DNS-rebinding-proof. A fully hardened
|
||||
* implementation would pin the connection to the validated IP (cURL
|
||||
* CURLOPT_RESOLVE). That is intentionally out of scope here — the realistic
|
||||
* finding is direct private targeting, which this fully covers.
|
||||
*/
|
||||
class PrivateNetworkGuard
|
||||
{
|
||||
/**
|
||||
* IPv4 CIDR blocks that must never be the target of a server-side request.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const BLOCKED_IPV4 = [
|
||||
'0.0.0.0/8', // "this" network / unspecified
|
||||
'10.0.0.0/8', // RFC1918 private
|
||||
'100.64.0.0/10', // RFC6598 carrier-grade NAT
|
||||
'127.0.0.0/8', // loopback
|
||||
'169.254.0.0/16', // link-local (incl. cloud metadata 169.254.169.254)
|
||||
'172.16.0.0/12', // RFC1918 private
|
||||
'192.0.0.0/24', // IETF protocol assignments
|
||||
'192.0.2.0/24', // TEST-NET-1
|
||||
'192.168.0.0/16', // RFC1918 private
|
||||
'198.18.0.0/15', // benchmarking
|
||||
'198.51.100.0/24', // TEST-NET-2
|
||||
'203.0.113.0/24', // TEST-NET-3
|
||||
'240.0.0.0/4', // reserved (incl. 255.255.255.255 broadcast)
|
||||
];
|
||||
|
||||
/**
|
||||
* IPv6 CIDR blocks that must never be the target of a server-side request.
|
||||
*
|
||||
* IPv4-mapped addresses (::ffff:0:0/96) are handled explicitly by unwrapping
|
||||
* the embedded IPv4 in {@see self::ipIsBlocked()}.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const BLOCKED_IPV6 = [
|
||||
'::1/128', // loopback
|
||||
'::/128', // unspecified
|
||||
'fc00::/7', // unique local address
|
||||
'fe80::/10', // link-local
|
||||
'64:ff9b::/96', // NAT64 (embeds IPv4)
|
||||
'2001:db8::/32', // documentation
|
||||
];
|
||||
|
||||
/**
|
||||
* Return a human-readable reason when the URL must NOT be requested, or null
|
||||
* when it is allowed (including the fail-open unresolvable-host case).
|
||||
*/
|
||||
public static function blockedReason(string $url): ?string
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
if ($parts === false || ! isset($parts['scheme']) || ! isset($parts['host'])) {
|
||||
return 'URL must include a scheme and host';
|
||||
}
|
||||
|
||||
$scheme = strtolower($parts['scheme']);
|
||||
if (! in_array($scheme, ['http', 'https'], true)) {
|
||||
return 'URL scheme must be http or https';
|
||||
}
|
||||
|
||||
// parse_url keeps IPv6 literals wrapped in brackets; strip them.
|
||||
$host = trim($parts['host'], '[]');
|
||||
if ($host === '') {
|
||||
return 'URL must include a host';
|
||||
}
|
||||
|
||||
// IP literal — check directly without DNS.
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
||||
return self::ipIsBlocked($host)
|
||||
? "URL host {$host} is a private or reserved address"
|
||||
: null;
|
||||
}
|
||||
|
||||
// Hostname — resolve and check every address it points at.
|
||||
$ips = self::resolveHost($host);
|
||||
if ($ips === []) {
|
||||
// Unresolvable: not provably unsafe. The actual request will fail.
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($ips as $ip) {
|
||||
if (self::ipIsBlocked($ip)) {
|
||||
return "URL host {$host} resolves to a private or reserved address ({$ip})";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw when the URL is not safe to request. Used by runtime driver guards.
|
||||
*
|
||||
* @throws BlockedUrlException
|
||||
*/
|
||||
public static function assertAllowed(string $url): void
|
||||
{
|
||||
$reason = self::blockedReason($url);
|
||||
|
||||
if ($reason !== null) {
|
||||
throw new BlockedUrlException($reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a single IP address falls inside any blocked range.
|
||||
*/
|
||||
public static function ipIsBlocked(string $ip): bool
|
||||
{
|
||||
// Normalise IPv4-mapped IPv6 (e.g. ::ffff:10.0.0.1) to its embedded IPv4.
|
||||
$mapped = self::extractMappedIpv4($ip);
|
||||
if ($mapped !== null) {
|
||||
return self::ipIsBlocked($mapped);
|
||||
}
|
||||
|
||||
$isIpv4 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
|
||||
$blocks = $isIpv4 ? self::BLOCKED_IPV4 : self::BLOCKED_IPV6;
|
||||
|
||||
foreach ($blocks as $cidr) {
|
||||
if (self::ipInCidr($ip, $cidr)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an IP (v4 or v6) is contained in the given CIDR block.
|
||||
*/
|
||||
public static function ipInCidr(string $ip, string $cidr): bool
|
||||
{
|
||||
[$subnet, $bits] = array_pad(explode('/', $cidr, 2), 2, null);
|
||||
|
||||
$ipBin = @inet_pton($ip);
|
||||
$subnetBin = @inet_pton((string) $subnet);
|
||||
|
||||
if ($ipBin === false || $subnetBin === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Different address families (v4 vs v6) can never match.
|
||||
if (strlen($ipBin) !== strlen($subnetBin)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$bits = (int) $bits;
|
||||
$wholeBytes = intdiv($bits, 8);
|
||||
$remainderBits = $bits % 8;
|
||||
|
||||
if ($wholeBytes > 0 && strncmp($ipBin, $subnetBin, $wholeBytes) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($remainderBits > 0) {
|
||||
$mask = (~((1 << (8 - $remainderBits)) - 1)) & 0xFF;
|
||||
|
||||
if ((ord($ipBin[$wholeBytes]) & $mask) !== (ord($subnetBin[$wholeBytes]) & $mask)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a hostname to all of its A and AAAA addresses.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function resolveHost(string $host): array
|
||||
{
|
||||
$ips = [];
|
||||
|
||||
if (function_exists('gethostbynamel')) {
|
||||
$v4 = @gethostbynamel($host);
|
||||
if (is_array($v4)) {
|
||||
$ips = $v4;
|
||||
}
|
||||
}
|
||||
|
||||
if (function_exists('dns_get_record')) {
|
||||
$records = @dns_get_record($host, DNS_AAAA);
|
||||
if (is_array($records)) {
|
||||
foreach ($records as $record) {
|
||||
if (isset($record['ipv6'])) {
|
||||
$ips[] = $record['ipv6'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ips));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the embedded IPv4 from an IPv4-mapped IPv6 address (::ffff:0:0/96),
|
||||
* or null if the value is not such an address.
|
||||
*/
|
||||
private static function extractMappedIpv4(string $ip): ?string
|
||||
{
|
||||
$bin = @inet_pton($ip);
|
||||
if ($bin === false || strlen($bin) !== 16) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ::ffff:0:0/96 = 10 zero bytes followed by 0xff 0xff.
|
||||
$prefix = str_repeat("\x00", 10)."\xff\xff";
|
||||
if (strncmp($bin, $prefix, 12) !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ipv4 = inet_ntop(substr($bin, 12, 4));
|
||||
|
||||
return $ipv4 === false ? null : $ipv4;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user