Files
Gnuboard7/tests/Playwright/specs/layout-script-src-trusted-host.spec.ts
T
HeuJung 71abdeaf60 fix(security): KVE-2026-1914/1915/1919 remediation 전건
위임 관리자(부관리자)가 권한·역할·표현식·비밀 콘텐츠 경계를 우회하던
결함군을 계층 대칭성 원칙으로 전건 차단한다. 약한 경로가 정상 응답을
내보내는 것이 유일한 증상이라, 게이트를 생산 지점 한 곳(SSoT)에 두고
같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유하도록 맞췄다.

- 등급 상한(rank ceiling): 슈퍼관리자 보호·역할/사용자 역할 배정(추가·제거
 대칭)·일괄 상태변경·순서변경을 상세 경로와 동일 강도로 재적용. 가드는
 DB 쓰기에 선행하여 거부 시 상태 불변.
- 레이아웃 표현식: new Function/with 실행을 AST 화이트리스트 평가기로 교체.
 비-문자열 computed 키 정규화(normalizeKey)·Object facade(리플렉션 static
 제거)·legacy 접근자 차단. 저장측 검증·정적 검사와 3계층 동형.
- secret 게이트: 비밀글의 댓글·첨부·문의 독립 경로 재적용, hash 파일서빙
 소유권·비밀·발행 상태 검사 통일.
- 신뢰 스크립트 호스트: 확장 선언 기반 + same-origin 브라우저 정규화를
 런타임·저장측·정적검사 3층 동형화.
- 회귀 감지: 단위·Feature·E2E·시나리오 매니페스트 전축 + audit 룰 4종 신설.
2026-08-17 01:45:09 +09:00

123 lines
6.0 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'),
hasCkeditorHost: hosts.includes('cdn.ckeditor.com'),
hasDaumHost: hosts.includes('t1.daumcdn.net'),
};
});
// 확장이 활성일 때만 그 선언 호스트가 목록에 있어야 한다(비활성이면 스킵 — 서버 상태 독립).
if (result.ckeditorActive) {
expect(result.hasCkeditorHost).toBe(true);
}
if (result.daumActive) {
expect(result.hasDaumHost).toBe(true);
}
});
// @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([]);
});
});