| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Http;
- use App\Http\Response;
- use InvalidArgumentException;
- use PHPUnit\Framework\Attributes\DataProvider;
- use PHPUnit\Framework\TestCase;
- /**
- * R01-N20: pin the redirect contract.
- *
- * `Response::redirect` is path-only. `Response::external` is the explicit
- * cross-origin helper. Both reject CR/LF (header injection) and protocol-
- * relative shapes (`//host/path`) that look like paths but leave the
- * origin.
- */
- final class ResponseTest extends TestCase
- {
- /** @return list<array{0:string,1:string}> */
- public static function pathOnlyAccepted(): array
- {
- return [
- ['/', 'root'],
- ['/foo', 'simple path'],
- ['/users?error=db', 'path with query string'],
- ['/sprints/1#tab=tasks', 'path with fragment'],
- ['/users?flash=demoted', 'flash query'],
- ['/sprints/import/abc-123', 'token-style segment'],
- ];
- }
- #[DataProvider('pathOnlyAccepted')]
- public function testRedirectAcceptsPathOnlyLocations(string $location, string $label): void
- {
- $resp = Response::redirect($location);
- self::assertSame(302, $resp->status, $label);
- self::assertSame($location, $resp->headers['Location'], $label);
- }
- public function testRedirectAcceptsCustomStatus(): void
- {
- $resp = Response::redirect('/foo', 303);
- self::assertSame(303, $resp->status);
- }
- /** @return list<array{0:string,1:string}> */
- public static function nonPathLocations(): array
- {
- return [
- ['', 'empty string'],
- ['foo', 'relative path'],
- ['foo/bar', 'multi-segment relative'],
- ['?next=/x', 'query-only (relative)'],
- ['#anchor', 'fragment-only'],
- ['//evil.example.com/x', 'protocol-relative URL → off-origin redirect'],
- ['///evil.example.com/x', 'triple-slash variant'],
- ['https://evil.example.com/x', 'absolute https URL'],
- ['http://evil.example.com/', 'absolute http URL'],
- ['javascript:alert(1)', 'javascript: scheme'],
- ['data:text/html,<script>', 'data: scheme'],
- ["/foo\r\nX-Injected: yes", 'CR/LF — header injection'],
- ["/foo\nLocation: https://evil/", 'LF — header injection'],
- ["/foo\0bar", 'null byte'],
- ];
- }
- #[DataProvider('nonPathLocations')]
- public function testRedirectRejectsNonPathLocations(string $location, string $label): void
- {
- $this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('path-only');
- Response::redirect($location);
- }
- /** @return list<array{0:string,1:string}> */
- public static function externalAccepted(): array
- {
- return [
- ['https://app.example.com/', 'https + path'],
- ['https://app.example.com/sprints/1?tab=foo', 'https + query'],
- ['http://localhost:8080/healthz', 'http on localhost with port'],
- ['HTTPS://APP.EXAMPLE.COM/x', 'uppercase scheme is normalised at compare'],
- ];
- }
- #[DataProvider('externalAccepted')]
- public function testExternalAcceptsHttpAndHttpsUrls(string $url, string $label): void
- {
- $resp = Response::external($url, 308);
- self::assertSame(308, $resp->status, $label);
- self::assertSame($url, $resp->headers['Location'], $label);
- }
- /** @return list<array{0:string,1:string}> */
- public static function externalRejected(): array
- {
- return [
- ['', 'empty'],
- ['/foo', 'path-only — must use redirect()'],
- ['//evil.example.com/x', 'protocol-relative'],
- ['javascript:alert(1)', 'javascript: scheme'],
- ['file:///etc/passwd', 'file: scheme'],
- ['ftp://files.example.com/x', 'ftp: scheme'],
- ['data:text/html,<x>', 'data: scheme'],
- ['https://', 'no host'],
- ['just text', 'unparseable'],
- ["https://evil.example.com/\r\n", 'CR/LF in URL'],
- ];
- }
- #[DataProvider('externalRejected')]
- public function testExternalRejectsNonHttpSchemesAndMalformed(string $url, string $label): void
- {
- $this->expectException(InvalidArgumentException::class);
- Response::external($url);
- }
- public function testIsPathOnlyHelperMatchesRedirectContract(): void
- {
- // Drift fence: if these helpers ever diverge from what redirect()
- // / external() accept, the test above will fail too — but pin the
- // pure helper independently so refactors are caught instantly.
- self::assertTrue(Response::isPathOnly('/'));
- self::assertTrue(Response::isPathOnly('/users'));
- self::assertFalse(Response::isPathOnly('//evil'));
- self::assertFalse(Response::isPathOnly(''));
- self::assertFalse(Response::isPathOnly('users'));
- self::assertFalse(Response::isPathOnly("/x\nfoo"));
- self::assertTrue(Response::isExternalUrl('https://x.example/'));
- self::assertTrue(Response::isExternalUrl('http://x.example/'));
- self::assertFalse(Response::isExternalUrl('/foo'));
- self::assertFalse(Response::isExternalUrl('javascript:x'));
- self::assertFalse(Response::isExternalUrl('//x.example/'));
- }
- }
|