|
@@ -0,0 +1,139 @@
|
|
|
|
|
+<?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/'));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|