Files
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

126 lines
6.5 KiB
TypeScript

/**
* Layout Editor — 별도 편집 모드(base/modal/extension/iteration) 브라우저 통합
*
* Phase 1 에서 액션 골격만 도입됐던 4개 별도 편집 모드의 캔버스 단독 렌더 + 진입/이탈 +
* URL 동기화를 브라우저에서 검증한다(단위 테스트가 못 잡는 render-cycle + URL pushState).
*
* 축 요약(마커 아님 — 평문): edit_mode, url_sync, followup_action.
* 효과 요약(마커 아님 — 평문): route_tree_renders_base_modal_extension_groups, base_edit_loads_and_renders_base_layout_standalone, modal_edit_renders_modal_open_standalone_no_dim, extension_edit_loads_via_layout_extensions_api, enter_edit_mode_pushes_edit_query_to_url, refresh_with_edit_query_restores_edit_mode, back_button_exits_edit_mode_to_route.
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
const TEMPLATE = 'sirsoft-admin_basic';
const EDITOR_URL = `/admin/layout-editor/${TEMPLATE}`;
async function enterEditor(page: import('@playwright/test').Page): Promise<void> {
const token = issueToken('core.templates.layouts.edit');
await authenticatePage(page, token);
await page.goto(EDITOR_URL);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.waitForSelector('[data-testid="g7le-toolbar"]', { timeout: 30_000 });
await page.waitForSelector('[data-testid="g7le-route-tree-item"]', { timeout: 30_000 });
}
/**
* 트리에서 항목을 찾아 클릭합니다.
*
* 화면 텍스트(라벨)와 식별자(`data-route-path`) 중 **어느 쪽으로든** 찾는다. 모달·확장 항목은
* 라벨이 다국어 제목("활동 로그 삭제 확인")이고 식별자(`delete_confirm_modal`)는
* `data-route-path` 에만 있어 텍스트 매칭만으로는 잡히지 않는다.
*
* @param page Playwright page
* @param pattern 라벨 또는 route-path 에 대해 매칭할 정규식
*/
async function clickTreeItem(page: import('@playwright/test').Page, pattern: RegExp): Promise<void> {
const path = await page.evaluate(
(src) => {
const re = new RegExp(src);
return (
Array.from(document.querySelectorAll('[data-testid="g7le-route-tree-item"]'))
.map((e) => ({
path: e.getAttribute('data-route-path') ?? '',
text: (e.textContent || '').trim(),
}))
.find((x) => re.test(x.path) || re.test(x.text))?.path ?? null
);
},
pattern.source,
);
expect(path, `라우트 트리에서 ${pattern} 에 해당하는 항목을 찾지 못했다`).toBeTruthy();
await page.locator(`[data-route-path="${path}"]`).first().click();
}
/** 캔버스의 편집 가능 노드(data-editor-path) 개수 */
async function editorNodeCount(page: import('@playwright/test').Page): Promise<number> {
return page.evaluate(() => document.querySelectorAll('[data-editor-path]').length);
}
test.describe('@layout-editor 별도 편집 모드', () => {
/** @effects base_edit_loads_and_renders_base_layout_standalone, enter_edit_mode_pushes_edit_query_to_url, route_tree_renders_base_modal_extension_groups */
test('base 진입 → URL ?edit= pushState + base 캔버스 단독 렌더', async ({ page }) => {
await enterEditor(page);
// 트리에 [공통 레이아웃] 그룹 + base 항목 존재.
await clickTreeItem(page, /_admin_base/);
// 모드 배지(확장 편집/공통 레이아웃 편집 중) + URL ?edit= 변경.
await expect.poll(() => page.url()).toMatch(/edit=__base__/);
await expect(page.locator('[data-mode="base"]')).toBeAttached();
// base 레이아웃 단독 렌더 — 편집 노드가 존재.
await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 20_000 });
await expect.poll(() => editorNodeCount(page)).toBeGreaterThan(0);
});
/** @effects back_button_exits_edit_mode_to_route */
test('base 진입 후 뒤로가기 → 편집 모드 종료(URL ?edit= 제거)', async ({ page }) => {
await enterEditor(page);
await clickTreeItem(page, /_admin_base/);
await expect.poll(() => page.url()).toMatch(/edit=__base__/);
await page.goBack();
// 뒤로가기로 편집 모드 종료 — URL 에서 ?edit= 제거.
await expect.poll(() => page.url()).not.toMatch(/edit=__base__/);
await expect(page.locator('[data-mode="base"]')).toHaveCount(0);
});
/** @effects extension_edit_loads_via_layout_extensions_api */
test('확장 주입 그룹 항목 진입 → 확장 조각 캔버스 렌더 + URL ?edit=__extension__', async ({ page }) => {
await enterEditor(page);
// [확장 주입] 그룹의 확장 항목(출처 · 대상명) 클릭.
await clickTreeItem(page, /·\s*(inquiry_board_setting|html_content|admin_user_detail)/);
await expect.poll(() => page.url()).toMatch(/edit=__extension__/);
await expect(page.locator('[data-mode="extension"]')).toBeAttached();
await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 20_000 });
await expect.poll(() => editorNodeCount(page)).toBeGreaterThan(0);
});
/** @effects refresh_with_edit_query_restores_edit_mode */
test('?edit=__extension__ URL 직접 진입(새로고침) → 확장 편집 모드 복원', async ({ page }) => {
const token = issueToken('core.templates.layouts.edit');
await authenticatePage(page, token);
// 확장 id=1 로 직접 진입.
await page.goto(`${EDITOR_URL}?edit=__extension__/1`);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.waitForSelector('[data-testid="g7le-toolbar"]', { timeout: 30_000 });
// 새로고침/직접 URL 로도 확장 편집 모드가 복원되고 캔버스가 렌더된다(크래시 회귀 가드).
await expect(page.locator('[data-mode="extension"]')).toBeAttached({ timeout: 20_000 });
await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 20_000 });
await expect.poll(() => editorNodeCount(page)).toBeGreaterThan(0);
});
/** @effects modal_edit_renders_modal_open_standalone_no_dim */
test('모달 그룹 항목 진입 → 모달 단독 open 렌더', async ({ page }) => {
await enterEditor(page);
// [모달] 그룹의 인라인 모달 항목 클릭(admin_user_list 의 delete_confirm_modal 등).
await clickTreeItem(page, /delete_confirm_modal|error_modal|bulk_/);
await expect(page.locator('[data-mode="modal"]')).toBeAttached();
await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 20_000 });
// 모달이 열린 상태로 단독 렌더 — 편집 노드 존재.
await expect.poll(() => editorNodeCount(page)).toBeGreaterThan(0);
});
});