Files
Gnuboard7/app/Support/TrustedScriptHosts.php
HeuJung 1e89335258 fix(core,extensions): 동적 스크립트 주입 경로 전부에 출처 게이트 적용
레이아웃 scripts[] 에만 있던 원격 스크립트 차단 게이트가 다른 주입 경로에는
없어, 저장측(SafeLayoutExpressions·NoExternalUrls)이 외부 URL 저장을 422 로
막아도 런타임 디스패치 한 번으로 임의 원격 코드가 로드됐다. 저장 검증이
통째로 무의미해지는 상태였고 오류도 경고도 남지 않았다.

판정식을 support/scriptSrcPolicy.ts 로 분리해 런타임 SSoT 를 하나로 두고,
loadScript 액션 · 확장 핸들러 재로드 · 편집기 프리뷰 · 편집기 자산 매니페스트 ·
G7Core.asset.loadScript · PG 플러그인 SDK 주입이 모두 그것을 경유하게 했다.
사본을 두지 않은 이유는 차집합이 그대로 우회로가 되기 때문이다.

- 게이트는 캐시 검사보다 앞에 둔다 (뒤에 두면 이미 로드된 미신뢰 스크립트가
 캐시 히트로 통과한다)
- NoExternalUrls 순회를 9키로 확대 — 번들 레이아웃 601개 사전 스캔으로
 정당한 외부 URL 0건을 확인한 뒤 넓혔다 (저장 회귀 없음)
- callExternal 은 생성자를 참조 동일성으로 거부하고(별칭 전역 포함),
 프로토타입 경로 세그먼트를 읽기·매핑 키 양쪽에서 차단한다
- PG SDK 는 서비스 SDK 라 자체 호스팅이 불가능하므로 manifest 선언 +
 주입 직전 호스트 확인이 게이트다. tosspayments SDK URL 은 확장자가 없어
 정적 검사에 걸리지 않아 그 런타임 확인이 유일한 게이트다
- 함께 드러난 결함: 동시 loadScript 가 로드 전에 완료 처리되던 문제,
 확장 재로드에서 script 기존재가 CSS 까지 건너뛰던 문제, loadCSS 의
 in-flight 미공유
2026-09-02 21:43:13 +09:00

181 lines
7.6 KiB
PHP

