|
|
@@ -106,6 +106,70 @@ final class IpsControllerTest extends AppTestCase
|
|
|
self::assertSame(400, $response->getStatusCode());
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * SEC_REVIEW F30: the substring fallback (`%q%`) used to drive a
|
|
|
+ * full-table scan on `ip_scores`. The controller now rejects any `q`
|
|
|
+ * that isn't IP-shaped so the repo only ever runs an anchored
|
|
|
+ * `LIKE 'q%'` prefix scan.
|
|
|
+ */
|
|
|
+ public function testSearchRejectsNonIpShapedQuery(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
|
|
|
+ // Each of these would previously have hit the `%q%` substring path.
|
|
|
+ $bad = ['foo', '203.0.113.10 OR 1=1', '%X%', 'g00d', '203.0.113.10%', '_____'];
|
|
|
+ foreach ($bad as $q) {
|
|
|
+ $response = $this->request('GET', '/api/v1/admin/ips?q=' . rawurlencode($q), [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ ]);
|
|
|
+ self::assertSame(400, $response->getStatusCode(), "expected 400 for q={$q}");
|
|
|
+ $body = $this->decode($response);
|
|
|
+ self::assertSame('validation_failed', $body['error']);
|
|
|
+ self::assertArrayHasKey('q', $body['details']);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * SEC_REVIEW F30: cap on `q` length so even an IP-shaped value cannot
|
|
|
+ * push a multi-megabyte LIKE parameter through the repository.
|
|
|
+ */
|
|
|
+ public function testSearchRejectsOverlongQuery(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
|
|
|
+ $tooLong = str_repeat('1', 65); // 64 is the cap.
|
|
|
+ $response = $this->request('GET', '/api/v1/admin/ips?q=' . $tooLong, [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ ]);
|
|
|
+ self::assertSame(400, $response->getStatusCode());
|
|
|
+ $body = $this->decode($response);
|
|
|
+ self::assertArrayHasKey('q', $body['details']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * SEC_REVIEW F30: an IP-shaped `q` is still treated as an anchored
|
|
|
+ * prefix — the substring path is gone, so a value that previously
|
|
|
+ * matched mid-string no longer matches at all.
|
|
|
+ */
|
|
|
+ public function testSearchQueryIsPrefixAnchoredNotSubstring(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
|
|
|
+ $this->seedScored('203.0.113.10', 'brute_force', 1.0);
|
|
|
+ $this->seedScored('198.51.100.5', 'brute_force', 1.0);
|
|
|
+
|
|
|
+ // "113" is a substring of 203.0.113.10 but not a prefix; with the
|
|
|
+ // substring path removed the result is empty.
|
|
|
+ $body = $this->decode($this->request('GET', '/api/v1/admin/ips?q=113', [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ ]));
|
|
|
+ self::assertSame(0, $body['total']);
|
|
|
+
|
|
|
+ // The legitimate prefix still matches.
|
|
|
+ $body = $this->decode($this->request('GET', '/api/v1/admin/ips?q=203.0.113', [
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
+ ]));
|
|
|
+ self::assertSame(1, $body['total']);
|
|
|
+ self::assertSame('203.0.113.10', $body['items'][0]['ip']);
|
|
|
+ }
|
|
|
+
|
|
|
public function testDetailReturnsScoresAndStatus(): void
|
|
|
{
|
|
|
$token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
|