diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a5b85d1..673f4068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,11 +37,16 @@ ### Security +- 화면이 새 스크립트를 불러오는 모든 경로에 같은 출처 확인을 적용했습니다. 종전에는 레이아웃에 적어 둔 스크립트만 확인 대상이어서, 화면 동작(액션)으로 스크립트를 불러오거나 확장을 활성화할 때 자산을 불러오는 경로, 레이아웃 편집기의 미리보기 화면은 확인 없이 외부 주소를 그대로 불러왔습니다. 이제 모든 경로가 사이트 자신의 주소이거나 확장이 미리 선언한 주소만 허용하며, 그 밖의 주소는 불러오지 않습니다. (#127 @glitter-gim 님께서 건의해주셨습니다.) +- 레이아웃을 저장할 때 외부 주소를 검사하는 범위를 넓혔습니다. 종전에는 검사가 컴포넌트 속성·동작과 화면 진입 동작에만 미쳐, 모달·이름 붙인 동작·오류 처리·컴포넌트 생명주기·슬롯·반응형 설정에 적어 넣은 외부 주소는 그대로 저장되었습니다. 예시·안내용 주소를 담는 데이터 항목은 종전처럼 검사하지 않습니다. (#127 @glitter-gim 님께서 건의해주셨습니다.) +- 화면 동작으로 외부 라이브러리를 호출하는 기능에 안전장치를 더했습니다. 임의 코드 실행에 쓰일 수 있는 내장 함수 호출과, 호출 결과를 화면 값에 옮길 때 프로그램 내부 구조를 건드리는 이름은 거부됩니다. (#127 @glitter-gim 님께서 건의해주셨습니다.) - 주소에 마침표처럼 보이는 특수문자(전각·표의문자 마침표 등)를 섞으면 서버가 내부 주소로 요청을 보내도록 유도할 수 있던 문제를 수정했습니다. 검사할 때와 실제로 연결할 때 주소를 읽는 방식이 달라 생긴 문제로, 이제 두 시점이 같은 방식으로 주소를 해석합니다. 스케줄의 URL 호출, 주소로 언어팩 설치, 외부 배송비 계산 API 등 서버가 대신 외부로 요청을 보내는 모든 지점이 함께 보호됩니다. 정상적인 국제화 도메인(한글·일본어 도메인 등)은 그대로 사용할 수 있습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2010) - 2단계 인증을 켠 상태에서 계정 잠금을 우회할 수 있던 문제를 수정했습니다. 잠기기 전에 받아 둔 인증 단계를 잠긴 뒤에 마치면 로그인이 되고 잠금까지 풀렸습니다. 이제 인증번호 확인 단계에서도 잠금 여부를 다시 확인하며, 잠긴 계정은 로그인 화면과 동일한 안내를 받습니다. 잠긴 계정은 기존 로그인 상태로도 인증 기간을 연장할 수 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2011) ### Fixed +- 같은 스크립트를 거의 동시에 두 번 불러오면, 두 번째 요청이 첫 번째 로드가 끝나기 전에 완료된 것으로 처리되어 그 뒤 동작이 아무 반응 없이 끝나던 문제를 수정했습니다. 이제 두 요청 모두 실제 로드가 끝난 뒤에 이어집니다. +- 확장을 활성화할 때 스크립트가 이미 있으면 그 확장의 스타일(CSS)까지 함께 건너뛰던 문제를 수정했습니다. 스타일만 제공하는 확장은 활성화해도 스타일이 적용되지 않았습니다. - 실제 화면 동작에 쓰이는 라이브러리(axios·laravel-echo·pusher-js)가 개발용으로 분류돼 있어 보안 점검에서 빠지던 문제를 수정했습니다. 이제 점검 대상에 포함되며, 함께 확인된 axios 취약점도 1.20.0 으로 올려 해소했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.) - 글을 쓰다 브라우저 창 크기가 바뀌면 저장 시 본문이 사라지던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 창 크기를 조금만 바꿔도(20픽셀 이내) 발생했으므로, 휴대폰에서 주소창이 숨겨지거나 키보드가 올라오거나 화면을 돌리는 것도 같은 상황입니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다. (#130 @jiwonpapa 님께서 제보해주셨습니다.) - 창 크기가 바뀐 뒤 본문을 고치고 제목 등 다른 입력칸을 건드리면, 저장 시 본문이 고치기 전 내용으로 되돌아가던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 편집기에는 고친 내용이 그대로 보였기 때문에 저장 후 다시 열어보기 전까지는 알 수 없었습니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다. diff --git a/app/Rules/NoExternalUrls.php b/app/Rules/NoExternalUrls.php index 9f3ea534..891da8ce 100644 --- a/app/Rules/NoExternalUrls.php +++ b/app/Rules/NoExternalUrls.php @@ -8,8 +8,12 @@ use Illuminate\Contracts\Validation\ValidationRule; /** * 레이아웃 JSON에서 외부 URL을 차단하는 Custom Rule * - * 컴포넌트 props·actions 와 최상위 init_actions 내의 http://, https://, data:, - * javascript: 등 위험한 URI 스킴을 감지하여 차단합니다. + * 컴포넌트 props·actions·lifecycle·onComponentEvent·slots·component_layout·responsive 와 + * 최상위 init_actions/initActions·modals·named_actions·errorHandling 내의 http://, https://, + * data:, javascript: 등 위험한 URI 스킴을 감지하여 차단합니다. + * + * 순회 대상은 "액션이 실행되거나 값이 sink(컴포넌트 prop)로 흘러 들어가는 자리" 다. + * 한 자리만 빠져도 그 키가 그대로 저장 우회로가 되며, 우회는 오류를 남기지 않는다. * * 검사 대상 구분(신뢰 경계): init_actions 는 로드 시 자동 실행되는 액션이라 외부 * navigate/apiCall URL 이 곧 자동 리다이렉트·데이터 유출 경로가 되므로 실행 지점에서 @@ -55,55 +59,128 @@ class NoExternalUrls implements ValidationRule $this->validateComponents($value['components'], $fail); } - // init_actions: 로드 시 자동 실행되는 액션 — 외부 navigate/apiCall URL 은 로드 시점 - // 자동 리다이렉트/데이터 유출 경로가 되므로 컴포넌트 actions 와 동일하게 검사한다. - if (isset($value['init_actions']) && is_array($value['init_actions'])) { - foreach ($value['init_actions'] as $i => $action) { - if (is_array($action)) { - $this->validateObject($action, "init_actions[$i]", $fail); + // init_actions / initActions: 로드 시 자동 실행되는 액션 — 외부 navigate/apiCall URL 은 + // 로드 시점 자동 리다이렉트/데이터 유출 경로가 되므로 컴포넌트 actions 와 동일하게 + // 검사한다. 엔진(LayoutLoader)이 두 철자를 모두 소비하므로 두 철자 모두 검사한다 — + // 한쪽만 보면 다른 철자가 그대로 우회로가 된다. + foreach (['init_actions', 'initActions'] as $initKey) { + if (isset($value[$initKey]) && is_array($value[$initKey])) { + foreach ($value[$initKey] as $i => $action) { + if (is_array($action)) { + $this->validateObject($action, "{$initKey}[$i]", $fail); + } } } } + + // modals: 각 항목이 컴포넌트 정의다 — 모달 안의 props/actions 도 같은 sink 이므로 + // 컴포넌트와 동일하게 재귀 검사한다. + if (isset($value['modals']) && is_array($value['modals'])) { + foreach ($value['modals'] as $modalKey => $modal) { + if (! is_array($modal)) { + continue; + } + + $this->validateComponents([$modal], $fail, "modals.$modalKey"); + + if (isset($modal['components']) && is_array($modal['components'])) { + $this->validateComponents($modal['components'], $fail, "modals.$modalKey.components"); + } + } + } + + // named_actions: 이름으로 호출되는 액션 정의 — 실행 시 컴포넌트 actions 와 동일한 sink. + if (isset($value['named_actions']) && is_array($value['named_actions'])) { + foreach ($value['named_actions'] as $name => $named) { + if (is_array($named)) { + $this->validateObject($named, "named_actions.$name", $fail); + } + } + } + + // errorHandling: 오류 시 실행되는 액션(navigate/apiCall)을 담는다. + if (isset($value['errorHandling']) && is_array($value['errorHandling'])) { + $this->validateObject($value['errorHandling'], 'errorHandling', $fail); + } } /** * components 배열을 재귀적으로 검증 + * + * @param array $components 컴포넌트 정의 배열 + * @param Closure $fail 실패 콜백 + * @param string $basePath 오류 메시지에 실을 경로 접두 (modals/slots 경로 보존) */ - private function validateComponents(array $components, Closure $fail): void + private function validateComponents(array $components, Closure $fail, string $basePath = 'components'): void { foreach ($components as $index => $component) { if (! is_array($component)) { continue; } + $path = "{$basePath}[$index]"; + // props 검사 if (isset($component['props']) && is_array($component['props'])) { - $this->validateObject($component['props'], "components[$index].props", $fail); + $this->validateObject($component['props'], "$path.props", $fail); } // actions 검사 if (isset($component['actions']) && is_array($component['actions'])) { - $this->validateActions($component['actions'], $index, $fail); + $this->validateActions($component['actions'], $path, $fail); + } + + // lifecycle: 마운트/언마운트 시 자동 실행되는 액션 — init_actions 와 같은 성격이다. + if (isset($component['lifecycle']) && is_array($component['lifecycle'])) { + $this->validateObject($component['lifecycle'], "$path.lifecycle", $fail); + } + + // onComponentEvent: 컴포넌트 이벤트로 발화되는 액션 배열. + if (isset($component['onComponentEvent']) && is_array($component['onComponentEvent'])) { + $this->validateObject($component['onComponentEvent'], "$path.onComponentEvent", $fail); + } + + // slots: 슬롯 이름별 컴포넌트 배열 — 슬롯 안의 컴포넌트도 같은 sink 다. + if (isset($component['slots']) && is_array($component['slots'])) { + foreach ($component['slots'] as $slotName => $slotComponents) { + if (is_array($slotComponents)) { + $this->validateComponents($slotComponents, $fail, "$path.slots.$slotName"); + } + } + } + + // component_layout: 컴포넌트가 품는 하위 레이아웃 정의. + if (isset($component['component_layout']) && is_array($component['component_layout'])) { + $this->validateObject($component['component_layout'], "$path.component_layout", $fail); + } + + // responsive: breakpoint 별 props/children 오버라이드 — 그 안의 값도 같은 sink 다. + if (isset($component['responsive']) && is_array($component['responsive'])) { + $this->validateObject($component['responsive'], "$path.responsive", $fail); } // children 재귀 검사 if (isset($component['children']) && is_array($component['children'])) { - $this->validateComponents($component['children'], $fail); + $this->validateComponents($component['children'], $fail, "$path.children"); } } } /** * actions 배열 검증 + * + * @param array $actions 액션 정의 배열 + * @param string $componentPath 컴포넌트 경로 (오류 메시지용) + * @param Closure $fail 실패 콜백 */ - private function validateActions(array $actions, int $componentIndex, Closure $fail): void + private function validateActions(array $actions, string $componentPath, Closure $fail): void { foreach ($actions as $actionIndex => $action) { if (! is_array($action)) { continue; } - $this->validateObject($action, "components[$componentIndex].actions[$actionIndex]", $fail); + $this->validateObject($action, "{$componentPath}.actions[$actionIndex]", $fail); } } diff --git a/app/Rules/SafeLayoutExpressions.php b/app/Rules/SafeLayoutExpressions.php index 794045f4..588e52e8 100644 --- a/app/Rules/SafeLayoutExpressions.php +++ b/app/Rules/SafeLayoutExpressions.php @@ -220,7 +220,9 @@ class SafeLayoutExpressions implements ValidationRule * 정규화 후 판정하면 경로 중간의 백슬래시·탭(`/js/a\b.js`)은 authority 를 만들지 * 않으므로 그대로 통과합니다(과차단 없음). * - * 클라이언트(`TemplateApp.isAllowedScriptSrc`)·정적 검사 + * 클라이언트(`resources/js/core/support/scriptSrcPolicy.ts::isAllowedScriptSrc` — 레이아웃 + * `scripts[]` 뿐 아니라 loadScript 액션·확장 핸들러 재로드·편집기 프리뷰· + * `G7Core.asset.loadScript` 가 공유하는 런타임 SSoT)·정적 검사 * (`layout-scripts-src-same-origin`)와 3층 동형이어야 합니다. * * 구현 SSoT 는 `TrustedScriptHosts::normalizeForOriginCheck` 입니다 — 같은 저장측 diff --git a/app/Support/TrustedScriptHosts.php b/app/Support/TrustedScriptHosts.php index 7ba0ce4f..e89000bd 100644 --- a/app/Support/TrustedScriptHosts.php +++ b/app/Support/TrustedScriptHosts.php @@ -135,7 +135,7 @@ class TrustedScriptHosts * * 이 메서드가 origin 판정 정규화의 SSoT 입니다 — 저장측 규칙 * (`App\Rules\SafeLayoutExpressions`)이 위임하고, 클라이언트 - * (`TemplateApp.normalizeScriptSrcForOriginCheck`)·정적 검사 + * (`resources/js/core/support/scriptSrcPolicy.ts::normalizeScriptSrcForOriginCheck`)·정적 검사 * (`layout-scripts-src-same-origin`)가 동형 구현을 갖습니다. 한 계층만 바꾸면 * 그 계층만 다른 출처를 보게 되며, 예외도 경고도 없이 판정만 갈립니다. * diff --git a/docs/extension/module-assets.md b/docs/extension/module-assets.md index a7354ab1..56df5029 100644 --- a/docs/extension/module-assets.md +++ b/docs/extension/module-assets.md @@ -138,7 +138,9 @@ 레이아웃 보안 정책은 `scripts[].src`·`data_sources[].endpoint` 를 기본적으로 same-origin 경로(`/` 로 시작)만 허용하고, 외부 origin·protocol-relative(`//host`)·scheme 포함 URL 은 -저장 시점과 렌더 시점 양쪽에서 차단합니다. 확장이 정당하게 외부 CDN 스크립트를 써야 하면 +저장 시점과 렌더 시점 양쪽에서 차단합니다. 같은 판정은 레이아웃 파일뿐 아니라 **브라우저에 +새 `