diff --git a/src/Http/Contracts/HeaderBuilder.php b/src/Http/Contracts/HeaderBuilder.php new file mode 100644 index 00000000..140dac26 --- /dev/null +++ b/src/Http/Contracts/HeaderBuilder.php @@ -0,0 +1,12 @@ +setHeader('Cross-Origin-Opener-Policy', $this->value()); + } + + protected function value(): string + { + return 'same-origin'; + } +} diff --git a/src/Http/Headers/CrossOriginResourcePolicy.php b/src/Http/Headers/CrossOriginResourcePolicy.php new file mode 100644 index 00000000..dbb3c5fb --- /dev/null +++ b/src/Http/Headers/CrossOriginResourcePolicy.php @@ -0,0 +1,20 @@ +setHeader('Cross-Origin-Resource-Policy', $this->value()); + } + + protected function value(): string + { + return 'same-origin'; + } +} diff --git a/src/Http/Headers/HeaderBuilder.php b/src/Http/Headers/HeaderBuilder.php new file mode 100644 index 00000000..0910ba29 --- /dev/null +++ b/src/Http/Headers/HeaderBuilder.php @@ -0,0 +1,12 @@ +setHeader('Referrer-Policy', $this->value()); + } + + protected function value(): string + { + return 'no-referrer'; + } +} diff --git a/src/Http/Headers/StrictTransportSecurity.php b/src/Http/Headers/StrictTransportSecurity.php new file mode 100644 index 00000000..4675fd34 --- /dev/null +++ b/src/Http/Headers/StrictTransportSecurity.php @@ -0,0 +1,20 @@ +setHeader('Strict-Transport-Security', $this->value()); + } + + protected function value(): string + { + return 'max-age=31536000; includeSubDomains; preload'; + } +} diff --git a/src/Http/Headers/XContentTypeOptions.php b/src/Http/Headers/XContentTypeOptions.php new file mode 100644 index 00000000..5d404595 --- /dev/null +++ b/src/Http/Headers/XContentTypeOptions.php @@ -0,0 +1,20 @@ +setHeader('X-Content-Type-Options', $this->value()); + } + + protected function value(): string + { + return 'nosniff'; + } +} diff --git a/src/Http/Headers/XDnsPrefetchControl.php b/src/Http/Headers/XDnsPrefetchControl.php new file mode 100644 index 00000000..337d6c5f --- /dev/null +++ b/src/Http/Headers/XDnsPrefetchControl.php @@ -0,0 +1,20 @@ +setHeader('X-DNS-Prefetch-Control', $this->value()); + } + + protected function value(): string + { + return 'off'; + } +} diff --git a/src/Http/Headers/XFrameOptions.php b/src/Http/Headers/XFrameOptions.php new file mode 100644 index 00000000..83ef4c13 --- /dev/null +++ b/src/Http/Headers/XFrameOptions.php @@ -0,0 +1,20 @@ +setHeader('X-Frame-Options', $this->value()); + } + + protected function value(): string + { + return 'SAMEORIGIN'; + } +} diff --git a/src/Http/Middlewares/ResponseHeaders.php b/src/Http/Middlewares/ResponseHeaders.php new file mode 100644 index 00000000..05ce5e17 --- /dev/null +++ b/src/Http/Middlewares/ResponseHeaders.php @@ -0,0 +1,47 @@ + + */ + protected array $builders; + + public function __construct() + { + $builders = Config::get('server.security.headers', []); + + foreach ($builders as $builder) { + assert(is_subclass_of($builder, HeaderBuilder::class)); + + $this->builders[] = new $builder(); + } + } + + public function handleRequest(Request $request, RequestHandler $next): Response + { + $response = $next->handleRequest($request); + + if ($response->getStatus() >= HttpStatus::MULTIPLE_CHOICES->value && $response->getStatus() < HttpStatus::BAD_REQUEST->value) { + return $response; + } + + foreach ($this->builders as $builder) { + $builder->apply($response); + } + + return $response; + } +} diff --git a/src/Http/Response.php b/src/Http/Response.php index a10de009..e2b0057c 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -66,6 +66,15 @@ public function view( return $this; } + public function redirect(string $location, HttpStatus $status = HttpStatus::FOUND, array $headers = []): self + { + $this->body = json_encode(['redirectTo' => $location]); + $this->status = $status; + $this->headers = [...['Location' => $location, 'content-type' => 'application/json'], ...$headers]; + + return $this; + } + public function send(): ServerResponse { return new ServerResponse( diff --git a/src/Testing/Concerns/InteractWithHeaders.php b/src/Testing/Concerns/InteractWithHeaders.php index a4c8c073..354d5e3b 100644 --- a/src/Testing/Concerns/InteractWithHeaders.php +++ b/src/Testing/Concerns/InteractWithHeaders.php @@ -23,8 +23,8 @@ public function getHeader(string $name): string|null public function assertHeaders(array $needles): self { foreach ($needles as $header => $value) { - Assert::assertNotNull($this->response->getHeader($header)); - Assert::assertEquals($value, $this->response->getHeader($header)); + Assert::assertNotNull($this->response->getHeader($header), "Response is missing expected header: {$header}"); + Assert::assertEquals($value, $this->response->getHeader($header), "Response header {$header} does not match expected value {$value}."); } return $this; @@ -37,6 +37,15 @@ public function assertHeaderIsMissing(string $name): self return $this; } + public function assertHeadersMissing(array $needles): self + { + foreach ($needles as $header) { + Assert::assertNull($this->response->getHeader($header), "Response has unexpected header: {$header}"); + } + + return $this; + } + public function assertIsJson(): self { $contentType = $this->response->getHeader('content-type'); diff --git a/tests/Feature/RequestTest.php b/tests/Feature/RequestTest.php index ece27c60..0eaa38b8 100644 --- a/tests/Feature/RequestTest.php +++ b/tests/Feature/RequestTest.php @@ -456,3 +456,38 @@ 'email' => 'jane@example.com', ], 'data'); }); + +it('adds secure headers to responses', function (): void { + Route::get('/secure', fn (): Response => response()->json(['message' => 'Secure'])); + + $this->app->run(); + + $this->get('/secure') + ->assertOk() + ->assertHeaders([ + 'X-Frame-Options' => 'SAMEORIGIN', + 'X-Content-Type-Options' => 'nosniff', + 'X-DNS-Prefetch-Control' => 'off', + 'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload', + 'Referrer-Policy' => 'no-referrer', + 'Cross-Origin-Resource-Policy' => 'same-origin', + 'Cross-Origin-Opener-Policy' => 'same-origin', + ]); +}); + +it('does not add secure headers to redirect responses', function (): void { + Route::get('/redirect', fn (): Response => response()->redirect('/target')); + + $this->app->run(); + + $this->get('/redirect') + ->assertHeadersMissing([ + 'X-Frame-Options', + 'X-Content-Type-Options', + 'X-DNS-Prefetch-Control', + 'Strict-Transport-Security', + 'Referrer-Policy', + 'Cross-Origin-Resource-Policy', + 'Cross-Origin-Opener-Policy', + ]); +}); diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index bbbca526..370af3fa 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -19,6 +19,7 @@ \Phenix\Auth\Middlewares\TokenRateLimit::class, ], 'router' => [ + \Phenix\Http\Middlewares\ResponseHeaders::class, ], ], 'providers' => [ diff --git a/tests/fixtures/application/config/cors.php b/tests/fixtures/application/config/cors.php index 0057562c..bf3b368a 100644 --- a/tests/fixtures/application/config/cors.php +++ b/tests/fixtures/application/config/cors.php @@ -1,5 +1,7 @@ env('CORS_ORIGIN', static fn (): array => ['*']), 'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE'], diff --git a/tests/fixtures/application/config/mail.php b/tests/fixtures/application/config/mail.php index bdef847b..3a11d099 100644 --- a/tests/fixtures/application/config/mail.php +++ b/tests/fixtures/application/config/mail.php @@ -1,5 +1,7 @@ env('MAIL_MAILER', static fn (): string => 'smtp'), diff --git a/tests/fixtures/application/config/queue.php b/tests/fixtures/application/config/queue.php index eaec4a42..25ab32b6 100644 --- a/tests/fixtures/application/config/queue.php +++ b/tests/fixtures/application/config/queue.php @@ -1,5 +1,7 @@ env('QUEUE_DRIVER', static fn (): string => 'database'), diff --git a/tests/fixtures/application/config/server.php b/tests/fixtures/application/config/server.php new file mode 100644 index 00000000..b46930f1 --- /dev/null +++ b/tests/fixtures/application/config/server.php @@ -0,0 +1,25 @@ + [ + 'headers' => [ + XDnsPrefetchControl::class, + XFrameOptions::class, + StrictTransportSecurity::class, + XContentTypeOptions::class, + ReferrerPolicy::class, + CrossOriginResourcePolicy::class, + CrossOriginOpenerPolicy::class, + ], + ], +]; diff --git a/tests/fixtures/application/config/services.php b/tests/fixtures/application/config/services.php index f382b6a9..df85bee4 100644 --- a/tests/fixtures/application/config/services.php +++ b/tests/fixtures/application/config/services.php @@ -1,5 +1,7 @@ [ 'key' => env('AWS_ACCESS_KEY_ID'), diff --git a/tests/fixtures/application/config/view.php b/tests/fixtures/application/config/view.php index a8de9c34..b3edbf58 100644 --- a/tests/fixtures/application/config/view.php +++ b/tests/fixtures/application/config/view.php @@ -1,5 +1,7 @@ env('VIEW_PATH', static fn () => base_path('resources/views')),