Hollis Creek Family Dental · Repair report

Exhibit D-3 Evidence: commit 2d168c3

Committed · 2d168c3b11b7c0a9e8b6db46e9174ed6945b9abc

XML-RPC was open

The repository itself is private; this page carries the commit as it is recorded there.

Commit message

Fix D-3: close xmlrpc.php

Root cause: xmlrpc.php was open (a WordPress default). It answered
system.listMethods with every method, including system.multicall, which
lets one request try hundreds of passwords, and pingback.ping, used for
reflection attacks; every page advertised it in X-Pingback and the RSD
link. Nothing on the site uses XML-RPC.

Fix (hardening mu-plugin): requests to xmlrpc.php get a 403 before any
method runs; the method list is empty even if something calls the server
directly; no X-Pingback header and no RSD link.

Proof: tests/phpunit/repair/XmlrpcTest.php,
e2e/tests/d-security-mail.spec.ts.

Exhibit: D-3

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               | 18 ++++++++++++++++++
 site/wp-content/mu-plugins/hollis-hardening.php | 32 +++++++++++++++++++++++++++++++-
 tests/phpunit/repair/XmlrpcTest.php             | 32 ++++++++++++++++++++++++++++++++
 3 files changed, 81 insertions(+), 1 deletion(-)
diff --git a/e2e/tests/d-security-mail.spec.ts b/e2e/tests/d-security-mail.spec.ts
index ed0bd46..1a7cb48 100644
--- a/e2e/tests/d-security-mail.spec.ts
+++ b/e2e/tests/d-security-mail.spec.ts
@@ -50,3 +50,21 @@ test('D-2 after: the same requests reveal no login name', async ({ request }) =>
   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();
+});
diff --git a/site/wp-content/mu-plugins/hollis-hardening.php b/site/wp-content/mu-plugins/hollis-hardening.php
index 605ba5e..ad7366b 100644
--- a/site/wp-content/mu-plugins/hollis-hardening.php
+++ b/site/wp-content/mu-plugins/hollis-hardening.php
@@ -1,7 +1,7 @@
 <?php
 /**
  * Plugin Name: Hollis Creek hardening
- * Description: Security headers on every page, no user enumeration.
+ * Description: Security headers on every page, no user enumeration, XML-RPC off.
  * Version:     1.0.0
  * Author:      Hollis Creek Family Dental (fictional demo)
  *
@@ -134,3 +134,33 @@ function hollis_hardening_login_error() {
 	return 'The user name or password is not right.';
 }
 add_filter( 'login_errors', 'hollis_hardening_login_error' );
+
+/**
+ * XML-RPC is off: the site uses no app or service that needs it, and xmlrpc.php is the usual target for
+ * password guessing (system.multicall tries hundreds of passwords per request) and pingback abuse.
+ * Requests to xmlrpc.php get a 403 before any method runs.
+ */
+function hollis_hardening_refuse_xmlrpc() {
+	if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
+		status_header( 403 );
+		header( 'Content-Type: text/plain; charset=utf-8' );
+		echo 'XML-RPC is disabled on this site.';
+		exit;
+	}
+}
+add_action( 'plugins_loaded', 'hollis_hardening_refuse_xmlrpc', 0 );
+add_filter( 'xmlrpc_enabled', '__return_false' );
+add_filter( 'xmlrpc_methods', '__return_empty_array' );
+remove_action( 'wp_head', 'rsd_link' );
+
+/**
+ * No X-Pingback header advertising xmlrpc.php.
+ *
+ * @param array $headers Headers.
+ * @return array
+ */
+function hollis_hardening_no_pingback_header( $headers ) {
+	unset( $headers['X-Pingback'] );
+	return $headers;
+}
+add_filter( 'wp_headers', 'hollis_hardening_no_pingback_header', 20 );
diff --git a/tests/phpunit/repair/XmlrpcTest.php b/tests/phpunit/repair/XmlrpcTest.php
new file mode 100644
index 0000000..6231e55
--- /dev/null
+++ b/tests/phpunit/repair/XmlrpcTest.php
@@ -0,0 +1,32 @@
+<?php
+/**
+ * Exhibit D-3: XML-RPC is closed.
+ *
+ * @package Hollis\Tests
+ */
+
+/**
+ * XML-RPC.
+ */
+class XmlrpcTest extends WP_UnitTestCase {
+
+	public function test_no_xmlrpc_method_is_offered() {
+		$this->assertFalse( apply_filters( 'xmlrpc_enabled', true ) );
+		$server = new wp_xmlrpc_server();
+		$this->assertSame( array(), $server->methods );
+	}
+
+	public function test_pages_do_not_advertise_xmlrpc() {
+		$this->go_to( home_url( '/' ) );
+		$headers = apply_filters( 'wp_headers', array( 'X-Pingback' => site_url( 'xmlrpc.php' ) ) );
+		$this->assertArrayNotHasKey( 'X-Pingback', $headers );
+		ob_start();
+		wp_head();
+		$head = ob_get_clean();
+		$this->assertStringNotContainsString( 'xmlrpc.php', $head );
+	}
+
+	public function test_xmlrpc_requests_are_refused_before_wordpress_handles_them() {
+		$this->assertSame( 0, has_action( 'plugins_loaded', 'hollis_hardening_refuse_xmlrpc' ) );
+	}
+}

The proving test

tests/phpunit/repair/XmlrpcTest.php

tests/phpunit/repair/XmlrpcTest.php, as committed.

<?php
/**
 * Exhibit D-3: XML-RPC is closed.
 *
 * @package Hollis\Tests
 */

/**
 * XML-RPC.
 */
class XmlrpcTest extends WP_UnitTestCase {

	public function test_no_xmlrpc_method_is_offered() {
		$this->assertFalse( apply_filters( 'xmlrpc_enabled', true ) );
		$server = new wp_xmlrpc_server();
		$this->assertSame( array(), $server->methods );
	}

	public function test_pages_do_not_advertise_xmlrpc() {
		$this->go_to( home_url( '/' ) );
		$headers = apply_filters( 'wp_headers', array( 'X-Pingback' => site_url( 'xmlrpc.php' ) ) );
		$this->assertArrayNotHasKey( 'X-Pingback', $headers );
		ob_start();
		wp_head();
		$head = ob_get_clean();
		$this->assertStringNotContainsString( 'xmlrpc.php', $head );
	}

	public function test_xmlrpc_requests_are_refused_before_wordpress_handles_them() {
		$this->assertSame( 0, has_action( 'plugins_loaded', 'hollis_hardening_refuse_xmlrpc' ) );
	}
}

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');
});

Back to Exhibit D-3