|
|
@@ -275,6 +275,38 @@ final class IpsControllerTest extends AppTestCase
|
|
|
self::assertSame(404, $response->getStatusCode());
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * @return iterable<string, array{string}>
|
|
|
+ */
|
|
|
+ public static function nonIpShapedPaths(): iterable
|
|
|
+ {
|
|
|
+ // SEC_REVIEW F43: the {ip} route now uses a strict
|
|
|
+ // `[0-9a-fA-F.:%]+` charset. Anything outside that — slashes,
|
|
|
+ // dots-and-slashes traversal, query separators, spaces, dashes —
|
|
|
+ // 404s at the route layer before `$args['ip']` is read by the
|
|
|
+ // handler. Even though `IpAddress::fromString` would also
|
|
|
+ // reject these inputs today, the tightened route protects
|
|
|
+ // any future code that uses `$args['ip']` as a filename, log
|
|
|
+ // key, or downstream URL component.
|
|
|
+ yield 'path traversal' => ['/api/v1/admin/ips/..%2Fetc%2Fpasswd'];
|
|
|
+ yield 'with subpath' => ['/api/v1/admin/ips/192.0.2.1/extra'];
|
|
|
+ yield 'with query injection' => ['/api/v1/admin/ips/?injected'];
|
|
|
+ yield 'with backslash' => ['/api/v1/admin/ips/192.0.2.1\\admin'];
|
|
|
+ yield 'with space' => ['/api/v1/admin/ips/192.0.2.1 admin'];
|
|
|
+ yield 'with dash' => ['/api/v1/admin/ips/not-an-ip'];
|
|
|
+ yield 'with brackets' => ['/api/v1/admin/ips/[2001:db8::1]'];
|
|
|
+ }
|
|
|
+
|
|
|
+ #[\PHPUnit\Framework\Attributes\DataProvider('nonIpShapedPaths')]
|
|
|
+ public function testDetailRejectsNonIpShapedPaths(string $path): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
|
|
|
+ $response = $this->request('GET', $path, [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ ]);
|
|
|
+ self::assertSame(404, $response->getStatusCode(), "expected 404 for {$path}");
|
|
|
+ }
|
|
|
+
|
|
|
public function testDetailRendersForUnknownIpWithCleanStatus(): void
|
|
|
{
|
|
|
$token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
|