홀리스 크릭 가족 치과 · 수리 보고서

증거물 D-2 근거: 커밋 8df9c85

커밋 시각 · 8df9c85b28f5c7c9673fc0f0ee3f8e22fce23359

누구나 로그인 이름을 알아낼 수 있었습니다

저장소 자체는 비공개입니다. 이 페이지에 커밋을 기록된 그대로 옮겼습니다.

커밋 메시지

?author=, REST, 사이트맵, oEmbed를 통한 사용자 이름 노출 차단

Fix D-2: stop user enumeration through ?author=, REST, sitemaps and oEmbed

Root cause: WordPress defaults left in place. Anyone could turn user IDs
into login names: /?author=1 redirects to /author/<login>/, the REST API
lists every author with their slug at /wp-json/wp/v2/users, the users
sitemap lists them again, and oEmbed responses name the author. With the
login names known, only the passwords are left to guess.

Fix (hardening mu-plugin): author queries are a 404 for visitors who are
not logged in (the site has no author pages), the REST users routes exist
only for logged-in users, no users sitemap, no author in oEmbed, and the
login form no longer says which of name or password was wrong.

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

Exhibit: D-2

변경 내용

git에 기록된 텍스트 파일만 보여 줍니다. +로 시작하는 줄은 추가, −로 시작하는 줄은 삭제입니다.

바뀐 파일

---
 e2e/tests/d-security-mail.spec.ts               | 20 +++++++++++++
 site/wp-content/mu-plugins/hollis-hardening.php | 73 ++++++++++++++++++++++++++++++++++++++++++++-
 tests/phpunit/repair/UserEnumerationTest.php    | 67 +++++++++++++++++++++++++++++++++++++++++
 3 files changed, 159 insertions(+), 1 deletion(-)
diff --git a/e2e/tests/d-security-mail.spec.ts b/e2e/tests/d-security-mail.spec.ts
index 88421ca..ed0bd46 100644
--- a/e2e/tests/d-security-mail.spec.ts
+++ b/e2e/tests/d-security-mail.spec.ts
@@ -30,3 +30,23 @@ test('D-1 after: the CSP does not break the page: no console violation, the menu
   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);
+});
diff --git a/site/wp-content/mu-plugins/hollis-hardening.php b/site/wp-content/mu-plugins/hollis-hardening.php
index 62cd527..605ba5e 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.
+ * Description: Security headers on every page, no user enumeration.
  * Version:     1.0.0
  * Author:      Hollis Creek Family Dental (fictional demo)
  *
@@ -63,3 +63,74 @@ function hollis_hardening_remove_powered_by() {
 	}
 }
 add_action( 'send_headers', 'hollis_hardening_remove_powered_by' );
+
+/**
+ * Visitors who are not logged in get a 404 for author archives, including the ?author=<id> scan that turns
+ * user IDs into login names through the canonical redirect. The site has no author pages.
+ *
+ * @param array $query_vars Parsed query variables.
+ * @return array
+ */
+function hollis_hardening_block_author_queries( $query_vars ) {
+	if ( is_admin() || is_user_logged_in() ) {
+		return $query_vars;
+	}
+	if ( isset( $query_vars['author'] ) || isset( $query_vars['author_name'] ) ) {
+		return array( 'error' => '404' );
+	}
+	return $query_vars;
+}
+add_filter( 'request', 'hollis_hardening_block_author_queries' );
+
+/**
+ * The REST API users routes answer only logged-in users.
+ *
+ * @param array $endpoints Registered routes.
+ * @return array
+ */
+function hollis_hardening_rest_users( $endpoints ) {
+	if ( is_user_logged_in() ) {
+		return $endpoints;
+	}
+	foreach ( array_keys( $endpoints ) as $route ) {
+		if ( 0 === strpos( $route, '/wp/v2/users' ) ) {
+			unset( $endpoints[ $route ] );
+		}
+	}
+	return $endpoints;
+}
+add_filter( 'rest_endpoints', 'hollis_hardening_rest_users' );
+
+/**
+ * No users sitemap (wp-sitemap-users-1.xml lists every author's login slug).
+ *
+ * @param WP_Sitemaps_Provider|false $provider Provider.
+ * @param string                     $name     Provider name.
+ * @return WP_Sitemaps_Provider|false
+ */
+function hollis_hardening_no_users_sitemap( $provider, $name ) {
+	return 'users' === $name ? false : $provider;
+}
+add_filter( 'wp_sitemaps_add_provider', 'hollis_hardening_no_users_sitemap', 10, 2 );
+
+/**
+ * oEmbed responses name the author and link the author archive; leave both out.
+ *
+ * @param array $data oEmbed data.
+ * @return array
+ */
+function hollis_hardening_oembed_author( $data ) {
+	unset( $data['author_name'], $data['author_url'] );
+	return $data;
+}
+add_filter( 'oembed_response_data', 'hollis_hardening_oembed_author' );
+
+/**
+ * The login form does not say whether the user name or the password was wrong.
+ *
+ * @return string
+ */
+function hollis_hardening_login_error() {
+	return 'The user name or password is not right.';
+}
+add_filter( 'login_errors', 'hollis_hardening_login_error' );
diff --git a/tests/phpunit/repair/UserEnumerationTest.php b/tests/phpunit/repair/UserEnumerationTest.php
new file mode 100644
index 0000000..46d5bd4
--- /dev/null
+++ b/tests/phpunit/repair/UserEnumerationTest.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Exhibit D-2: an anonymous visitor cannot list the site's login names.
+ *
+ * @package Hollis\Tests
+ */
+
+/**
+ * User enumeration.
+ */
+class UserEnumerationTest extends WP_UnitTestCase {
+
+	/**
+	 * Author with a published post (author archives only exist for people who wrote something).
+	 *
+	 * @var int
+	 */
+	private $author;
+
+	public function set_up() {
+		parent::set_up();
+		$this->author = self::factory()->user->create(
+			array(
+				'role'       => 'editor',
+				'user_login' => 'quiet.author',
+			)
+		);
+		self::factory()->post->create( array( 'post_author' => $this->author ) );
+		wp_set_current_user( 0 );
+	}
+
+	public function test_author_id_scan_is_a_404_not_a_redirect_to_the_login_name() {
+		$this->go_to( home_url( '/?author=' . $this->author ) );
+		$this->assertTrue( is_404() );
+		$this->assertFalse( is_author() );
+		$this->assertEmpty( redirect_canonical( home_url( "/?author={$this->author}" ), false ) );
+	}
+
+	public function test_author_archive_by_name_is_a_404() {
+		$this->set_permalink_structure( '/%postname%/' );
+		$this->go_to( home_url( '/author/quiet.author/' ) );
+		$this->assertTrue( is_404() );
+	}
+
+	public function test_rest_users_route_is_gone_for_anonymous_visitors() {
+		rest_get_server()->override_by_default = true;
+		$GLOBALS['wp_rest_server']             = null;
+		$response                              = rest_do_request( new WP_REST_Request( 'GET', '/wp/v2/users' ) );
+		$this->assertSame( 404, $response->get_status() );
+		$this->assertStringNotContainsString( 'quiet.author', wp_json_encode( $response->get_data() ) );
+	}
+
+	public function test_rest_users_route_still_works_for_logged_in_admins() {
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+		$GLOBALS['wp_rest_server'] = null;
+		$response                  = rest_do_request( new WP_REST_Request( 'GET', '/wp/v2/users' ) );
+		$this->assertSame( 200, $response->get_status() );
+	}
+
+	public function test_no_users_sitemap_and_no_author_in_oembed() {
+		$this->assertEmpty( wp_sitemaps_get_server()->registry->get_provider( "users" ) );
+		$post = self::factory()->post->create( array( 'post_author' => $this->author ) );
+		$data = get_oembed_response_data( $post, 600 );
+		$this->assertArrayNotHasKey( 'author_name', $data );
+		$this->assertArrayNotHasKey( 'author_url', $data );
+	}
+}

