위임 관리자(부관리자)가 권한·역할·표현식·비밀 콘텐츠 경계를 우회하던 결함군을 계층 대칭성 원칙으로 전건 차단한다. 약한 경로가 정상 응답을 내보내는 것이 유일한 증상이라, 게이트를 생산 지점 한 곳(SSoT)에 두고 같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유하도록 맞췄다. - 등급 상한(rank ceiling): 슈퍼관리자 보호·역할/사용자 역할 배정(추가·제거 대칭)·일괄 상태변경·순서변경을 상세 경로와 동일 강도로 재적용. 가드는 DB 쓰기에 선행하여 거부 시 상태 불변. - 레이아웃 표현식: new Function/with 실행을 AST 화이트리스트 평가기로 교체. 비-문자열 computed 키 정규화(normalizeKey)·Object facade(리플렉션 static 제거)·legacy 접근자 차단. 저장측 검증·정적 검사와 3계층 동형. - secret 게이트: 비밀글의 댓글·첨부·문의 독립 경로 재적용, hash 파일서빙 소유권·비밀·발행 상태 검사 통일. - 신뢰 스크립트 호스트: 확장 선언 기반 + same-origin 브라우저 정규화를 런타임·저장측·정적검사 3층 동형화. - 회귀 감지: 단위·Feature·E2E·시나리오 매니페스트 전축 + audit 룰 4종 신설.
159 lines
7.2 KiB
TypeScript
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);
|
|
});
|
|
});
|