<?php
namespace App\Support;
use App\Extension\HookManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use Illuminate\Support\Facades\Log;
/**
* 신뢰하는 외부 스크립트 호스트 집계 (KVE-2026-1915 신뢰 출처 허용목록)
*
* 레이아웃 `scripts[].src` 는 기본적으로 same-origin path-only 만 허용한다(원격 코드 로드
* 차단). 그러나 CKEditor5(cdn.ckeditor.com)·Daum 우편번호(t1.daumcdn.net)처럼 외부 CDN
* 스크립트를 정당하게 로드하는 번들 확장이 있다. 그 확장이 manifest 의
* `trusted_script_hosts` 로 자기 호스트를 **선언**하고, 코어가 활성 확장 전수에서 이 목록을
* 집계한다. 런타임 스크립트 로더(TemplateApp)·저장측 검증(SafeLayoutExpressions)·정적 검사
* (audit)가 모두 이 집계 결과만 신뢰 호스트로 허용한다.
*
* 신뢰 경계: 편집기가 저장하는 레이아웃(저권한 액터)에는 외부 스크립트를 허용하지 않는
* 것이 기본이고, 여기서 허용되는 것은 **확장이 코드로 선언한 호스트**뿐이다.
*/
class TrustedScriptHosts
{
/**
* 확장이 동적으로 신뢰 호스트를 추가할 수 있는 필터 훅 이름.
*/
public const FILTER_HOOK = 'core.layout.trusted_script_hosts';
/**
* 활성 모듈·플러그인·템플릿이 선언한 신뢰 호스트 전체를 집계합니다.
*
* @return array<int, string> 중복 제거된 호스트명 목록 (소문자)
*/
public static function hosts(): array
{
$hosts = [];
try {
foreach (app(ModuleManager::class)->getActiveModules() as $module) {
if (method_exists($module, 'getTrustedScriptHosts')) {
$hosts = array_merge($hosts, $module->getTrustedScriptHosts());
}
}
} catch (\Throwable $e) {
Log::warning('TrustedScriptHosts: 모듈 집계 실패 - '.$e->getMessage());
}
try {
foreach (app(PluginManager::class)->getActivePlugins() as $plugin) {
if (method_exists($plugin, 'getTrustedScriptHosts')) {
$hosts = array_merge($hosts, $plugin->getTrustedScriptHosts());
}
}
} catch (\Throwable $e) {
Log::warning('TrustedScriptHosts: 플러그인 집계 실패 - '.$e->getMessage());
}
// 템플릿은 모듈/플러그인과 달리 PHP 확장 클래스가 없고 manifest 배열로 다뤄지므로,
// 활성 템플릿의 template.json 에서 직접 읽는다. 타입별 활성 템플릿은 각각 하나뿐이다.
try {
$templateManager = app(TemplateManager::class);
foreach (['admin', 'user'] as $type) {
$template = $templateManager->getActiveTemplate($type);
if (! is_array($template) || ! isset($template['trusted_script_hosts'])) {
continue;
}
$declared = $template['trusted_script_hosts'];
if (! is_array($declared)) {
continue;
}
$hosts = array_merge($hosts, $declared);
}
} catch (\Throwable $e) {
Log::warning('TrustedScriptHosts: 템플릿 집계 실패 - '.$e->getMessage());
}
// 확장이 훅으로 동적 추가할 수 있는 경로 (manifest 외 경로)
$hosts = HookManager::applyFilters(self::FILTER_HOOK, $hosts);
if (! is_array($hosts)) {
$hosts = [];
}
// 정규화: 문자열·비어있지 않음·소문자·중복 제거
$normalized = [];
foreach ($hosts as $host) {
if (! is_string($host)) {
continue;
}
$host = strtolower(trim($host));
if ($host !== '') {
$normalized[$host] = true;
}
}
return array_keys($normalized);
}
/**
* URL 에서 호스트명을 추출합니다.
*
* `//host/path`(protocol-relative)·`https://host/path`(scheme 포함) 모두 처리합니다.
* same-origin 경로(`/path`)는 호스트가 없으므로 null 을 반환합니다.
*
* @param string $url 검사 대상 URL
* @return string|null 소문자 호스트명 (없으면 null)
*/
public static function hostOf(string $url): ?string
{
$host = parse_url(self::normalizeForOriginCheck(trim($url)), PHP_URL_HOST);
return is_string($host) && $host !== '' ? strtolower($host) : null;
}
/**
* origin 판정 전에 URL 을 브라우저 URL 파서와 동일하게 정규화합니다.
*
* 브라우저(WHATWG URL)는 파싱 전에 ASCII tab·LF·CR 을 제거하고, special scheme
* (http/https)에서 백슬래시를 슬래시와 동등하게 처리합니다. 이 정규화 없이 판정하면
* 같은 문자열을 계층마다 다른 출처로 읽습니다:
*
* - `https://evil.com\@cdn.ckeditor.com/x.js` — 정규화 없이는 호스트가
* `cdn.ckeditor.com`(userinfo 해석)이지만 브라우저는 `evil.com` 에서 로드합니다.
* - `/\/cdn.ckeditor.com/x.js` — 문자열상 path 지만 브라우저는 authority 로 읽습니다.
*
* 정규화 후 판정하면 경로 중간의 백슬래시·탭(`/js/a\b.js`)은 authority 를 만들지
* 않으므로 그대로 통과합니다(과차단 없음).
*
* 이 메서드가 origin 판정 정규화의 SSoT 입니다 — 저장측 규칙
* (`App\Rules\SafeLayoutExpressions`)이 위임하고, 클라이언트
* (`resources/js/core/support/scriptSrcPolicy.ts::normalizeScriptSrcForOriginCheck`)·정적 검사
* (`layout-scripts-src-same-origin`)가 동형 구현을 갖습니다. 한 계층만 바꾸면
* 그 계층만 다른 출처를 보게 되며, 예외도 경고도 없이 판정만 갈립니다.
*
* @param string $url 원본 URL
* @return string 정규화된 URL
*/
public static function normalizeForOriginCheck(string $url): string
{
// ASCII tab / LF / CR 제거 (브라우저 파서가 파싱 전에 제거하는 문자)
$stripped = str_replace(["\t", "\n", "\r"], '', $url);
// 백슬래시를 슬래시로 (special scheme 에서 등가)
$slashed = str_replace('\\', '/', $stripped);
// 선행 슬래시가 3개 이상이어도 브라우저는 authority 시작으로 접는다
// (`///host/x` ≡ `//host/x`, `https:///host/x` ≡ `https://host/x`).
// 접지 않으면 `/\/host/x` 가 정규화 후 `///host/x` 가 되어 parse_url 은
// 호스트를 못 찾는데 브라우저는 host 에서 로드하는 갈림이 생긴다.
// 경로 중간의 연속 슬래시(`/js//a.js`)는 브라우저도 경로로 두므로 건드리지 않는다.
return preg_replace('#^([a-z][a-z0-9+.\-]*:)?/{2,}#i', '$1//', $slashed) ?? $slashed;
}
/**
* URL 의 호스트가 신뢰 목록에 있는지 판정합니다.
*
* @param string $url 검사 대상 URL
* @param array<int, string>|null $hosts 신뢰 호스트 목록 (미지정 시 self::hosts())
* @return bool 신뢰 호스트면 true (호스트 없는 same-origin 경로는 false)
*/
public static function isTrustedUrl(string $url, ?array $hosts = null): bool
{
$host = self::hostOf($url);
if ($host === null) {
return false;
}
$hosts ??= self::hosts();
return in_array($host, $hosts, true);
}
}