Exhibit D-1 Evidence: commit bd639a1
Committed · bd639a1317184c747cc721ec4f6052f9e7ff0354
No security headers
The repository itself is private; this page carries the commit as it is recorded there.
Commit message
The change
Text files only, as recorded in git. Lines starting with + were added, lines with − removed.
Files changed
---
e2e/tests/d-security-mail.spec.ts | 32 ++++++++++++++++++++++
site/wp-content/hollis-config.php | 1 +
site/wp-content/mu-plugins/hollis-hardening.php | 65 +++++++++++++++++++++++++++++++++++++++++++++
tests/phpunit/repair/SecurityHeadersTest.php | 41 ++++++++++++++++++++++++++++
4 files changed, 139 insertions(+)
diff --git a/e2e/tests/d-security-mail.spec.ts b/e2e/tests/d-security-mail.spec.ts
new file mode 100644
index 0000000..88421ca
--- /dev/null
+++ b/e2e/tests/d-security-mail.spec.ts
@@ -0,0 +1,32 @@
+import { test, expect } from '@playwright/test';
+import { BEFORE, AFTER } from './helpers';
+
+// Exhibit D: what an outsider (or a patient) gets back from each site.
+const SECURITY_HEADERS = ['content-security-policy', 'x-frame-options', 'x-content-type-options', 'referrer-policy', 'permissions-policy'];
+
+test('D-1 before: the home page sends none of the five security headers', async ({ request }) => {
+ const res = await request.get(BEFORE + '/');
+ const headers = res.headers();
+ expect(SECURITY_HEADERS.filter((h) => h in headers)).toEqual([]);
+ expect(headers['x-powered-by']).toMatch(/PHP/);
+});
+
+test('D-1 after: the home page sends all five security headers and no PHP version', async ({ request }) => {
+ const res = await request.get(AFTER + '/');
+ const headers = res.headers();
+ expect(SECURITY_HEADERS.filter((h) => !(h in headers))).toEqual([]);
+ expect(headers['x-frame-options']).toBe('SAMEORIGIN');
+ expect(headers['x-powered-by']).toBeUndefined();
+});
+
+test('D-1 after: the CSP does not break the page: no console violation, the menu script runs', async ({ page }) => {
+ const violations: string[] = [];
+ page.on('console', (msg) => {
+ if (/Content Security Policy/i.test(msg.text())) violations.push(msg.text());
+ });
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto(AFTER + '/');
+ await page.getByRole('button', { name: 'Menu' }).click();
+ await expect(page.getByRole('navigation', { name: 'Primary' }).getByRole('link', { name: 'Services' })).toBeVisible();
+ expect(violations).toEqual([]);
+});
diff --git a/site/wp-content/hollis-config.php b/site/wp-content/hollis-config.php
index 014eae1..731ae23 100644
--- a/site/wp-content/hollis-config.php
+++ b/site/wp-content/hollis-config.php
@@ -15,6 +15,7 @@ if ( ! defined( 'WP_DEBUG_LOG' ) ) {
}
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- WordPress's own settings.
+define( 'DISALLOW_FILE_EDIT', true );
define( 'WP_AUTO_UPDATE_CORE', false );
define( 'AUTOMATIC_UPDATER_DISABLED', true );
// phpcs:enable
diff --git a/site/wp-content/mu-plugins/hollis-hardening.php b/site/wp-content/mu-plugins/hollis-hardening.php
new file mode 100644
index 0000000..62cd527
--- /dev/null
+++ b/site/wp-content/mu-plugins/hollis-hardening.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Plugin Name: Hollis Creek hardening
+ * Description: Security headers on every page.
+ * Version: 1.0.0
+ * Author: Hollis Creek Family Dental (fictional demo)
+ *
+ * @package Hollis\Hardening
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * The security headers the site sends.
+ *
+ * @return array<string, string>
+ */
+function hollis_hardening_security_headers() {
+ $headers = array(
+ 'X-Content-Type-Options' => 'nosniff',
+ 'X-Frame-Options' => 'SAMEORIGIN',
+ 'Referrer-Policy' => 'strict-origin-when-cross-origin',
+ 'Permissions-Policy' => 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
+ 'Content-Security-Policy' => "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'",
+ );
+ if ( is_ssl() ) {
+ $headers['Strict-Transport-Security'] = 'max-age=31536000';
+ }
+ return $headers;
+}
+
+/**
+ * Adds the headers to front-end responses.
+ *
+ * @param array $headers Headers WordPress is about to send.
+ * @return array
+ */
+function hollis_hardening_wp_headers( $headers ) {
+ return array_merge( $headers, hollis_hardening_security_headers() );
+}
+add_filter( 'wp_headers', 'hollis_hardening_wp_headers' );
+
+/**
+ * The login screen and the admin are not served through WP::send_headers(); send them there too.
+ */
+function hollis_hardening_send_headers_directly() {
+ if ( headers_sent() ) {
+ return;
+ }
+ foreach ( hollis_hardening_security_headers() as $name => $value ) {
+ header( $name . ': ' . $value );
+ }
+}
+add_action( 'login_init', 'hollis_hardening_send_headers_directly' );
+add_action( 'admin_init', 'hollis_hardening_send_headers_directly' );
+
+/**
+ * PHP announces its exact version in X-Powered-By; nobody needs it.
+ */
+function hollis_hardening_remove_powered_by() {
+ if ( ! headers_sent() ) {
+ header_remove( 'X-Powered-By' );
+ }
+}
+add_action( 'send_headers', 'hollis_hardening_remove_powered_by' );
diff --git a/tests/phpunit/repair/SecurityHeadersTest.php b/tests/phpunit/repair/SecurityHeadersTest.php
new file mode 100644
index 0000000..280289c
--- /dev/null
+++ b/tests/phpunit/repair/SecurityHeadersTest.php
@@ -0,0 +1,41 @@
+<?php
+/**
+ * Exhibit D-1: front-end responses carry the security headers.
+ *
+ * @package Hollis\Tests
+ */
+
+/**
+ * Security headers.
+ */
+class SecurityHeadersTest extends WP_UnitTestCase {
+
+ public function test_front_end_headers_include_the_five_security_headers() {
+ $this->go_to( home_url( '/' ) );
+ $headers = apply_filters( 'wp_headers', array( 'Content-Type' => 'text/html; charset=UTF-8' ) );
+ $this->assertSame( 'nosniff', $headers['X-Content-Type-Options'] );
+ $this->assertSame( 'SAMEORIGIN', $headers['X-Frame-Options'] );
+ $this->assertSame( 'strict-origin-when-cross-origin', $headers['Referrer-Policy'] );
+ $this->assertStringContainsString( 'camera=()', $headers['Permissions-Policy'] );
+ $this->assertStringContainsString( "frame-ancestors 'self'", $headers['Content-Security-Policy'] );
+ $this->assertStringContainsString( "object-src 'none'", $headers['Content-Security-Policy'] );
+ $this->assertSame( 'text/html; charset=UTF-8', $headers['Content-Type'] );
+ }
+
+ public function test_csp_allows_only_this_site_as_a_source() {
+ $csp = hollis_hardening_security_headers()['Content-Security-Policy'];
+ $this->assertDoesNotMatchRegularExpression( '#https?://#', $csp );
+ $this->assertStringContainsString( "default-src 'self'", $csp );
+ }
+
+ public function test_hsts_only_over_https() {
+ $_SERVER['HTTPS'] = 'on';
+ $this->assertArrayHasKey( 'Strict-Transport-Security', hollis_hardening_security_headers() );
+ unset( $_SERVER['HTTPS'] );
+ $this->assertArrayNotHasKey( 'Strict-Transport-Security', hollis_hardening_security_headers() );
+ }
+
+ public function test_theme_and_plugin_file_editor_is_disabled() {
+ $this->assertMatchesRegularExpression( "/define\(\s*'DISALLOW_FILE_EDIT',\s*true\s*\)/", file_get_contents( HOLLIS_CODE . '/hollis-config.php' ) );
+ }
+}
The proving test
tests/phpunit/repair/SecurityHeadersTest.php
tests/phpunit/repair/SecurityHeadersTest.php, as committed.
<?php
/**
* Exhibit D-1: front-end responses carry the security headers.
*
* @package Hollis\Tests
*/
/**
* Security headers.
*/
class SecurityHeadersTest extends WP_UnitTestCase {
public function test_front_end_headers_include_the_five_security_headers() {
$this->go_to( home_url( '/' ) );
$headers = apply_filters( 'wp_headers', array( 'Content-Type' => 'text/html; charset=UTF-8' ) );
$this->assertSame( 'nosniff', $headers['X-Content-Type-Options'] );
$this->assertSame( 'SAMEORIGIN', $headers['X-Frame-Options'] );
$this->assertSame( 'strict-origin-when-cross-origin', $headers['Referrer-Policy'] );
$this->assertStringContainsString( 'camera=()', $headers['Permissions-Policy'] );
$this->assertStringContainsString( "frame-ancestors 'self'", $headers['Content-Security-Policy'] );
$this->assertStringContainsString( "object-src 'none'", $headers['Content-Security-Policy'] );
$this->assertSame( 'text/html; charset=UTF-8', $headers['Content-Type'] );
}
public function test_csp_allows_only_this_site_as_a_source() {
$csp = hollis_hardening_security_headers()['Content-Security-Policy'];
$this->assertDoesNotMatchRegularExpression( '#https?://#', $csp );
$this->assertStringContainsString( "default-src 'self'", $csp );
// WordPress's emoji support check runs in a worker created from a blob: URL.
$this->assertStringContainsString( "worker-src 'self' blob:", $csp );
}
public function test_hsts_only_over_https() {
$_SERVER['HTTPS'] = 'on';
$this->assertArrayHasKey( 'Strict-Transport-Security', hollis_hardening_security_headers() );
unset( $_SERVER['HTTPS'] );
$this->assertArrayNotHasKey( 'Strict-Transport-Security', hollis_hardening_security_headers() );
}
public function test_theme_and_plugin_file_editor_is_disabled() {
$this->assertMatchesRegularExpression( "/define\(\s*'DISALLOW_FILE_EDIT',\s*true\s*\)/", file_get_contents( HOLLIS_CODE . '/hollis-config.php' ) );
}
}
e2e/tests/d-security-mail.spec.ts
e2e/tests/d-security-mail.spec.ts, as committed.
import { test, expect } from '@playwright/test';
import { BEFORE, AFTER, MAILPIT, fillAppointment, mailsWithSubject, patientName } from './helpers';
// Exhibit D: what an outsider (or a patient) gets back from each site.
const SECURITY_HEADERS = ['content-security-policy', 'x-frame-options', 'x-content-type-options', 'referrer-policy', 'permissions-policy'];
test('D-1 before: the home page sends none of the five security headers', async ({ request }) => {
const res = await request.get(BEFORE + '/');
const headers = res.headers();
expect(SECURITY_HEADERS.filter((h) => h in headers)).toEqual([]);
expect(headers['x-powered-by']).toMatch(/PHP/);
});
test('D-1 after: the home page sends all five security headers and no PHP version', async ({ request }) => {
const res = await request.get(AFTER + '/');
const headers = res.headers();
expect(SECURITY_HEADERS.filter((h) => !(h in headers))).toEqual([]);
expect(headers['x-frame-options']).toBe('SAMEORIGIN');
expect(headers['x-powered-by']).toBeUndefined();
});
test('D-1 after: the CSP does not break the page: no console violation, the menu script runs', async ({ page }) => {
const violations: string[] = [];
page.on('console', (msg) => {
if (/Content Security Policy/i.test(msg.text())) violations.push(msg.text());
});
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(AFTER + '/');
await page.getByRole('button', { name: 'Menu' }).click();
await expect(page.getByRole('navigation', { name: 'Primary' }).getByRole('link', { name: 'Services' })).toBeVisible();
expect(violations).toEqual([]);
});
test('D-2 before: ?author=1 and the REST API hand out login names', async ({ request }) => {
const scan = await request.get(BEFORE + '/?author=1', { maxRedirects: 0 });
expect(scan.status()).toBe(301);
expect(scan.headers()['location']).toContain('/author/owner/');
const users = await (await request.get(BEFORE + '/wp-json/wp/v2/users')).text();
expect(users).toContain('"slug":"owner"');
expect(users).toContain('"slug":"mara-ellison"');
});
test('D-2 after: the same requests reveal no login name', async ({ request }) => {
const scan = await request.get(AFTER + '/?author=1', { maxRedirects: 0 });
expect(scan.status()).toBe(404);
expect(scan.headers()['location']).toBeUndefined();
const users = await request.get(AFTER + '/wp-json/wp/v2/users');
expect(users.status()).toBe(404);
const body = await users.text();
expect(body).not.toContain('owner');
expect((await request.get(AFTER + '/wp-sitemap-users-1.xml')).status()).toBe(404);
});
const LIST_METHODS = '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName><params></params></methodCall>';
test('D-3 before: xmlrpc.php answers and offers its methods, including system.multicall', async ({ request }) => {
const res = await request.post(BEFORE + '/xmlrpc.php', { data: LIST_METHODS, headers: { 'Content-Type': 'text/xml' } });
expect(res.status()).toBe(200);
const body = await res.text();
expect(body).toContain('system.multicall');
expect(body).toContain('wp.getUsersBlogs');
});
test('D-3 after: xmlrpc.php refuses the request', async ({ request }) => {
const res = await request.post(AFTER + '/xmlrpc.php', { data: LIST_METHODS, headers: { 'Content-Type': 'text/xml' } });
expect(res.status()).toBe(403);
expect(await res.text()).not.toContain('methodResponse');
const home = await request.get(AFTER + '/');
expect(home.headers()['x-pingback']).toBeUndefined();
});
test('D-4 before: the form thanks the patient, but no email ever arrives', async ({ page, request }) => {
const name = patientName('Before');
await page.goto(BEFORE + '/request-appointment/');
await fillAppointment(page, name, '(555) 010-0101');
await page.getByRole('button', { name: 'Send request' }).click();
await expect(page.getByText('Thank you. Your request is with our front desk.')).toBeVisible();
// Give a real delivery every chance to land before concluding it never will.
await page.waitForTimeout(3000);
expect(await mailsWithSubject(request, name)).toHaveLength(0);
});
test('D-4 after: the request arrives in the front desk inbox with every detail', async ({ page, request }) => {
const name = patientName('After');
await page.goto(AFTER + '/request-appointment/');
await fillAppointment(page, name, '(555) 010-0102');
await page.getByLabel('Reason for the visit').selectOption({ label: 'A tooth hurts' });
await page.getByRole('button', { name: 'Send request' }).click();
await expect(page.getByText('Thank you. Your request is with our front desk.')).toBeVisible();
await expect.poll(async () => (await mailsWithSubject(request, name)).length, { timeout: 15_000 }).toBe(1);
const [mail] = await mailsWithSubject(request, name);
const full = await (await request.get(`${MAILPIT}/api/v1/message/${mail.ID}`)).json();
expect(full.To[0].Address).toBe('frontdesk@hollis-creek.example');
expect(full.Text).toContain('Phone: (555) 010-0102');
expect(full.Text).toContain('Reason: pain');
});