| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181 |
- <?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)),
- );
- }
- }
|