Files
Gnuboard7/tests/Playwright/specs/layout-expression-sandbox.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

159 lines
7.2 KiB
TypeScript

/**
* E2E: 레이아웃 표현식 평가 샌드박스 (배포 번들 기준, KVE-2026-1915)
*
* 배경: 표현식 평가가 `new Function`/`with(ctx)` 로 실행되어
* `''.constructor.constructor('code')()` 형태의 샌드박스 탈출이 가능했다.
* engine-v1.59.0 에서 화이트리스트 AST 평가기(SafeExpressionEvaluator)로 교체했다.
*
* 단위 테스트(SafeExpressionEvaluator.test.ts)가 소스 레벨을 잠그지만, 실제 사용자가
* 실행하는 것은 빌드된 번들이므로 브라우저에서 배포 번들의 공개 API(`G7Core.evaluateCondition`)를
* 그대로 호출해 (a) 정상 표현식 회귀 없음 + (b) 익스플로잇 미실행 양면을 잠근다.
*
* 시나리오 축(case)·효과는 매니페스트 tests/scenarios/layout-expression-sandbox.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).G7Core?.evaluateCondition === 'function',
null,
{ timeout: 30_000 },
);
});
// @scenario case=safe_expression
// @effects same_expression_same_value_across_paths
test('정상 표현식(삼항·옵셔널체이닝·nullish)이 배포 번들에서 정상 평가된다', async ({ page }) => {
const result = await page.evaluate(() => {
const g7 = (window as any).G7Core;
const ctx = { user: { name: '홍길동' }, count: 2 };
return {
ternary: g7.evaluateCondition('{{user?.name ? true : false}}', ctx),
nullish: g7.evaluateCondition('{{(user?.missing ?? "fallback") === "fallback"}}', ctx),
compare: g7.evaluateCondition('{{count > 1}}', ctx),
};
});
expect(result.ternary).toBe(true);
expect(result.nullish).toBe(true);
expect(result.compare).toBe(true);
});
// @scenario case=arrow_template_literal
// @effects same_expression_same_value_across_paths
test('화살표 함수·스프레드·템플릿 리터럴·new Date 가 배포 번들에서 동작한다', async ({ page }) => {
const result = await page.evaluate(() => {
const g7 = (window as any).G7Core;
const ctx = { items: [{ v: 'a' }, { v: 'b' }, { v: 'a' }], $args: ['x'] };
return {
// 화살표 함수 + includes
arrow: g7.evaluateCondition("{{(items ?? []).filter(i => i.v === 'a').length === 2}}", ctx),
// 스프레드 + Set + Array.from(new Set(...))
spreadSet: g7.evaluateCondition("{{Array.from(new Set((items ?? []).map(i => i.v))).length === 2}}", ctx),
// 템플릿 리터럴 (내비게이션 경로 조립 형태)
template: g7.evaluateCondition('{{`/mypage/${$args[0]}` === "/mypage/x"}}', ctx),
// new Date
date: g7.evaluateCondition('{{new Date("2020-01-01").getTime() > 0}}', ctx),
};
});
expect(result.arrow).toBe(true);
expect(result.spreadSet).toBe(true);
expect(result.template).toBe(true);
expect(result.date).toBe(true);
});
// @scenario case=constructor_escape
// @effects sandbox_escape_blocked, dangerous_payload_does_not_set_global
test('constructor 체인 샌드박스 탈출이 코드를 실행하지 못한다', async ({ page }) => {
const result = await page.evaluate(() => {
const g7 = (window as any).G7Core;
// 익스플로잇: 성공하면 window.__g7jsi 전역이 설정된다.
delete (window as any).__g7jsi;
const payloads = [
"{{''.constructor.constructor('window.__g7jsi = 1')()}}",
"{{''['constructor']['constructor']('window.__g7jsi = 2')()}}",
'{{({}).__proto__}}',
"{{Function('window.__g7jsi = 3')()}}",
];
// 각 페이로드를 평가 — 평가기는 거부(예외→false)해야 하며 코드는 실행되지 않아야 한다.
const results = payloads.map((p) => {
try {
return g7.evaluateCondition(p, {});
} catch {
return false;
}
});
return {
globalSet: (window as any).__g7jsi !== undefined,
allBlocked: results.every((r) => !r),
};
});
// 익스플로잇 코드가 실행되지 않았다(전역 canary 미설정).
expect(result.globalSet).toBe(false);
// 위험 표현식은 truthy 로 평가되지 않는다(거부).
expect(result.allBlocked).toBe(true);
});
// @scenario case=nonstring_key_escape
// @effects sandbox_escape_blocked, dangerous_payload_does_not_set_global
test('비-문자열 computed 키·Object 리플렉션 탈출이 배포 번들에서 코드를 실행하지 못한다', async ({ page }) => {
const result = await page.evaluate(() => {
const g7 = (window as any).G7Core;
delete (window as any).__g7jsi;
const payloads = [
// 배열 키가 ToPropertyKey 강제변환으로 constructor 도달 (engine-v1.60.1 이전 우회)
"{{''[['constructor']][['constructor']]('window.__g7jsi = 1')()}}",
// 문자열 조립 난독화 — 정적 룰이 못 잡는 형태(런타임 정규화가 최종 게이트)
"{{''[['const' + 'ructor']][['const' + 'ructor']]('window.__g7jsi = 2')()}}",
// 객체 toString 강제변환 키
"{{''[{ toString: () => 'constructor' }]}}",
// Object 리플렉션 static 으로 Function 도달
"{{Object.getOwnPropertyDescriptor(Object.getPrototypeOf(String), 'constructor').value('window.__g7jsi = 3')()}}",
// 프로토타입 오염 시도
'{{Object.setPrototypeOf({}, { polluted: 1 })}}',
];
const results = payloads.map((p) => {
try {
return g7.evaluateCondition(p, {});
} catch {
return false;
}
});
return {
globalSet: (window as any).__g7jsi !== undefined,
allBlocked: results.every((r) => !r),
};
});
expect(result.globalSet).toBe(false);
expect(result.allBlocked).toBe(true);
});
// @scenario case=object_facade_safe
// @effects same_expression_same_value_across_paths
test('안전한 Object 데이터 메서드(keys/assign/create)가 배포 번들에서 정상 동작한다', async ({ page }) => {
const result = await page.evaluate(() => {
const g7 = (window as any).G7Core;
const ctx = { o: { a: 1, b: 2 }, base: { x: 1 }, k: 'y', v: 2 };
return {
keys: g7.evaluateCondition("{{Object.keys(o).join(',') === 'a,b'}}", ctx),
// 실제 레이아웃(_tab_reviews.json)의 필터 맵 생성 패턴
createAssign: g7.evaluateCondition('{{Object.assign(Object.create(null), base, { [k]: v }).y === 2}}', ctx),
};
});
expect(result.keys).toBe(true);
expect(result.createAssign).toBe(true);
});
});