증명 테스트

tests/phpunit/repair/UserEnumerationTest.php

tests/phpunit/repair/UserEnumerationTest.php, 커밋된 그대로.

<?php
/**
 * Exhibit D-2: an anonymous visitor cannot list the site's login names.
 *
 * @package Hollis\Tests
 */

/**
 * User enumeration.
 */
class UserEnumerationTest extends WP_UnitTestCase {

	/**
	 * Author with a published post (author archives only exist for people who wrote something).
	 *
	 * @var int
	 */
	private $author;

	public function set_up() {
		parent::set_up();
		$this->author = self::factory()->user->create(
			array(
				'role'       => 'editor',
				'user_login' => 'quiet.author',
			)
		);
		self::factory()->post->create( array( 'post_author' => $this->author ) );
		wp_set_current_user( 0 );
	}

	public function test_author_id_scan_is_a_404_not_a_redirect_to_the_login_name() {
		$this->go_to( home_url( '/?author=' . $this->author ) );
		$this->assertTrue( is_404() );
		$this->assertFalse( is_author() );
		$this->assertEmpty( redirect_canonical( home_url( "/?author={$this->author}" ), false ) );
	}

	public function test_author_archive_by_name_is_a_404() {
		$this->set_permalink_structure( '/%postname%/' );
		$this->go_to( home_url( '/author/quiet.author/' ) );
		$this->assertTrue( is_404() );
	}

	public function test_rest_users_route_is_gone_for_anonymous_visitors() {
		rest_get_server()->override_by_default = true;
		$GLOBALS['wp_rest_server']             = null;
		$response                              = rest_do_request( new WP_REST_Request( 'GET', '/wp/v2/users' ) );
		$this->assertSame( 404, $response->get_status() );
		$this->assertStringNotContainsString( 'quiet.author', wp_json_encode( $response->get_data() ) );
	}

	public function test_rest_users_route_still_works_for_logged_in_admins() {
		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
		$GLOBALS['wp_rest_server'] = null;
		$response                  = rest_do_request( new WP_REST_Request( 'GET', '/wp/v2/users' ) );
		$this->assertSame( 200, $response->get_status() );
	}

	public function test_no_users_sitemap_and_no_author_in_oembed() {
		$this->assertEmpty( wp_sitemaps_get_server()->registry->get_provider( "users" ) );
		$post = self::factory()->post->create( array( 'post_author' => $this->author ) );
		$data = get_oembed_response_data( $post, 600 );
		$this->assertArrayNotHasKey( 'author_name', $data );
		$this->assertArrayNotHasKey( 'author_url', $data );
	}
}

e2e/tests/d-security-mail.spec.ts

e2e/tests/d-security-mail.spec.ts, 커밋된 그대로.

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

증거물 D-2로 돌아가기