ResponseTest.php 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Http;
  4. use App\Http\Response;
  5. use InvalidArgumentException;
  6. use PHPUnit\Framework\Attributes\DataProvider;
  7. use PHPUnit\Framework\TestCase;
  8. /**
  9. * R01-N20: pin the redirect contract.
  10. *
  11. * `Response::redirect` is path-only. `Response::external` is the explicit
  12. * cross-origin helper. Both reject CR/LF (header injection) and protocol-
  13. * relative shapes (`//host/path`) that look like paths but leave the
  14. * origin.
  15. */
  16. final class ResponseTest extends TestCase
  17. {
  18. /** @return list<array{0:string,1:string}> */
  19. public static function pathOnlyAccepted(): array
  20. {
  21. return [
  22. ['/', 'root'],
  23. ['/foo', 'simple path'],
  24. ['/users?error=db', 'path with query string'],
  25. ['/sprints/1#tab=tasks', 'path with fragment'],
  26. ['/users?flash=demoted', 'flash query'],
  27. ['/sprints/import/abc-123', 'token-style segment'],
  28. ];
  29. }
  30. #[DataProvider('pathOnlyAccepted')]
  31. public function testRedirectAcceptsPathOnlyLocations(string $location, string $label): void
  32. {
  33. $resp = Response::redirect($location);
  34. self::assertSame(302, $resp->status, $label);
  35. self::assertSame($location, $resp->headers['Location'], $label);
  36. }
  37. public function testRedirectAcceptsCustomStatus(): void
  38. {
  39. $resp = Response::redirect('/foo', 303);
  40. self::assertSame(303, $resp->status);
  41. }
  42. /** @return list<array{0:string,1:string}> */
  43. public static function nonPathLocations(): array
  44. {
  45. return [
  46. ['', 'empty string'],
  47. ['foo', 'relative path'],
  48. ['foo/bar', 'multi-segment relative'],
  49. ['?next=/x', 'query-only (relative)'],
  50. ['#anchor', 'fragment-only'],
  51. ['//evil.example.com/x', 'protocol-relative URL → off-origin redirect'],
  52. ['///evil.example.com/x', 'triple-slash variant'],
  53. ['https://evil.example.com/x', 'absolute https URL'],
  54. ['http://evil.example.com/', 'absolute http URL'],
  55. ['javascript:alert(1)', 'javascript: scheme'],
  56. ['data:text/html,<script>', 'data: scheme'],
  57. ["/foo\r\nX-Injected: yes", 'CR/LF — header injection'],
  58. ["/foo\nLocation: https://evil/", 'LF — header injection'],
  59. ["/foo\0bar", 'null byte'],
  60. ];
  61. }
  62. #[DataProvider('nonPathLocations')]
  63. public function testRedirectRejectsNonPathLocations(string $location, string $label): void
  64. {
  65. $this->expectException(InvalidArgumentException::class);
  66. $this->expectExceptionMessage('path-only');
  67. Response::redirect($location);
  68. }
  69. /** @return list<array{0:string,1:string}> */
  70. public static function externalAccepted(): array
  71. {
  72. return [
  73. ['https://app.example.com/', 'https + path'],
  74. ['https://app.example.com/sprints/1?tab=foo', 'https + query'],
  75. ['http://localhost:8080/healthz', 'http on localhost with port'],
  76. ['HTTPS://APP.EXAMPLE.COM/x', 'uppercase scheme is normalised at compare'],
  77. ];
  78. }
  79. #[DataProvider('externalAccepted')]
  80. public function testExternalAcceptsHttpAndHttpsUrls(string $url, string $label): void
  81. {
  82. $resp = Response::external($url, 308);
  83. self::assertSame(308, $resp->status, $label);
  84. self::assertSame($url, $resp->headers['Location'], $label);
  85. }
  86. /** @return list<array{0:string,1:string}> */
  87. public static function externalRejected(): array
  88. {
  89. return [
  90. ['', 'empty'],
  91. ['/foo', 'path-only — must use redirect()'],
  92. ['//evil.example.com/x', 'protocol-relative'],
  93. ['javascript:alert(1)', 'javascript: scheme'],
  94. ['file:///etc/passwd', 'file: scheme'],
  95. ['ftp://files.example.com/x', 'ftp: scheme'],
  96. ['data:text/html,<x>', 'data: scheme'],
  97. ['https://', 'no host'],
  98. ['just text', 'unparseable'],
  99. ["https://evil.example.com/\r\n", 'CR/LF in URL'],
  100. ];
  101. }
  102. #[DataProvider('externalRejected')]
  103. public function testExternalRejectsNonHttpSchemesAndMalformed(string $url, string $label): void
  104. {
  105. $this->expectException(InvalidArgumentException::class);
  106. Response::external($url);
  107. }
  108. public function testIsPathOnlyHelperMatchesRedirectContract(): void
  109. {
  110. // Drift fence: if these helpers ever diverge from what redirect()
  111. // / external() accept, the test above will fail too — but pin the
  112. // pure helper independently so refactors are caught instantly.
  113. self::assertTrue(Response::isPathOnly('/'));
  114. self::assertTrue(Response::isPathOnly('/users'));
  115. self::assertFalse(Response::isPathOnly('//evil'));
  116. self::assertFalse(Response::isPathOnly(''));
  117. self::assertFalse(Response::isPathOnly('users'));
  118. self::assertFalse(Response::isPathOnly("/x\nfoo"));
  119. self::assertTrue(Response::isExternalUrl('https://x.example/'));
  120. self::assertTrue(Response::isExternalUrl('http://x.example/'));
  121. self::assertFalse(Response::isExternalUrl('/foo'));
  122. self::assertFalse(Response::isExternalUrl('javascript:x'));
  123. self::assertFalse(Response::isExternalUrl('//x.example/'));
  124. }
  125. }