From 24e2865a1cfbd54f1a77c6e8653baed01fb9fe25 Mon Sep 17 00:00:00 2001 From: bepsvpt <8221099+bepsvpt@users.noreply.github.com> Date: Sun, 13 Oct 2024 17:45:19 +0800 Subject: [PATCH] feat: support Reporting-Endpoints and NEL headers (#49) --- composer.json | 4 +-- config/secure-headers.php | 35 ++++++++++++++++++ src/SecureHeaders.php | 60 +++++++++++++++++++++++++++++++ tests/SecureHeadersTest.php | 71 +++++++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 563943a..23b2daf 100644 --- a/composer.json +++ b/composer.json @@ -23,10 +23,10 @@ ], "homepage": "https://github.com/bepsvpt/secure-headers", "require": { - "php": "^7.0 || ^8.0" + "php": "^7.0 || ^8.0", + "ext-json": "*" }, "require-dev": { - "ext-json": "*", "ext-xdebug": "*", "ergebnis/composer-normalize": "^2.42", "laravel/pint": "^1.14", diff --git a/config/secure-headers.php b/config/secure-headers.php index 0e4df4f..d3ad344 100644 --- a/config/secure-headers.php +++ b/config/secure-headers.php @@ -170,6 +170,41 @@ 'preload' => false, ], + /* + * Reporting Endpoints + * + * Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Reporting-Endpoints + * + * The array key is the endpoint name, and the value is the URL. + */ + + 'reporting' => [ + // 'csp' => 'https://example.com/csp-reports', + // 'nel' => 'https://example.com/nel-reports', + ], + + /* + * Network Error Logging + * + * Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Network_Error_Logging + */ + + 'nel' => [ + 'enable' => false, + + // The name of reporting API, not the endpoint URL. + // @see https://developer.mozilla.org/en-US/docs/Web/API/Reporting_API + 'report-to' => '', + + 'max-age' => 86400, + + 'include-subdomains' => false, + + 'success-fraction' => 0.0, + + 'failure-fraction' => 1.0, + ], + /* * Expect-CT * diff --git a/src/SecureHeaders.php b/src/SecureHeaders.php index e60b5bc..a332f33 100644 --- a/src/SecureHeaders.php +++ b/src/SecureHeaders.php @@ -103,11 +103,13 @@ public function send() public function headers(): array { $headers = array_merge( + $this->reporting(), $this->csp(), $this->permissionsPolicy(), $this->hsts(), $this->expectCT(), $this->clearSiteData(), + $this->networkErrorLogging(), $this->miscellaneous() ); @@ -116,6 +118,28 @@ public function headers(): array return array_filter($headers); } + /** + * Get Reporting Endpoints header. + * + * @return array + */ + protected function reporting(): array + { + $config = $this->config['reporting'] ?? []; + + if (empty($config)) { + return []; + } + + $endpoints = []; + + foreach ($config as $name => $url) { + $endpoints[] = sprintf('%s="%s"', $name, $url); + } + + return ['Reporting-Endpoints' => implode(', ', $endpoints)]; + } + /** * Get CSP header. * @@ -214,6 +238,42 @@ protected function clearSiteData(): array return ['Clear-Site-Data' => $builder->get()]; } + /** + * Generate NEL header. + * + * @return array + */ + protected function networkErrorLogging(): array + { + $config = $this->config['nel'] ?? []; + + if (! ($config['enable'] ?? false)) { + return []; + } + + if (empty($config['report-to'])) { + return []; + } + + unset($config['enable']); + + $nel = []; + + foreach ($config as $key => $value) { + $key = str_replace('-', '_', $key); + + $nel[$key] = $value; + } + + $encoded = json_encode($nel, JSON_PRESERVE_ZERO_FRACTION); + + if ($encoded === false) { + return []; + } + + return ['NEL' => $encoded]; + } + /** * Get miscellaneous headers. * diff --git a/tests/SecureHeadersTest.php b/tests/SecureHeadersTest.php index f5dc86c..c29aeb8 100644 --- a/tests/SecureHeadersTest.php +++ b/tests/SecureHeadersTest.php @@ -336,6 +336,77 @@ public function testCrossOriginPolicy() ); } + public function testReportingEndpoints() + { + $config = $this->config(); + + $config['reporting'] = [ + 'nel' => 'https://example.com/nel', + 'csp' => 'https://example.com/csp', + ]; + + $headers = (new SecureHeaders($config))->headers(); + + $this->assertArrayHasKey('Reporting-Endpoints', $headers); + + $this->assertSame('nel="https://example.com/nel", csp="https://example.com/csp"', $headers['Reporting-Endpoints']); + + // ensure backward compatibility + + unset($config['reporting']); + + $this->assertArrayNotHasKey( + 'Reporting-Endpoints', + (new SecureHeaders($config))->headers() + ); + } + + public function testNetworkErrorLogging() + { + $config = $this->config(); + + $config['nel']['enable'] = true; + + $this->assertArrayNotHasKey( + 'NEL', + (new SecureHeaders($config))->headers() + ); + + $config['nel']['report-to'] = 'nel'; + + $headers = (new SecureHeaders($config))->headers(); + + $this->assertArrayHasKey('NEL', $headers); + + $this->assertSame('{"report_to":"nel","max_age":86400,"include_subdomains":false,"success_fraction":0.0,"failure_fraction":1.0}', $headers['NEL']); + + $config['nel']['include-subdomains'] = true; + + $config['nel']['failure-fraction'] = 0.01; + + $headers = (new SecureHeaders($config))->headers(); + + $this->assertArrayHasKey('NEL', $headers); + + $this->assertSame('{"report_to":"nel","max_age":86400,"include_subdomains":true,"success_fraction":0.0,"failure_fraction":0.01}', $headers['NEL']); + + $config['nel']['enable'] = false; + + $this->assertArrayNotHasKey( + 'NEL', + (new SecureHeaders($config))->headers() + ); + + // ensure backward compatibility + + unset($config['reporting-endpoints']); + + $this->assertArrayNotHasKey( + 'NEL', + (new SecureHeaders($config))->headers() + ); + } + /** * Get secure-headers config. *