InputControlCharStrippingTest.php 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Admin;
  4. use App\Domain\Auth\Role;
  5. use App\Domain\Auth\TokenKind;
  6. use App\Tests\Integration\Support\AppTestCase;
  7. /**
  8. * SEC_REVIEW F52 — admin CRUD endpoints must strip C0/C1 control
  9. * characters from free-form string fields (`name`, `description`,
  10. * `reason`) before they land in `audit_log.target_label` /
  11. * `details_json`. NULs, newlines and ANSI escapes otherwise enable
  12. * log-injection (`\n[CRIT] fake event`) and terminal-escape attacks
  13. * on log viewers.
  14. *
  15. * One test per controller is enough — the helper is shared in
  16. * `AdminControllerSupport` and applied at every relevant call site.
  17. */
  18. final class InputControlCharStrippingTest extends AppTestCase
  19. {
  20. public function testReporterNameAndDescriptionAreStripped(): void
  21. {
  22. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  23. $resp = $this->request(
  24. 'POST',
  25. '/api/v1/admin/reporters',
  26. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  27. (string) json_encode([
  28. 'name' => "web\x00prod\n01\u{007f}",
  29. 'description' => "first\nwebserver\x07with\x00control\u{001b}chars",
  30. 'trust_weight' => 1.0,
  31. ]),
  32. );
  33. self::assertSame(201, $resp->getStatusCode());
  34. $body = $this->decode($resp);
  35. self::assertControlBytesGone($body['name']);
  36. self::assertControlBytesGone($body['description']);
  37. // Visible payload (post-scrub) round-trips byte-for-byte.
  38. self::assertSame('webprod01', $body['name']);
  39. self::assertSame('firstwebserverwithcontrolchars', $body['description']);
  40. // Audit log target_label / details_json must also be clean.
  41. $audit = $this->db->fetchAssociative(
  42. "SELECT target_label, details_json FROM audit_log WHERE action = 'reporter.created' AND target_id = ?",
  43. [(string) $body['id']],
  44. );
  45. self::assertIsArray($audit);
  46. self::assertSame('webprod01', $audit['target_label']);
  47. self::assertControlBytesGone((string) $audit['details_json']);
  48. }
  49. public function testConsumerNameIsStripped(): void
  50. {
  51. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  52. $policyId = (int) $this->db->fetchOne(
  53. 'SELECT id FROM policies WHERE name = :name',
  54. ['name' => 'moderate'],
  55. );
  56. $resp = $this->request(
  57. 'POST',
  58. '/api/v1/admin/consumers',
  59. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  60. (string) json_encode([
  61. 'name' => "fw\x00edge\nok\u{007f}",
  62. 'description' => "edge\nrouter\x00",
  63. 'policy_id' => $policyId,
  64. ]),
  65. );
  66. self::assertSame(201, $resp->getStatusCode());
  67. $body = $this->decode($resp);
  68. self::assertControlBytesGone($body['name']);
  69. self::assertControlBytesGone($body['description']);
  70. self::assertSame('fwedgeok', $body['name']);
  71. self::assertSame('edgerouter', $body['description']);
  72. }
  73. public function testCategoryNameIsStripped(): void
  74. {
  75. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  76. $resp = $this->request(
  77. 'POST',
  78. '/api/v1/admin/categories',
  79. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  80. (string) json_encode([
  81. 'slug' => 'sec_review_f52',
  82. 'name' => "Brute\x00 force\n attempts",
  83. 'description' => "Repeated\nlogin\x00fails",
  84. 'decay_function' => 'linear',
  85. 'decay_param' => 14,
  86. ]),
  87. );
  88. self::assertSame(201, $resp->getStatusCode());
  89. $body = $this->decode($resp);
  90. self::assertControlBytesGone($body['name']);
  91. self::assertControlBytesGone($body['description']);
  92. self::assertSame('Brute force attempts', $body['name']);
  93. }
  94. public function testManualBlockReasonIsStripped(): void
  95. {
  96. $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
  97. $resp = $this->request(
  98. 'POST',
  99. '/api/v1/admin/manual-blocks',
  100. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  101. (string) json_encode([
  102. 'kind' => 'ip',
  103. 'ip' => '198.51.100.42',
  104. 'reason' => "abuse\n\x00alert\x07more\u{007f}",
  105. ]),
  106. );
  107. self::assertSame(201, $resp->getStatusCode());
  108. $body = $this->decode($resp);
  109. self::assertControlBytesGone($body['reason']);
  110. self::assertSame('abusealertmore', $body['reason']);
  111. }
  112. public function testAllowlistReasonIsStripped(): void
  113. {
  114. $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
  115. $resp = $this->request(
  116. 'POST',
  117. '/api/v1/admin/allowlist',
  118. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  119. (string) json_encode([
  120. 'kind' => 'ip',
  121. 'ip' => '203.0.113.99',
  122. 'reason' => "trusted\nsource\x00ok",
  123. ]),
  124. );
  125. self::assertSame(201, $resp->getStatusCode());
  126. $body = $this->decode($resp);
  127. self::assertControlBytesGone($body['reason']);
  128. self::assertSame('trustedsourceok', $body['reason']);
  129. }
  130. public function testReporterUpdateAlsoStrips(): void
  131. {
  132. // Defence-in-depth: update path must apply the same scrub.
  133. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  134. $reporterId = $this->createReporter('web-update-strip');
  135. $resp = $this->request(
  136. 'PATCH',
  137. "/api/v1/admin/reporters/{$reporterId}",
  138. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  139. (string) json_encode([
  140. 'name' => "renamed\x00\n",
  141. 'description' => "later\u{001b}",
  142. ]),
  143. );
  144. self::assertSame(200, $resp->getStatusCode());
  145. $body = $this->decode($resp);
  146. self::assertControlBytesGone($body['name']);
  147. self::assertControlBytesGone($body['description']);
  148. self::assertSame('renamed', $body['name']);
  149. self::assertSame('later', $body['description']);
  150. }
  151. private static function assertControlBytesGone(string $value): void
  152. {
  153. // Empty regex match → no C0/C1/DEL bytes in the value. The
  154. // ESC byte (0x1B) is the lead-in for ANSI escape sequences;
  155. // its absence neutralises terminal-interpretation attacks
  156. // even if the trailing `[31m`-style payload remains as
  157. // visible text.
  158. self::assertSame(
  159. 0,
  160. preg_match('/[\x00-\x1f\x7f-\x9f]/u', $value),
  161. sprintf('control byte found in %s', json_encode($value)),
  162. );
  163. }
  164. }