Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/Http/Contracts/HeaderBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Phenix\Http\Contracts;

use Amp\Http\Server\Response;

interface HeaderBuilder
{
public function apply(Response $response): void;
}
20 changes: 20 additions & 0 deletions src/Http/Headers/CrossOriginOpenerPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Phenix\Http\Headers;

use Amp\Http\Server\Response;

class CrossOriginOpenerPolicy extends HeaderBuilder
{
public function apply(Response $response): void
{
$response->setHeader('Cross-Origin-Opener-Policy', $this->value());
}

protected function value(): string
{
return 'same-origin';
}
}
20 changes: 20 additions & 0 deletions src/Http/Headers/CrossOriginResourcePolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Phenix\Http\Headers;

use Amp\Http\Server\Response;

class CrossOriginResourcePolicy extends HeaderBuilder
{
public function apply(Response $response): void
{
$response->setHeader('Cross-Origin-Resource-Policy', $this->value());
}

protected function value(): string
{
return 'same-origin';
}
}
12 changes: 12 additions & 0 deletions src/Http/Headers/HeaderBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Phenix\Http\Headers;

use Phenix\Http\Contracts\HeaderBuilder as HeaderBuilderContract;

abstract class HeaderBuilder implements HeaderBuilderContract
{
abstract protected function value(): string;
}
20 changes: 20 additions & 0 deletions src/Http/Headers/ReferrerPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Phenix\Http\Headers;

use Amp\Http\Server\Response;

class ReferrerPolicy extends HeaderBuilder
{
public function apply(Response $response): void
{
$response->setHeader('Referrer-Policy', $this->value());
}

protected function value(): string
{
return 'no-referrer';
}
}
20 changes: 20 additions & 0 deletions src/Http/Headers/StrictTransportSecurity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Phenix\Http\Headers;

use Amp\Http\Server\Response;

class StrictTransportSecurity extends HeaderBuilder
{
public function apply(Response $response): void
{
$response->setHeader('Strict-Transport-Security', $this->value());
}

protected function value(): string
{
return 'max-age=31536000; includeSubDomains; preload';
}
}
20 changes: 20 additions & 0 deletions src/Http/Headers/XContentTypeOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Phenix\Http\Headers;

use Amp\Http\Server\Response;

class XContentTypeOptions extends HeaderBuilder
{
public function apply(Response $response): void
{
$response->setHeader('X-Content-Type-Options', $this->value());
}

protected function value(): string
{
return 'nosniff';
}
}
20 changes: 20 additions & 0 deletions src/Http/Headers/XDnsPrefetchControl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Phenix\Http\Headers;

use Amp\Http\Server\Response;

class XDnsPrefetchControl extends HeaderBuilder
{
public function apply(Response $response): void
{
$response->setHeader('X-DNS-Prefetch-Control', $this->value());
}

protected function value(): string
{
return 'off';
}
}
20 changes: 20 additions & 0 deletions src/Http/Headers/XFrameOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Phenix\Http\Headers;

use Amp\Http\Server\Response;

class XFrameOptions extends HeaderBuilder
{
public function apply(Response $response): void
{
$response->setHeader('X-Frame-Options', $this->value());
}

protected function value(): string
{
return 'SAMEORIGIN';
}
}
47 changes: 47 additions & 0 deletions src/Http/Middlewares/ResponseHeaders.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Phenix\Http\Middlewares;

use Amp\Http\Server\Middleware;
use Amp\Http\Server\Request;
use Amp\Http\Server\RequestHandler;
use Amp\Http\Server\Response;
use Phenix\Facades\Config;
use Phenix\Http\Constants\HttpStatus;
use Phenix\Http\Contracts\HeaderBuilder;

class ResponseHeaders implements Middleware
{
/**
* @var array<int, HeaderBuilder>
*/
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;
}
}
9 changes: 9 additions & 0 deletions src/Http/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
13 changes: 11 additions & 2 deletions src/Testing/Concerns/InteractWithHeaders.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
Expand Down
35 changes: 35 additions & 0 deletions tests/Feature/RequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
});
1 change: 1 addition & 0 deletions tests/fixtures/application/config/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
\Phenix\Auth\Middlewares\TokenRateLimit::class,
],
'router' => [
\Phenix\Http\Middlewares\ResponseHeaders::class,
],
],
'providers' => [
Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/application/config/cors.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

return [
'origins' => env('CORS_ORIGIN', static fn (): array => ['*']),
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE'],
Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/application/config/mail.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

return [
'default' => env('MAIL_MAILER', static fn (): string => 'smtp'),

Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/application/config/queue.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

return [
'default' => env('QUEUE_DRIVER', static fn (): string => 'database'),

Expand Down
25 changes: 25 additions & 0 deletions tests/fixtures/application/config/server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

use Phenix\Http\Headers\CrossOriginOpenerPolicy;
use Phenix\Http\Headers\CrossOriginResourcePolicy;
use Phenix\Http\Headers\ReferrerPolicy;
use Phenix\Http\Headers\StrictTransportSecurity;
use Phenix\Http\Headers\XContentTypeOptions;
use Phenix\Http\Headers\XDnsPrefetchControl;
use Phenix\Http\Headers\XFrameOptions;

return [
'security' => [
'headers' => [
XDnsPrefetchControl::class,
XFrameOptions::class,
StrictTransportSecurity::class,
XContentTypeOptions::class,
ReferrerPolicy::class,
CrossOriginResourcePolicy::class,
CrossOriginOpenerPolicy::class,
],
],
];
2 changes: 2 additions & 0 deletions tests/fixtures/application/config/services.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

return [
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/application/config/view.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

return [
'path' => env('VIEW_PATH', static fn () => base_path('resources/views')),

Expand Down