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

증거물 D-1 근거: 커밋 d532fbd

커밋 시각 · d532fbd3fdd7c764c2d878e0d202111364996538

보안 헤더가 없었습니다

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

커밋 메시지

후속: CSP에서 WordPress의 blob: 워커 허용

Fix D-1 follow-up: allow WordPress's blob: worker in the CSP

The browser test for D-1 caught a CSP violation on the repaired site:
WordPress's emoji support check starts a worker from a blob: URL, and
without worker-src the policy fell back to script-src and blocked it.

Fix: worker-src 'self' blob:. Nothing else is loosened.

Proof: e2e/tests/d-security-mail.spec.ts (no CSP message in the console),
tests/phpunit/repair/SecurityHeadersTest.php.

Exhibit: D-1

변경 내용

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

바뀐 파일

---
 site/wp-content/mu-plugins/hollis-hardening.php | 2 +-
 tests/phpunit/repair/SecurityHeadersTest.php    | 2 ++
 2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/site/wp-content/mu-plugins/hollis-hardening.php b/site/wp-content/mu-plugins/hollis-hardening.php
index b9d813c..6d67aa3 100644
--- a/site/wp-content/mu-plugins/hollis-hardening.php
+++ b/site/wp-content/mu-plugins/hollis-hardening.php
@@ -21,7 +21,7 @@ function hollis_hardening_security_headers() {
 		'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'",
+		'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'; worker-src 'self' blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'",
 	);
 	if ( is_ssl() ) {
 		$headers['Strict-Transport-Security'] = 'max-age=31536000';
diff --git a/tests/phpunit/repair/SecurityHeadersTest.php b/tests/phpunit/repair/SecurityHeadersTest.php
index 280289c..9c02809 100644
--- a/tests/phpunit/repair/SecurityHeadersTest.php
+++ b/tests/phpunit/repair/SecurityHeadersTest.php
@@ -26,6 +26,8 @@ class SecurityHeadersTest extends WP_UnitTestCase {
 		$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() {

증명 테스트

tests/phpunit/repair/SecurityHeadersTest.php

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

<?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, 커밋된 그대로.

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-1로 돌아가기