Files
Gnuboard7/tests/Playwright/specs/layout-script-src-trusted-host.spec.ts
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

152 lines
7.3 KiB
TypeScript

/**
* E2E: 레이아웃 외부 스크립트 신뢰 출처 허용목록 (배포 번들 기준, KVE-2026-1915)
*
* 배경: B-2 원격 스크립트 차단이 same-origin 이 아닌 모든 스크립트를 무조건 막아
* 번들 확장(CKEditor5 cdn.ckeditor.com, Daum 우편번호 t1.daumcdn.net)의 CDN 스크립트까지
* 깨뜨렸다. 확장이 manifest(`trusted_script_hosts`)로 선언한 호스트를 코어가 집계해
* `window.G7Config.trustedScriptHosts` 로 노출하고, 런타임 스크립트 로더가 그 목록만
* 예외로 허용하도록 고쳤다.
*
* 이 스펙은 서버→블레이드→배포 번들로 이어지는 주입 배선이 실제로 연결돼 있는지
* (G7Config.trustedScriptHosts 노출), 활성 확장의 선언이 그 목록에 반영되는지, 그리고
* 실제 화면 로드에서 신뢰 목록 밖 외부 스크립트가 하나도 실행되지 않는지(차단 관찰)를 잠근다.
*
* 차단 결정 함수(isAllowedScriptSrc) 자체는 minify 된 배포 번들에서 이름으로 호출할 수 없어
* 단위 스위트(TemplateApp.scriptSrc.test.ts)가 고정한다. 이 스펙은 그 결정의
* 관찰 가능한 결과 — "실제 페이지에 same-origin/신뢰호스트 외 스크립트가 로드되지 않음" —
* 을 네트워크 레벨에서 무조건 측정한다.
*
* authority 우회(`/\/evil.com/x.js` · 탭·개행 삽입)도 이 관찰에 포함된다. 그 형태는
* 브라우저가 외부 origin 으로 해석하므로, 판정이 뚫리면 네트워크에 cross-origin script
* 요청으로 나타나 아래 offenders 단언에 걸린다 — 요청 URL 은 이미 해석된 절대 URL 이라
* 우회 문자열 형태와 무관하게 origin 으로 판정된다.
*
* 시나리오 축(case)·효과는 매니페스트 tests/scenarios/trusted-script-hosts.yaml 참조.
* 각 test 의 `// @scenario case=…` 라인 마커가 축 조합을, `// @effects …` 가 효과를 커버한다.
*
* 효과 목록을 이 파일 레벨에 몰아 적지 않는다 — 커버리지 룰은 마커 레벨을 구분하지 않으므로,
* 파일 레벨 목록이 있으면 해당 test 를 지워도 효과가 "언급됨" 으로 집계돼 삭제가 무증상 green
* 이 된다. 마커는 test 에만 둔다.
*/
import { test, expect } from '../fixtures/auth';
test.describe('레이아웃 외부 스크립트 신뢰 출처 허용목록', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin/login');
await page.waitForFunction(
() => typeof (window as any).G7Config !== 'undefined',
null,
{ timeout: 30_000 },
);
});
// @scenario case=trusted_hosts_exposed_in_config
// @effects trusted_script_host_allowlist_wired
test('배포 번들에 G7Config.trustedScriptHosts 가 배열로 노출된다', async ({ page }) => {
const shape = await page.evaluate(() => {
const hosts = (window as any).G7Config?.trustedScriptHosts;
return { isArray: Array.isArray(hosts), allStrings: Array.isArray(hosts) && hosts.every((h: unknown) => typeof h === 'string') };
});
expect(shape.isArray).toBe(true);
expect(shape.allStrings).toBe(true);
});
// @scenario case=active_extension_host_reflected
// @effects trusted_script_host_allowlist_wired
test('활성 확장이 선언한 신뢰 호스트가 허용목록에 반영된다', async ({ page }) => {
const result = await page.evaluate(() => {
const cfg = (window as any).G7Config ?? {};
const hosts: string[] = Array.isArray(cfg.trustedScriptHosts) ? cfg.trustedScriptHosts : [];
const plugins: Array<{ identifier?: string }> = Array.isArray(cfg.activePlugins) ? cfg.activePlugins : [];
const has = (id: string) => plugins.some((p) => p.identifier === id);
return {
ckeditorActive: has('sirsoft-ckeditor5'),
daumActive: has('sirsoft-daum_postcode'),
kginicisActive: has('sirsoft-pay_kginicis'),
tossActive: has('sirsoft-tosspayments'),
niceActive: has('sirsoft-pay_nicepayments'),
kcpActive: has('sirsoft-pay_nhnkcp'),
hasCkeditorHost: hosts.includes('cdn.ckeditor.com'),
hasDaumHost: hosts.includes('t1.daumcdn.net'),
hasKginicisHosts:
hosts.includes('stdpay.inicis.com') && hosts.includes('stgstdpay.inicis.com'),
hasTossHost: hosts.includes('js.tosspayments.com'),
hasNiceHost: hosts.includes('web.nicepay.co.kr'),
hasKcpHosts: hosts.includes('pay.kcp.co.kr') && hosts.includes('testpay.kcp.co.kr'),
};
});
// 확장이 활성일 때만 그 선언 호스트가 목록에 있어야 한다(비활성이면 스킵 — 서버 상태 독립).
if (result.daumActive) {
expect(result.hasDaumHost).toBe(true);
}
// 결제 플러그인의 PG SDK 호스트 — 서비스 SDK 라 자체 호스팅이 불가능해 선언 대상이다.
if (result.kginicisActive) {
expect(result.hasKginicisHosts).toBe(true);
}
if (result.tossActive) {
expect(result.hasTossHost).toBe(true);
}
if (result.niceActive) {
expect(result.hasNiceHost).toBe(true);
}
if (result.kcpActive) {
expect(result.hasKcpHosts).toBe(true);
}
// CKEditor5 는 7.0.10 에서 자체 제공으로 전환해 더 이상 CDN 호스트를 선언하지 않는다.
// 이 호스트가 다시 나타나면 외부 CDN 의존으로 되돌아갔다는 뜻이다(자체 제공 원칙 회귀).
if (result.ckeditorActive) {
expect(
result.hasCkeditorHost,
'CKEditor5 가 자체 제공에서 외부 CDN 의존으로 되돌아갔다',
).toBe(false);
}
});
// @scenario case=untrusted_external_script_not_loaded
// @effects untrusted_external_script_blocked
test('실제 화면 로드에서 same-origin·신뢰호스트 외 스크립트는 하나도 로드되지 않는다', async ({ page }) => {
// 새 로드의 모든 script 네트워크 요청을 수집한다
const scriptUrls: string[] = [];
page.on('request', (req) => {
if (req.resourceType() === 'script') {
scriptUrls.push(req.url());
}
});
// beforeEach 가 이미 진입한 화면을 다시 로드해 엔진 부팅부터의 스크립트 요청을 포착한다
await page.reload({ waitUntil: 'networkidle' });
const cfg = await page.evaluate(() => ({
origin: window.location.origin,
hosts: (Array.isArray((window as any).G7Config?.trustedScriptHosts)
? (window as any).G7Config.trustedScriptHosts
: []) as string[],
}));
const trusted = new Set(cfg.hosts.map((h) => h.toLowerCase()));
// 측정 대상이 실제로 존재하는지(공허한 통과 방지) 확인 — 최소한 번들 스크립트는 로드된다
expect(scriptUrls.length).toBeGreaterThan(0);
const offenders = scriptUrls.filter((url) => {
let u: URL;
try {
u = new URL(url);
} catch {
return false; // 파싱 불가 URL 은 판정 대상 아님
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
return false; // data:/blob: 등은 별개 축
}
const sameOrigin = u.origin === cfg.origin;
const trustedHost = trusted.has(u.hostname.toLowerCase());
return ! sameOrigin && ! trustedHost;
});
expect(offenders, `허용되지 않은 외부 스크립트가 로드됨: ${offenders.join(', ')}`).toEqual([]);
});
});