Files
InvoiceShelf/tests/Unit/PrivateNetworkGuardTest.php
Darko Gjorgjijoski ac2a8ca939 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
2026-06-05 00:01:09 +02:00

74 lines
3.2 KiB
PHP

<?php
use App\Support\Net\BlockedUrlException;
use App\Support\Net\PrivateNetworkGuard;
test('blockedReason flags private, loopback, reserved and bad-scheme URLs', function (string $url) {
expect(PrivateNetworkGuard::blockedReason($url))->not->toBeNull();
})->with([
'loopback v4' => 'http://127.0.0.1',
'loopback v4 with port/path' => 'http://127.0.0.1:8080/v1/chat',
'rfc1918 10/8' => 'http://10.0.0.1',
'rfc1918 10/8 upper' => 'http://10.255.255.255/models',
'rfc1918 172.16/12' => 'http://172.16.0.5',
'rfc1918 172.31' => 'https://172.31.255.255',
'rfc1918 192.168/16' => 'http://192.168.1.1',
'link-local / cloud metadata' => 'http://169.254.169.254/latest/meta-data/',
'unspecified 0.0.0.0' => 'http://0.0.0.0',
'cgnat 100.64/10' => 'http://100.64.0.1',
'broadcast' => 'http://255.255.255.255',
'loopback v6' => 'http://[::1]',
'ula v6' => 'http://[fd00::1]',
'link-local v6' => 'http://[fe80::1]',
'ipv4-mapped v6' => 'http://[::ffff:10.0.0.1]',
'localhost' => 'http://localhost',
'bad scheme ftp' => 'ftp://1.1.1.1',
'bad scheme file' => 'file:///etc/passwd',
'no scheme or host' => 'justsometext',
]);
test('blockedReason allows public addresses and fails open on unresolvable hosts', function (string $url) {
expect(PrivateNetworkGuard::blockedReason($url))->toBeNull();
})->with([
'cloudflare dns literal' => 'https://1.1.1.1',
'google dns literal with path' => 'https://8.8.8.8/v1/models?x=1',
'public literal' => 'http://93.184.216.34',
'unresolvable .invalid (fail-open)' => 'https://surely-not-a-real-host.invalid/api',
]);
test('blockedReason returns null for an empty string', function () {
expect(PrivateNetworkGuard::blockedReason(''))->toBeNull();
expect(PrivateNetworkGuard::blockedReason(' '))->toBeNull();
});
test('assertAllowed throws on a blocked URL', function () {
expect(fn () => PrivateNetworkGuard::assertAllowed('http://169.254.169.254'))
->toThrow(BlockedUrlException::class);
});
test('assertAllowed is silent on a public URL', function () {
PrivateNetworkGuard::assertAllowed('https://1.1.1.1');
expect(true)->toBeTrue();
});
test('ipIsBlocked classifies individual addresses', function () {
expect(PrivateNetworkGuard::ipIsBlocked('10.0.0.1'))->toBeTrue();
expect(PrivateNetworkGuard::ipIsBlocked('169.254.169.254'))->toBeTrue();
expect(PrivateNetworkGuard::ipIsBlocked('::1'))->toBeTrue();
expect(PrivateNetworkGuard::ipIsBlocked('fd00::1'))->toBeTrue();
expect(PrivateNetworkGuard::ipIsBlocked('::ffff:192.168.0.1'))->toBeTrue();
expect(PrivateNetworkGuard::ipIsBlocked('1.1.1.1'))->toBeFalse();
expect(PrivateNetworkGuard::ipIsBlocked('2606:4700:4700::1111'))->toBeFalse();
});
test('ipInCidr handles v4 boundaries and rejects cross-family comparisons', function () {
expect(PrivateNetworkGuard::ipInCidr('172.16.0.0', '172.16.0.0/12'))->toBeTrue();
expect(PrivateNetworkGuard::ipInCidr('172.31.255.255', '172.16.0.0/12'))->toBeTrue();
expect(PrivateNetworkGuard::ipInCidr('172.32.0.0', '172.16.0.0/12'))->toBeFalse();
// v6 address against a v4 CIDR must never match.
expect(PrivateNetworkGuard::ipInCidr('::1', '10.0.0.0/8'))->toBeFalse();
});