From e9bff0d516390d1f0550343ac0c975ba06b29866 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 8 Dec 2025 12:34:33 -0500 Subject: [PATCH 1/5] refactor: add strict types declaration to configuration files --- tests/fixtures/application/config/cors.php | 2 ++ tests/fixtures/application/config/mail.php | 2 ++ tests/fixtures/application/config/queue.php | 2 ++ tests/fixtures/application/config/services.php | 2 ++ tests/fixtures/application/config/view.php | 2 ++ 5 files changed, 10 insertions(+) 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/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')), From 855ac468289cbfd5e72707c76936d0a9f1ca552b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 8 Dec 2025 13:26:57 -0500 Subject: [PATCH 2/5] feat: implement security headers middleware and related classes --- src/Http/Contracts/HeaderBuilder.php | 12 +++++ src/Http/Headers/CrossOriginOpenerPolicy.php | 20 ++++++++ .../Headers/CrossOriginResourcePolicy.php | 20 ++++++++ src/Http/Headers/HeaderBuilder.php | 12 +++++ src/Http/Headers/ReferrerPolicy.php | 20 ++++++++ src/Http/Headers/StrictTransportSecurity.php | 20 ++++++++ src/Http/Headers/XContentTypeOptions.php | 20 ++++++++ src/Http/Headers/XDnsPrefetchControl.php | 20 ++++++++ src/Http/Headers/XFrameOptions.php | 20 ++++++++ src/Http/Middlewares/ResponseHeaders.php | 47 +++++++++++++++++++ tests/Feature/RequestTest.php | 18 +++++++ tests/fixtures/application/config/app.php | 1 + tests/fixtures/application/config/server.php | 25 ++++++++++ 13 files changed, 255 insertions(+) create mode 100644 src/Http/Contracts/HeaderBuilder.php create mode 100644 src/Http/Headers/CrossOriginOpenerPolicy.php create mode 100644 src/Http/Headers/CrossOriginResourcePolicy.php create mode 100644 src/Http/Headers/HeaderBuilder.php create mode 100644 src/Http/Headers/ReferrerPolicy.php create mode 100644 src/Http/Headers/StrictTransportSecurity.php create mode 100644 src/Http/Headers/XContentTypeOptions.php create mode 100644 src/Http/Headers/XDnsPrefetchControl.php create mode 100644 src/Http/Headers/XFrameOptions.php create mode 100644 src/Http/Middlewares/ResponseHeaders.php create mode 100644 tests/fixtures/application/config/server.php 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..c5d5ca34 --- /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 && $response->getStatus() < HttpStatus::BAD_REQUEST) { + return $response; + } + + foreach ($this->builders as $builder) { + $builder->apply($response); + } + + return $response; + } +} diff --git a/tests/Feature/RequestTest.php b/tests/Feature/RequestTest.php index ece27c60..d2f18d23 100644 --- a/tests/Feature/RequestTest.php +++ b/tests/Feature/RequestTest.php @@ -456,3 +456,21 @@ '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', + ]); +}); 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/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, + ], + ], +]; From 6c3e9b19ba0d65ccce4e199cb12a6fa5b01f73c9 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 8 Dec 2025 13:27:04 -0500 Subject: [PATCH 3/5] refactor: enhance assertions in assertHeaders method for better error messages --- src/Testing/Concerns/InteractWithHeaders.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Testing/Concerns/InteractWithHeaders.php b/src/Testing/Concerns/InteractWithHeaders.php index a4c8c073..090cf52b 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; From 1f21f8c85caf994f5f62f0734c0214eb01819d13 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 8 Dec 2025 13:39:48 -0500 Subject: [PATCH 4/5] fix: update status check in handleRequest method to use enum values --- src/Http/Middlewares/ResponseHeaders.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Middlewares/ResponseHeaders.php b/src/Http/Middlewares/ResponseHeaders.php index c5d5ca34..05ce5e17 100644 --- a/src/Http/Middlewares/ResponseHeaders.php +++ b/src/Http/Middlewares/ResponseHeaders.php @@ -34,7 +34,7 @@ public function handleRequest(Request $request, RequestHandler $next): Response { $response = $next->handleRequest($request); - if ($response->getStatus() >= HttpStatus::MULTIPLE_CHOICES && $response->getStatus() < HttpStatus::BAD_REQUEST) { + if ($response->getStatus() >= HttpStatus::MULTIPLE_CHOICES->value && $response->getStatus() < HttpStatus::BAD_REQUEST->value) { return $response; } From daeab3f9c20832652e6a2dfa2f784f6fbe60e5ae Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 8 Dec 2025 13:49:53 -0500 Subject: [PATCH 5/5] feat: add redirect method to Response class and test for missing secure headers --- src/Http/Response.php | 9 +++++++++ src/Testing/Concerns/InteractWithHeaders.php | 9 +++++++++ tests/Feature/RequestTest.php | 17 +++++++++++++++++ 3 files changed, 35 insertions(+) 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 090cf52b..354d5e3b 100644 --- a/src/Testing/Concerns/InteractWithHeaders.php +++ b/src/Testing/Concerns/InteractWithHeaders.php @@ -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 d2f18d23..0eaa38b8 100644 --- a/tests/Feature/RequestTest.php +++ b/tests/Feature/RequestTest.php @@ -474,3 +474,20 @@ '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', + ]); +});