|
|
@@ -0,0 +1,181 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace App\Tests\Integration\Admin;
|
|
|
+
|
|
|
+use App\Domain\Auth\Role;
|
|
|
+use App\Domain\Auth\TokenKind;
|
|
|
+use App\Tests\Integration\Support\AppTestCase;
|
|
|
+
|
|
|
+/**
|
|
|
+ * SEC_REVIEW F52 — admin CRUD endpoints must strip C0/C1 control
|
|
|
+ * characters from free-form string fields (`name`, `description`,
|
|
|
+ * `reason`) before they land in `audit_log.target_label` /
|
|
|
+ * `details_json`. NULs, newlines and ANSI escapes otherwise enable
|
|
|
+ * log-injection (`\n[CRIT] fake event`) and terminal-escape attacks
|
|
|
+ * on log viewers.
|
|
|
+ *
|
|
|
+ * One test per controller is enough — the helper is shared in
|
|
|
+ * `AdminControllerSupport` and applied at every relevant call site.
|
|
|
+ */
|
|
|
+final class InputControlCharStrippingTest extends AppTestCase
|
|
|
+{
|
|
|
+ public function testReporterNameAndDescriptionAreStripped(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
|
|
|
+
|
|
|
+ $resp = $this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/admin/reporters',
|
|
|
+ ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
|
|
|
+ (string) json_encode([
|
|
|
+ 'name' => "web\x00prod\n01\u{007f}",
|
|
|
+ 'description' => "first\nwebserver\x07with\x00control\u{001b}chars",
|
|
|
+ 'trust_weight' => 1.0,
|
|
|
+ ]),
|
|
|
+ );
|
|
|
+ self::assertSame(201, $resp->getStatusCode());
|
|
|
+ $body = $this->decode($resp);
|
|
|
+ self::assertControlBytesGone($body['name']);
|
|
|
+ self::assertControlBytesGone($body['description']);
|
|
|
+ // Visible payload (post-scrub) round-trips byte-for-byte.
|
|
|
+ self::assertSame('webprod01', $body['name']);
|
|
|
+ self::assertSame('firstwebserverwithcontrolchars', $body['description']);
|
|
|
+
|
|
|
+ // Audit log target_label / details_json must also be clean.
|
|
|
+ $audit = $this->db->fetchAssociative(
|
|
|
+ "SELECT target_label, details_json FROM audit_log WHERE action = 'reporter.created' AND target_id = ?",
|
|
|
+ [(string) $body['id']],
|
|
|
+ );
|
|
|
+ self::assertIsArray($audit);
|
|
|
+ self::assertSame('webprod01', $audit['target_label']);
|
|
|
+ self::assertControlBytesGone((string) $audit['details_json']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testConsumerNameIsStripped(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
|
|
|
+ $policyId = (int) $this->db->fetchOne(
|
|
|
+ 'SELECT id FROM policies WHERE name = :name',
|
|
|
+ ['name' => 'moderate'],
|
|
|
+ );
|
|
|
+
|
|
|
+ $resp = $this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/admin/consumers',
|
|
|
+ ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
|
|
|
+ (string) json_encode([
|
|
|
+ 'name' => "fw\x00edge\nok\u{007f}",
|
|
|
+ 'description' => "edge\nrouter\x00",
|
|
|
+ 'policy_id' => $policyId,
|
|
|
+ ]),
|
|
|
+ );
|
|
|
+ self::assertSame(201, $resp->getStatusCode());
|
|
|
+ $body = $this->decode($resp);
|
|
|
+ self::assertControlBytesGone($body['name']);
|
|
|
+ self::assertControlBytesGone($body['description']);
|
|
|
+ self::assertSame('fwedgeok', $body['name']);
|
|
|
+ self::assertSame('edgerouter', $body['description']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testCategoryNameIsStripped(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
|
|
|
+
|
|
|
+ $resp = $this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/admin/categories',
|
|
|
+ ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
|
|
|
+ (string) json_encode([
|
|
|
+ 'slug' => 'sec_review_f52',
|
|
|
+ 'name' => "Brute\x00 force\n attempts",
|
|
|
+ 'description' => "Repeated\nlogin\x00fails",
|
|
|
+ 'decay_function' => 'linear',
|
|
|
+ 'decay_param' => 14,
|
|
|
+ ]),
|
|
|
+ );
|
|
|
+ self::assertSame(201, $resp->getStatusCode());
|
|
|
+ $body = $this->decode($resp);
|
|
|
+ self::assertControlBytesGone($body['name']);
|
|
|
+ self::assertControlBytesGone($body['description']);
|
|
|
+ self::assertSame('Brute force attempts', $body['name']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testManualBlockReasonIsStripped(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
|
|
|
+
|
|
|
+ $resp = $this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/admin/manual-blocks',
|
|
|
+ ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
|
|
|
+ (string) json_encode([
|
|
|
+ 'kind' => 'ip',
|
|
|
+ 'ip' => '198.51.100.42',
|
|
|
+ 'reason' => "abuse\n\x00alert\x07more\u{007f}",
|
|
|
+ ]),
|
|
|
+ );
|
|
|
+ self::assertSame(201, $resp->getStatusCode());
|
|
|
+ $body = $this->decode($resp);
|
|
|
+ self::assertControlBytesGone($body['reason']);
|
|
|
+ self::assertSame('abusealertmore', $body['reason']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testAllowlistReasonIsStripped(): void
|
|
|
+ {
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
|
|
|
+
|
|
|
+ $resp = $this->request(
|
|
|
+ 'POST',
|
|
|
+ '/api/v1/admin/allowlist',
|
|
|
+ ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
|
|
|
+ (string) json_encode([
|
|
|
+ 'kind' => 'ip',
|
|
|
+ 'ip' => '203.0.113.99',
|
|
|
+ 'reason' => "trusted\nsource\x00ok",
|
|
|
+ ]),
|
|
|
+ );
|
|
|
+ self::assertSame(201, $resp->getStatusCode());
|
|
|
+ $body = $this->decode($resp);
|
|
|
+ self::assertControlBytesGone($body['reason']);
|
|
|
+ self::assertSame('trustedsourceok', $body['reason']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testReporterUpdateAlsoStrips(): void
|
|
|
+ {
|
|
|
+ // Defence-in-depth: update path must apply the same scrub.
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
|
|
|
+ $reporterId = $this->createReporter('web-update-strip');
|
|
|
+
|
|
|
+ $resp = $this->request(
|
|
|
+ 'PATCH',
|
|
|
+ "/api/v1/admin/reporters/{$reporterId}",
|
|
|
+ ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
|
|
|
+ (string) json_encode([
|
|
|
+ 'name' => "renamed\x00\n",
|
|
|
+ 'description' => "later\u{001b}",
|
|
|
+ ]),
|
|
|
+ );
|
|
|
+ self::assertSame(200, $resp->getStatusCode());
|
|
|
+ $body = $this->decode($resp);
|
|
|
+ self::assertControlBytesGone($body['name']);
|
|
|
+ self::assertControlBytesGone($body['description']);
|
|
|
+ self::assertSame('renamed', $body['name']);
|
|
|
+ self::assertSame('later', $body['description']);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static function assertControlBytesGone(string $value): void
|
|
|
+ {
|
|
|
+ // Empty regex match → no C0/C1/DEL bytes in the value. The
|
|
|
+ // ESC byte (0x1B) is the lead-in for ANSI escape sequences;
|
|
|
+ // its absence neutralises terminal-interpretation attacks
|
|
|
+ // even if the trailing `[31m`-style payload remains as
|
|
|
+ // visible text.
|
|
|
+ self::assertSame(
|
|
|
+ 0,
|
|
|
+ preg_match('/[\x00-\x1f\x7f-\x9f]/u', $value),
|
|
|
+ sprintf('control byte found in %s', json_encode($value)),
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|