Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding X-Frame-Options/Content-Security-Policy headers #68

Merged
merged 9 commits into from
Jan 17, 2024
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

This library adheres to [Semantic Versioning](https://semver.org/) and [Keep a CHANGELOG](https://keepachangelog.com/en/1.0.0/).

## Unreleased

### Added

* `prevent_framing`: Added a feature to prevent framing of the site via the
`X-Frame-Options` header.

## 2.3.1

### Changed
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,25 @@ This feature prevents the editing of themes and plugins directly from the admin.

Such editing can introduce unexpected and undocumented code changes.

### `login_nonce`

This feature adds a nonce to the login form to prevent CSRF attacks.

### `prevent_framing`

This feature prevents the site from being framed by other sites by outputting a
`X-Frame-Options: SAMEORIGIN` header. The header can be disabled by filtering
`alleyvate_prevent_framing_disable` to return true. The value of the header can
be filtered using the `alleyvate_prevent_framing_x_frame_options` filter.

The feature can also output a `Content-Security-Policy` header instead of
`X-Frame-Options` by filtering `alleyvate_prevent_framing_csp` to return true.
By default, it will output `Content-Security-Policy: frame-ancestors 'self'`.
The value of the header can be filtered using
`alleyvate_prevent_framing_csp_frame_ancestors` to filter the allowed
frame-ancestors. The entire header can be filtered using
`alleyvate_prevent_framing_csp_header`.

### `redirect_guess_shortcircuit`

This feature stops WordPress from attempting to guess a redirect URL for a 404 request.
Expand Down
124 changes: 124 additions & 0 deletions src/alley/wp/alleyvate/features/class-prevent-framing.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php
/**
* Class file for Prevent_Framing
*
* (c) Alley <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @package wp-alleyvate
*/

namespace Alley\WP\Alleyvate\Features;

use Alley\WP\Alleyvate\Feature;

/**
* Headers to prevent iframe-ing of the site.
*
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
*/
final class Prevent_Framing implements Feature {
/**
* Boot the feature.
*/
public function boot(): void {
add_filter( 'wp_headers', [ self::class, 'filter__wp_headers' ] );
}

/**
* Output the X-Frame-Options header to prevent sites from being able to be iframe'd.
*
* @param array $headers The headers to be sent.
* @return array The headers to be sent.
*/
public static function filter__wp_headers( $headers ): array {
if ( ! \is_array( $headers ) ) {
$headers = [];
}

/**
* Optionally allow the Content-Security-Policy header to be used
* instead of X-Frame-Options.
*
* The Content-Security-Policy header obsoletes the X-Frame-Options
* header when used.
*/
if ( apply_filters( 'alleyvate_prevent_framing_csp', false ) ) {
if ( isset( $headers['Content-Security-Policy'] ) ) {
return $headers;
}

$headers['Content-Security-Policy'] = self::get_content_security_policy_header();

return $headers;
}

if ( isset( $headers['X-Frame-Options'] ) ) {
return $headers;
}

/**
* Allow the X-Frame-Options header to be disabled.
*
* @param bool $prevent_framing Whether to prevent framing. Default false.
*/
if ( apply_filters( 'alleyvate_prevent_framing_disable', false ) ) {
return $headers;
}

/**
* Filter the X-Frame-Options header value.
*
* The header can return DENY, SAMEORIGIN, or ALLOW-FROM uri.
*
* @param string $value The value of the X-Frame-Options header. Default SAMEORIGIN.
*/
$headers['X-Frame-Options'] = apply_filters( 'alleyvate_prevent_framing_x_frame_options', 'SAMEORIGIN' );

if ( ! \in_array( $headers['X-Frame-Options'], [ 'DENY', 'SAMEORIGIN' ], true ) && 0 !== strpos( $headers['X-Frame-Options'], 'ALLOW-FROM' ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: %s: The value of the X-Frame-Options header. */
esc_html__( 'Invalid value for %s. Must be DENY, SAMEORIGIN, or ALLOW-FROM uri.', 'alley' ),
'X-Frame-Options'
),
'2.4.0'
);
}

return $headers;
}

/**
* Get the Content-Security-Policy header value.
*
* @return string
*/
protected static function get_content_security_policy_header(): string {
/**
* Filter the Content-Security-Policy header ancestors.
*
* @param array<string> $frame_ancestors The frame ancestors. Default ['\'self\''].
*/
$frame_ancestors = apply_filters(
'alleyvate_prevent_framing_csp_frame_ancestors',
[
'\'self\'',
]
);

/**
* Filter the value of the Content-Security-Policy header.
*
* @param string $value The value of the Content-Security-Policy header. Defaults to 'frame-ancestors \'self\''
*/
return apply_filters(
'alleyvate_prevent_framing_csp_header',
'frame-ancestors ' . implode( ' ', $frame_ancestors )
);
}
}
1 change: 1 addition & 0 deletions src/alley/wp/alleyvate/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function available_features(): array {
'disable_trackbacks' => new Features\Disable_Trackbacks(),
'disallow_file_edit' => new Features\Disallow_File_Edit(),
'login_nonce' => new Features\Login_Nonce(),
'prevent_framing' => new Features\Prevent_Framing(),
'redirect_guess_shortcircuit' => new Features\Redirect_Guess_Shortcircuit(),
'site_health' => new Features\Site_Health(),
'user_enumeration_restrictions' => new Features\User_Enumeration_Restrictions(),
Expand Down
168 changes: 168 additions & 0 deletions tests/alley/wp/alleyvate/features/test-prevent-framing.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<?php
/**
* Class file for Test_Prevent_Framing
*
* (c) Alley <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @package wp-alleyvate
*/

namespace Alley\WP\Alleyvate\Features;

use Alley\WP\Alleyvate\Feature;
use Mantle\Testkit\Test_Case;

/**
* Tests for the preventing the iframing of a site.
*/
final class Test_Prevent_Framing extends Test_Case {
use \Mantle\Testing\Concerns\Refresh_Database;
Copy link
Contributor

Choose a reason for hiding this comment

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

do you need it even though you are creating no post?


/**
* Feature instance.
*
* @var Feature
*/
private Feature $feature;

/**
* Set up.
*/
protected function setUp(): void {
parent::setUp();

$this->feature = new Prevent_Framing();
}

/**
* Test that the X-Frame-Options header is output.
*/
public function test_x_frame_options_header(): void {
$this->expectApplied( 'alleyvate_prevent_framing_x_frame_options' )->andReturnString();

$this->feature->boot();

$this->get( '/' )->assertHeader( 'X-Frame-Options', 'SAMEORIGIN' );
$this->get( '/wp-json/wp/v2/posts' )->assertHeader( 'X-Frame-Options', 'SAMEORIGIN' );
}

/**
* Test that the X-Frame-Options header can be filtered.
*/
public function test_filter_x_frame_options_header(): void {
add_filter( 'alleyvate_prevent_framing_x_frame_options', fn () => 'DENY' );

$this->feature->boot();

$this->get( '/' )->assertHeader( 'X-Frame-Options', 'DENY' );
}

/**
* Test that the X-Frame-Options header can be filtered to an invalid value
* while throwing a _doing_it_wrong() notice.
*/
public function test_filter_x_frame_options_invalid_header(): void {
$this->setExpectedIncorrectUsage( Prevent_Framing::class . '::filter__wp_headers' );

add_filter( 'alleyvate_prevent_framing_x_frame_options', fn () => 'INVALID' );

$this->feature->boot();

$this->get( '/' )->assertHeader( 'X-Frame-Options', 'INVALID' );
}

/**
* Test that the X-Frame-Options header is not output if it already exists.
*/
public function test_x_frame_options_header_already_exists(): void {
add_filter( 'wp_headers', fn () => [ 'X-Frame-Options' => 'CUSTOM' ] );

$this->feature->boot();

$this->get( '/' )->assertHeader( 'X-Frame-Options', 'CUSTOM' );
}

/**
* Test that the X-Frame-Options header is not output if the feature is disabled.
*/
public function test_x_frame_options_header_disabled(): void {
add_filter( 'alleyvate_prevent_framing_disable', fn () => true );

$this->feature->boot();

$this->get( '/' )->assertHeaderMissing( 'X-Frame-Options' );
}

/**
* Test that the CSP header is not output unless enabled.
*/
public function test_csp_header_default(): void {
$this->expectApplied( 'alleyvate_prevent_framing_csp' )->andReturnFalse();

$this->feature->boot();

$this->get( '/' )->assertHeaderMissing( 'Content-Security-Policy' );
}

/**
* Test the default value of the CSP header when enabled.
*/
public function test_csp_header_enabled(): void {
$this->expectApplied( 'alleyvate_prevent_framing_csp' )->andReturnTrue();

add_filter( 'alleyvate_prevent_framing_csp', fn () => true );

$this->feature->boot();

$this->get( '/' )->assertHeader( 'Content-Security-Policy', "frame-ancestors 'self'" );
}

/**
* Test that the CSP header is not output if it already exists.
*/
public function test_csp_header_already_exists(): void {
add_filter( 'wp_headers', fn () => [ 'Content-Security-Policy' => 'CUSTOM' ] );

$this->feature->boot();

$this->get( '/' )->assertHeader( 'Content-Security-Policy', 'CUSTOM' );
}

/**
* Test the CSP header with custom frame ancestors.
*/
public function test_csp_header_custom_frame_ancestors(): void {
$this->expectApplied( 'alleyvate_prevent_framing_csp_frame_ancestors' )->andReturnArray();

add_filter( 'alleyvate_prevent_framing_csp', fn () => true );

add_filter(
'alleyvate_prevent_framing_csp_frame_ancestors',
fn () => [
'example.com',
'example.org',
]
);

$this->feature->boot();

$this->get( '/' )->assertHeader( 'Content-Security-Policy', 'frame-ancestors example.com example.org' );
}

/**
* Test the CSP header being overridden completely.
*/
public function test_csp_header_override(): void {
$this->expectApplied( 'alleyvate_prevent_framing_csp_header' )->andReturnString();

add_filter( 'alleyvate_prevent_framing_csp', fn () => true );
add_filter( 'alleyvate_prevent_framing_csp_header', fn () => 'custom-value' );

$this->feature->boot();

$this->get( '/' )->assertHeader( 'Content-Security-Policy', 'custom-value' );
}
}
Loading