Skip to content
Open
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
16 changes: 13 additions & 3 deletions system/HTTP/ContentSecurityPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -427,9 +427,7 @@ public function getScriptNonce(): string
*/
public function finalize(ResponseInterface $response)
{
if ($this->autoNonce) {
$this->generateNonces($response);
}
$this->generateNonces($response);

$this->buildHeaders($response);
}
Expand Down Expand Up @@ -892,6 +890,10 @@ protected function addOption($options, string $target, ?bool $explicitReporting
*/
protected function generateNonces(ResponseInterface $response)
{
if ($this->enabled() && ! $this->autoNonce) {
return;
}

$body = (string) $response->getBody();

if ($body === '') {
Expand All @@ -905,6 +907,10 @@ protected function generateNonces(ResponseInterface $response)
$pattern = sprintf('/(%s|%s)/', preg_quote($this->styleNonceTag, '/'), preg_quote($this->scriptNonceTag, '/'));

$body = preg_replace_callback($pattern, function ($match) use ($jsonEscape): string {
if (! $this->enabled()) {
return '';
}

$nonce = $match[0] === $this->styleNonceTag ? $this->getStyleNonce() : $this->getScriptNonce();
$attr = 'nonce="' . $nonce . '"';

Expand All @@ -923,6 +929,10 @@ protected function generateNonces(ResponseInterface $response)
*/
protected function buildHeaders(ResponseInterface $response)
{
if (! $this->enabled()) {
return;
}

$response->setHeader('Content-Security-Policy', []);
$response->setHeader('Content-Security-Policy-Report-Only', []);
$response->setHeader('Reporting-Endpoints', []);
Expand Down
6 changes: 1 addition & 5 deletions system/HTTP/ResponseTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -367,11 +367,7 @@ public function send()
{
// If we're enforcing a Content Security Policy,
// we need to give it a chance to build out it's headers.
if ($this->CSP->enabled()) {
$this->CSP->finalize($this);
} else {
$this->body = str_replace(['{csp-style-nonce}', '{csp-script-nonce}'], '', $this->body ?? '');
}
$this->CSP->finalize($this);

$this->sendHeaders();
$this->sendCookies();
Expand Down
153 changes: 153 additions & 0 deletions tests/system/HTTP/ResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use DateTimeZone;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use ReflectionClass;

/**
* @internal
Expand Down Expand Up @@ -577,4 +578,156 @@ public function testPretendOutput(): void

$this->assertSame('Happy days', $actual);
}

public function testSendRemovesDefaultNoncePlaceholdersWhenCSPDisabled(): void
{
$config = new App();
$config->CSPEnabled = false;

$response = new Response($config);
$response->pretend(true);

$body = '<html><script {csp-script-nonce}>console.log("test")</script><style {csp-style-nonce}>.test{}</style></html>';
$response->setBody($body);

ob_start();
$response->send();
$actual = ob_get_clean();

// Nonce placeholders should be removed when CSP is disabled
$this->assertIsString($actual);
$this->assertStringNotContainsString('{csp-script-nonce}', $actual);
$this->assertStringNotContainsString('{csp-style-nonce}', $actual);
$this->assertStringContainsString('<script >console.log("test")</script>', $actual);
$this->assertStringContainsString('<style >.test{}</style>', $actual);
}

public function testSendRemovesCustomNoncePlaceholdersWhenCSPDisabled(): void
{
$appConfig = new App();
$appConfig->CSPEnabled = false;

// Create custom CSP config with custom nonce tags
$cspConfig = new \Config\ContentSecurityPolicy();
$cspConfig->scriptNonceTag = '{custom-script-tag}';
$cspConfig->styleNonceTag = '{custom-style-tag}';

$response = new Response($appConfig);
$response->pretend(true);

// Inject the custom CSP config
$reflection = new ReflectionClass($response);
$cspProperty = $reflection->getProperty('CSP');
$cspProperty->setValue($response, new ContentSecurityPolicy($cspConfig));
Comment on lines +618 to +621
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of these lines, call Services::injectMock for the csp service by injecting the custom CSP instance. Make sure to call that before Response is instantiated.


$body = '<html><script {custom-script-tag}>test()</script><style {custom-style-tag}>.x{}</style></html>';
$response->setBody($body);

ob_start();
$response->send();
$actual = ob_get_clean();

// Custom nonce placeholders should be removed when CSP is disabled
$this->assertIsString($actual);
$this->assertStringNotContainsString('{custom-script-tag}', $actual);
$this->assertStringNotContainsString('{custom-style-tag}', $actual);
$this->assertStringContainsString('<script >test()</script>', $actual);
$this->assertStringContainsString('<style >.x{}</style>', $actual);
}

public function testSendNoEffectWhenBodyEmptyAndCSPDisabled(): void
{
$config = new App();
$config->CSPEnabled = false;

$response = new Response($config);
$response->pretend(true);

$body = '';
$response->setBody($body);

ob_start();
$response->send();
$actual = ob_get_clean();

$this->assertIsString($actual);
$this->assertSame('', $actual);
}

public function testSendNoEffectWithNoPlaceholdersAndCSPDisabled(): void
{
$config = new App();
$config->CSPEnabled = false;

$response = new Response($config);
$response->pretend(true);

$body = '<html><head><title>Test</title></head><body><p>No placeholders here</p></body></html>';
$response->setBody($body);

ob_start();
$response->send();
$actual = ob_get_clean();

// Body should be unchanged when there are no placeholders and CSP is disabled
$this->assertIsString($actual);
$this->assertSame($body, $actual);
}

public function testSendRemovesMultiplePlaceholdersWhenCSPDisabled(): void
{
$config = new App();
$config->CSPEnabled = false;

$response = new Response($config);
$response->pretend(true);

$body = '<html><script {csp-script-nonce}>console.log("test")</script><script {csp-script-nonce}>console.log("test2")</script><style {csp-style-nonce}>.test{}</style><style {csp-style-nonce}>.test2{}</style></html>';
$response->setBody($body);

ob_start();
$response->send();
$actual = ob_get_clean();

// All nonce placeholders should be removed when CSP is disabled
$this->assertIsString($actual);
$this->assertStringNotContainsString('{csp-script-nonce}', $actual);
$this->assertStringNotContainsString('{csp-style-nonce}', $actual);
$this->assertStringContainsString('<script >console.log("test")</script>', $actual);
$this->assertStringContainsString('<script >console.log("test2")</script>', $actual);
$this->assertStringContainsString('<style >.test{}</style>', $actual);
$this->assertStringContainsString('<style >.test2{}</style>', $actual);
}

public function testSendRemovesPlaceholdersWhenBothCSPAndAutoNonceAreDisabled(): void
{
$appConfig = new App();
$appConfig->CSPEnabled = false;

// Create custom CSP config with custom nonce tags
$cspConfig = new \Config\ContentSecurityPolicy();
$cspConfig->autoNonce = false;

$response = new Response($appConfig);
$response->pretend(true);

// Inject the custom CSP config
$reflection = new ReflectionClass($response);
$cspProperty = $reflection->getProperty('CSP');
$cspProperty->setValue($response, new ContentSecurityPolicy($cspConfig));
Comment on lines +714 to +717
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment above.


$body = '<html><script {csp-script-nonce}>test()</script><style {csp-style-nonce}>.x{}</style></html>';
$response->setBody($body);

ob_start();
$response->send();
$actual = ob_get_clean();

// Custom nonce placeholders should be removed when CSP is disabled
$this->assertIsString($actual);
$this->assertStringNotContainsString('{csp-script-nonce}', $actual);
$this->assertStringNotContainsString('{csp-style-nonce}', $actual);
$this->assertStringContainsString('<script >test()</script>', $actual);
$this->assertStringContainsString('<style >.x{}</style>', $actual);
}
}
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.7.1.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Deprecations
Bugs Fixed
**********

- **ContentSecurityPolicy:** Fixed a bug where custom CSP tags were not removed from generated HTML when CSP was disabled. The method now ensures that all custom CSP tags are removed from the generated HTML.
- **ContentSecurityPolicy:** Fixed a bug where ``generateNonces()`` produces corrupted JSON responses by replacing CSP nonce placeholders with unescaped double quotes. The method now automatically JSON-escapes nonce attributes when the response Content-Type is JSON.
- **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty.

Expand Down
Loading