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