Files
Gnuboard7/tests/Unit/Support/TrustedScriptHostsTest.php
HeuJung ed985dd175 chore(release): 7.0.7 공개 릴리즈 사전점검 정비 — 누출 단서 주석 정리 + 태그 관례 정합
- 공개 릴리즈 누출 검사 error 3건 해소: 소스 주석의 내부 규정 파일명·내부 스크립트
 경로 참조를 공개 문서 참조/도구 비귀속 표현으로 교체
- warn 4건 해소: 테스트·주석의 내부 역할 호칭을 "운영 실측 제보" 로 정리
- 릴리즈 스크립트(코어·확장)의 버전 태그를 공개 저장소 기존 태그 관례(v 접두사 없음)에
 정합 — 접두사 혼재로 태그 시리즈가 갈라지는 것을 차단
2026-08-17 21:36:15 +09:00

398 lines
17 KiB
PHP

<?php
namespace Tests\Unit\Support;
use App\Extension\AbstractModule;
use App\Extension\AbstractPlugin;
use App\Extension\HookManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Support\TrustedScriptHosts;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* TrustedScriptHosts 집계/판정 테스트 (KVE-2026-1915 신뢰 출처 허용목록)
*
* 확장이 manifest 로 선언한 외부 스크립트 호스트를 집계하고, URL 의 호스트가 그 목록에
* 속하는지 판정하는 로직을 고정합니다. 런타임·저장측·audit 이 모두 이 SSoT 를 참조하므로
* 호스트 추출/정규화가 틀리면 세 계층이 함께 어긋납니다.
*
* 선언 주체는 모듈·플러그인·템플릿 셋이고 집계 경로가 서로 다릅니다 — 모듈/플러그인은
* PHP 추상 클래스의 getTrustedScriptHosts(), 템플릿은 manifest 배열 직접 읽기. 한 주체만
* 검사하면 나머지 주체의 집계 누락이 드러나지 않습니다(템플릿 축이 그렇게 빠져 있던 것이
* B-2b 결함이었습니다).
*
* 이 관심사는 시나리오 매니페스트(tests/scenarios/trusted-script-hosts.yaml)의
* sub_flow `trusted_host_declaring_subject` 로 등록되어 있습니다. 브라우저 관찰 축(case)과
* 교차하지 않는 별개 관심사라 축으로 올리면 어떤 조합도 관측 지점을 갖지 못합니다 —
* 그래서 축이 아니라 sub_flow 이고, 커버는 아래 메서드들의 효과 마커가 담당합니다.
*
* 주의: 산문에 마커 문법을 그대로 인용하면 시나리오 마커 파서가
* 그 문장을 마커로 읽어 실재하지 않는 조합을 만듭니다. 마커 이름은 평문으로만 언급합니다.
*
* 효과 요약(마커 아님 — 평문): every_extension_type_declaration_is_collected,
* malformed_or_absent_declaration_is_ignored. 실제 마커는 아래 메서드에만 둡니다.
*/
class TrustedScriptHostsTest extends TestCase
{
protected function tearDown(): void
{
HookManager::clearFilter(TrustedScriptHosts::FILTER_HOOK);
parent::tearDown();
}
// ── hostOf: URL → 호스트 추출 ──────────────────────────
public function test_host_of_extracts_scheme_url_host(): void
{
$this->assertSame('cdn.ckeditor.com', TrustedScriptHosts::hostOf('https://cdn.ckeditor.com/ckeditor5/x.js'));
}
public function test_host_of_extracts_protocol_relative_host(): void
{
$this->assertSame('t1.daumcdn.net', TrustedScriptHosts::hostOf('//t1.daumcdn.net/mapjsapi/postcode.js'));
}
public function test_host_of_returns_null_for_same_origin_path(): void
{
$this->assertNull(TrustedScriptHosts::hostOf('/api/modules/x/y.js'));
}
public function test_host_of_lowercases(): void
{
$this->assertSame('cdn.ckeditor.com', TrustedScriptHosts::hostOf('https://CDN.CKEditor.COM/x.js'));
}
// ── isTrustedUrl: 명시 목록 대조 ───────────────────────
public function test_is_trusted_url_allows_listed_host(): void
{
$this->assertTrue(
TrustedScriptHosts::isTrustedUrl('https://cdn.ckeditor.com/x.js', ['cdn.ckeditor.com'])
);
}
public function test_is_trusted_url_blocks_unlisted_host(): void
{
$this->assertFalse(
TrustedScriptHosts::isTrustedUrl('https://evil.com/x.js', ['cdn.ckeditor.com'])
);
}
public function test_is_trusted_url_blocks_same_origin_path(): void
{
// same-origin 경로는 호스트가 없으므로 신뢰 목록 판정 대상이 아니다(별도 path 규칙이 허용)
$this->assertFalse(
TrustedScriptHosts::isTrustedUrl('/local/x.js', ['cdn.ckeditor.com'])
);
}
// ── hostOf: authority 우회 정규화 (KVE-2026-1915 3층 동형) ──
//
// 브라우저(WHATWG URL)는 파싱 전에 ASCII tab·LF·CR 을 제거하고 special scheme 에서
// 백슬래시를 슬래시와 동등 처리한다. 그 정규화 없이 parse_url 로 호스트를 뽑으면
// `https://evil.com\@cdn.ckeditor.com/x.js` 의 호스트를 `cdn.ckeditor.com`(userinfo 해석)
// 으로 보게 되는데, 브라우저는 같은 문자열을 `evil.com` 에서 로드한다.
// 런타임(TemplateApp)·정적검사(layout-scripts-src-same-origin)는 이미 정규화하므로
// 이 판정만 갈리면 "저장은 되는데 로드는 안 되는" 비대칭이 생긴다.
/**
* @return array<string, array{0: string, 1: string|null}>
*/
public static function authorityNormalizationProvider(): array
{
$bs = chr(92); // 백슬래시
$tab = chr(9);
$lf = chr(10);
$cr = chr(13);
return [
// 신뢰 호스트를 userinfo 로 위장 → 실제 출처는 evil.com
'backslash userinfo (scheme)' => ['https://evil.com'.$bs.'@cdn.ckeditor.com/x.js', 'evil.com'],
'backslash userinfo (protocol-relative)' => ['//evil.com'.$bs.'@cdn.ckeditor.com/x.js', 'evil.com'],
'tab userinfo' => ['https://evil.com'.$tab.'@cdn.ckeditor.com/x.js', 'cdn.ckeditor.com'],
'lf in host' => ['https://evil'.$lf.'.com/x.js', 'evil.com'],
'cr in host' => ['https://evil'.$cr.'.com/x.js', 'evil.com'],
// 역방향: 문자열상 path 지만 브라우저는 authority 로 해석 → 호스트가 나와야 한다
'backslash authority from path' => ['/'.$bs.'/cdn.ckeditor.com/x.js', 'cdn.ckeditor.com'],
];
}
#[DataProvider('authorityNormalizationProvider')]
public function test_host_of_normalizes_authority_like_browser(string $url, ?string $expected): void
{
$this->assertSame($expected, TrustedScriptHosts::hostOf($url));
}
public function test_is_trusted_url_rejects_backslash_userinfo_disguise(): void
{
$this->assertFalse(
TrustedScriptHosts::isTrustedUrl(
'https://evil.com'.chr(92).'@cdn.ckeditor.com/x.js',
['cdn.ckeditor.com']
)
);
}
public function test_is_trusted_url_allows_backslash_authority_to_trusted_host(): void
{
// 런타임은 이 형태를 `//cdn.ckeditor.com/x.js` 로 해석해 허용한다.
// 저장측이 거부하면 신뢰 호스트 스크립트가 저장 불가가 되는 과차단 비대칭이다.
$this->assertTrue(
TrustedScriptHosts::isTrustedUrl(
'/'.chr(92).'/cdn.ckeditor.com/x.js',
['cdn.ckeditor.com']
)
);
}
public function test_host_of_still_returns_null_for_plain_path_with_inner_backslash(): void
{
// 경로 중간의 백슬래시는 authority 를 만들지 않는다 (과차단 회귀 방지)
$this->assertNull(TrustedScriptHosts::hostOf('/js/a'.chr(92).'b.js'));
}
// ── hosts(): 활성 모듈·플러그인 manifest 집계 ───────────
//
// 모듈/플러그인은 PHP 추상 클래스의 `getTrustedScriptHosts()` 로 manifest 를 읽어
// 집계된다(템플릿과 경로가 다르다). 이 축이 없으면 추상 클래스의 manifest 파싱이
// 깨져도 템플릿 테스트만 green 이라 드러나지 않는다.
/**
* 모듈 manifest 의 선언이 읽히고, 비문자열·공백 항목은 걸러진다.
*
* @effects every_extension_type_declaration_is_collected, malformed_or_absent_declaration_is_ignored
*/
public function test_module_reads_declared_hosts_from_manifest(): void
{
$module = $this->fakeExtension(AbstractModule::class, [
'trusted_script_hosts' => [' cdn.module.example ', 123, null, ''],
]);
$this->assertSame(['cdn.module.example'], $module->getTrustedScriptHosts());
}
/**
* 모듈 manifest 가 배열이 아니면 빈 목록으로 처리된다 (손상된 manifest 방어).
*
* @effects malformed_or_absent_declaration_is_ignored
*/
public function test_module_ignores_non_array_declaration(): void
{
$module = $this->fakeExtension(AbstractModule::class, ['trusted_script_hosts' => 'not-an-array']);
$this->assertSame([], $module->getTrustedScriptHosts());
}
/**
* 플러그인 manifest 의 선언이 읽히고, 비문자열·공백 항목은 걸러진다.
*
* @effects every_extension_type_declaration_is_collected, malformed_or_absent_declaration_is_ignored
*/
public function test_plugin_reads_declared_hosts_from_manifest(): void
{
$plugin = $this->fakeExtension(AbstractPlugin::class, [
'trusted_script_hosts' => ['cdn.plugin.example', false],
]);
$this->assertSame(['cdn.plugin.example'], $plugin->getTrustedScriptHosts());
}
/**
* 선언이 아예 없는 manifest 는 빈 목록이다 (미선언 확장이 다수인 정상 경로).
*
* @effects malformed_or_absent_declaration_is_ignored
*/
public function test_plugin_without_declaration_returns_empty(): void
{
$plugin = $this->fakeExtension(AbstractPlugin::class, ['name' => 'x']);
$this->assertSame([], $plugin->getTrustedScriptHosts());
}
/**
* 활성 모듈·플러그인이 선언한 호스트가 집계에 합류한다.
*
* @effects every_extension_type_declaration_is_collected
*/
public function test_hosts_includes_active_module_and_plugin_declared_hosts(): void
{
$this->fakeActiveExtensions(
modules: [$this->fakeExtension(AbstractModule::class, ['trusted_script_hosts' => ['cdn.module.example']])],
plugins: [$this->fakeExtension(AbstractPlugin::class, ['trusted_script_hosts' => ['cdn.plugin.example']])]
);
$hosts = TrustedScriptHosts::hosts();
$this->assertContains('cdn.module.example', $hosts);
$this->assertContains('cdn.plugin.example', $hosts);
}
/**
* 모듈 집계가 실패해도 나머지 주체의 집계는 계속된다 (fine-grained 실패 격리).
*
* @effects malformed_or_absent_declaration_is_ignored
*/
public function test_hosts_survives_module_aggregation_failure(): void
{
$moduleManager = \Mockery::mock(ModuleManager::class);
$moduleManager->shouldReceive('getActiveModules')->andThrow(new \RuntimeException('boom'));
$this->instance(ModuleManager::class, $moduleManager);
$pluginManager = \Mockery::mock(PluginManager::class);
$pluginManager->shouldReceive('getActivePlugins')->andReturn([
$this->fakeExtension(AbstractPlugin::class, ['trusted_script_hosts' => ['cdn.plugin.example']]),
]);
$this->instance(PluginManager::class, $pluginManager);
$this->assertContains('cdn.plugin.example', TrustedScriptHosts::hosts());
}
// ── hosts(): 집계 + 훅 확장 ────────────────────────────
public function test_hosts_includes_hook_declared_hosts(): void
{
HookManager::addFilter(
TrustedScriptHosts::FILTER_HOOK,
fn (array $hosts) => array_merge($hosts, ['cdn.example.com']),
priority: 1
);
$this->assertContains('cdn.example.com', TrustedScriptHosts::hosts());
}
public function test_hosts_normalizes_and_dedupes(): void
{
HookManager::addFilter(
TrustedScriptHosts::FILTER_HOOK,
fn (array $hosts) => array_merge($hosts, [' CDN.Example.com ', 'cdn.example.com']),
priority: 1
);
$hosts = TrustedScriptHosts::hosts();
$matches = array_filter($hosts, fn ($h) => $h === 'cdn.example.com');
$this->assertCount(1, $matches, '정규화(trim/lowercase) 후 중복이 제거되어야 합니다');
}
// ── hosts(): 활성 템플릿 manifest 집계 ─────────────────
//
// 템플릿은 모듈/플러그인과 달리 PHP 추상 클래스가 없고 manifest 배열로 다뤄지므로,
// `getActiveTemplate()` 이 돌려주는 template.json 배열에서 직접 읽는다.
// 이 축이 빠지면 템플릿이 선언한 호스트를 정적 검사는 통과시키는데 런타임·저장측이
// 차단해, 문서가 지원한다고 적은 기능이 화면에서 조용히 동작하지 않는다.
/**
* 활성 관리자 템플릿이 선언한 호스트가 집계된다.
*
* @effects every_extension_type_declaration_is_collected
*/
public function test_hosts_includes_active_admin_template_declared_hosts(): void
{
$this->fakeActiveTemplates(
admin: ['identifier' => 'vendor-admin_x', 'trusted_script_hosts' => ['cdn.admin-template.example']],
user: null
);
$this->assertContains('cdn.admin-template.example', TrustedScriptHosts::hosts());
}
/**
* 활성 사용자 템플릿이 선언한 호스트가 집계된다.
*
* @effects every_extension_type_declaration_is_collected
*/
public function test_hosts_includes_active_user_template_declared_hosts(): void
{
$this->fakeActiveTemplates(
admin: null,
user: ['identifier' => 'vendor-user_x', 'trusted_script_hosts' => ['cdn.user-template.example']]
);
$this->assertContains('cdn.user-template.example', TrustedScriptHosts::hosts());
}
/**
* 템플릿 manifest 의 비문자열/비배열 값은 무시된다 (손상된 manifest 방어).
*
* @effects malformed_or_absent_declaration_is_ignored
*/
public function test_hosts_ignores_malformed_template_declarations(): void
{
$this->fakeActiveTemplates(
admin: ['identifier' => 'vendor-admin_x', 'trusted_script_hosts' => 'not-an-array'],
user: ['identifier' => 'vendor-user_x', 'trusted_script_hosts' => ['cdn.ok.example', 123, null]]
);
$hosts = TrustedScriptHosts::hosts();
$this->assertContains('cdn.ok.example', $hosts);
$this->assertNotContains('not-an-array', $hosts);
}
/**
* 활성 템플릿이 없어도 집계가 실패하지 않는다.
*
* @effects malformed_or_absent_declaration_is_ignored
*/
public function test_hosts_survives_absent_active_templates(): void
{
$this->fakeActiveTemplates(admin: null, user: null);
$this->assertIsArray(TrustedScriptHosts::hosts());
}
/**
* manifest 를 주입한 확장(모듈/플러그인) 인스턴스를 만듭니다.
*
* `loadManifest()` 만 대체하므로 `getTrustedScriptHosts()` 의 실제 파싱·필터 로직이
* 그대로 실행됩니다.
*
* @param class-string $abstract AbstractModule::class 또는 AbstractPlugin::class
* @param array<string, mixed> $manifest 주입할 manifest 배열
* @return mixed 부분 목 확장 인스턴스
*/
private function fakeExtension(string $abstract, array $manifest): mixed
{
$extension = \Mockery::mock($abstract)->makePartial();
$extension->shouldAllowMockingProtectedMethods();
$extension->shouldReceive('loadManifest')->andReturn($manifest);
return $extension;
}
/**
* ModuleManager/PluginManager 를 대체해 활성 확장 목록을 주입합니다.
*
* @param array<int, mixed> $modules 활성 모듈 인스턴스 목록
* @param array<int, mixed> $plugins 활성 플러그인 인스턴스 목록
*/
private function fakeActiveExtensions(array $modules, array $plugins): void
{
$moduleManager = \Mockery::mock(ModuleManager::class);
$moduleManager->shouldReceive('getActiveModules')->andReturn($modules);
$this->instance(ModuleManager::class, $moduleManager);
$pluginManager = \Mockery::mock(PluginManager::class);
$pluginManager->shouldReceive('getActivePlugins')->andReturn($plugins);
$this->instance(PluginManager::class, $pluginManager);
}
/**
* TemplateManager 를 대체해 활성 템플릿 manifest 를 주입합니다.
*
* @param array<string, mixed>|null $admin 활성 관리자 템플릿 manifest
* @param array<string, mixed>|null $user 활성 사용자 템플릿 manifest
*/
private function fakeActiveTemplates(?array $admin, ?array $user): void
{
$manager = \Mockery::mock(TemplateManager::class);
$manager->shouldReceive('getActiveTemplate')->with('admin')->andReturn($admin);
$manager->shouldReceive('getActiveTemplate')->with('user')->andReturn($user);
$this->instance(TemplateManager::class, $manager);
}
}