Merge pull request from gnuboard:HeuJung/issue629
HeuJung/issue629
This commit is contained in:
@@ -37,11 +37,16 @@
|
|||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
- 화면이 새 스크립트를 불러오는 모든 경로에 같은 출처 확인을 적용했습니다. 종전에는 레이아웃에 적어 둔 스크립트만 확인 대상이어서, 화면 동작(액션)으로 스크립트를 불러오거나 확장을 활성화할 때 자산을 불러오는 경로, 레이아웃 편집기의 미리보기 화면은 확인 없이 외부 주소를 그대로 불러왔습니다. 이제 모든 경로가 사이트 자신의 주소이거나 확장이 미리 선언한 주소만 허용하며, 그 밖의 주소는 불러오지 않습니다. (#127 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
|
- 레이아웃을 저장할 때 외부 주소를 검사하는 범위를 넓혔습니다. 종전에는 검사가 컴포넌트 속성·동작과 화면 진입 동작에만 미쳐, 모달·이름 붙인 동작·오류 처리·컴포넌트 생명주기·슬롯·반응형 설정에 적어 넣은 외부 주소는 그대로 저장되었습니다. 예시·안내용 주소를 담는 데이터 항목은 종전처럼 검사하지 않습니다. (#127 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
|
- 화면 동작으로 외부 라이브러리를 호출하는 기능에 안전장치를 더했습니다. 임의 코드 실행에 쓰일 수 있는 내장 함수 호출과, 호출 결과를 화면 값에 옮길 때 프로그램 내부 구조를 건드리는 이름은 거부됩니다. (#127 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
- 주소에 마침표처럼 보이는 특수문자(전각·표의문자 마침표 등)를 섞으면 서버가 내부 주소로 요청을 보내도록 유도할 수 있던 문제를 수정했습니다. 검사할 때와 실제로 연결할 때 주소를 읽는 방식이 달라 생긴 문제로, 이제 두 시점이 같은 방식으로 주소를 해석합니다. 스케줄의 URL 호출, 주소로 언어팩 설치, 외부 배송비 계산 API 등 서버가 대신 외부로 요청을 보내는 모든 지점이 함께 보호됩니다. 정상적인 국제화 도메인(한글·일본어 도메인 등)은 그대로 사용할 수 있습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2010)
|
- 주소에 마침표처럼 보이는 특수문자(전각·표의문자 마침표 등)를 섞으면 서버가 내부 주소로 요청을 보내도록 유도할 수 있던 문제를 수정했습니다. 검사할 때와 실제로 연결할 때 주소를 읽는 방식이 달라 생긴 문제로, 이제 두 시점이 같은 방식으로 주소를 해석합니다. 스케줄의 URL 호출, 주소로 언어팩 설치, 외부 배송비 계산 API 등 서버가 대신 외부로 요청을 보내는 모든 지점이 함께 보호됩니다. 정상적인 국제화 도메인(한글·일본어 도메인 등)은 그대로 사용할 수 있습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2010)
|
||||||
- 2단계 인증을 켠 상태에서 계정 잠금을 우회할 수 있던 문제를 수정했습니다. 잠기기 전에 받아 둔 인증 단계를 잠긴 뒤에 마치면 로그인이 되고 잠금까지 풀렸습니다. 이제 인증번호 확인 단계에서도 잠금 여부를 다시 확인하며, 잠긴 계정은 로그인 화면과 동일한 안내를 받습니다. 잠긴 계정은 기존 로그인 상태로도 인증 기간을 연장할 수 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2011)
|
- 2단계 인증을 켠 상태에서 계정 잠금을 우회할 수 있던 문제를 수정했습니다. 잠기기 전에 받아 둔 인증 단계를 잠긴 뒤에 마치면 로그인이 되고 잠금까지 풀렸습니다. 이제 인증번호 확인 단계에서도 잠금 여부를 다시 확인하며, 잠긴 계정은 로그인 화면과 동일한 안내를 받습니다. 잠긴 계정은 기존 로그인 상태로도 인증 기간을 연장할 수 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2011)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- 같은 스크립트를 거의 동시에 두 번 불러오면, 두 번째 요청이 첫 번째 로드가 끝나기 전에 완료된 것으로 처리되어 그 뒤 동작이 아무 반응 없이 끝나던 문제를 수정했습니다. 이제 두 요청 모두 실제 로드가 끝난 뒤에 이어집니다.
|
||||||
|
- 확장을 활성화할 때 스크립트가 이미 있으면 그 확장의 스타일(CSS)까지 함께 건너뛰던 문제를 수정했습니다. 스타일만 제공하는 확장은 활성화해도 스타일이 적용되지 않았습니다.
|
||||||
- 실제 화면 동작에 쓰이는 라이브러리(axios·laravel-echo·pusher-js)가 개발용으로 분류돼 있어 보안 점검에서 빠지던 문제를 수정했습니다. 이제 점검 대상에 포함되며, 함께 확인된 axios 취약점도 1.20.0 으로 올려 해소했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
- 실제 화면 동작에 쓰이는 라이브러리(axios·laravel-echo·pusher-js)가 개발용으로 분류돼 있어 보안 점검에서 빠지던 문제를 수정했습니다. 이제 점검 대상에 포함되며, 함께 확인된 axios 취약점도 1.20.0 으로 올려 해소했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||||
- 글을 쓰다 브라우저 창 크기가 바뀌면 저장 시 본문이 사라지던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 창 크기를 조금만 바꿔도(20픽셀 이내) 발생했으므로, 휴대폰에서 주소창이 숨겨지거나 키보드가 올라오거나 화면을 돌리는 것도 같은 상황입니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다. (#130 @jiwonpapa 님께서 제보해주셨습니다.)
|
- 글을 쓰다 브라우저 창 크기가 바뀌면 저장 시 본문이 사라지던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 창 크기를 조금만 바꿔도(20픽셀 이내) 발생했으므로, 휴대폰에서 주소창이 숨겨지거나 키보드가 올라오거나 화면을 돌리는 것도 같은 상황입니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다. (#130 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||||
- 창 크기가 바뀐 뒤 본문을 고치고 제목 등 다른 입력칸을 건드리면, 저장 시 본문이 고치기 전 내용으로 되돌아가던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 편집기에는 고친 내용이 그대로 보였기 때문에 저장 후 다시 열어보기 전까지는 알 수 없었습니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다.
|
- 창 크기가 바뀐 뒤 본문을 고치고 제목 등 다른 입력칸을 건드리면, 저장 시 본문이 고치기 전 내용으로 되돌아가던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 편집기에는 고친 내용이 그대로 보였기 때문에 저장 후 다시 열어보기 전까지는 알 수 없었습니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다.
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ use Illuminate\Contracts\Validation\ValidationRule;
|
|||||||
/**
|
/**
|
||||||
* 레이아웃 JSON에서 외부 URL을 차단하는 Custom Rule
|
* 레이아웃 JSON에서 외부 URL을 차단하는 Custom Rule
|
||||||
*
|
*
|
||||||
* 컴포넌트 props·actions 와 최상위 init_actions 내의 http://, https://, data:,
|
* 컴포넌트 props·actions·lifecycle·onComponentEvent·slots·component_layout·responsive 와
|
||||||
* javascript: 등 위험한 URI 스킴을 감지하여 차단합니다.
|
* 최상위 init_actions/initActions·modals·named_actions·errorHandling 내의 http://, https://,
|
||||||
|
* data:, javascript: 등 위험한 URI 스킴을 감지하여 차단합니다.
|
||||||
|
*
|
||||||
|
* 순회 대상은 "액션이 실행되거나 값이 sink(컴포넌트 prop)로 흘러 들어가는 자리" 다.
|
||||||
|
* 한 자리만 빠져도 그 키가 그대로 저장 우회로가 되며, 우회는 오류를 남기지 않는다.
|
||||||
*
|
*
|
||||||
* 검사 대상 구분(신뢰 경계): init_actions 는 로드 시 자동 실행되는 액션이라 외부
|
* 검사 대상 구분(신뢰 경계): init_actions 는 로드 시 자동 실행되는 액션이라 외부
|
||||||
* navigate/apiCall URL 이 곧 자동 리다이렉트·데이터 유출 경로가 되므로 실행 지점에서
|
* navigate/apiCall URL 이 곧 자동 리다이렉트·데이터 유출 경로가 되므로 실행 지점에서
|
||||||
@@ -55,55 +59,128 @@ class NoExternalUrls implements ValidationRule
|
|||||||
$this->validateComponents($value['components'], $fail);
|
$this->validateComponents($value['components'], $fail);
|
||||||
}
|
}
|
||||||
|
|
||||||
// init_actions: 로드 시 자동 실행되는 액션 — 외부 navigate/apiCall URL 은 로드 시점
|
// init_actions / initActions: 로드 시 자동 실행되는 액션 — 외부 navigate/apiCall URL 은
|
||||||
// 자동 리다이렉트/데이터 유출 경로가 되므로 컴포넌트 actions 와 동일하게 검사한다.
|
// 로드 시점 자동 리다이렉트/데이터 유출 경로가 되므로 컴포넌트 actions 와 동일하게
|
||||||
if (isset($value['init_actions']) && is_array($value['init_actions'])) {
|
// 검사한다. 엔진(LayoutLoader)이 두 철자를 모두 소비하므로 두 철자 모두 검사한다 —
|
||||||
foreach ($value['init_actions'] as $i => $action) {
|
// 한쪽만 보면 다른 철자가 그대로 우회로가 된다.
|
||||||
if (is_array($action)) {
|
foreach (['init_actions', 'initActions'] as $initKey) {
|
||||||
$this->validateObject($action, "init_actions[$i]", $fail);
|
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 배열을 재귀적으로 검증
|
* 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) {
|
foreach ($components as $index => $component) {
|
||||||
if (! is_array($component)) {
|
if (! is_array($component)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$path = "{$basePath}[$index]";
|
||||||
|
|
||||||
// props 검사
|
// props 검사
|
||||||
if (isset($component['props']) && is_array($component['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 검사
|
// actions 검사
|
||||||
if (isset($component['actions']) && is_array($component['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 재귀 검사
|
// children 재귀 검사
|
||||||
if (isset($component['children']) && is_array($component['children'])) {
|
if (isset($component['children']) && is_array($component['children'])) {
|
||||||
$this->validateComponents($component['children'], $fail);
|
$this->validateComponents($component['children'], $fail, "$path.children");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* actions 배열 검증
|
* 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) {
|
foreach ($actions as $actionIndex => $action) {
|
||||||
if (! is_array($action)) {
|
if (! is_array($action)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->validateObject($action, "components[$componentIndex].actions[$actionIndex]", $fail);
|
$this->validateObject($action, "{$componentPath}.actions[$actionIndex]", $fail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,7 +220,9 @@ class SafeLayoutExpressions implements ValidationRule
|
|||||||
* 정규화 후 판정하면 경로 중간의 백슬래시·탭(`/js/a\b.js`)은 authority 를 만들지
|
* 정규화 후 판정하면 경로 중간의 백슬래시·탭(`/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층 동형이어야 합니다.
|
* (`layout-scripts-src-same-origin`)와 3층 동형이어야 합니다.
|
||||||
*
|
*
|
||||||
* 구현 SSoT 는 `TrustedScriptHosts::normalizeForOriginCheck` 입니다 — 같은 저장측
|
* 구현 SSoT 는 `TrustedScriptHosts::normalizeForOriginCheck` 입니다 — 같은 저장측
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class TrustedScriptHosts
|
|||||||
*
|
*
|
||||||
* 이 메서드가 origin 판정 정규화의 SSoT 입니다 — 저장측 규칙
|
* 이 메서드가 origin 판정 정규화의 SSoT 입니다 — 저장측 규칙
|
||||||
* (`App\Rules\SafeLayoutExpressions`)이 위임하고, 클라이언트
|
* (`App\Rules\SafeLayoutExpressions`)이 위임하고, 클라이언트
|
||||||
* (`TemplateApp.normalizeScriptSrcForOriginCheck`)·정적 검사
|
* (`resources/js/core/support/scriptSrcPolicy.ts::normalizeScriptSrcForOriginCheck`)·정적 검사
|
||||||
* (`layout-scripts-src-same-origin`)가 동형 구현을 갖습니다. 한 계층만 바꾸면
|
* (`layout-scripts-src-same-origin`)가 동형 구현을 갖습니다. 한 계층만 바꾸면
|
||||||
* 그 계층만 다른 출처를 보게 되며, 예외도 경고도 없이 판정만 갈립니다.
|
* 그 계층만 다른 출처를 보게 되며, 예외도 경고도 없이 판정만 갈립니다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -138,7 +138,9 @@
|
|||||||
|
|
||||||
레이아웃 보안 정책은 `scripts[].src`·`data_sources[].endpoint` 를 기본적으로 same-origin
|
레이아웃 보안 정책은 `scripts[].src`·`data_sources[].endpoint` 를 기본적으로 same-origin
|
||||||
경로(`/` 로 시작)만 허용하고, 외부 origin·protocol-relative(`//host`)·scheme 포함 URL 은
|
경로(`/` 로 시작)만 허용하고, 외부 origin·protocol-relative(`//host`)·scheme 포함 URL 은
|
||||||
저장 시점과 렌더 시점 양쪽에서 차단합니다. 확장이 정당하게 외부 CDN 스크립트를 써야 하면
|
저장 시점과 렌더 시점 양쪽에서 차단합니다. 같은 판정은 레이아웃 파일뿐 아니라 **브라우저에
|
||||||
|
새 `<script>` 를 붙이는 모든 경로**(`loadScript` 액션 · 확장 핸들러 재로드 · 편집기 프리뷰 ·
|
||||||
|
`G7Core.asset.loadScript`)에 적용됩니다. 확장이 정당하게 외부 CDN 스크립트를 써야 하면
|
||||||
그 호스트를 이 배열에 선언합니다. 활성 확장이 선언한 호스트만 집계되며(편집자는 추가 불가 —
|
그 호스트를 이 배열에 선언합니다. 활성 확장이 선언한 호스트만 집계되며(편집자는 추가 불가 —
|
||||||
manifest 는 배포물), 코어가 활성 확장 전체의 선언을 모아 allowlist 를 구성합니다.
|
manifest 는 배포물), 코어가 활성 확장 전체의 선언을 모아 allowlist 를 구성합니다.
|
||||||
|
|
||||||
@@ -156,6 +158,14 @@ manifest 는 배포물), 코어가 활성 확장 전체의 선언을 모아 allo
|
|||||||
```
|
```
|
||||||
- 외부 의존이 남는 기능은 **그 자산을 못 불러왔을 때의 동작**을 함께 갖춰야 합니다. 예: 주소
|
- 외부 의존이 남는 기능은 **그 자산을 못 불러왔을 때의 동작**을 함께 갖춰야 합니다. 예: 주소
|
||||||
검색 SDK 가 없으면 우편번호·주소를 직접 입력할 수 있게 두고 안내를 띄웁니다.
|
검색 SDK 가 없으면 우편번호·주소를 직접 입력할 수 있게 두고 안내를 띄웁니다.
|
||||||
|
- 결제 플러그인의 PG SDK 도 같은 부류입니다(그 회사 서버와 통신하므로 자체 호스팅 불가).
|
||||||
|
선언 사례: KG 이니시스 `stgstdpay.inicis.com`·`stdpay.inicis.com` / 토스페이먼츠
|
||||||
|
`js.tosspayments.com` / 나이스페이먼츠 `web.nicepay.co.kr` / NHN KCP `testpay.kcp.co.kr`·
|
||||||
|
`pay.kcp.co.kr`. 이 플러그인들은 **코드에도 같은 호스트 목록을 두고 주입 직전에 확인**하며,
|
||||||
|
확인에 실패하면 결제를 진행하지 않습니다(fail-closed). SDK URL 이 확장자로 끝나지 않는
|
||||||
|
경우(예: `/v2/standard`)에는 그 런타임 확인이 유일한 게이트입니다. PG사가 호스트를 바꾸면
|
||||||
|
manifest 와 코드 상수를 **함께** 갱신해야 하며, 두 목록의 일치는 각 플러그인 테스트가
|
||||||
|
고정합니다.
|
||||||
- 이 기능은 코어 7.0.7 에서 도입되었습니다. 선언하는 확장은 `g7_version` 을 `>=7.0.7` 로 두는
|
- 이 기능은 코어 7.0.7 에서 도입되었습니다. 선언하는 확장은 `g7_version` 을 `>=7.0.7` 로 두는
|
||||||
것이 계약상 정확합니다(하위 코어에서는 필드가 무시되어 무해).
|
것이 계약상 정확합니다(하위 코어에서는 필드가 무시되어 무해).
|
||||||
- 관련 보안 정책 상세: [frontend/security.md](../frontend/security.md).
|
- 관련 보안 정책 상세: [frontend/security.md](../frontend/security.md).
|
||||||
|
|||||||
@@ -295,6 +295,8 @@ public function restoreVersion(int $layoutId, int $version): TemplateLayout
|
|||||||
|
|
||||||
`externals`는 페이지 최초 진입에 항상 필요한 리소스만 선언합니다. 라우트별 조건부 스크립트나 액션 실행 중 동적 로딩은 layout `scripts`와 `loadScript` 책임입니다.
|
`externals`는 페이지 최초 진입에 항상 필요한 리소스만 선언합니다. 라우트별 조건부 스크립트나 액션 실행 중 동적 로딩은 layout `scripts`와 `loadScript` 책임입니다.
|
||||||
|
|
||||||
|
세 경로 모두 **같은 출처 정책**을 받습니다 — same-origin 경로이거나 확장이 manifest(`trusted_script_hosts`)로 선언한 신뢰 호스트여야 합니다. 상세: [module-assets.md](module-assets.md#trusted_script_hosts--외부-스크립트-신뢰-호스트) · [frontend/security.md](../frontend/security.md#외부-스크립트-신뢰-출처-허용목록)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 에러 페이지 설정 (error_config)
|
## 에러 페이지 설정 (error_config)
|
||||||
|
|||||||
@@ -827,6 +827,8 @@ sequence 내에서 `target: "isolated"` setState가 실행되면, 이후 스텝
|
|||||||
|
|
||||||
외부 스크립트를 동적으로 로드합니다. 외부 서비스(Daum 우편번호, 결제 SDK 등) 연동 시 사용합니다.
|
외부 스크립트를 동적으로 로드합니다. 외부 서비스(Daum 우편번호, 결제 SDK 등) 연동 시 사용합니다.
|
||||||
|
|
||||||
|
`src` 는 레이아웃 `scripts[]` 와 **같은 출처 정책**을 받습니다 — same-origin 절대 경로(`/` 로 시작)이거나, 확장이 manifest(`trusted_script_hosts`)로 선언한 신뢰 호스트여야 합니다. 그 밖의 원격 URL 은 로드 전에 차단되고 액션이 실패합니다(`onError`·`errorHandling` 오류 채널로 전달). 상세: [security.md](security.md#외부-스크립트-신뢰-출처-허용목록)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "click",
|
"type": "click",
|
||||||
@@ -859,7 +861,7 @@ sequence 내에서 `target: "isolated"` setState가 실행되면, 이후 스텝
|
|||||||
{
|
{
|
||||||
"handler": "loadScript",
|
"handler": "loadScript",
|
||||||
"params": {
|
"params": {
|
||||||
"src": "https://example.com/sdk.js"
|
"src": "/api/plugins/assets/vendor-plugin/dist/vendor/example-sdk/1.0.0/sdk.js"
|
||||||
},
|
},
|
||||||
"onLoad": {
|
"onLoad": {
|
||||||
"handler": "callExternal",
|
"handler": "callExternal",
|
||||||
@@ -875,11 +877,18 @@ sequence 내에서 `target: "isolated"` setState가 실행되면, 이후 스텝
|
|||||||
|
|
||||||
동일한 ID의 스크립트가 이미 로드되었거나 DOM에 존재하면 다시 로드하지 않고 `onLoad`만 즉시 실행합니다.
|
동일한 ID의 스크립트가 이미 로드되었거나 DOM에 존재하면 다시 로드하지 않고 `onLoad`만 즉시 실행합니다.
|
||||||
|
|
||||||
|
같은 스크립트를 **동시에** 요청하면 태그는 하나만 만들어지고, 두 호출자 모두 그 태그의 로드가 끝난 뒤에 완료됩니다. 로드 중인 스크립트를 "이미 있다"는 이유로 먼저 완료 처리하면, 그 호출자의 `onLoad` 가 SDK 전역이 아직 없는 시점에 실행되어 아무 일도 일어나지 않습니다.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
✅ 스크립트 중복 로드 자동 방지
|
✅ 스크립트 중복 로드 자동 방지
|
||||||
✅ 이미 로드된 경우 onLoad 즉시 실행
|
✅ 이미 로드된 경우 onLoad 즉시 실행
|
||||||
|
✅ 동시 요청은 하나의 태그를 공유하고 각자 onLoad 실행
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 동의 관리(개인정보 배너)와의 관계
|
||||||
|
|
||||||
|
동의 관리 플러그인이 아직 동의받지 않은 스크립트를 차단하고 있으면, 이 액션은 스크립트를 붙이지 않고 **미완료 상태로 끝납니다**(오류가 아니라 "동의 전"이라는 상태이므로 실패로 취급하지 않습니다). `onLoad` 는 실행되지 않고, 캐시에도 기록하지 않으므로 동의 후 다시 호출하면 정상적으로 로드됩니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## callExternal
|
## callExternal
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
19. [sequence / parallel](actions-handlers-ui.md#sequence--parallel) - 액션 조합
|
19. [sequence / parallel](actions-handlers-ui.md#sequence--parallel) - 액션 조합
|
||||||
20. [reloadTranslations](actions-handlers-ui.md#reloadtranslations) (deprecated) - 다국어 재로드 (extension 라이프사이클은 `reloadExtensions` 사용)
|
20. [reloadTranslations](actions-handlers-ui.md#reloadtranslations) (deprecated) - 다국어 재로드 (extension 라이프사이클은 `reloadExtensions` 사용)
|
||||||
21. [showErrorPage](actions-handlers-ui.md#showerrorpage) - 에러 페이지
|
21. [showErrorPage](actions-handlers-ui.md#showerrorpage) - 에러 페이지
|
||||||
22. [loadScript](actions-handlers-ui.md#loadscript) ⭐ NEW - 외부 스크립트 로드
|
22. [loadScript](actions-handlers-ui.md#loadscript) ⭐ NEW - 외부 스크립트 로드 (same-origin 경로 또는 선언된 신뢰 호스트만)
|
||||||
23. [callExternal](actions-handlers-ui.md#callexternal) ⭐ NEW - 외부 라이브러리 호출
|
23. [callExternal](actions-handlers-ui.md#callexternal) ⭐ NEW - 외부 라이브러리 호출
|
||||||
24. [실전 예시](actions-handlers-ui.md#실전-예시)
|
24. [실전 예시](actions-handlers-ui.md#실전-예시)
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ const text = DETAIL_REF_TRANSLATIONS[locale]?.[key] ?? key;
|
|||||||
| 상태 변경 안 됨 | [state](actions-handlers-state.md#setstate) | setState, target: global/local/isolated |
|
| 상태 변경 안 됨 | [state](actions-handlers-state.md#setstate) | setState, target: global/local/isolated |
|
||||||
| 모달 안 열림/안 닫힘 | [ui](actions-handlers-ui.md#openmodal--closemodal) | openModal, closeModal, modalStack |
|
| 모달 안 열림/안 닫힘 | [ui](actions-handlers-ui.md#openmodal--closemodal) | openModal, closeModal, modalStack |
|
||||||
| 토스트 안 나옴 | [ui](actions-handlers-ui.md#showalert--toast) | toast, params.type |
|
| 토스트 안 나옴 | [ui](actions-handlers-ui.md#showalert--toast) | toast, params.type |
|
||||||
| 외부 스크립트 로드 | [ui](actions-handlers-ui.md#loadscript) | loadScript, onLoad |
|
| 외부 스크립트 로드 | [ui](actions-handlers-ui.md#loadscript) | loadScript, onLoad, src 출처 게이트 |
|
||||||
| 조건부 액션 분기 | [ui](actions-handlers-ui.md#switch) | switch, cases, default |
|
| 조건부 액션 분기 | [ui](actions-handlers-ui.md#switch) | switch, cases, default |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -923,8 +923,11 @@ declare global {
|
|||||||
module: (identifier: string, path: string, version?: number | string | null) => string;
|
module: (identifier: string, path: string, version?: number | string | null) => string;
|
||||||
plugin: (identifier: string, path: string, version?: number | string | null) => string;
|
plugin: (identifier: string, path: string, version?: number | string | null) => string;
|
||||||
convertToCurrentMode: (url: string) => string;
|
convertToCurrentMode: (url: string) => string;
|
||||||
|
// 출처 게이트 적용 (engine-v1.64.0+) — 미신뢰 URL 은 reject
|
||||||
loadScript: (url: string, attrs?: Record<string, string>, options?: Record<string, unknown>) => Promise<void>;
|
loadScript: (url: string, attrs?: Record<string, string>, options?: Record<string, unknown>) => Promise<void>;
|
||||||
loadStylesheet: (url: string, attrs?: Record<string, string>, options?: Record<string, unknown>) => Promise<void>;
|
loadStylesheet: (url: string, attrs?: Record<string, string>, options?: Record<string, unknown>) => Promise<void>;
|
||||||
|
// 같은 판정을 직접 물어보는 seam (engine-v1.64.0+)
|
||||||
|
isAllowedScriptSrc: (url: string) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 자산 실패 안내 (engine-v1.62.0+)
|
// 자산 실패 안내 (engine-v1.62.0+)
|
||||||
|
|||||||
@@ -814,8 +814,15 @@ const PriceDisplay: React.FC<{ price: number }> = ({ price }) => {
|
|||||||
| `module` | `(identifier, path, version?) => string` | 모듈 자산 URL |
|
| `module` | `(identifier, path, version?) => string` | 모듈 자산 URL |
|
||||||
| `plugin` | `(identifier, path, version?) => string` | 플러그인 자산 URL |
|
| `plugin` | `(identifier, path, version?) => string` | 플러그인 자산 URL |
|
||||||
| `convertToCurrentMode` | `(url) => string` | 서버가 확장자 형태로 굳혀 내려준 URL 을 현재 모드로 보정 |
|
| `convertToCurrentMode` | `(url) => string` | 서버가 확장자 형태로 굳혀 내려준 URL 을 현재 모드로 보정 |
|
||||||
| `loadScript` | `(url, attrs?, options?) => Promise<void>` | 재시도 계층을 갖춘 스크립트 로더 |
|
| `loadScript` | `(url, attrs?, options?) => Promise<void>` | 재시도 계층을 갖춘 스크립트 로더 (출처 게이트 적용) |
|
||||||
| `loadStylesheet` | `(url, attrs?, options?) => Promise<void>` | 재시도 계층을 갖춘 스타일시트 로더 |
|
| `loadStylesheet` | `(url, attrs?, options?) => Promise<void>` | 재시도 계층을 갖춘 스타일시트 로더 |
|
||||||
|
| `isAllowedScriptSrc` | `(url) => boolean` | 스크립트 URL 이 주입 허용 대상인지 판정 |
|
||||||
|
|
||||||
|
`loadScript` 의 `url` 은 레이아웃 `scripts[]` 와 **같은 출처 정책**을 받습니다 — same-origin
|
||||||
|
절대 경로이거나 확장이 manifest(`trusted_script_hosts`)로 선언한 신뢰 호스트여야 하며, 그 밖의
|
||||||
|
원격 URL 은 reject 됩니다. 로더를 쓸 수 없는 주입(iframe `document.write` 등)은
|
||||||
|
`isAllowedScriptSrc` 로 같은 판정을 재사용하세요.
|
||||||
|
상세: [security.md](security.md#외부-스크립트-신뢰-출처-허용목록)
|
||||||
|
|
||||||
`path` 기준이 확장 타입마다 다릅니다. **템플릿은 서버가 `dist/` 를 자동으로 붙이므로 `path` 에
|
`path` 기준이 확장 타입마다 다릅니다. **템플릿은 서버가 `dist/` 를 자동으로 붙이므로 `path` 에
|
||||||
`dist/` 를 포함하지 않고**, 모듈·플러그인은 확장 루트 기준이라 `dist/` 를 직접 포함합니다.
|
`dist/` 를 포함하지 않고**, 모듈·플러그인은 확장 루트 기준이라 `dist/` 를 직접 포함합니다.
|
||||||
|
|||||||
@@ -203,6 +203,19 @@ HTML을 렌더링해야 하는 경우 (게시판 본문, 상품 설명 등) **
|
|||||||
|
|
||||||
레이아웃의 `scripts[].src` 와 `data_sources[].endpoint` 는 기본적으로 **same-origin 절대 경로**(`/` 로 시작)만 허용합니다. `//`(protocol-relative)·scheme 포함 외부 URL 은 원격 코드 로드 경로이므로 런타임 스크립트 로더가 차단합니다.
|
레이아웃의 `scripts[].src` 와 `data_sources[].endpoint` 는 기본적으로 **same-origin 절대 경로**(`/` 로 시작)만 허용합니다. `//`(protocol-relative)·scheme 포함 외부 URL 은 원격 코드 로드 경로이므로 런타임 스크립트 로더가 차단합니다.
|
||||||
|
|
||||||
|
이 판정은 레이아웃 `scripts[]` 에만 적용되는 것이 아닙니다. **브라우저에 새 `<script>` 를 만들어 붙이는 모든 경로**가 같은 게이트를 경유합니다 — 한 곳만 게이트를 건너뛰면 저장측 검증이 통째로 무의미해지기 때문입니다.
|
||||||
|
|
||||||
|
| 주입 경로 | 게이트 실패 시 |
|
||||||
|
|----------|---------------|
|
||||||
|
| 레이아웃 `scripts[].src` | skip + 경고 (나머지 스크립트는 계속 로드) |
|
||||||
|
| `loadScript` 액션 | 액션 실패 (`onError`·`errorHandling` 오류 채널로 전달) |
|
||||||
|
| `reloadModuleHandlers` / `reloadPluginHandlers` 의 `assets.js` · `assets.css` | 액션 실패 |
|
||||||
|
| 편집기 프리뷰 캔버스 | skip + 콘솔 경고 (런타임과 같은 판정·같은 결과) |
|
||||||
|
| `G7Core.asset.loadScript` (확장이 자기 자산을 직접 로드하는 seam) | reject |
|
||||||
|
| 결제 플러그인의 PG SDK 주입 | 결제 중단 (fail-closed) |
|
||||||
|
|
||||||
|
`G7Core.asset.isAllowedScriptSrc(url)` 로 같은 판정을 직접 물어볼 수 있습니다. 로더를 쓸 수 없는 주입(iframe `document.write` 등)은 이 함수로 같은 게이트를 재사용합니다.
|
||||||
|
|
||||||
구동에 필요한 자산은 확장이 함께 담아 자체 제공하는 것이 원칙입니다. 자체 제공이 불가능한 경우 — 라이브러리가 아니라 그 회사 서버와 통신하는 **서비스 SDK**(예: Daum 우편번호 → `t1.daumcdn.net`) — 에만 외부 호스트를 씁니다. 이런 확장은 자신의 manifest 에 신뢰 호스트를 **선언**하고, 코어가 활성 확장 전수에서 이 목록을 집계해 `window.G7Config.trustedScriptHosts` 로 노출합니다. 런타임 로더·저장측 검증·정적 검사는 모두 이 목록에 속한 호스트만 예외로 허용합니다.
|
구동에 필요한 자산은 확장이 함께 담아 자체 제공하는 것이 원칙입니다. 자체 제공이 불가능한 경우 — 라이브러리가 아니라 그 회사 서버와 통신하는 **서비스 SDK**(예: Daum 우편번호 → `t1.daumcdn.net`) — 에만 외부 호스트를 씁니다. 이런 확장은 자신의 manifest 에 신뢰 호스트를 **선언**하고, 코어가 활성 확장 전수에서 이 목록을 집계해 `window.G7Config.trustedScriptHosts` 로 노출합니다. 런타임 로더·저장측 검증·정적 검사는 모두 이 목록에 속한 호스트만 예외로 허용합니다.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -240,7 +253,7 @@ HTML을 렌더링해야 하는 경우 (게시판 본문, 상품 설명 등) **
|
|||||||
|
|
||||||
판정 전에 **tab·LF·CR 를 제거하고, 백슬래시를 슬래시로 바꾸고, 선행 슬래시 런을 접은** 뒤 접두 검사를 적용합니다. 브라우저는 선행 슬래시가 몇 개든 authority 시작으로 접습니다(`///host` ≡ `//host`, `https:///host` ≡ `https://host`). 경로 중간의 백슬래시·탭·연속 슬래시는 authority 를 만들지 않으므로 그대로 통과합니다.
|
판정 전에 **tab·LF·CR 를 제거하고, 백슬래시를 슬래시로 바꾸고, 선행 슬래시 런을 접은** 뒤 접두 검사를 적용합니다. 브라우저는 선행 슬래시가 몇 개든 authority 시작으로 접습니다(`///host` ≡ `//host`, `https:///host` ≡ `https://host`). 경로 중간의 백슬래시·탭·연속 슬래시는 authority 를 만들지 않으므로 그대로 통과합니다.
|
||||||
|
|
||||||
이 정규화는 런타임 로더·저장측 검증·정적 검사 **세 계층이 공유**해야 합니다. 세 계층이 같은 판정 로직을 쓰므로, 한쪽만 고치면 나머지가 우회로로 남고 반대로 한 형태로 셋이 함께 뚫립니다. 새 URL 검증 지점을 추가할 때 접두 검사를 직접 작성하지 말고 기존 정규화를 경유하세요.
|
이 정규화는 런타임 로더·저장측 검증·정적 검사 **세 계층이 공유**해야 합니다. 런타임 쪽 구현은 한 곳(`resources/js/core/support/scriptSrcPolicy.ts`)에 있고 위 표의 주입 경로가 모두 그것을 씁니다 — 사본이 생기면 그 차집합이 그대로 우회로가 됩니다. 세 계층이 같은 판정 로직을 쓰므로, 한쪽만 고치면 나머지가 우회로로 남고 반대로 한 형태로 셋이 함께 뚫립니다. 새 URL 검증 지점을 추가할 때 접두 검사를 직접 작성하지 말고 기존 정규화를 경유하세요.
|
||||||
|
|
||||||
**same-origin 판정과 신뢰 출처 판정도 같은 정규화를 씁니다.** 두 판정은 한 조건문에서 이어집니다("내 사이트 경로인가, 아니면 신뢰 출처인가"). 한쪽만 정규화하면 신뢰 출처 이름을 userinfo 자리에 끼워 넣은 주소(`https://evil.com\@cdn.신뢰.com/x.js`)가 저장 단계에서만 신뢰 출처로 보여 통과하고, 반대로 브라우저가 신뢰 출처로 읽는 형태를 저장 단계만 거부하는 과차단도 생깁니다. 호스트 추출은 반드시 정규화를 경유하세요.
|
**same-origin 판정과 신뢰 출처 판정도 같은 정규화를 씁니다.** 두 판정은 한 조건문에서 이어집니다("내 사이트 경로인가, 아니면 신뢰 출처인가"). 한쪽만 정규화하면 신뢰 출처 이름을 userinfo 자리에 끼워 넣은 주소(`https://evil.com\@cdn.신뢰.com/x.js`)가 저장 단계에서만 신뢰 출처로 보여 통과하고, 반대로 브라우저가 신뢰 출처로 읽는 형태를 저장 단계만 거부하는 과차단도 생깁니다. 호스트 추출은 반드시 정규화를 경유하세요.
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||||
|
|
||||||
|
## [1.1.2] - 2026-09-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 결제 모듈 주소를 확인하지 못했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 한국어 안내가 보이던 문제가 해소됩니다.
|
||||||
|
|
||||||
## [1.1.1] - 2026-08-19
|
## [1.1.1] - 2026-08-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -325,5 +325,10 @@
|
|||||||
"copy": "コピー",
|
"copy": "コピー",
|
||||||
"japan_restrict_jpy_payment_methods": "JPY注文決済方法制限",
|
"japan_restrict_jpy_payment_methods": "JPY注文決済方法制限",
|
||||||
"japan_restrict_jpy_payment_methods_hint": "有効化するとJPY注文はクレジットカード、PayPay、日本コンビニ決済のみCBTで進行されます。無効化すると従来どおり選択決済方法を制限しません。"
|
"japan_restrict_jpy_payment_methods_hint": "有効化するとJPY注文はクレジットカード、PayPay、日本コンビニ決済のみCBTで進行されます。無効化すると従来どおり選択決済方法を制限しません。"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"error": {
|
||||||
|
"sdk_url_untrusted": "決済モジュールのアドレスが正しくないため、決済を進めることができません。管理者にお問い合わせください。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack (bundled)",
|
"en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack (bundled)",
|
||||||
"ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック(バンドル)"
|
"ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック(バンドル)"
|
||||||
},
|
},
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scope": "plugin",
|
"scope": "plugin",
|
||||||
"target_identifier": "sirsoft-pay_kginicis",
|
"target_identifier": "sirsoft-pay_kginicis",
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||||
|
|
||||||
|
## [1.0.4] - 2026-09-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 결제 모듈 주소를 확인하지 못했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 한국어 안내가 보이던 문제가 해소됩니다.
|
||||||
|
|
||||||
## [1.0.3] - 2026-08-19
|
## [1.0.3] - 2026-08-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -154,5 +154,10 @@
|
|||||||
},
|
},
|
||||||
"easy_pay": {
|
"easy_pay": {
|
||||||
"section_title": "簡便決済"
|
"section_title": "簡便決済"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"error": {
|
||||||
|
"sdk_url_untrusted": "決済モジュールのアドレスが正しくないため、決済を進めることができません。管理者にお問い合わせください。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack (bundled)",
|
"en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack (bundled)",
|
||||||
"ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック(バンドル)"
|
"ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック(バンドル)"
|
||||||
},
|
},
|
||||||
"version": "1.0.3",
|
"version": "1.0.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scope": "plugin",
|
"scope": "plugin",
|
||||||
"target_identifier": "sirsoft-pay_nhnkcp",
|
"target_identifier": "sirsoft-pay_nhnkcp",
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||||
|
|
||||||
|
## [1.0.4] - 2026-09-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 결제 모듈 주소를 확인하지 못했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 한국어 안내가 보이던 문제가 해소됩니다.
|
||||||
|
|
||||||
## [1.0.3] - 2026-08-19
|
## [1.0.3] - 2026-08-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -150,5 +150,10 @@
|
|||||||
"amount_mismatch": "決済金額が一致しません。",
|
"amount_mismatch": "決済金額が一致しません。",
|
||||||
"authorize_failed": "決済承認に失敗しました。"
|
"authorize_failed": "決済承認に失敗しました。"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"error": {
|
||||||
|
"sdk_url_untrusted": "決済モジュールのアドレスが正しくないため、決済を進めることができません。管理者にお問い合わせください。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack (bundled)",
|
"en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack (bundled)",
|
||||||
"ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック(バンドル)"
|
"ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック(バンドル)"
|
||||||
},
|
},
|
||||||
"version": "1.0.3",
|
"version": "1.0.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scope": "plugin",
|
"scope": "plugin",
|
||||||
"target_identifier": "sirsoft-pay_nicepayments",
|
"target_identifier": "sirsoft-pay_nicepayments",
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||||
|
|
||||||
|
## [1.0.3] - 2026-09-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 결제 모듈 주소를 확인하지 못했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 한국어 안내가 보이던 문제가 해소됩니다.
|
||||||
|
|
||||||
## [1.0.2] - 2026-08-19
|
## [1.0.2] - 2026-08-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -90,5 +90,10 @@
|
|||||||
"vbank_due_label": "入金期限",
|
"vbank_due_label": "入金期限",
|
||||||
"receipt_label": "領収書",
|
"receipt_label": "領収書",
|
||||||
"receipt_view": "領収書を表示"
|
"receipt_view": "領収書を表示"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"error": {
|
||||||
|
"sdk_url_untrusted": "決済モジュールのアドレスが正しくないため、決済を進めることができません。管理者にお問い合わせください。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"en": "G7 plugin (sirsoft-tosspayments) Japanese language pack (bundled)",
|
"en": "G7 plugin (sirsoft-tosspayments) Japanese language pack (bundled)",
|
||||||
"ja": "G7 プラグイン (sirsoft-tosspayments) 日本語 言語パック(バンドル)"
|
"ja": "G7 プラグイン (sirsoft-tosspayments) 日本語 言語パック(バンドル)"
|
||||||
},
|
},
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scope": "plugin",
|
"scope": "plugin",
|
||||||
"target_identifier": "sirsoft-tosspayments",
|
"target_identifier": "sirsoft-tosspayments",
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ CBT 인증 URL 로 폼 POST → KG 이니시스가 `sid` 를 콜백으로 전달
|
|||||||
- [ ] IP 화이트리스트(`InicisNotifyIpWhitelist`) 대상 라우트를 추가/변경하면 미들웨어 부착 대상(targets)도 함께 갱신
|
- [ ] IP 화이트리스트(`InicisNotifyIpWhitelist`) 대상 라우트를 추가/변경하면 미들웨어 부착 대상(targets)도 함께 갱신
|
||||||
- [ ] 새 결제수단·통화를 추가하면 그 결제수단의 콜백 URL을 관리자 설정 안내(README "콜백/통보 URL 등록")에도 반영
|
- [ ] 새 결제수단·통화를 추가하면 그 결제수단의 콜백 URL을 관리자 설정 안내(README "콜백/통보 URL 등록")에도 반영
|
||||||
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-pay_kginicis --force`
|
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-pay_kginicis --force`
|
||||||
|
- [ ] KG 이니시스가 SDK 호스트를 바꾸면 `plugin.json` 의 `trusted_script_hosts`(+`trusted_script_hosts_reason`)와 `resources/js/handlers/requestPayment.ts` 의 `KNOWN_SDK_HOSTS` 를 **함께** 갱신 — 두 목록이 어긋나면 테스트가 실패하며, 코드 상수에 없는 호스트는 주입 직전 확인에서 거부되어 결제가 진행되지 않는다(fail-closed). 변경 후 `php artisan ext:docgen --scope=plugin:sirsoft-pay_kginicis` 재실행
|
||||||
|
|
||||||
## 6. 금지 패턴
|
## 6. 금지 패턴
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
- 결제창 프로그램을 불러오는 주소가 KG 이니시스의 주소인지 불러오기 직전에 확인합니다. 확인되지 않는 주소면 결제를 진행하지 않고 안내를 표시합니다. 또한 결제창 프로그램이 실제로 준비되었는지를 기준으로 다음 단계를 진행하도록 바꿔, 프로그램이 아직 준비되지 않았는데 결제창이 열리지 않고 멈추던 상황을 없앴습니다.
|
||||||
- 제3자가 남의 주문번호만 알면 결제창을 거치지 않고도 그 주문을 취소시킬 수 있던 문제를 수정했습니다. 결제 결과 콜백은 로그인도 서명 확인도 거치지 않는 경로여서, 위조한 인증 정보를 보내 승인을 일부러 실패시키면 그 주문이 결제 실패로 처리되었습니다. 이제 승인이 성립하지 않은 콜백은 주문 상태를 바꾸지 않고 결제 화면으로 되돌려 보내기만 합니다. PC·모바일·해외결제(CBT) 결제창 모두에 적용됩니다. 구매자가 결제창을 닫아 생기는 정상적인 결제 실패는 종전처럼 기록됩니다.
|
- 제3자가 남의 주문번호만 알면 결제창을 거치지 않고도 그 주문을 취소시킬 수 있던 문제를 수정했습니다. 결제 결과 콜백은 로그인도 서명 확인도 거치지 않는 경로여서, 위조한 인증 정보를 보내 승인을 일부러 실패시키면 그 주문이 결제 실패로 처리되었습니다. 이제 승인이 성립하지 않은 콜백은 주문 상태를 바꾸지 않고 결제 화면으로 되돌려 보내기만 합니다. PC·모바일·해외결제(CBT) 결제창 모두에 적용됩니다. 구매자가 결제창을 닫아 생기는 정상적인 결제 실패는 종전처럼 기록됩니다.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ CBT 결제창으로 진입하며, 설정이 부족하면 한국 표준결제로
|
|||||||
| 그누보드7 코어 | `>=7.0.10` |
|
| 그누보드7 코어 | `>=7.0.10` |
|
||||||
| PHP | `^8.2` |
|
| PHP | `^8.2` |
|
||||||
| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
|
| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
|
||||||
|
| 외부 스크립트 호스트 | `stgstdpay.inicis.com`, `stdpay.inicis.com` |
|
||||||
<!-- @generated:requirements END -->
|
<!-- @generated:requirements END -->
|
||||||
|
|
||||||
<!-- @intent START -->
|
<!-- @intent START -->
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -20,6 +20,14 @@
|
|||||||
},
|
},
|
||||||
"plugins": {}
|
"plugins": {}
|
||||||
},
|
},
|
||||||
|
"trusted_script_hosts": [
|
||||||
|
"stgstdpay.inicis.com",
|
||||||
|
"stdpay.inicis.com"
|
||||||
|
],
|
||||||
|
"trusted_script_hosts_reason": {
|
||||||
|
"stgstdpay.inicis.com": "KG 이니시스 테스트 표준결제창 SDK 다. 결제창 스크립트가 KG 이니시스 서버와 통신하므로 자체 호스팅해도 결제가 성립하지 않는다.",
|
||||||
|
"stdpay.inicis.com": "KG 이니시스 운영 표준결제창 SDK 다. 결제창 스크립트가 KG 이니시스 서버와 통신하므로 자체 호스팅해도 결제가 성립하지 않는다."
|
||||||
|
},
|
||||||
"assets": {
|
"assets": {
|
||||||
"js": {
|
"js": {
|
||||||
"entry": "resources/js/index.ts",
|
"entry": "resources/js/index.ts",
|
||||||
|
|||||||
+54
-1
@@ -5,8 +5,10 @@
|
|||||||
* SDK 로드/INIStdPay.pay 호출/모바일 redirect 등 외부 부수효과 의존 흐름은
|
* SDK 로드/INIStdPay.pay 호출/모바일 redirect 등 외부 부수효과 의존 흐름은
|
||||||
* tests/scenarios 매니페스트에서 다루며, 본 단위 테스트는 초기 가드 위주.
|
* tests/scenarios 매니페스트에서 다루며, 본 단위 테스트는 초기 가드 위주.
|
||||||
*/
|
*/
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { requestPaymentHandler } from '../../handlers/requestPayment';
|
import { assertTrustedSdkUrl, KNOWN_SDK_HOSTS, requestPaymentHandler } from '../../handlers/requestPayment';
|
||||||
import {
|
import {
|
||||||
clearMobilePaymentReturnPending,
|
clearMobilePaymentReturnPending,
|
||||||
consumeMobilePaymentReturnPending,
|
consumeMobilePaymentReturnPending,
|
||||||
@@ -832,3 +834,54 @@ describe('requestPaymentHandler — 모바일 P_INI_PAYMENT 매핑', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 주입 출처 게이트 (동적 스크립트 주입 정책).
|
||||||
|
*
|
||||||
|
* PG SDK 는 서비스 SDK 라 자체 호스팅할 수 없다. 그래서 **주입 직전에** 호스트를
|
||||||
|
* 확인하는 것이 유일한 게이트다. 확장자 없는 SDK URL 은 정적 검사에도 걸리지 않는다.
|
||||||
|
*
|
||||||
|
* manifest `trusted_script_hosts` 와 코드 상수가 어긋나면 그 차집합이 그대로
|
||||||
|
* 사각이 되므로 두 목록의 일치도 함께 고정한다.
|
||||||
|
*/
|
||||||
|
describe('SDK URL 신뢰 호스트 게이트', () => {
|
||||||
|
const TRUSTED_SDK_URL = 'https://stdpay.inicis.com/stdjs/INIStdPay.js';
|
||||||
|
|
||||||
|
it('신뢰 호스트 https URL 은 통과한다', () => {
|
||||||
|
expect(() => assertTrustedSdkUrl(TRUSTED_SDK_URL)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['미신뢰 호스트', 'https://cdn.evil.com/sdk.js'],
|
||||||
|
['http (평문)', TRUSTED_SDK_URL.replace('https://', 'http://')],
|
||||||
|
['protocol-relative', TRUSTED_SDK_URL.replace('https://', '//')],
|
||||||
|
['상대 경로', '/local/sdk.js'],
|
||||||
|
['빈 문자열', ''],
|
||||||
|
['javascript 스킴', 'javascript:alert(1)'],
|
||||||
|
['신뢰 호스트를 userinfo 로 위장', 'https://evil.com/@' + new URL(TRUSTED_SDK_URL).hostname + '/x.js'],
|
||||||
|
['신뢰 호스트를 서브도메인 접미로 위장', 'https://' + new URL(TRUSTED_SDK_URL).hostname + '.evil.com/x.js'],
|
||||||
|
])('%s 는 거부된다', (_label, url) => {
|
||||||
|
expect(() => assertTrustedSdkUrl(url as string)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manifest trusted_script_hosts 와 KNOWN_SDK_HOSTS 가 일치한다', () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
readFileSync(resolve(__dirname, '../../../../plugin.json'), 'utf-8')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect([...(manifest.trusted_script_hosts ?? [])].sort()).toEqual(
|
||||||
|
[...KNOWN_SDK_HOSTS].sort()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manifest 가 호스트별 사유를 함께 선언한다', () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
readFileSync(resolve(__dirname, '../../../../plugin.json'), 'utf-8')
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const host of KNOWN_SDK_HOSTS) {
|
||||||
|
expect(typeof manifest.trusted_script_hosts_reason?.[host]).toBe('string');
|
||||||
|
expect(manifest.trusted_script_hosts_reason[host].length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -139,13 +139,93 @@ function isMobileUserAgent(): boolean {
|
|||||||
return /macintosh|mac os x/.test(ua) && touchPoints > 1;
|
return /macintosh|mac os x/.test(ua) && touchPoints > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadScript(src: string): Promise<void> {
|
/**
|
||||||
return new Promise((resolve, reject) => {
|
* SDK 스크립트를 로드할 수 있는 호스트 (plugin.json `trusted_script_hosts` 미러).
|
||||||
if (document.querySelector(`script[src="${src}"]`)) {
|
*
|
||||||
resolve();
|
* KG 이니시스 결제창은 라이브러리가 아니라 그 회사 서버와 통신하는 서비스 SDK 라
|
||||||
return;
|
* 자체 호스팅할 수 없다. 대신 **주입 직전에** 호스트를 확인해, 설정·응답이 어떤
|
||||||
}
|
* 경로로든 다른 주소를 지시하면 결제를 진행하지 않는다(fail-closed).
|
||||||
|
*
|
||||||
|
* PG사가 SDK 호스트를 바꾸면 이 상수와 plugin.json 을 **함께** 갱신한다 —
|
||||||
|
* 둘이 어긋나면 테스트가 실패한다.
|
||||||
|
*/
|
||||||
|
export const KNOWN_SDK_HOSTS: readonly string[] = ['stgstdpay.inicis.com', 'stdpay.inicis.com'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 번역 문자열을 얻습니다.
|
||||||
|
*
|
||||||
|
* @param key 번역 키 (플러그인 네임스페이스 이하)
|
||||||
|
* @param fallback 번역 엔진 부재 시 사용할 문구
|
||||||
|
* @returns 번역된 문자열
|
||||||
|
*/
|
||||||
|
function t(key: string, fallback: string): string {
|
||||||
|
const translate = (window as any)?.G7Core?.t;
|
||||||
|
|
||||||
|
if (typeof translate !== 'function') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const full = `sirsoft-pay_kginicis.${key}`;
|
||||||
|
const result = translate(full);
|
||||||
|
|
||||||
|
return typeof result === 'string' && result !== full ? result : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK URL 이 신뢰 호스트인지 확인하고, 아니면 예외를 던집니다.
|
||||||
|
*
|
||||||
|
* @param url 주입할 SDK URL
|
||||||
|
* @throws Error 미신뢰 호스트이거나 https 가 아닌 경우
|
||||||
|
*/
|
||||||
|
export function assertTrustedSdkUrl(url: string): void {
|
||||||
|
let parsed: URL | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed === null
|
||||||
|
|| parsed.protocol !== 'https:'
|
||||||
|
|| !KNOWN_SDK_HOSTS.includes(parsed.hostname.toLowerCase())
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
t('payment.error.sdk_url_untrusted', '결제 모듈 주소가 올바르지 않아 결제를 진행할 수 없습니다.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 스크립트를 로드합니다.
|
||||||
|
*
|
||||||
|
* 완료 판정은 **SDK 전역 확보**로 한다 — DOM 에 태그가 있다는 것은 로드 완료를
|
||||||
|
* 뜻하지 않는다(로드 중이거나, 실패해 남은 잔재일 수 있다). 종전에는 태그 존재만으로
|
||||||
|
* 즉시 resolve 해서, 전역이 없는 상태로 다음 단계가 진행되고 결제창이 열리지 않았다.
|
||||||
|
*
|
||||||
|
* @param src SDK URL
|
||||||
|
* @throws Error 미신뢰 호스트이거나 로드에 실패한 경우
|
||||||
|
*/
|
||||||
|
async function loadScript(src: string): Promise<void> {
|
||||||
|
assertTrustedSdkUrl(src);
|
||||||
|
|
||||||
|
if (window.INIStdPay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역이 없는데 태그만 남아 있으면 미완료·실패 잔재다 — 제거 후 새로 로드한다.
|
||||||
|
document.querySelectorAll(`script[src="${CSS.escape(src)}"]`).forEach((el) => el.remove());
|
||||||
|
|
||||||
|
const loader = (window as any)?.G7Core?.asset?.loadScript;
|
||||||
|
|
||||||
|
if (typeof loader === 'function') {
|
||||||
|
await loader(src, {}, { label: 'kginicis SDK' });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = src;
|
script.src = src;
|
||||||
script.async = true;
|
script.async = true;
|
||||||
|
|||||||
@@ -308,5 +308,10 @@
|
|||||||
"vbank_notify_hint": "Register this URL as the PC virtual account deposit notification URL in the KG Inicis merchant admin.",
|
"vbank_notify_hint": "Register this URL as the PC virtual account deposit notification URL in the KG Inicis merchant admin.",
|
||||||
"vbank_notify_copied": "URL copied to clipboard.",
|
"vbank_notify_copied": "URL copied to clipboard.",
|
||||||
"copy": "Copy"
|
"copy": "Copy"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"error": {
|
||||||
|
"sdk_url_untrusted": "The payment module address is not valid, so the payment cannot proceed. Please contact the administrator."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,5 +311,10 @@
|
|||||||
"vbank_notify_hint": "KG 이니시스 가맹점 어드민에서 이 URL을 PC 가상계좌 입금통보 URL로 등록해야 합니다.",
|
"vbank_notify_hint": "KG 이니시스 가맹점 어드민에서 이 URL을 PC 가상계좌 입금통보 URL로 등록해야 합니다.",
|
||||||
"vbank_notify_copied": "URL이 클립보드에 복사되었습니다.",
|
"vbank_notify_copied": "URL이 클립보드에 복사되었습니다.",
|
||||||
"copy": "복사"
|
"copy": "복사"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"error": {
|
||||||
|
"sdk_url_untrusted": "결제 모듈 주소가 올바르지 않아 결제를 진행할 수 없습니다. 관리자에게 문의해 주세요."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ OS 판별 후 `executeCliWindows()`/`executeCliLinux()` 로 분기 → CLI 인
|
|||||||
- [ ] IP 화이트리스트(`RestrictKcpIp`) 대상 라우트를 추가/변경하면 미들웨어 부착 대상(targets)도 함께 갱신
|
- [ ] IP 화이트리스트(`RestrictKcpIp`) 대상 라우트를 추가/변경하면 미들웨어 부착 대상(targets)도 함께 갱신
|
||||||
- [ ] 새 결제수단·통화를 추가하면 그 결제수단의 콜백 URL을 관리자 설정 안내(README "콜백 및 통보 URL")에도 반영
|
- [ ] 새 결제수단·통화를 추가하면 그 결제수단의 콜백 URL을 관리자 설정 안내(README "콜백 및 통보 URL")에도 반영
|
||||||
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-pay_nhnkcp --force`
|
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-pay_nhnkcp --force`
|
||||||
|
- [ ] NHN KCP 가 SDK 호스트를 바꾸면 `plugin.json` 의 `trusted_script_hosts`(+`trusted_script_hosts_reason`)와 `resources/js/handlers/requestPayment.ts` 의 `KNOWN_SDK_HOSTS` 를 **함께** 갱신 — 두 목록이 어긋나면 테스트가 실패하며, 코드 상수에 없는 호스트는 주입 직전 확인에서 거부되어 결제가 진행되지 않는다(fail-closed). 변경 후 `php artisan ext:docgen --scope=plugin:sirsoft-pay_nhnkcp` 재실행
|
||||||
|
|
||||||
## 6. 금지 패턴
|
## 6. 금지 패턴
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
- 결제창 프로그램을 불러오는 주소가 NHN KCP 의 주소인지 불러오기 직전에 확인합니다. 확인되지 않는 주소면 결제를 진행하지 않고 안내를 표시합니다.
|
||||||
- 제3자가 남의 주문번호만 알면 결제창을 거치지 않고도 그 주문을 취소시킬 수 있던 문제를 수정했습니다. 결제 결과 콜백은 로그인도 서명 확인도 거치지 않는 경로여서, 위조한 결제 정보를 보내 승인을 일부러 실패시키면 그 주문이 결제 실패로 처리되었습니다. 이제 실제 결제 승인이 이루어진 뒤의 실패만 주문에 반영하며, 승인 전 단계의 실패는 결제 화면으로 되돌려 보내기만 합니다. 구매자가 결제창을 닫아 생기는 정상적인 결제 실패는 종전처럼 기록됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2018)
|
- 제3자가 남의 주문번호만 알면 결제창을 거치지 않고도 그 주문을 취소시킬 수 있던 문제를 수정했습니다. 결제 결과 콜백은 로그인도 서명 확인도 거치지 않는 경로여서, 위조한 결제 정보를 보내 승인을 일부러 실패시키면 그 주문이 결제 실패로 처리되었습니다. 이제 실제 결제 승인이 이루어진 뒤의 실패만 주문에 반영하며, 승인 전 단계의 실패는 결제 화면으로 되돌려 보내기만 합니다. 구매자가 결제창을 닫아 생기는 정상적인 결제 실패는 종전처럼 기록됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2018)
|
||||||
- 같은 결제 결과 콜백에서 가상계좌 경로를 통해서도 남의 주문을 건드릴 수 있던 문제를 수정했습니다. 결제수단을 가상계좌라고 주장하는 값을 요청에 섞으면, 카드로 주문한 건도 결제사 확인을 거치지 않는 경로로 흘러 그 주문이 취소되거나 위조된 입금 계좌가 그 주문에 기록될 수 있었습니다. 이제 결제수단은 요청에 실려 온 값이 아니라 주문에 저장된 값으로만 판단하며, 가상계좌 발급이 확인되지 않은 경우에는 주문을 그대로 두고 결제 화면으로 되돌려 보냅니다.
|
- 같은 결제 결과 콜백에서 가상계좌 경로를 통해서도 남의 주문을 건드릴 수 있던 문제를 수정했습니다. 결제수단을 가상계좌라고 주장하는 값을 요청에 섞으면, 카드로 주문한 건도 결제사 확인을 거치지 않는 경로로 흘러 그 주문이 취소되거나 위조된 입금 계좌가 그 주문에 기록될 수 있었습니다. 이제 결제수단은 요청에 실려 온 값이 아니라 주문에 저장된 값으로만 판단하며, 가상계좌 발급이 확인되지 않은 경우에는 주문을 그대로 두고 결제 화면으로 되돌려 보냅니다.
|
||||||
- 취소된 주문을 다시 결제 대기 상태로 되돌리는 처리가, 결제사 승인이 확인되기 전에 이루어지던 문제를 수정했습니다. 주문번호만 알면 남의 취소된 주문을 되살릴 수 있었습니다. 이제 승인이나 계좌 발급이 확인된 뒤에만 되돌립니다.
|
- 취소된 주문을 다시 결제 대기 상태로 되돌리는 처리가, 결제사 승인이 확인되기 전에 이루어지던 문제를 수정했습니다. 주문번호만 알면 남의 취소된 주문을 되살릴 수 있었습니다. 이제 승인이나 계좌 발급이 확인된 뒤에만 되돌립니다.
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ PC 결제 승인은 `NhnKcpApiService`가 OS 를 판별해 `pp_cli`/`pp_cli_x64`
|
|||||||
| 그누보드7 코어 | `>=7.0.10` |
|
| 그누보드7 코어 | `>=7.0.10` |
|
||||||
| PHP | `^8.2` |
|
| PHP | `^8.2` |
|
||||||
| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
|
| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
|
||||||
|
| 외부 스크립트 호스트 | `testpay.kcp.co.kr`, `pay.kcp.co.kr` |
|
||||||
<!-- @generated:requirements END -->
|
<!-- @generated:requirements END -->
|
||||||
|
|
||||||
<!-- @intent START -->
|
<!-- @intent START -->
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -14,9 +14,9 @@
|
|||||||
| `sirsoft-pay_nhnkcp.escrow.purchase_cancelled` | action | — | `src/Controllers/EscrowCommonNotifyController.php:97` |
|
| `sirsoft-pay_nhnkcp.escrow.purchase_cancelled` | action | — | `src/Controllers/EscrowCommonNotifyController.php:97` |
|
||||||
| `sirsoft-pay_nhnkcp.escrow.purchase_confirmed` | action | — | `src/Controllers/EscrowCommonNotifyController.php:96` |
|
| `sirsoft-pay_nhnkcp.escrow.purchase_confirmed` | action | — | `src/Controllers/EscrowCommonNotifyController.php:96` |
|
||||||
| `sirsoft-pay_nhnkcp.payment.after_cancel` | action | KCP 결제 취소 완료 후 | `src/Services/NhnKcpApiService.php:250` |
|
| `sirsoft-pay_nhnkcp.payment.after_cancel` | action | KCP 결제 취소 완료 후 | `src/Services/NhnKcpApiService.php:250` |
|
||||||
| `sirsoft-pay_nhnkcp.payment.after_confirm` | action | KCP 결제 승인 확인 완료 후 | `src/Controllers/PaymentCallbackController.php:276` |
|
| `sirsoft-pay_nhnkcp.payment.after_confirm` | action | KCP 결제 승인 확인 완료 후 | `src/Controllers/PaymentCallbackController.php:278` |
|
||||||
| `sirsoft-pay_nhnkcp.payment.before_cancel` | action | KCP 결제 취소 API 호출 전 (본인인증 등 확장 지점) | `src/Services/NhnKcpApiService.php:229` |
|
| `sirsoft-pay_nhnkcp.payment.before_cancel` | action | KCP 결제 취소 API 호출 전 (본인인증 등 확장 지점) | `src/Services/NhnKcpApiService.php:229` |
|
||||||
| `sirsoft-pay_nhnkcp.payment.before_confirm` | action | KCP 결제 승인 확인 전 | `src/Controllers/PaymentCallbackController.php:271` |
|
| `sirsoft-pay_nhnkcp.payment.before_confirm` | action | KCP 결제 승인 확인 전 | `src/Controllers/PaymentCallbackController.php:273` |
|
||||||
<!-- @generated:hooks-published END -->
|
<!-- @generated:hooks-published END -->
|
||||||
|
|
||||||
<!-- @intent START -->
|
<!-- @intent START -->
|
||||||
|
|||||||
@@ -20,6 +20,14 @@
|
|||||||
},
|
},
|
||||||
"plugins": {}
|
"plugins": {}
|
||||||
},
|
},
|
||||||
|
"trusted_script_hosts": [
|
||||||
|
"testpay.kcp.co.kr",
|
||||||
|
"pay.kcp.co.kr"
|
||||||
|
],
|
||||||
|
"trusted_script_hosts_reason": {
|
||||||
|
"testpay.kcp.co.kr": "NHN KCP 테스트 결제창 SDK 다. 스크립트가 KCP 서버와 통신하므로 자체 호스팅해도 결제가 성립하지 않는다.",
|
||||||
|
"pay.kcp.co.kr": "NHN KCP 운영 결제창 SDK 다. 스크립트가 KCP 서버와 통신하므로 자체 호스팅해도 결제가 성립하지 않는다."
|
||||||
|
},
|
||||||
"assets": {
|
"assets": {
|
||||||
"js": {
|
"js": {
|
||||||
"entry": "resources/js/index.ts",
|
"entry": "resources/js/index.ts",
|
||||||
|
|||||||
+54
-1
@@ -6,8 +6,10 @@
|
|||||||
* tests/scenarios 매니페스트(통합 시나리오)에서 다루며, 본 단위 테스트는
|
* tests/scenarios 매니페스트(통합 시나리오)에서 다루며, 본 단위 테스트는
|
||||||
* "초기 가드 + catch 블록 정상 호출" 두 축에 집중합니다.
|
* "초기 가드 + catch 블록 정상 호출" 두 축에 집중합니다.
|
||||||
*/
|
*/
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import {
|
import { assertTrustedSdkUrl, KNOWN_SDK_HOSTS,
|
||||||
buildKcpEasyPayReturnFields,
|
buildKcpEasyPayReturnFields,
|
||||||
buildKcpTaxFields,
|
buildKcpTaxFields,
|
||||||
isSupportedKcpCurrency,
|
isSupportedKcpCurrency,
|
||||||
@@ -223,3 +225,54 @@ describe('requestPaymentHandler', () => {
|
|||||||
expect(buildKcpEasyPayReturnFields('nhnkcp_unknown', true)).toEqual({});
|
expect(buildKcpEasyPayReturnFields('nhnkcp_unknown', true)).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 주입 출처 게이트 (동적 스크립트 주입 정책).
|
||||||
|
*
|
||||||
|
* PG SDK 는 서비스 SDK 라 자체 호스팅할 수 없다. 그래서 **주입 직전에** 호스트를
|
||||||
|
* 확인하는 것이 유일한 게이트다. 확장자 없는 SDK URL 은 정적 검사에도 걸리지 않는다.
|
||||||
|
*
|
||||||
|
* manifest `trusted_script_hosts` 와 코드 상수가 어긋나면 그 차집합이 그대로
|
||||||
|
* 사각이 되므로 두 목록의 일치도 함께 고정한다.
|
||||||
|
*/
|
||||||
|
describe('SDK URL 신뢰 호스트 게이트', () => {
|
||||||
|
const TRUSTED_SDK_URL = 'https://pay.kcp.co.kr/plugin/payplus_web.jsp';
|
||||||
|
|
||||||
|
it('신뢰 호스트 https URL 은 통과한다', () => {
|
||||||
|
expect(() => assertTrustedSdkUrl(TRUSTED_SDK_URL)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['미신뢰 호스트', 'https://cdn.evil.com/sdk.js'],
|
||||||
|
['http (평문)', TRUSTED_SDK_URL.replace('https://', 'http://')],
|
||||||
|
['protocol-relative', TRUSTED_SDK_URL.replace('https://', '//')],
|
||||||
|
['상대 경로', '/local/sdk.js'],
|
||||||
|
['빈 문자열', ''],
|
||||||
|
['javascript 스킴', 'javascript:alert(1)'],
|
||||||
|
['신뢰 호스트를 userinfo 로 위장', 'https://evil.com/@' + new URL(TRUSTED_SDK_URL).hostname + '/x.js'],
|
||||||
|
['신뢰 호스트를 서브도메인 접미로 위장', 'https://' + new URL(TRUSTED_SDK_URL).hostname + '.evil.com/x.js'],
|
||||||
|
])('%s 는 거부된다', (_label, url) => {
|
||||||
|
expect(() => assertTrustedSdkUrl(url as string)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manifest trusted_script_hosts 와 KNOWN_SDK_HOSTS 가 일치한다', () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
readFileSync(resolve(__dirname, '../../../../plugin.json'), 'utf-8')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect([...(manifest.trusted_script_hosts ?? [])].sort()).toEqual(
|
||||||
|
[...KNOWN_SDK_HOSTS].sort()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manifest 가 호스트별 사유를 함께 선언한다', () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
readFileSync(resolve(__dirname, '../../../../plugin.json'), 'utf-8')
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const host of KNOWN_SDK_HOSTS) {
|
||||||
|
expect(typeof manifest.trusted_script_hosts_reason?.[host]).toBe('string');
|
||||||
|
expect(manifest.trusted_script_hosts_reason[host].length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -47,6 +47,64 @@ export function isSupportedKcpCurrency(currency?: string): boolean {
|
|||||||
return normalizeCurrency(currency) === 'KRW';
|
return normalizeCurrency(currency) === 'KRW';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 스크립트를 로드할 수 있는 호스트 (plugin.json `trusted_script_hosts` 미러).
|
||||||
|
*
|
||||||
|
* NHN KCP 결제창은 라이브러리가 아니라 그 회사 서버와 통신하는 서비스 SDK 라
|
||||||
|
* 자체 호스팅할 수 없다. 대신 **주입 직전에** 호스트를 확인해, 설정·응답이 어떤
|
||||||
|
* 경로로든 다른 주소를 지시하면 결제를 진행하지 않는다(fail-closed).
|
||||||
|
*
|
||||||
|
* PG사가 SDK 호스트를 바꾸면 이 상수와 plugin.json 을 **함께** 갱신한다 —
|
||||||
|
* 둘이 어긋나면 테스트가 실패한다.
|
||||||
|
*/
|
||||||
|
export const KNOWN_SDK_HOSTS: readonly string[] = ['testpay.kcp.co.kr', 'pay.kcp.co.kr'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 번역 문자열을 얻습니다.
|
||||||
|
*
|
||||||
|
* @param key 번역 키 (플러그인 네임스페이스 이하)
|
||||||
|
* @param fallback 번역 엔진 부재 시 사용할 문구
|
||||||
|
* @returns 번역된 문자열
|
||||||
|
*/
|
||||||
|
function t(key: string, fallback: string): string {
|
||||||
|
const translate = (window as any)?.G7Core?.t;
|
||||||
|
|
||||||
|
if (typeof translate !== 'function') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const full = `sirsoft-pay_nhnkcp.${key}`;
|
||||||
|
const result = translate(full);
|
||||||
|
|
||||||
|
return typeof result === 'string' && result !== full ? result : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK URL 이 신뢰 호스트인지 확인하고, 아니면 예외를 던집니다.
|
||||||
|
*
|
||||||
|
* @param url 주입할 SDK URL
|
||||||
|
* @throws Error 미신뢰 호스트이거나 https 가 아닌 경우
|
||||||
|
*/
|
||||||
|
export function assertTrustedSdkUrl(url: string): void {
|
||||||
|
let parsed: URL | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed === null
|
||||||
|
|| parsed.protocol !== 'https:'
|
||||||
|
|| !KNOWN_SDK_HOSTS.includes(parsed.hostname.toLowerCase())
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
t('payment.error.sdk_url_untrusted', '결제 모듈 주소가 올바르지 않아 결제를 진행할 수 없습니다.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function unsupportedCurrencyMessage(currency?: string): string {
|
function unsupportedCurrencyMessage(currency?: string): string {
|
||||||
const normalized = normalizeCurrency(currency);
|
const normalized = normalizeCurrency(currency);
|
||||||
const isKo = ((typeof document !== 'undefined' ? document.documentElement.lang : '') || '')
|
const isKo = ((typeof document !== 'undefined' ? document.documentElement.lang : '') || '')
|
||||||
@@ -468,6 +526,11 @@ async function handlePcPayment(
|
|||||||
.map(([n, v]) => `<input type="hidden" name="${n}" value="${v.replace(/"/g, '"')}">`)
|
.map(([n, v]) => `<input type="hidden" name="${n}" value="${v.replace(/"/g, '"')}">`)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
|
// SDK 주입 직전 출처 확인 — 아래 iframe `document.write` 는 코어 로더를 쓸 수 없으므로
|
||||||
|
// (동기 실행 순서가 KCP_Pay_Execute 호출 계약이다) 검증만 선행한다. 실패 시 결제를
|
||||||
|
// 진행하지 않는다(fail-closed).
|
||||||
|
assertTrustedSdkUrl(config.sdk_url);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
// 기존 요소 정리
|
// 기존 요소 정리
|
||||||
document.getElementById('kcp-sdk-iframe')?.remove();
|
document.getElementById('kcp-sdk-iframe')?.remove();
|
||||||
|
|||||||
@@ -154,5 +154,10 @@
|
|||||||
},
|
},
|
||||||
"easy_pay": {
|
"easy_pay": {
|
||||||
"section_title": "Easy Pay"
|
"section_title": "Easy Pay"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"error": {
|
||||||
|
"sdk_url_untrusted": "The payment module address is not valid, so the payment cannot proceed. Please contact the administrator."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,5 +154,10 @@
|
|||||||
},
|
},
|
||||||
"easy_pay": {
|
"easy_pay": {
|
||||||
"section_title": "간편결제"
|
"section_title": "간편결제"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"error": {
|
||||||
|
"sdk_url_untrusted": "결제 모듈 주소가 올바르지 않아 결제를 진행할 수 없습니다. 관리자에게 문의해 주세요."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ API 와 동기화하는 역할만 합니다. 등록은 훅 기반입니다
|
|||||||
- [ ] IP 화이트리스트(`VbankNotifyIpWhitelist`) 대상 라우트를 추가/변경하면 미들웨어 부착 대상(targets)도 함께 갱신
|
- [ ] IP 화이트리스트(`VbankNotifyIpWhitelist`) 대상 라우트를 추가/변경하면 미들웨어 부착 대상(targets)도 함께 갱신
|
||||||
- [ ] 새 간편결제 수단을 추가하면 그 결제수단의 계약 상태를 관리자 안내에도 반영
|
- [ ] 새 간편결제 수단을 추가하면 그 결제수단의 계약 상태를 관리자 안내에도 반영
|
||||||
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-pay_nicepayments --force`
|
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-pay_nicepayments --force`
|
||||||
|
- [ ] 나이스페이먼츠가 SDK 호스트를 바꾸면 `plugin.json` 의 `trusted_script_hosts`(+`trusted_script_hosts_reason`)와 `resources/js/handlers/requestPayment.ts` 의 `KNOWN_SDK_HOSTS` 를 **함께** 갱신 — 두 목록이 어긋나면 테스트가 실패하며, 코드 상수에 없는 호스트는 주입 직전 확인에서 거부되어 결제가 진행되지 않는다(fail-closed). 변경 후 `php artisan ext:docgen --scope=plugin:sirsoft-pay_nicepayments` 재실행
|
||||||
|
|
||||||
## 6. 금지 패턴
|
## 6. 금지 패턴
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
- 결제창 프로그램을 불러오는 주소가 나이스페이먼츠의 주소인지 불러오기 직전에 확인합니다. 확인되지 않는 주소면 결제를 진행하지 않고 안내를 표시합니다. 또한 결제창 프로그램이 실제로 준비되었는지를 기준으로 다음 단계를 진행하도록 바꿔, 프로그램이 아직 준비되지 않았는데 결제창이 열리지 않고 멈추던 상황을 없앴습니다.
|
||||||
- 결제창이 넘겨준 승인 요청 주소에 마침표·빗금처럼 보이는 특수문자를 섞으면, 나이스페이먼츠 도메인 검사를 통과하면서 실제로는 다른 서버로 승인 요청이 나갈 수 있던 문제를 수정했습니다. 그 요청에는 인증 토큰과 상점 아이디가 실려 있어 외부로 유출될 수 있었습니다. 이제 검사와 실제 연결이 주소를 같은 방식으로 해석하며, 로그인 정보가 포함된 주소도 거부합니다. 정상적인 나이스페이먼츠 주소는 그대로 사용됩니다.
|
- 결제창이 넘겨준 승인 요청 주소에 마침표·빗금처럼 보이는 특수문자를 섞으면, 나이스페이먼츠 도메인 검사를 통과하면서 실제로는 다른 서버로 승인 요청이 나갈 수 있던 문제를 수정했습니다. 그 요청에는 인증 토큰과 상점 아이디가 실려 있어 외부로 유출될 수 있었습니다. 이제 검사와 실제 연결이 주소를 같은 방식으로 해석하며, 로그인 정보가 포함된 주소도 거부합니다. 정상적인 나이스페이먼츠 주소는 그대로 사용됩니다.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ URL로 결과를 POST 하면 결제 완료 처리됩니다.
|
|||||||
| 그누보드7 코어 | `>=7.0.10` |
|
| 그누보드7 코어 | `>=7.0.10` |
|
||||||
| PHP | `^8.2` |
|
| PHP | `^8.2` |
|
||||||
| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
|
| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
|
||||||
|
| 외부 스크립트 호스트 | `web.nicepay.co.kr` |
|
||||||
<!-- @generated:requirements END -->
|
<!-- @generated:requirements END -->
|
||||||
|
|
||||||
<!-- @intent START -->
|
<!-- @intent START -->
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"identifier": "sirsoft-pay_nicepayments",
|
"identifier": "sirsoft-pay_nicepayments",
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"components": {
|
"components": {
|
||||||
"basic": [],
|
"basic": [],
|
||||||
"composite": [],
|
"composite": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -20,6 +20,12 @@
|
|||||||
},
|
},
|
||||||
"plugins": {}
|
"plugins": {}
|
||||||
},
|
},
|
||||||
|
"trusted_script_hosts": [
|
||||||
|
"web.nicepay.co.kr"
|
||||||
|
],
|
||||||
|
"trusted_script_hosts_reason": {
|
||||||
|
"web.nicepay.co.kr": "나이스페이먼츠 결제창 SDK 다. 스크립트가 나이스페이먼츠 서버와 통신하므로 자체 호스팅해도 결제가 성립하지 않는다."
|
||||||
|
},
|
||||||
"assets": {
|
"assets": {
|
||||||
"js": {
|
"js": {
|
||||||
"entry": "resources/js/index.ts",
|
"entry": "resources/js/index.ts",
|
||||||
|
|||||||
+54
-1
@@ -6,8 +6,10 @@
|
|||||||
* tests/scenarios 매니페스트(통합 시나리오)에서 다루며, 본 단위 테스트는
|
* tests/scenarios 매니페스트(통합 시나리오)에서 다루며, 본 단위 테스트는
|
||||||
* "초기 가드 + catch 블록 정상 호출" 두 축에 집중합니다.
|
* "초기 가드 + catch 블록 정상 호출" 두 축에 집중합니다.
|
||||||
*/
|
*/
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { requestPaymentHandler } from '../../handlers/requestPayment';
|
import { assertTrustedSdkUrl, KNOWN_SDK_HOSTS, requestPaymentHandler } from '../../handlers/requestPayment';
|
||||||
|
|
||||||
const PG_PAYMENT = {
|
const PG_PAYMENT = {
|
||||||
order_number: 'ORD-001',
|
order_number: 'ORD-001',
|
||||||
@@ -283,3 +285,54 @@ describe('requestPaymentHandler', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 주입 출처 게이트 (동적 스크립트 주입 정책).
|
||||||
|
*
|
||||||
|
* PG SDK 는 서비스 SDK 라 자체 호스팅할 수 없다. 그래서 **주입 직전에** 호스트를
|
||||||
|
* 확인하는 것이 유일한 게이트다. 확장자 없는 SDK URL 은 정적 검사에도 걸리지 않는다.
|
||||||
|
*
|
||||||
|
* manifest `trusted_script_hosts` 와 코드 상수가 어긋나면 그 차집합이 그대로
|
||||||
|
* 사각이 되므로 두 목록의 일치도 함께 고정한다.
|
||||||
|
*/
|
||||||
|
describe('SDK URL 신뢰 호스트 게이트', () => {
|
||||||
|
const TRUSTED_SDK_URL = 'https://web.nicepay.co.kr/v3/webstd/js/nicepay-3.0.js';
|
||||||
|
|
||||||
|
it('신뢰 호스트 https URL 은 통과한다', () => {
|
||||||
|
expect(() => assertTrustedSdkUrl(TRUSTED_SDK_URL)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['미신뢰 호스트', 'https://cdn.evil.com/sdk.js'],
|
||||||
|
['http (평문)', TRUSTED_SDK_URL.replace('https://', 'http://')],
|
||||||
|
['protocol-relative', TRUSTED_SDK_URL.replace('https://', '//')],
|
||||||
|
['상대 경로', '/local/sdk.js'],
|
||||||
|
['빈 문자열', ''],
|
||||||
|
['javascript 스킴', 'javascript:alert(1)'],
|
||||||
|
['신뢰 호스트를 userinfo 로 위장', 'https://evil.com/@' + new URL(TRUSTED_SDK_URL).hostname + '/x.js'],
|
||||||
|
['신뢰 호스트를 서브도메인 접미로 위장', 'https://' + new URL(TRUSTED_SDK_URL).hostname + '.evil.com/x.js'],
|
||||||
|
])('%s 는 거부된다', (_label, url) => {
|
||||||
|
expect(() => assertTrustedSdkUrl(url as string)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manifest trusted_script_hosts 와 KNOWN_SDK_HOSTS 가 일치한다', () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
readFileSync(resolve(__dirname, '../../../../plugin.json'), 'utf-8')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect([...(manifest.trusted_script_hosts ?? [])].sort()).toEqual(
|
||||||
|
[...KNOWN_SDK_HOSTS].sort()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manifest 가 호스트별 사유를 함께 선언한다', () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
readFileSync(resolve(__dirname, '../../../../plugin.json'), 'utf-8')
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const host of KNOWN_SDK_HOSTS) {
|
||||||
|
expect(typeof manifest.trusted_script_hosts_reason?.[host]).toBe('string');
|
||||||
|
expect(manifest.trusted_script_hosts_reason[host].length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -82,13 +82,93 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadScript(src: string): Promise<void> {
|
/**
|
||||||
return new Promise((resolve, reject) => {
|
* SDK 스크립트를 로드할 수 있는 호스트 (plugin.json `trusted_script_hosts` 미러).
|
||||||
if (document.querySelector(`script[src="${src}"]`)) {
|
*
|
||||||
resolve();
|
* 나이스페이먼츠 결제창은 라이브러리가 아니라 그 회사 서버와 통신하는 서비스 SDK 라
|
||||||
return;
|
* 자체 호스팅할 수 없다. 대신 **주입 직전에** 호스트를 확인해, 설정·응답이 어떤
|
||||||
}
|
* 경로로든 다른 주소를 지시하면 결제를 진행하지 않는다(fail-closed).
|
||||||
|
*
|
||||||
|
* PG사가 SDK 호스트를 바꾸면 이 상수와 plugin.json 을 **함께** 갱신한다 —
|
||||||
|
* 둘이 어긋나면 테스트가 실패한다.
|
||||||
|
*/
|
||||||
|
export const KNOWN_SDK_HOSTS: readonly string[] = ['web.nicepay.co.kr'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 번역 문자열을 얻습니다.
|
||||||
|
*
|
||||||
|
* @param key 번역 키 (플러그인 네임스페이스 이하)
|
||||||
|
* @param fallback 번역 엔진 부재 시 사용할 문구
|
||||||
|
* @returns 번역된 문자열
|
||||||
|
*/
|
||||||
|
function t(key: string, fallback: string): string {
|
||||||
|
const translate = (window as any)?.G7Core?.t;
|
||||||
|
|
||||||
|
if (typeof translate !== 'function') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const full = `sirsoft-pay_nicepayments.${key}`;
|
||||||
|
const result = translate(full);
|
||||||
|
|
||||||
|
return typeof result === 'string' && result !== full ? result : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK URL 이 신뢰 호스트인지 확인하고, 아니면 예외를 던집니다.
|
||||||
|
*
|
||||||
|
* @param url 주입할 SDK URL
|
||||||
|
* @throws Error 미신뢰 호스트이거나 https 가 아닌 경우
|
||||||
|
*/
|
||||||
|
export function assertTrustedSdkUrl(url: string): void {
|
||||||
|
let parsed: URL | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed === null
|
||||||
|
|| parsed.protocol !== 'https:'
|
||||||
|
|| !KNOWN_SDK_HOSTS.includes(parsed.hostname.toLowerCase())
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
t('payment.error.sdk_url_untrusted', '결제 모듈 주소가 올바르지 않아 결제를 진행할 수 없습니다.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 스크립트를 로드합니다.
|
||||||
|
*
|
||||||
|
* 완료 판정은 **SDK 전역 확보**로 한다 — DOM 에 태그가 있다는 것은 로드 완료를
|
||||||
|
* 뜻하지 않는다(로드 중이거나, 실패해 남은 잔재일 수 있다). 종전에는 태그 존재만으로
|
||||||
|
* 즉시 resolve 해서, 전역이 없는 상태로 다음 단계가 진행되고 결제창이 열리지 않았다.
|
||||||
|
*
|
||||||
|
* @param src SDK URL
|
||||||
|
* @throws Error 미신뢰 호스트이거나 로드에 실패한 경우
|
||||||
|
*/
|
||||||
|
async function loadScript(src: string): Promise<void> {
|
||||||
|
assertTrustedSdkUrl(src);
|
||||||
|
|
||||||
|
if (typeof window.goPay === 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역이 없는데 태그만 남아 있으면 미완료·실패 잔재다 — 제거 후 새로 로드한다.
|
||||||
|
document.querySelectorAll(`script[src="${CSS.escape(src)}"]`).forEach((el) => el.remove());
|
||||||
|
|
||||||
|
const loader = (window as any)?.G7Core?.asset?.loadScript;
|
||||||
|
|
||||||
|
if (typeof loader === 'function') {
|
||||||
|
await loader(src, {}, { label: 'nicepayments SDK' });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = src;
|
script.src = src;
|
||||||
script.onload = () => resolve();
|
script.onload = () => resolve();
|
||||||
|
|||||||
@@ -150,5 +150,10 @@
|
|||||||
"amount_mismatch": "Payment amount does not match.",
|
"amount_mismatch": "Payment amount does not match.",
|
||||||
"authorize_failed": "Payment authorization failed."
|
"authorize_failed": "Payment authorization failed."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"error": {
|
||||||
|
"sdk_url_untrusted": "The payment module address is not valid, so the payment cannot proceed. Please contact the administrator."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,5 +150,10 @@
|
|||||||
"amount_mismatch": "결제 금액이 일치하지 않습니다.",
|
"amount_mismatch": "결제 금액이 일치하지 않습니다.",
|
||||||
"authorize_failed": "결제 승인에 실패했습니다."
|
"authorize_failed": "결제 승인에 실패했습니다."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"error": {
|
||||||
|
"sdk_url_untrusted": "결제 모듈 주소가 올바르지 않아 결제를 진행할 수 없습니다. 관리자에게 문의해 주세요."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@
|
|||||||
- [ ] `order_sheet_mode` 관련 로직을 고칠 때 `RegisterPgProviderListener`(enabled_methods)와 `RegisterTossPaymentMethodsListener`(builtin 결제수단 주입) 양쪽을 함께 갱신 — 한쪽만 고치면 설정과 노출 목록이 어긋난다
|
- [ ] `order_sheet_mode` 관련 로직을 고칠 때 `RegisterPgProviderListener`(enabled_methods)와 `RegisterTossPaymentMethodsListener`(builtin 결제수단 주입) 양쪽을 함께 갱신 — 한쪽만 고치면 설정과 노출 목록이 어긋난다
|
||||||
- [ ] `ValidateTossSettingsListener`에 새 범위 검증을 추가하면 `core.plugin_settings.before_save` 의 `sync: true`를 유지
|
- [ ] `ValidateTossSettingsListener`에 새 범위 검증을 추가하면 `core.plugin_settings.before_save` 의 `sync: true`를 유지
|
||||||
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 를 확인 — 이 확장은 편집기 스펙이 없어도 되는 상태(공용 ID 만 사용)다. 이 확장만 쓰는 `data_source` 를 새로 붙이는 순간 `editor-spec.json` 신설이 필요해진다
|
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 를 확인 — 이 확장은 편집기 스펙이 없어도 되는 상태(공용 ID 만 사용)다. 이 확장만 쓰는 `data_source` 를 새로 붙이는 순간 `editor-spec.json` 신설이 필요해진다
|
||||||
|
- [ ] 토스페이먼츠가 SDK 호스트를 바꾸면 `plugin.json` 의 `trusted_script_hosts`(+`trusted_script_hosts_reason`)와 `resources/js/handlers/requestPayment.ts` 의 `KNOWN_SDK_HOSTS` 를 **함께** 갱신 — 두 목록이 어긋나면 테스트가 실패하며, 코드 상수에 없는 호스트는 주입 직전 확인에서 거부되어 결제가 진행되지 않는다(fail-closed). 변경 후 `php artisan ext:docgen --scope=plugin:sirsoft-tosspayments` 재실행
|
||||||
|
|
||||||
## 6. 금지 패턴
|
## 6. 금지 패턴
|
||||||
|
|
||||||
@@ -151,7 +152,7 @@
|
|||||||
| PHPUnit | 13개 | `plugins/_bundled/sirsoft-tosspayments/tests` |
|
| PHPUnit | 13개 | `plugins/_bundled/sirsoft-tosspayments/tests` |
|
||||||
| Vitest | 6개 | `vitest.config.ts` |
|
| Vitest | 6개 | `vitest.config.ts` |
|
||||||
| Playwright | 0개 | — |
|
| Playwright | 0개 | — |
|
||||||
| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
|
| 시나리오 매니페스트 | 4개 | `tests/scenarios` |
|
||||||
|
|
||||||
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
|
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
- 결제창 프로그램을 불러오는 주소가 토스페이먼츠의 주소인지 불러오기 직전에 확인합니다. 확인되지 않는 주소면 결제를 진행하지 않고 안내를 표시합니다. 또한 결제창 프로그램이 실제로 준비되었는지를 기준으로 다음 단계를 진행하도록 바꿔, 프로그램이 아직 준비되지 않았는데 결제창이 열리지 않고 멈추던 상황을 없앴습니다.
|
||||||
- 제3자가 남의 주문번호만 알면 주소 하나로 그 주문을 취소시킬 수 있던 문제를 수정했습니다. 결제 실패 안내 주소는 로그인도 서명 확인도 거치지 않고 주문번호와 실패 사유를 주소에 담아 받는 경로여서, 그 주소를 열기만 해도 해당 주문이 결제 실패로 처리되었습니다. 이제 이 주소는 주문 상태를 바꾸지 않고 결제 화면으로 되돌려 보내기만 합니다. 결제 성립은 종전처럼 서버 승인 확인으로, 결제완료 후 취소는 서명이 확인된 입금·취소 통보로 처리됩니다.
|
- 제3자가 남의 주문번호만 알면 주소 하나로 그 주문을 취소시킬 수 있던 문제를 수정했습니다. 결제 실패 안내 주소는 로그인도 서명 확인도 거치지 않고 주문번호와 실패 사유를 주소에 담아 받는 경로여서, 그 주소를 열기만 해도 해당 주문이 결제 실패로 처리되었습니다. 이제 이 주소는 주문 상태를 바꾸지 않고 결제 화면으로 되돌려 보내기만 합니다. 결제 성립은 종전처럼 서버 승인 확인으로, 결제완료 후 취소는 서명이 확인된 입금·취소 통보로 처리됩니다.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ flowchart LR
|
|||||||
| 그누보드7 코어 | `>=7.0.10` |
|
| 그누보드7 코어 | `>=7.0.10` |
|
||||||
| PHP | `^8.2` |
|
| PHP | `^8.2` |
|
||||||
| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
|
| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
|
||||||
|
| 외부 스크립트 호스트 | `js.tosspayments.com` |
|
||||||
<!-- @generated:requirements END -->
|
<!-- @generated:requirements END -->
|
||||||
|
|
||||||
## 설치
|
## 설치
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -20,6 +20,12 @@
|
|||||||
},
|
},
|
||||||
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-tosspayments",
|
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-tosspayments",
|
||||||
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-tosspayments/blob/main/CHANGELOG.md",
|
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-tosspayments/blob/main/CHANGELOG.md",
|
||||||
|
"trusted_script_hosts": [
|
||||||
|
"js.tosspayments.com"
|
||||||
|
],
|
||||||
|
"trusted_script_hosts_reason": {
|
||||||
|
"js.tosspayments.com": "토스페이먼츠 결제위젯 SDK 다. 스크립트가 토스페이먼츠 서버와 통신하므로 자체 호스팅해도 결제가 성립하지 않는다."
|
||||||
|
},
|
||||||
"assets": {
|
"assets": {
|
||||||
"js": {
|
"js": {
|
||||||
"entry": "resources/js/index.ts",
|
"entry": "resources/js/index.ts",
|
||||||
|
|||||||
+54
-1
@@ -11,8 +11,10 @@
|
|||||||
* escrow_products_absent_when_escrow_off,
|
* escrow_products_absent_when_escrow_off,
|
||||||
* escrow_products_absent_for_card
|
* escrow_products_absent_for_card
|
||||||
*/
|
*/
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { requestPaymentHandler } from '../../handlers/requestPayment';
|
import { assertTrustedSdkUrl, KNOWN_SDK_HOSTS, requestPaymentHandler } from '../../handlers/requestPayment';
|
||||||
|
|
||||||
const CLIENT_CONFIG_DATA = {
|
const CLIENT_CONFIG_DATA = {
|
||||||
data: {
|
data: {
|
||||||
@@ -419,3 +421,54 @@ describe('requestPaymentHandler', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 주입 출처 게이트 (동적 스크립트 주입 정책).
|
||||||
|
*
|
||||||
|
* PG SDK 는 서비스 SDK 라 자체 호스팅할 수 없다. 그래서 **주입 직전에** 호스트를
|
||||||
|
* 확인하는 것이 유일한 게이트다. 확장자 없는 SDK URL 은 정적 검사에도 걸리지 않는다.
|
||||||
|
*
|
||||||
|
* manifest `trusted_script_hosts` 와 코드 상수가 어긋나면 그 차집합이 그대로
|
||||||
|
* 사각이 되므로 두 목록의 일치도 함께 고정한다.
|
||||||
|
*/
|
||||||
|
describe('SDK URL 신뢰 호스트 게이트', () => {
|
||||||
|
const TRUSTED_SDK_URL = 'https://js.tosspayments.com/v2/standard';
|
||||||
|
|
||||||
|
it('신뢰 호스트 https URL 은 통과한다', () => {
|
||||||
|
expect(() => assertTrustedSdkUrl(TRUSTED_SDK_URL)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['미신뢰 호스트', 'https://cdn.evil.com/sdk.js'],
|
||||||
|
['http (평문)', TRUSTED_SDK_URL.replace('https://', 'http://')],
|
||||||
|
['protocol-relative', TRUSTED_SDK_URL.replace('https://', '//')],
|
||||||
|
['상대 경로', '/local/sdk.js'],
|
||||||
|
['빈 문자열', ''],
|
||||||
|
['javascript 스킴', 'javascript:alert(1)'],
|
||||||
|
['신뢰 호스트를 userinfo 로 위장', 'https://evil.com/@' + new URL(TRUSTED_SDK_URL).hostname + '/x.js'],
|
||||||
|
['신뢰 호스트를 서브도메인 접미로 위장', 'https://' + new URL(TRUSTED_SDK_URL).hostname + '.evil.com/x.js'],
|
||||||
|
])('%s 는 거부된다', (_label, url) => {
|
||||||
|
expect(() => assertTrustedSdkUrl(url as string)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manifest trusted_script_hosts 와 KNOWN_SDK_HOSTS 가 일치한다', () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
readFileSync(resolve(__dirname, '../../../../plugin.json'), 'utf-8')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect([...(manifest.trusted_script_hosts ?? [])].sort()).toEqual(
|
||||||
|
[...KNOWN_SDK_HOSTS].sort()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manifest 가 호스트별 사유를 함께 선언한다', () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
readFileSync(resolve(__dirname, '../../../../plugin.json'), 'utf-8')
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const host of KNOWN_SDK_HOSTS) {
|
||||||
|
expect(typeof manifest.trusted_script_hosts_reason?.[host]).toBe('string');
|
||||||
|
expect(manifest.trusted_script_hosts_reason[host].length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -78,14 +78,96 @@ declare global {
|
|||||||
* @param src 스크립트 URL
|
* @param src 스크립트 URL
|
||||||
* @returns Promise
|
* @returns Promise
|
||||||
*/
|
*/
|
||||||
function loadScript(src: string): Promise<void> {
|
/**
|
||||||
return new Promise((resolve, reject) => {
|
* SDK 스크립트를 로드할 수 있는 호스트 (plugin.json `trusted_script_hosts` 미러).
|
||||||
// 이미 로드된 스크립트인지 확인
|
*
|
||||||
if (document.querySelector(`script[src="${src}"]`)) {
|
* 토스페이먼츠 결제위젯은 라이브러리가 아니라 그 회사 서버와 통신하는 서비스 SDK 라
|
||||||
resolve();
|
* 자체 호스팅할 수 없다. 대신 **주입 직전에** 호스트를 확인해, 설정·응답이 어떤
|
||||||
return;
|
* 경로로든 다른 주소를 지시하면 결제를 진행하지 않는다(fail-closed).
|
||||||
}
|
*
|
||||||
|
* PG사가 SDK 호스트를 바꾸면 이 상수와 plugin.json 을 **함께** 갱신한다 —
|
||||||
|
* 둘이 어긋나면 테스트가 실패한다.
|
||||||
|
*
|
||||||
|
* 이 SDK URL(`/v2/standard`)은 확장자가 없어 정적 검사에 걸리지 않는다 —
|
||||||
|
* 이 런타임 검증이 유일한 게이트다.
|
||||||
|
*/
|
||||||
|
export const KNOWN_SDK_HOSTS: readonly string[] = ['js.tosspayments.com'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 번역 문자열을 얻습니다.
|
||||||
|
*
|
||||||
|
* @param key 번역 키 (플러그인 네임스페이스 이하)
|
||||||
|
* @param fallback 번역 엔진 부재 시 사용할 문구
|
||||||
|
* @returns 번역된 문자열
|
||||||
|
*/
|
||||||
|
function t(key: string, fallback: string): string {
|
||||||
|
const translate = (window as any)?.G7Core?.t;
|
||||||
|
|
||||||
|
if (typeof translate !== 'function') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const full = `sirsoft-tosspayments.${key}`;
|
||||||
|
const result = translate(full);
|
||||||
|
|
||||||
|
return typeof result === 'string' && result !== full ? result : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK URL 이 신뢰 호스트인지 확인하고, 아니면 예외를 던집니다.
|
||||||
|
*
|
||||||
|
* @param url 주입할 SDK URL
|
||||||
|
* @throws Error 미신뢰 호스트이거나 https 가 아닌 경우
|
||||||
|
*/
|
||||||
|
export function assertTrustedSdkUrl(url: string): void {
|
||||||
|
let parsed: URL | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed === null
|
||||||
|
|| parsed.protocol !== 'https:'
|
||||||
|
|| !KNOWN_SDK_HOSTS.includes(parsed.hostname.toLowerCase())
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
t('payment.error.sdk_url_untrusted', '결제 모듈 주소가 올바르지 않아 결제를 진행할 수 없습니다.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK 스크립트를 로드합니다.
|
||||||
|
*
|
||||||
|
* 완료 판정은 **SDK 전역 확보**로 한다 — DOM 에 태그가 있다는 것은 로드 완료를
|
||||||
|
* 뜻하지 않는다(로드 중이거나, 실패해 남은 잔재일 수 있다). 종전에는 태그 존재만으로
|
||||||
|
* 즉시 resolve 해서, 전역이 없는 상태로 다음 단계가 진행되고 결제창이 열리지 않았다.
|
||||||
|
*
|
||||||
|
* @param src SDK URL
|
||||||
|
* @throws Error 미신뢰 호스트이거나 로드에 실패한 경우
|
||||||
|
*/
|
||||||
|
async function loadScript(src: string): Promise<void> {
|
||||||
|
assertTrustedSdkUrl(src);
|
||||||
|
|
||||||
|
if (window.TossPayments) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역이 없는데 태그만 남아 있으면 미완료·실패 잔재다 — 제거 후 새로 로드한다.
|
||||||
|
document.querySelectorAll(`script[src="${CSS.escape(src)}"]`).forEach((el) => el.remove());
|
||||||
|
|
||||||
|
const loader = (window as any)?.G7Core?.asset?.loadScript;
|
||||||
|
|
||||||
|
if (typeof loader === 'function') {
|
||||||
|
await loader(src, {}, { label: 'tosspayments SDK' });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = src;
|
script.src = src;
|
||||||
script.async = true;
|
script.async = true;
|
||||||
|
|||||||
@@ -90,5 +90,10 @@
|
|||||||
"vbank_due_label": "Deposit deadline",
|
"vbank_due_label": "Deposit deadline",
|
||||||
"receipt_label": "Receipt",
|
"receipt_label": "Receipt",
|
||||||
"receipt_view": "View receipt"
|
"receipt_view": "View receipt"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"error": {
|
||||||
|
"sdk_url_untrusted": "The payment module address is not valid, so the payment cannot proceed. Please contact the administrator."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,5 +90,10 @@
|
|||||||
"vbank_due_label": "입금 기한",
|
"vbank_due_label": "입금 기한",
|
||||||
"receipt_label": "영수증",
|
"receipt_label": "영수증",
|
||||||
"receipt_view": "영수증 보기"
|
"receipt_view": "영수증 보기"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"error": {
|
||||||
|
"sdk_url_untrusted": "결제 모듈 주소가 올바르지 않아 결제를 진행할 수 없습니다. 관리자에게 문의해 주세요."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
File diff suppressed because one or more lines are too long
+19
-19
File diff suppressed because one or more lines are too long
@@ -39,6 +39,12 @@ import {
|
|||||||
import { notifyAssetFailure, clearAssetFailure } from './assets/AssetFailureNotice';
|
import { notifyAssetFailure, clearAssetFailure } from './assets/AssetFailureNotice';
|
||||||
import { suffixed, extStaticUrl, convertToCurrentMode } from './support/assetUrl';
|
import { suffixed, extStaticUrl, convertToCurrentMode } from './support/assetUrl';
|
||||||
import { fetchStaticFirst } from './support/fetchStaticFirst';
|
import { fetchStaticFirst } from './support/fetchStaticFirst';
|
||||||
|
import {
|
||||||
|
normalizeScriptSrcForOriginCheck as normalizeScriptSrcForOriginCheckImpl,
|
||||||
|
extractScriptHost as extractScriptHostImpl,
|
||||||
|
getTrustedScriptHosts as getTrustedScriptHostsImpl,
|
||||||
|
isAllowedScriptSrc as isAllowedScriptSrcImpl,
|
||||||
|
} from './support/scriptSrcPolicy';
|
||||||
import { resetLocalInitTracking } from './template-engine/localInitSlot';
|
import { resetLocalInitTracking } from './template-engine/localInitSlot';
|
||||||
/**
|
/**
|
||||||
* DevTools 추적 - G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다.
|
* DevTools 추적 - G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다.
|
||||||
@@ -2167,35 +2173,15 @@ export class TemplateApp {
|
|||||||
* (cdn.ckeditor.com), Daum 우편번호(t1.daumcdn.net).
|
* (cdn.ckeditor.com), Daum 우편번호(t1.daumcdn.net).
|
||||||
* 차단: 그 외 `//`(protocol-relative)·scheme 포함 외부 origin(미선언 원격 코드 로드).
|
* 차단: 그 외 `//`(protocol-relative)·scheme 포함 외부 origin(미선언 원격 코드 로드).
|
||||||
*
|
*
|
||||||
|
* 판정식 자체는 `support/scriptSrcPolicy` 가 SSoT 다 — 같은 판정을 쓰는 주입 경로가
|
||||||
|
* 레이아웃 `scripts[]` 말고도 여럿(loadScript 액션·확장 핸들러 재로드·편집기 프리뷰·
|
||||||
|
* `G7Core.asset.loadScript`)이라, 사본이 생기면 그 차집합이 우회로가 된다.
|
||||||
|
*
|
||||||
* @param src 스크립트 src 문자열
|
* @param src 스크립트 src 문자열
|
||||||
* @returns 로드 허용이면 true
|
* @returns 로드 허용이면 true
|
||||||
*/
|
*/
|
||||||
private isAllowedScriptSrc(src: string): boolean {
|
private isAllowedScriptSrc(src: string): boolean {
|
||||||
if (typeof src !== 'string') {
|
return isAllowedScriptSrcImpl(src);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = src.trim();
|
|
||||||
|
|
||||||
if (trimmed === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 접두 검사 전에 브라우저 URL 파서와 동일하게 정규화한다 (아래 메서드 주석 참조)
|
|
||||||
const normalized = TemplateApp.normalizeScriptSrcForOriginCheck(trimmed);
|
|
||||||
|
|
||||||
const isProtocolRelative = normalized.startsWith('//');
|
|
||||||
const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(normalized);
|
|
||||||
|
|
||||||
// same-origin path-only 절대 경로 (`/api/...`) — 항상 허용
|
|
||||||
if (!isProtocolRelative && !hasScheme && normalized.startsWith('/')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 외부 origin — 확장이 선언한 신뢰 호스트만 허용
|
|
||||||
const host = this.extractScriptHost(normalized);
|
|
||||||
|
|
||||||
return host !== null && this.getTrustedScriptHosts().includes(host);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2211,22 +2197,15 @@ export class TemplateApp {
|
|||||||
* 않으므로 그대로 same-origin 으로 통과합니다(과차단 없음).
|
* 않으므로 그대로 same-origin 으로 통과합니다(과차단 없음).
|
||||||
*
|
*
|
||||||
* 저장측 `SafeLayoutExpressions::normalizeForOriginCheck` · 정적 검사
|
* 저장측 `SafeLayoutExpressions::normalizeForOriginCheck` · 정적 검사
|
||||||
* `layout-scripts-src-same-origin` 과 3층 동형이어야 합니다.
|
* `layout-scripts-src-same-origin` 과 3층 동형이어야 합니다. 구현은
|
||||||
|
* `support/scriptSrcPolicy` 가 SSoT 이며 이 메서드는 위임입니다.
|
||||||
*
|
*
|
||||||
* @since engine-v1.60.2
|
* @since engine-v1.60.2
|
||||||
* @param src 원본 src 문자열
|
* @param src 원본 src 문자열
|
||||||
* @returns 정규화된 src
|
* @returns 정규화된 src
|
||||||
*/
|
*/
|
||||||
private static normalizeScriptSrcForOriginCheck(src: string): string {
|
private static normalizeScriptSrcForOriginCheck(src: string): string {
|
||||||
// ASCII tab / LF / CR 제거 (브라우저 파서가 파싱 전에 제거하는 문자)
|
return normalizeScriptSrcForOriginCheckImpl(src);
|
||||||
// → 백슬래시를 슬래시로 (special scheme 에서 등가)
|
|
||||||
const slashed = src.replace(/[\t\n\r]/g, '').replace(/\\/g, '/');
|
|
||||||
|
|
||||||
// 선행 슬래시가 3개 이상이어도 브라우저는 authority 시작으로 접는다
|
|
||||||
// (`///host/x` ≡ `//host/x`, `https:///host/x` ≡ `https://host/x`).
|
|
||||||
// 경로 중간의 연속 슬래시(`/js//a.js`)는 브라우저도 경로로 두므로 건드리지 않는다.
|
|
||||||
// @since engine-v1.60.3
|
|
||||||
return slashed.replace(/^([a-z][a-z0-9+.\-]*:)?\/{2,}/i, '$1//');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2239,20 +2218,7 @@ export class TemplateApp {
|
|||||||
* @returns 소문자 호스트명 (판정 불가 시 null)
|
* @returns 소문자 호스트명 (판정 불가 시 null)
|
||||||
*/
|
*/
|
||||||
private extractScriptHost(src: string): string | null {
|
private extractScriptHost(src: string): string | null {
|
||||||
try {
|
return extractScriptHostImpl(src);
|
||||||
const normalized = src.startsWith('//')
|
|
||||||
? `${window.location.protocol}${src}`
|
|
||||||
: src;
|
|
||||||
const url = new URL(normalized, window.location.origin);
|
|
||||||
|
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return url.hostname.toLowerCase();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2261,11 +2227,7 @@ export class TemplateApp {
|
|||||||
* @returns 소문자 호스트명 배열 (window.G7Config.trustedScriptHosts)
|
* @returns 소문자 호스트명 배열 (window.G7Config.trustedScriptHosts)
|
||||||
*/
|
*/
|
||||||
private getTrustedScriptHosts(): string[] {
|
private getTrustedScriptHosts(): string[] {
|
||||||
const hosts = (window as any).G7Config?.trustedScriptHosts;
|
return getTrustedScriptHostsImpl();
|
||||||
|
|
||||||
return Array.isArray(hosts)
|
|
||||||
? hosts.map((host: unknown) => String(host).toLowerCase())
|
|
||||||
: [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1653,6 +1653,46 @@ describe('G7CoreGlobals - 자산 API', () => {
|
|||||||
expect(typeof G7Core.asset.loadStylesheet).toBe('function');
|
expect(typeof G7Core.asset.loadStylesheet).toBe('function');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loadScript 출처 게이트', () => {
|
||||||
|
/**
|
||||||
|
* @effects untrusted_external_script_blocked
|
||||||
|
*/
|
||||||
|
it('미신뢰 외부 URL 은 reject 된다', async () => {
|
||||||
|
await expect(G7Core.asset.loadScript('https://cdn.evil.com/x.js')).rejects.toThrow(
|
||||||
|
/Blocked untrusted script src/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authority 우회 형태도 reject 된다', async () => {
|
||||||
|
await expect(G7Core.asset.loadScript('/\\/evil.com/x.js')).rejects.toThrow(
|
||||||
|
/Blocked untrusted script src/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @effects trusted_script_host_allowlist_wired
|
||||||
|
*/
|
||||||
|
it('same-origin 경로와 선언된 신뢰 호스트는 게이트를 통과한다', () => {
|
||||||
|
(window as any).G7Config.trustedScriptHosts = ['t1.daumcdn.net'];
|
||||||
|
|
||||||
|
// 실제 네트워크를 타지 않도록 로드는 즉시 실패시키고, 게이트 통과 여부만 본다
|
||||||
|
vi.spyOn(document.head, 'appendChild').mockImplementation(((node: any) => {
|
||||||
|
queueMicrotask(() => node.onerror?.(new Event('error')));
|
||||||
|
return node;
|
||||||
|
}) as any);
|
||||||
|
|
||||||
|
expect(G7Core.asset.isAllowedScriptSrc('/api/plugins/assets/x/dist/a.js')).toBe(true);
|
||||||
|
expect(G7Core.asset.isAllowedScriptSrc('//t1.daumcdn.net/postcode.v2.js')).toBe(true);
|
||||||
|
expect(G7Core.asset.isAllowedScriptSrc('https://cdn.evil.com/x.js')).toBe(false);
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('판정 함수를 공개 seam 으로 노출한다 (로더를 못 쓰는 주입용)', () => {
|
||||||
|
expect(typeof G7Core.asset.isAllowedScriptSrc).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @scenario asset_class=vendored, outcome=failed
|
* @scenario asset_class=vendored, outcome=failed
|
||||||
* @effects failed_asset_shows_retry_notice
|
* @effects failed_asset_shows_retry_notice
|
||||||
|
|||||||
@@ -397,6 +397,15 @@ export class ModuleAssetLoader {
|
|||||||
/**
|
/**
|
||||||
* CSS 파일을 동적으로 로드합니다.
|
* CSS 파일을 동적으로 로드합니다.
|
||||||
*
|
*
|
||||||
|
* `loadJS`·`loadBundleJs` 와 같이 in-flight Promise 를 공유한다 — CSS 경로만 이
|
||||||
|
* 계층이 없어서, 같은 확장의 CSS 를 동시에 요청하면 `<link>` 가 중복 생성되고
|
||||||
|
* 재시도 로더가 기존 element 를 제거하면서 서로의 시도를 지웠다.
|
||||||
|
*
|
||||||
|
* 키는 `module-css-{id}` 로 둔다 — `loadJS` 가 raw identifier 를 키로 쓰므로,
|
||||||
|
* 같은 키공간을 쓰면 JS 로드가 CSS 로드로 오인되어 조용히 건너뛰어진다.
|
||||||
|
*
|
||||||
|
* 실패해도 throw 하지 않는 기존 계약을 유지한다(`surfaceCssFailure`).
|
||||||
|
*
|
||||||
* @param identifier 모듈 식별자
|
* @param identifier 모듈 식별자
|
||||||
* @param url CSS 파일 URL
|
* @param url CSS 파일 URL
|
||||||
*/
|
*/
|
||||||
@@ -409,18 +418,31 @@ export class ModuleAssetLoader {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// 이미 로딩 중인 경우 대기 (JS 키공간과 겹치지 않는 elementId 를 키로 쓴다)
|
||||||
await loadStylesheetWithRetry(url, { id: elementId }, { label: `CSS: ${identifier}` });
|
const existingPromise = this.loadingPromises.get(elementId);
|
||||||
|
if (existingPromise) {
|
||||||
logger.log(`CSS loaded: ${identifier}`);
|
logger.log(`CSS already loading: ${identifier}`);
|
||||||
|
return existingPromise;
|
||||||
const link = document.getElementById(elementId);
|
|
||||||
if (link) {
|
|
||||||
this.registerLoadedAsset(identifier, { type: 'css', element: link });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.surfaceCssFailure(identifier, identifier, url, error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadPromise = loadStylesheetWithRetry(url, { id: elementId }, { label: `CSS: ${identifier}` })
|
||||||
|
.then(() => {
|
||||||
|
logger.log(`CSS loaded: ${identifier}`);
|
||||||
|
|
||||||
|
const link = document.getElementById(elementId);
|
||||||
|
if (link) {
|
||||||
|
this.registerLoadedAsset(identifier, { type: 'css', element: link });
|
||||||
|
}
|
||||||
|
this.loadingPromises.delete(elementId);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.loadingPromises.delete(elementId);
|
||||||
|
this.surfaceCssFailure(identifier, identifier, url, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loadingPromises.set(elementId, loadPromise);
|
||||||
|
|
||||||
|
return loadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -479,4 +479,63 @@ describe('ModuleAssetLoader', () => {
|
|||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loadCSS in-flight Promise 공유', () => {
|
||||||
|
/**
|
||||||
|
* appendChild 를 가로채 생성된 element 를 모으고 즉시 load 를 발화시킨다.
|
||||||
|
*
|
||||||
|
* @return 생성된 element 목록
|
||||||
|
*/
|
||||||
|
function stubStylesheetLoading(): HTMLElement[] {
|
||||||
|
const created: HTMLElement[] = [];
|
||||||
|
|
||||||
|
vi.spyOn(document.head, 'appendChild').mockImplementation(((node: any) => {
|
||||||
|
if (node.tagName === 'LINK' || node.tagName === 'SCRIPT') {
|
||||||
|
created.push(node);
|
||||||
|
document.body.appendChild(node);
|
||||||
|
queueMicrotask(() => node.onload?.(new Event('load')));
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}) as any);
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('같은 확장 CSS 동시 2회 → link 1개만 생성된다', async () => {
|
||||||
|
const created = stubStylesheetLoading();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
(loader as any).loadCSS('vendor-ext', '/api/modules/x.css'),
|
||||||
|
(loader as any).loadCSS('vendor-ext', '/api/modules/x.css'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(created.filter(el => el.tagName === 'LINK')).toHaveLength(1);
|
||||||
|
expect(document.getElementById('module-css-vendor-ext')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CSS 로드 실패는 여전히 throw 하지 않는다 (no-throw 계약 유지)', async () => {
|
||||||
|
vi.spyOn(document.head, 'appendChild').mockImplementation(((node: any) => {
|
||||||
|
if (node.tagName === 'LINK') {
|
||||||
|
document.body.appendChild(node);
|
||||||
|
queueMicrotask(() => node.onerror?.(new Event('error')));
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}) as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(loader as any).loadCSS('vendor-fail', '/api/modules/x.css')
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CSS in-flight 키는 JS 키공간과 겹치지 않는다', async () => {
|
||||||
|
stubStylesheetLoading();
|
||||||
|
|
||||||
|
const pending = (loader as any).loadCSS('vendor-ext', '/api/modules/x.css');
|
||||||
|
|
||||||
|
expect((loader as any).loadingPromises.has('module-css-vendor-ext')).toBe(true);
|
||||||
|
expect((loader as any).loadingPromises.has('vendor-ext')).toBe(false);
|
||||||
|
|
||||||
|
await pending;
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* 동적 스크립트 주입 출처 정책 (support/scriptSrcPolicy) 단위 테스트.
|
||||||
|
*
|
||||||
|
* `docs/frontend/security.md` "same-origin 판정은 브라우저 URL 파서와 같아야 한다" 의
|
||||||
|
* 우회 표 전 케이스를 고정한다. 이 판정은 레이아웃 `scripts[]` 뿐 아니라 loadScript
|
||||||
|
* 액션·확장 핸들러 재로드·편집기 프리뷰·`G7Core.asset.loadScript` 가 공유하므로,
|
||||||
|
* 여기서 뚫리면 그 모든 주입 경로가 같이 뚫린다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
normalizeScriptSrcForOriginCheck,
|
||||||
|
extractScriptHost,
|
||||||
|
getTrustedScriptHosts,
|
||||||
|
isAllowedScriptSrc,
|
||||||
|
} from '../scriptSrcPolicy';
|
||||||
|
|
||||||
|
describe('scriptSrcPolicy', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: {
|
||||||
|
href: 'https://g7.test/',
|
||||||
|
origin: 'https://g7.test',
|
||||||
|
protocol: 'https:',
|
||||||
|
pathname: '/',
|
||||||
|
search: '',
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete (window as any).G7Config;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeScriptSrcForOriginCheck', () => {
|
||||||
|
it('tab/LF/CR 를 제거한다', () => {
|
||||||
|
expect(normalizeScriptSrcForOriginCheck('/\t/evil.com/x.js')).toBe('//evil.com/x.js');
|
||||||
|
expect(normalizeScriptSrcForOriginCheck('/\n/evil.com/x.js')).toBe('//evil.com/x.js');
|
||||||
|
expect(normalizeScriptSrcForOriginCheck('/\r/evil.com/x.js')).toBe('//evil.com/x.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('백슬래시를 슬래시로 바꾼다', () => {
|
||||||
|
expect(normalizeScriptSrcForOriginCheck('/\\/evil.com/x.js')).toBe('//evil.com/x.js');
|
||||||
|
expect(normalizeScriptSrcForOriginCheck('/\\evil.com/x.js')).toBe('//evil.com/x.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('선행 슬래시 런을 접는다 (scheme 유무 모두)', () => {
|
||||||
|
expect(normalizeScriptSrcForOriginCheck('///evil.com/x.js')).toBe('//evil.com/x.js');
|
||||||
|
expect(normalizeScriptSrcForOriginCheck('https:///evil.com/x.js')).toBe(
|
||||||
|
'https://evil.com/x.js'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('경로 중간의 연속 슬래시는 건드리지 않는다 (과차단 방지)', () => {
|
||||||
|
expect(normalizeScriptSrcForOriginCheck('/js//a.js')).toBe('/js//a.js');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractScriptHost', () => {
|
||||||
|
it('protocol-relative 와 절대 URL 의 호스트를 소문자로 돌려준다', () => {
|
||||||
|
expect(extractScriptHost('//CDN.Example.com/x.js')).toBe('cdn.example.com');
|
||||||
|
expect(extractScriptHost('https://CDN.Example.com/x.js')).toBe('cdn.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('http/https 가 아닌 scheme 은 null', () => {
|
||||||
|
expect(extractScriptHost('javascript:alert(1)')).toBeNull();
|
||||||
|
expect(extractScriptHost('data:text/javascript,alert(1)')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('same-origin 경로는 문서 origin 의 호스트로 해석된다', () => {
|
||||||
|
expect(extractScriptHost('/api/widget.js')).toBe('g7.test');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTrustedScriptHosts', () => {
|
||||||
|
it('G7Config 미설정 시 빈 배열', () => {
|
||||||
|
expect(getTrustedScriptHosts()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('배열이 아니면 빈 배열', () => {
|
||||||
|
(window as any).G7Config = { trustedScriptHosts: 'cdn.example.com' };
|
||||||
|
expect(getTrustedScriptHosts()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('소문자로 정규화해 돌려준다', () => {
|
||||||
|
(window as any).G7Config = { trustedScriptHosts: ['CDN.Example.com'] };
|
||||||
|
expect(getTrustedScriptHosts()).toEqual(['cdn.example.com']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAllowedScriptSrc — security.md 우회 표', () => {
|
||||||
|
/** @effects trusted_script_host_allowlist_wired */
|
||||||
|
it('same-origin 절대 경로는 허용', () => {
|
||||||
|
expect(isAllowedScriptSrc('/api/widget.js', [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @effects untrusted_external_script_blocked */
|
||||||
|
it('protocol-relative 외부 origin 은 차단', () => {
|
||||||
|
expect(isAllowedScriptSrc('//evil.com/x.js', [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['/\\/evil.com/x.js'],
|
||||||
|
['/\\evil.com/x.js'],
|
||||||
|
['/\t/evil.com/x.js'],
|
||||||
|
['///evil.com/x.js'],
|
||||||
|
])('authority 우회 형태 %s 는 차단 (@effects untrusted_external_script_blocked)', src => {
|
||||||
|
expect(isAllowedScriptSrc(src, [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('신뢰 호스트를 우회 형태로 쓴 것은 허용 (과차단 방지)', () => {
|
||||||
|
expect(isAllowedScriptSrc('/\\/cdn.trusted.com/x.js', ['cdn.trusted.com'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('경로 중간 백슬래시는 same-origin 으로 통과 (과차단 방지)', () => {
|
||||||
|
expect(isAllowedScriptSrc('/js/a\\b.js', [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('userinfo 자리에 신뢰 호스트를 끼운 주소는 실제 호스트로 판정한다', () => {
|
||||||
|
expect(
|
||||||
|
isAllowedScriptSrc('https://evil.com\\@cdn.trusted.com/x.js', ['cdn.trusted.com'])
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @effects trusted_script_host_allowlist_wired */
|
||||||
|
it('신뢰 호스트는 허용 (protocol-relative / https 양쪽)', () => {
|
||||||
|
expect(isAllowedScriptSrc('//cdn.trusted.com/x.js', ['cdn.trusted.com'])).toBe(true);
|
||||||
|
expect(isAllowedScriptSrc('https://cdn.trusted.com/x.js', ['cdn.trusted.com'])).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('신뢰 호스트 비교는 대소문자를 무시한다', () => {
|
||||||
|
expect(isAllowedScriptSrc('https://CDN.Trusted.com/x.js', ['cdn.trusted.com'])).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(isAllowedScriptSrc('https://cdn.trusted.com/x.js', ['CDN.Trusted.com'])).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('javascript:/data: scheme 은 차단', () => {
|
||||||
|
expect(isAllowedScriptSrc('javascript:alert(1)', [])).toBe(false);
|
||||||
|
expect(isAllowedScriptSrc('data:text/javascript,alert(1)', [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('빈 값·비문자열은 차단', () => {
|
||||||
|
expect(isAllowedScriptSrc('', [])).toBe(false);
|
||||||
|
expect(isAllowedScriptSrc(' ', [])).toBe(false);
|
||||||
|
expect(isAllowedScriptSrc(undefined as any, [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('상대 경로(`/` 미시작)는 차단', () => {
|
||||||
|
expect(isAllowedScriptSrc('js/widget.js', [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('인자 생략 시 window.G7Config.trustedScriptHosts 를 읽는다', () => {
|
||||||
|
(window as any).G7Config = { trustedScriptHosts: ['cdn.trusted.com'] };
|
||||||
|
expect(isAllowedScriptSrc('https://cdn.trusted.com/x.js')).toBe(true);
|
||||||
|
expect(isAllowedScriptSrc('https://evil.com/x.js')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* 동적 스크립트 주입 출처 정책 (프론트측 SSoT).
|
||||||
|
*
|
||||||
|
* 브라우저에 새 `<script>` 를 만들어 붙이는 모든 경로는 이 모듈의 판정을 경유한다.
|
||||||
|
* 레이아웃 `scripts[]`(TemplateApp) 뿐 아니라 `loadScript` 액션 · 확장 핸들러 재로드 ·
|
||||||
|
* 편집기 프리뷰 · `G7Core.asset.loadScript` 공개 seam 이 같은 판정을 쓴다.
|
||||||
|
*
|
||||||
|
* ## 허용 규칙
|
||||||
|
*
|
||||||
|
* 1. `/` 로 시작하는 same-origin 절대 경로.
|
||||||
|
* 2. 확장이 manifest(`trusted_script_hosts`)로 선언한 신뢰 호스트에 속한 외부 스크립트
|
||||||
|
* — 코어가 집계해 `window.G7Config.trustedScriptHosts` 로 노출한다.
|
||||||
|
*
|
||||||
|
* 그 외(`//` protocol-relative · scheme 포함 외부 origin)는 미선언 원격 코드 로드 경로다.
|
||||||
|
*
|
||||||
|
* ## 3층 동형
|
||||||
|
*
|
||||||
|
* 저장측 `App\Rules\SafeLayoutExpressions::normalizeForOriginCheck` · 정적 검사
|
||||||
|
* `layout-scripts-src-same-origin` 과 **같은 정규화**를 공유해야 한다. 한 층만 바뀌면
|
||||||
|
* 그 차집합이 그대로 우회로가 된다 (KVE-2026-1915 B-2).
|
||||||
|
*
|
||||||
|
* @since engine-v1.64.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* origin 판정 전에 스크립트 src 를 브라우저 URL 파서와 동일하게 정규화합니다.
|
||||||
|
*
|
||||||
|
* 문자열 접두 검사만으로는 authority 우회를 막지 못합니다. 브라우저(WHATWG URL)는
|
||||||
|
* 파싱 전에 ASCII tab·개행을 제거하고, special scheme(http/https)에서 백슬래시를
|
||||||
|
* 슬래시와 동등하게 처리하기 때문입니다. 그래서 `/\/evil.com/x.js` ·
|
||||||
|
* `/{tab}/evil.com/x.js` 는 `//` 로 시작하지 않고 scheme 도 없는데 실제로는
|
||||||
|
* `https://evil.com/x.js` 로 해석되어 원격 스크립트가 로드됩니다.
|
||||||
|
*
|
||||||
|
* 정규화 후 판정하면 경로 중간의 백슬래시·탭(`/js/a\b.js`)은 authority 를 만들지
|
||||||
|
* 않으므로 그대로 same-origin 으로 통과합니다(과차단 없음).
|
||||||
|
*
|
||||||
|
* @param src 원본 src 문자열
|
||||||
|
* @returns 정규화된 src
|
||||||
|
*/
|
||||||
|
export function normalizeScriptSrcForOriginCheck(src: string): string {
|
||||||
|
// ASCII tab / LF / CR 제거 (브라우저 파서가 파싱 전에 제거하는 문자)
|
||||||
|
// → 백슬래시를 슬래시로 (special scheme 에서 등가)
|
||||||
|
const slashed = src.replace(/[\t\n\r]/g, '').replace(/\\/g, '/');
|
||||||
|
|
||||||
|
// 선행 슬래시가 3개 이상이어도 브라우저는 authority 시작으로 접는다
|
||||||
|
// (`///host/x` ≡ `//host/x`, `https:///host/x` ≡ `https://host/x`).
|
||||||
|
// 경로 중간의 연속 슬래시(`/js//a.js`)는 브라우저도 경로로 두므로 건드리지 않는다.
|
||||||
|
return slashed.replace(/^([a-z][a-z0-9+.\-]*:)?\/{2,}/i, '$1//');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스크립트 src 에서 http(s) 호스트명을 추출합니다.
|
||||||
|
*
|
||||||
|
* `//host/...`(protocol-relative)·`https://host/...` 를 처리하며, http/https 가 아닌
|
||||||
|
* scheme(`javascript:`·`data:` 등)은 null 을 반환해 신뢰 호스트 판정 대상에서 제외합니다.
|
||||||
|
*
|
||||||
|
* @param src 스크립트 src 문자열 (정규화된 값을 넘길 것)
|
||||||
|
* @param baseOrigin 상대 경로 해석 기준 origin (기본: 현재 문서 origin)
|
||||||
|
* @returns 소문자 호스트명 (판정 불가 시 null)
|
||||||
|
*/
|
||||||
|
export function extractScriptHost(src: string, baseOrigin?: string): string | null {
|
||||||
|
try {
|
||||||
|
// protocol/origin 이 비어 있을 수 있다 (테스트 하네스가 location 을 교체한 경우 등).
|
||||||
|
// 그때 빈 문자열을 그대로 이어 붙이면 `//host` 가 파싱 불가가 되어 호스트 추출이
|
||||||
|
// null 로 떨어지고, protocol-relative 로 적은 **신뢰 호스트가 조용히 차단**된다.
|
||||||
|
const protocol =
|
||||||
|
(typeof window !== 'undefined' && window.location?.protocol) || 'https:';
|
||||||
|
const base =
|
||||||
|
baseOrigin ||
|
||||||
|
(typeof window !== 'undefined' ? window.location?.origin : undefined) ||
|
||||||
|
'https://localhost';
|
||||||
|
|
||||||
|
const normalized = src.startsWith('//') ? `${protocol}${src}` : src;
|
||||||
|
const url = new URL(normalized, base);
|
||||||
|
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코어가 집계해 노출한 신뢰 외부 스크립트 호스트 목록을 반환합니다.
|
||||||
|
*
|
||||||
|
* @returns 소문자 호스트명 배열 (window.G7Config.trustedScriptHosts)
|
||||||
|
*/
|
||||||
|
export function getTrustedScriptHosts(): string[] {
|
||||||
|
const hosts =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? (window as any).G7Config?.trustedScriptHosts
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return Array.isArray(hosts)
|
||||||
|
? hosts.map((host: unknown) => String(host).toLowerCase())
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스크립트 src 가 주입 허용 대상인지 판정합니다.
|
||||||
|
*
|
||||||
|
* @param src 스크립트 src 문자열
|
||||||
|
* @param trustedHosts 신뢰 호스트 목록 (생략 시 `getTrustedScriptHosts()`)
|
||||||
|
* @returns 주입 허용이면 true
|
||||||
|
*/
|
||||||
|
export function isAllowedScriptSrc(src: string, trustedHosts?: readonly string[]): boolean {
|
||||||
|
if (typeof src !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = src.trim();
|
||||||
|
|
||||||
|
if (trimmed === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 접두 검사 전에 브라우저 URL 파서와 동일하게 정규화한다
|
||||||
|
const normalized = normalizeScriptSrcForOriginCheck(trimmed);
|
||||||
|
|
||||||
|
const isProtocolRelative = normalized.startsWith('//');
|
||||||
|
const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(normalized);
|
||||||
|
|
||||||
|
// same-origin path-only 절대 경로 (`/api/...`) — 항상 허용
|
||||||
|
if (!isProtocolRelative && !hasScheme && normalized.startsWith('/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 외부 origin — 확장이 선언한 신뢰 호스트만 허용
|
||||||
|
const host = extractScriptHost(normalized);
|
||||||
|
const hosts = trustedHosts ?? getTrustedScriptHosts();
|
||||||
|
|
||||||
|
return host !== null && hosts.map(h => String(h).toLowerCase()).includes(host);
|
||||||
|
}
|
||||||
@@ -32,7 +32,8 @@ import { addMissingLeafKeys } from './helpers/StateMerge';
|
|||||||
import { triggerModalParentUpdate } from './ParentContextProvider';
|
import { triggerModalParentUpdate } from './ParentContextProvider';
|
||||||
import type { GlobalHeaderRule } from './LayoutLoader';
|
import type { GlobalHeaderRule } from './LayoutLoader';
|
||||||
import { IdentityGuardInterceptor } from '../identity/IdentityGuardInterceptor';
|
import { IdentityGuardInterceptor } from '../identity/IdentityGuardInterceptor';
|
||||||
import { isAbortError, isNetworkFailure } from './networkResilience';
|
import { isAbortError, isNetworkFailure, loadScriptWithRetry, loadStylesheetWithRetry } from './networkResilience';
|
||||||
|
import { isAllowedScriptSrc, getTrustedScriptHosts } from '../support/scriptSrcPolicy';
|
||||||
|
|
||||||
const logger = createLogger('ActionDispatcher');
|
const logger = createLogger('ActionDispatcher');
|
||||||
|
|
||||||
@@ -1410,43 +1411,36 @@ export class ActionDispatcher {
|
|||||||
const scriptUrl = moduleData.assets.js;
|
const scriptUrl = moduleData.assets.js;
|
||||||
const scriptId = `module-${identifier}`;
|
const scriptId = `module-${identifier}`;
|
||||||
|
|
||||||
// 이미 로드된 스크립트인지 확인
|
// 출처 게이트 — 정상 확장 자산 URL 은 전부 `/api/...` same-origin 이다.
|
||||||
|
this.assertAllowedExtensionAssetUrl(scriptUrl, `module script (${identifier})`, action);
|
||||||
|
|
||||||
|
// 이미 로드된 스크립트면 JS 만 건너뛴다 (CSS 는 아래 형제 블록이 계속 처리).
|
||||||
if (document.getElementById(scriptId)) {
|
if (document.getElementById(scriptId)) {
|
||||||
logger.warn(`reloadModuleHandlers: Script ${scriptId} already loaded`);
|
logger.warn(`reloadModuleHandlers: Script ${scriptId} already loaded`);
|
||||||
return;
|
} else {
|
||||||
|
await loadScriptWithRetry(
|
||||||
|
scriptUrl,
|
||||||
|
{ id: scriptId },
|
||||||
|
{ label: `module-script:${identifier}` }
|
||||||
|
);
|
||||||
|
logger.log(`reloadModuleHandlers: Script loaded successfully for ${identifier}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// <script> 태그 동적 생성
|
// CSS 파일이 있으면 동적 로드 (JS 유무·기존재와 무관한 형제 작업)
|
||||||
const script = document.createElement('script');
|
if (moduleData.assets.css) {
|
||||||
script.id = scriptId;
|
const cssUrl = moduleData.assets.css;
|
||||||
script.src = scriptUrl;
|
const linkId = `module-css-${identifier}`;
|
||||||
script.async = true;
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
this.assertAllowedExtensionAssetUrl(cssUrl, `module stylesheet (${identifier})`, action);
|
||||||
script.onload = () => {
|
|
||||||
logger.log(`reloadModuleHandlers: Script loaded successfully for ${identifier}`);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
script.onerror = () => {
|
|
||||||
logger.error(`reloadModuleHandlers: Failed to load script for ${identifier}`);
|
|
||||||
reject(new Error(`Failed to load module script: ${scriptUrl}`));
|
|
||||||
};
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
|
|
||||||
// CSS 파일이 있으면 동적 로드
|
if (!document.getElementById(linkId)) {
|
||||||
if (moduleData.assets.css) {
|
await loadStylesheetWithRetry(
|
||||||
const cssUrl = moduleData.assets.css;
|
cssUrl,
|
||||||
const linkId = `module-css-${identifier}`;
|
{ id: linkId },
|
||||||
|
{ label: `module-css:${identifier}` }
|
||||||
if (!document.getElementById(linkId)) {
|
);
|
||||||
const link = document.createElement('link');
|
logger.log(`reloadModuleHandlers: CSS loaded for ${identifier}`);
|
||||||
link.id = linkId;
|
|
||||||
link.rel = 'stylesheet';
|
|
||||||
link.href = cssUrl;
|
|
||||||
document.head.appendChild(link);
|
|
||||||
logger.log(`reloadModuleHandlers: CSS loaded for ${identifier}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1530,43 +1524,36 @@ export class ActionDispatcher {
|
|||||||
const scriptUrl = pluginData.assets.js;
|
const scriptUrl = pluginData.assets.js;
|
||||||
const scriptId = `plugin-${identifier}`;
|
const scriptId = `plugin-${identifier}`;
|
||||||
|
|
||||||
// 이미 로드된 스크립트인지 확인
|
// 출처 게이트 — 정상 확장 자산 URL 은 전부 `/api/...` same-origin 이다.
|
||||||
|
this.assertAllowedExtensionAssetUrl(scriptUrl, `plugin script (${identifier})`, action);
|
||||||
|
|
||||||
|
// 이미 로드된 스크립트면 JS 만 건너뛴다 (CSS 는 아래 형제 블록이 계속 처리).
|
||||||
if (document.getElementById(scriptId)) {
|
if (document.getElementById(scriptId)) {
|
||||||
logger.warn(`reloadPluginHandlers: Script ${scriptId} already loaded`);
|
logger.warn(`reloadPluginHandlers: Script ${scriptId} already loaded`);
|
||||||
return;
|
} else {
|
||||||
|
await loadScriptWithRetry(
|
||||||
|
scriptUrl,
|
||||||
|
{ id: scriptId },
|
||||||
|
{ label: `plugin-script:${identifier}` }
|
||||||
|
);
|
||||||
|
logger.log(`reloadPluginHandlers: Script loaded successfully for ${identifier}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// <script> 태그 동적 생성
|
// CSS 파일이 있으면 동적 로드 (JS 유무·기존재와 무관한 형제 작업)
|
||||||
const script = document.createElement('script');
|
if (pluginData.assets.css) {
|
||||||
script.id = scriptId;
|
const cssUrl = pluginData.assets.css;
|
||||||
script.src = scriptUrl;
|
const linkId = `plugin-css-${identifier}`;
|
||||||
script.async = true;
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
this.assertAllowedExtensionAssetUrl(cssUrl, `plugin stylesheet (${identifier})`, action);
|
||||||
script.onload = () => {
|
|
||||||
logger.log(`reloadPluginHandlers: Script loaded successfully for ${identifier}`);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
script.onerror = () => {
|
|
||||||
logger.error(`reloadPluginHandlers: Failed to load script for ${identifier}`);
|
|
||||||
reject(new Error(`Failed to load plugin script: ${scriptUrl}`));
|
|
||||||
};
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
|
|
||||||
// CSS 파일이 있으면 동적 로드
|
if (!document.getElementById(linkId)) {
|
||||||
if (pluginData.assets.css) {
|
await loadStylesheetWithRetry(
|
||||||
const cssUrl = pluginData.assets.css;
|
cssUrl,
|
||||||
const linkId = `plugin-css-${identifier}`;
|
{ id: linkId },
|
||||||
|
{ label: `plugin-css:${identifier}` }
|
||||||
if (!document.getElementById(linkId)) {
|
);
|
||||||
const link = document.createElement('link');
|
logger.log(`reloadPluginHandlers: CSS loaded for ${identifier}`);
|
||||||
link.id = linkId;
|
|
||||||
link.rel = 'stylesheet';
|
|
||||||
link.href = cssUrl;
|
|
||||||
document.head.appendChild(link);
|
|
||||||
logger.log(`reloadPluginHandlers: CSS loaded for ${identifier}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5790,17 +5777,63 @@ export class ActionDispatcher {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 확장 자산 URL(js/css)이 주입 허용 대상인지 검사하고, 아니면 차단합니다.
|
||||||
|
*
|
||||||
|
* 확장 활성화 응답이 지시하는 URL 을 그대로 `<script>`/`<link>` 로 붙이는 경로는
|
||||||
|
* 레이아웃 `scripts[]` 와 같은 출처 정책을 받아야 한다. 정상 확장 자산 URL 은 전부
|
||||||
|
* `/api/...` same-origin 이므로 과차단은 발생하지 않는다.
|
||||||
|
*
|
||||||
|
* @param url 자산 URL
|
||||||
|
* @param what 오류 메시지에 실을 대상 설명
|
||||||
|
* @param action 액션 정의 (ActionError 컨텍스트)
|
||||||
|
* @throws ActionError 미신뢰 출처인 경우
|
||||||
|
* @since engine-v1.64.0
|
||||||
|
*/
|
||||||
|
private assertAllowedExtensionAssetUrl(url: string, what: string, action: ActionDefinition): void {
|
||||||
|
if (isAllowedScriptSrc(url, getTrustedScriptHosts())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(`Blocked untrusted ${what} asset url: ${url}`);
|
||||||
|
throw new ActionError(
|
||||||
|
`Blocked untrusted ${what} url (same-origin path or declared trusted host required): ${url}`,
|
||||||
|
action
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로드된 외부 스크립트 ID를 추적하기 위한 Set
|
* 로드된 외부 스크립트 ID를 추적하기 위한 Set
|
||||||
*/
|
*/
|
||||||
private static loadedScripts: Set<string> = new Set();
|
private static loadedScripts: Set<string> = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로드 진행 중인 스크립트의 공유 Promise (키 = scriptId).
|
||||||
|
*
|
||||||
|
* 같은 스크립트를 동시에 요청한 호출자들이 하나의 `<script>` 태그를 공유하고,
|
||||||
|
* **1회차 onload 이후에** 함께 완료되도록 한다. 종전에는 in-flight 를 추적하지 않아
|
||||||
|
* 2번째 호출이 "DOM 에 태그가 있다" 는 이유로 로드 완료 전에 즉시 resolve 했고,
|
||||||
|
* 그 호출자의 onLoad 는 SDK 전역이 아직 없는 시점에 실행됐다 (예외 없이 무반응).
|
||||||
|
*
|
||||||
|
* 공유 Promise 는 "로드 완료" 만 담는다 — onLoad 는 호출자별로 await 후 각자 실행한다.
|
||||||
|
*
|
||||||
|
* @since engine-v1.64.0
|
||||||
|
*/
|
||||||
|
private static loadingScripts: Map<string, Promise<void>> = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 외부 스크립트를 동적으로 로드합니다.
|
* 외부 스크립트를 동적으로 로드합니다.
|
||||||
*
|
*
|
||||||
* 이미 로드된 스크립트는 재로드하지 않고 캐시된 상태를 사용합니다.
|
* 이미 로드된 스크립트는 재로드하지 않고 캐시된 상태를 사용합니다.
|
||||||
* 스크립트 로드 완료 시 onLoad 액션을 실행합니다.
|
* 스크립트 로드 완료 시 onLoad 액션을 실행합니다.
|
||||||
*
|
*
|
||||||
|
* `src` 는 레이아웃 `scripts[]` 와 **같은 출처 정책**을 받는다 — same-origin 절대 경로이거나
|
||||||
|
* 확장이 manifest 로 선언한 신뢰 호스트여야 하며, 그 밖의 원격 URL 은 로드 전에 차단된다
|
||||||
|
* (`support/scriptSrcPolicy`). 이 경로만 게이트가 없으면 저장측 검증을 우회한 임의 원격
|
||||||
|
* 코드가 그대로 실행된다.
|
||||||
|
*
|
||||||
|
* 같은 스크립트를 동시에 요청하면 하나의 태그를 공유하고 모두 1회차 로드 완료 후 resolve 한다.
|
||||||
|
*
|
||||||
* @param params 스크립트 로드 파라미터
|
* @param params 스크립트 로드 파라미터
|
||||||
* - src: 스크립트 URL (필수)
|
* - src: 스크립트 URL (필수)
|
||||||
* - id: 스크립트 요소 ID (선택, 중복 로드 방지에 사용)
|
* - id: 스크립트 요소 ID (선택, 중복 로드 방지에 사용)
|
||||||
@@ -5832,6 +5865,18 @@ export class ActionDispatcher {
|
|||||||
throw new ActionError('loadScript handler requires "src" parameter', action);
|
throw new ActionError('loadScript handler requires "src" parameter', action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 출처 게이트 — 캐시 검사보다 앞이다. 뒤에 두면 이미 로드된 미신뢰 스크립트가
|
||||||
|
// 캐시 히트로 통과한다.
|
||||||
|
if (!isAllowedScriptSrc(src, getTrustedScriptHosts())) {
|
||||||
|
logger.warn(
|
||||||
|
`loadScript: blocked untrusted script src (same-origin path or declared trusted host required): ${src}`
|
||||||
|
);
|
||||||
|
throw new ActionError(
|
||||||
|
`Blocked untrusted script src (same-origin path or declared trusted host required): ${src}`,
|
||||||
|
action
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const scriptId = id || `script_${src.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
const scriptId = id || `script_${src.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||||
|
|
||||||
// 이미 로드된 스크립트인지 확인
|
// 이미 로드된 스크립트인지 확인
|
||||||
@@ -5846,7 +5891,22 @@ export class ActionDispatcher {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 같은 스크립트가 로드 중이면 그 Promise 를 공유한다 (태그 1개, 완료는 함께).
|
||||||
|
const inFlight = ActionDispatcher.loadingScripts.get(scriptId);
|
||||||
|
if (inFlight) {
|
||||||
|
logger.log(`loadScript: joining in-flight load: ${scriptId}`);
|
||||||
|
await inFlight;
|
||||||
|
|
||||||
|
if (action.onLoad) {
|
||||||
|
await this.executeAction(action.onLoad, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// DOM에 이미 스크립트가 존재하는지 확인
|
// DOM에 이미 스크립트가 존재하는지 확인
|
||||||
|
// (dispatcher 가 만들지 않은 외래 태그 — 로드 상태를 식별할 수 없으므로 완료로 간주한다.
|
||||||
|
// dispatcher 자신의 경합은 위 in-flight Map 이 이미 제거했다.)
|
||||||
const existingScript = document.getElementById(scriptId);
|
const existingScript = document.getElementById(scriptId);
|
||||||
if (existingScript) {
|
if (existingScript) {
|
||||||
logger.log(`loadScript: script element already exists: ${scriptId}`);
|
logger.log(`loadScript: script element already exists: ${scriptId}`);
|
||||||
@@ -5862,36 +5922,56 @@ export class ActionDispatcher {
|
|||||||
|
|
||||||
logger.log(`loadScript: loading script: ${src}`);
|
logger.log(`loadScript: loading script: ${src}`);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
const script = document.createElement('script');
|
||||||
const script = document.createElement('script');
|
script.id = scriptId;
|
||||||
script.id = scriptId;
|
script.src = src;
|
||||||
script.src = src;
|
script.async = async;
|
||||||
script.async = async;
|
script.defer = defer;
|
||||||
script.defer = defer;
|
|
||||||
|
|
||||||
script.onload = async () => {
|
// 동의 관리(gdpr) preblocker 가 src setter 에서 동기적으로 차단을 기록한다.
|
||||||
|
// 차단된 스크립트는 append 해도 영영 로드되지 않으므로, 태그를 붙이지 않고
|
||||||
|
// 미완료 상태(resolve(false))로 돌려준다 — 오류가 아니라 "동의 전" 이라는 상태다.
|
||||||
|
// 캐시·in-flight 에 기록하지 않으므로 동의 후 재디스패치하면 정상 로드된다.
|
||||||
|
if (script.hasAttribute('data-gdpr-blocked-src')) {
|
||||||
|
logger.warn(`loadScript: script blocked by consent manager (not loaded): ${src}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
script.onload = () => {
|
||||||
logger.log(`loadScript: script loaded successfully: ${scriptId}`);
|
logger.log(`loadScript: script loaded successfully: ${scriptId}`);
|
||||||
ActionDispatcher.loadedScripts.add(scriptId);
|
ActionDispatcher.loadedScripts.add(scriptId);
|
||||||
|
ActionDispatcher.loadingScripts.delete(scriptId);
|
||||||
// onLoad 액션 실행
|
resolve();
|
||||||
if (action.onLoad) {
|
|
||||||
try {
|
|
||||||
await this.executeAction(action.onLoad, context);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('loadScript: onLoad action failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
script.onerror = (error) => {
|
script.onerror = (error) => {
|
||||||
logger.error(`loadScript: failed to load script: ${src}`, error);
|
logger.error(`loadScript: failed to load script: ${src}`, error);
|
||||||
|
ActionDispatcher.loadingScripts.delete(scriptId);
|
||||||
reject(new ActionError(`Failed to load script: ${src}`, action));
|
reject(new ActionError(`Failed to load script: ${src}`, action));
|
||||||
};
|
};
|
||||||
|
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 공유 Promise 의 rejection 이 구독자 없는 시점에 unhandled 로 새지 않게 한다
|
||||||
|
// (아래 await 와 join 경로가 각자 처리한다).
|
||||||
|
loadPromise.catch(() => {});
|
||||||
|
|
||||||
|
ActionDispatcher.loadingScripts.set(scriptId, loadPromise);
|
||||||
|
|
||||||
|
await loadPromise;
|
||||||
|
|
||||||
|
// onLoad 액션 실행
|
||||||
|
if (action.onLoad) {
|
||||||
|
try {
|
||||||
|
await this.executeAction(action.onLoad, context);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('loadScript: onLoad action failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -5974,6 +6054,10 @@ export class ActionDispatcher {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 심층 방어: 스크립트 로드 게이트를 통과한 전역만 호출된다는 전제 위에서,
|
||||||
|
// 임의 코드 실행으로 직결되는 생성자는 참조 동일성으로 거부한다.
|
||||||
|
this.assertCallableExternalConstructor(Constructor, constructorPath, action);
|
||||||
|
|
||||||
logger.log(`callExternal: calling constructor ${constructorPath}`);
|
logger.log(`callExternal: calling constructor ${constructorPath}`);
|
||||||
|
|
||||||
// 콜백 함수 생성 (callbackEvent가 지정된 경우)
|
// 콜백 함수 생성 (callbackEvent가 지정된 경우)
|
||||||
@@ -5999,8 +6083,15 @@ export class ActionDispatcher {
|
|||||||
const processMapping = (mapping: Record<string, any>): Record<string, any> => {
|
const processMapping = (mapping: Record<string, any>): Record<string, any> => {
|
||||||
const result: Record<string, any> = {};
|
const result: Record<string, any> = {};
|
||||||
for (const [fieldName, dataPath] of Object.entries(mapping)) {
|
for (const [fieldName, dataPath] of Object.entries(mapping)) {
|
||||||
|
// 프로토타입 오염 차단: 이 결과는 deepMergeWithState → setState 로 흘러가므로
|
||||||
|
// 매핑 키 하나가 앱 전역의 모든 객체를 오염시킬 수 있다.
|
||||||
|
if (ActionDispatcher.isPrototypePathSegment(fieldName)) {
|
||||||
|
logger.warn(`callExternal: skipped prototype-polluting mapping key: ${fieldName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof dataPath === 'string') {
|
if (typeof dataPath === 'string') {
|
||||||
// 리프 노드: 실제 데이터 매핑
|
// 리프 노드: 실제 데이터 매핑 (경로 세그먼트도 같은 판정을 받는다)
|
||||||
result[fieldName] = this.getNestedProperty(data, dataPath);
|
result[fieldName] = this.getNestedProperty(data, dataPath);
|
||||||
} else if (typeof dataPath === 'object' && dataPath !== null) {
|
} else if (typeof dataPath === 'object' && dataPath !== null) {
|
||||||
// 중첩 객체: 재귀 처리
|
// 중첩 객체: 재귀 처리
|
||||||
@@ -6125,6 +6216,10 @@ export class ActionDispatcher {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 심층 방어: 스크립트 로드 게이트를 통과한 전역만 호출된다는 전제 위에서,
|
||||||
|
// 임의 코드 실행으로 직결되는 생성자는 참조 동일성으로 거부한다.
|
||||||
|
this.assertCallableExternalConstructor(Constructor, constructorPath, action);
|
||||||
|
|
||||||
logger.log(`callExternalEmbed: creating layer for ${constructorPath}`);
|
logger.log(`callExternalEmbed: creating layer for ${constructorPath}`);
|
||||||
|
|
||||||
// 레이어 요소들 생성
|
// 레이어 요소들 생성
|
||||||
@@ -6152,8 +6247,15 @@ export class ActionDispatcher {
|
|||||||
const processMapping = (mapping: Record<string, any>): Record<string, any> => {
|
const processMapping = (mapping: Record<string, any>): Record<string, any> => {
|
||||||
const result: Record<string, any> = {};
|
const result: Record<string, any> = {};
|
||||||
for (const [fieldName, dataPath] of Object.entries(mapping)) {
|
for (const [fieldName, dataPath] of Object.entries(mapping)) {
|
||||||
|
// 프로토타입 오염 차단: 이 결과는 deepMergeWithState → setState 로 흘러가므로
|
||||||
|
// 매핑 키 하나가 앱 전역의 모든 객체를 오염시킬 수 있다.
|
||||||
|
if (ActionDispatcher.isPrototypePathSegment(fieldName)) {
|
||||||
|
logger.warn(`callExternal: skipped prototype-polluting mapping key: ${fieldName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof dataPath === 'string') {
|
if (typeof dataPath === 'string') {
|
||||||
// 리프 노드: 실제 데이터 매핑
|
// 리프 노드: 실제 데이터 매핑 (경로 세그먼트도 같은 판정을 받는다)
|
||||||
result[fieldName] = this.getNestedProperty(data, dataPath);
|
result[fieldName] = this.getNestedProperty(data, dataPath);
|
||||||
} else if (typeof dataPath === 'object' && dataPath !== null) {
|
} else if (typeof dataPath === 'object' && dataPath !== null) {
|
||||||
// 중첩 객체: 재귀 처리
|
// 중첩 객체: 재귀 처리
|
||||||
@@ -6326,19 +6428,91 @@ export class ActionDispatcher {
|
|||||||
return { overlay, layer, closeLayer };
|
return { overlay, layer, closeLayer };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로토타입 체인에 도달하는 경로 세그먼트 (읽기·쓰기 양쪽에서 거부한다).
|
||||||
|
*
|
||||||
|
* 이 세그먼트를 타면 객체의 데이터가 아니라 **모든 객체가 공유하는 프로토타입**에
|
||||||
|
* 닿는다. 읽기 쪽에서는 `Object.constructor` 같은 경로가 임의 함수 생성자로 이어지고,
|
||||||
|
* 쓰기 쪽에서는 `__proto__` 매핑 한 줄이 앱 전역의 객체를 오염시킨다.
|
||||||
|
*
|
||||||
|
* @since engine-v1.64.0
|
||||||
|
*/
|
||||||
|
private static readonly PROTOTYPE_PATH_SEGMENTS: readonly string[] = [
|
||||||
|
'__proto__',
|
||||||
|
'prototype',
|
||||||
|
'constructor',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 경로 세그먼트가 프로토타입 체인에 닿는지 판정합니다.
|
||||||
|
*
|
||||||
|
* @param segment 경로 세그먼트
|
||||||
|
* @returns 프로토타입 체인 세그먼트면 true
|
||||||
|
* @since engine-v1.64.0
|
||||||
|
*/
|
||||||
|
private static isPrototypePathSegment(segment: string): boolean {
|
||||||
|
return ActionDispatcher.PROTOTYPE_PATH_SEGMENTS.includes(segment);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 중첩된 객체 속성을 경로로 접근합니다.
|
* 중첩된 객체 속성을 경로로 접근합니다.
|
||||||
*
|
*
|
||||||
|
* 프로토타입 체인 세그먼트(`__proto__`/`prototype`/`constructor`)가 포함된 경로는
|
||||||
|
* `undefined` 를 돌려준다 — 던지지 않는 이유는 이 함수가 상태 경로 해석에도 쓰이는
|
||||||
|
* 공용 함수이기 때문이다. callExternal 에서는 이 undefined 가 기존의
|
||||||
|
* "Constructor not found" ActionError 로 수렴한다.
|
||||||
|
*
|
||||||
* @param obj 대상 객체 (예: window)
|
* @param obj 대상 객체 (예: window)
|
||||||
* @param path 점으로 구분된 경로 (예: "daum.Postcode")
|
* @param path 점으로 구분된 경로 (예: "daum.Postcode")
|
||||||
* @returns 해당 경로의 값 또는 undefined
|
* @returns 해당 경로의 값 또는 undefined
|
||||||
*/
|
*/
|
||||||
private getNestedProperty(obj: Record<string, any>, path: string): any {
|
private getNestedProperty(obj: Record<string, any>, path: string): any {
|
||||||
return path.split('.').reduce((current: any, key: string) => {
|
return path.split('.').reduce((current: any, key: string) => {
|
||||||
|
if (ActionDispatcher.isPrototypePathSegment(key)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return current && current[key] !== undefined ? current[key] : undefined;
|
return current && current[key] !== undefined ? current[key] : undefined;
|
||||||
}, obj);
|
}, obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* callExternal 이 해석한 생성자가 임의 코드 실행 seam 인지 검사합니다.
|
||||||
|
*
|
||||||
|
* 이름이 아니라 **참조 동일성**으로 판정한다 — `window.myAlias = Function` 처럼 별칭을
|
||||||
|
* 만들어 두면 이름 비교는 그대로 통과하기 때문이다. 스크립트 로드 게이트를 통과한
|
||||||
|
* 전역만 호출된다는 전제 위의 심층 방어다.
|
||||||
|
*
|
||||||
|
* @param Constructor 해석된 생성자
|
||||||
|
* @param constructorPath 오류 메시지에 실을 경로
|
||||||
|
* @param action 액션 정의 (ActionError 컨텍스트)
|
||||||
|
* @throws ActionError 임의 코드 실행 seam 인 경우
|
||||||
|
* @since engine-v1.64.0
|
||||||
|
*/
|
||||||
|
private assertCallableExternalConstructor(
|
||||||
|
Constructor: unknown,
|
||||||
|
constructorPath: string,
|
||||||
|
action: ActionDefinition
|
||||||
|
): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const denied: unknown[] = [
|
||||||
|
(window as any).Function,
|
||||||
|
(window as any).eval,
|
||||||
|
(window as any).setTimeout,
|
||||||
|
(window as any).setInterval,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (denied.some(fn => fn !== undefined && fn === Constructor)) {
|
||||||
|
logger.error(`callExternal: blocked arbitrary code execution seam: ${constructorPath}`);
|
||||||
|
throw new ActionError(
|
||||||
|
`Blocked constructor (arbitrary code execution): ${constructorPath}`,
|
||||||
|
action
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로컬스토리지에 데이터를 저장합니다.
|
* 로컬스토리지에 데이터를 저장합니다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -5,6 +5,38 @@
|
|||||||
>
|
>
|
||||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||||
|
|
||||||
|
## [engine-v1.64.0] - 2026-09-02
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
#### 동적 스크립트 주입 경로 전부에 출처 게이트 적용 (KVE-2026-1915 B-2 후속)
|
||||||
|
|
||||||
|
- 레이아웃 `scripts[]` 에만 있던 원격 스크립트 차단 게이트를 **브라우저에 새 `<script>` 를 붙이는 모든 경로**로 넓혔다. 종전에는 `loadScript` 액션 · `reloadModuleHandlers`/`reloadPluginHandlers` 의 확장 자산 · 편집기 프리뷰 캔버스 · `G7Core.asset.loadScript` 가 게이트 밖이라, 저장측(SafeLayoutExpressions·NoExternalUrls)이 외부 URL 저장을 422 로 막아도 런타임 디스패치 한 번으로 임의 원격 코드가 로드됐다(실브라우저 실측: 미신뢰 CDN 스크립트 로드 성공, 전역 생성 확인).
|
||||||
|
- 판정식을 `resources/js/core/support/scriptSrcPolicy.ts` 로 분리해 런타임 SSoT 를 하나로 두었다. `TemplateApp` 의 private 4개(`isAllowedScriptSrc` · `normalizeScriptSrcForOriginCheck` · `extractScriptHost` · `getTrustedScriptHosts`)는 그 모듈로 위임하는 thin delegate 로 남긴다(테스트 seam 보존). `ActionDispatcher → TemplateApp` import 는 순환의존이라 불가능하므로 공유 모듈이 유일한 해법이다.
|
||||||
|
- 게이트 실패 시 동작은 경로 성격에 맞춘다: 액션(`loadScript` · 확장 자산 재로드)은 `ActionError` 로 실패해 `onError`/`errorHandling` 오류 채널로 전달되고, 레이아웃 `scripts[]` 와 편집기 프리뷰는 종전대로 skip + 경고다.
|
||||||
|
- `G7Core.asset.isAllowedScriptSrc(url)` 를 공개 seam 으로 노출한다. 코어 로더를 쓸 수 없는 주입(iframe `document.write` 등)이 같은 판정을 재사용하는 통로다. `G7Core.asset.loadScript` 는 미신뢰 URL 을 reject 한다 — 공개 API 계약 변경이다.
|
||||||
|
- `callExternal`/`callExternalEmbed` 에 심층 방어를 더했다. 해석된 생성자가 `Function`·`eval`·`setTimeout`·`setInterval` 이면 **참조 동일성**으로 거부하고(별칭 전역도 차단), 경로 세그먼트가 `__proto__`/`prototype`/`constructor` 면 `getNestedProperty` 가 `undefined` 를 돌려준다. `callbackSetState` 매핑 키도 같은 판정을 받아, 매핑 한 줄이 `deepMergeWithState` → `setState` 를 타고 앱 전역 객체를 오염시키던 쓰기 경로를 닫았다.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
#### 같은 스크립트를 동시에 요청하면 로드 전에 완료 처리되던 문제
|
||||||
|
|
||||||
|
- `loadScript` 액션이 in-flight 를 추적하지 않아, 2번째 호출이 "DOM 에 태그가 있다" 는 이유로 **1번째 로드가 끝나기 전에** 즉시 완료됐다(실측 0ms resolve). 그 호출자의 `onLoad` 는 SDK 전역이 아직 없는 시점에 실행되어 아무 일도 일어나지 않았고, 예외도 콘솔 에러도 남지 않았다.
|
||||||
|
- 이제 `scriptId` 별 공유 Promise 를 두어 태그는 하나만 만들고, 동시 호출자 모두 그 태그의 `onload` 이후에 완료된다. 공유 Promise 는 "로드 완료" 만 담고 `onLoad` 는 호출자별로 각자 실행한다. dispatcher 가 만들지 않은 외래 태그는 로드 상태를 식별할 수 없으므로 종전대로 완료로 간주한다.
|
||||||
|
|
||||||
|
#### 확장 핸들러 재로드에서 script 가 이미 있으면 CSS 까지 건너뛰던 문제
|
||||||
|
|
||||||
|
- `reloadModuleHandlers`/`reloadPluginHandlers` 의 `assets.js` 기존재 early `return` 이 뒤따르는 CSS 로드까지 통째로 건너뛰었다. CSS 블록이 `if (assets.js)` 안에 중첩돼 있어 **CSS 만 있는 확장**은 아예 스타일이 붙지 않았다. 이제 JS 만 건너뛰고 CSS 는 형제 블록에서 독립적으로 처리한다.
|
||||||
|
- 두 핸들러의 원시 `<script>`/`<link>` 생성을 `loadScriptWithRetry`/`loadStylesheetWithRetry` 로 교체했다. 실패한 element 를 남기지 않으므로 잔존 태그가 다음 시도를 조기 완료시키지 않는다.
|
||||||
|
|
||||||
|
#### 확장 CSS 를 동시에 요청하면 `<link>` 가 중복 생성되던 문제
|
||||||
|
|
||||||
|
- `ModuleAssetLoader.loadCSS` 만 in-flight Promise 공유(`loadingPromises`)가 없어 같은 확장의 CSS 동시 요청이 `<link>` 를 중복 생성했고, 재시도 로더가 기존 element 를 제거하면서 서로의 시도를 지웠다. 키는 `module-css-{id}` 로 둬 `loadJS` 의 raw identifier 키공간과 겹치지 않게 한다. 실패 시 throw 하지 않는 기존 계약은 유지한다.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 동의 관리(gdpr) preblocker 가 차단한 스크립트는 `loadScript` 액션에서 **오류가 아니라 미완료 상태**로 끝난다(`false` 반환). 태그를 붙이지 않고 캐시·in-flight 에도 기록하지 않으므로, 동의 후 다시 디스패치하면 정상 로드된다. `onLoad` 는 실행되지 않는다.
|
||||||
|
|
||||||
## [engine-v1.63.5] - 2026-09-02
|
## [engine-v1.63.5] - 2026-09-02
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
pluginAsset,
|
pluginAsset,
|
||||||
convertToCurrentMode,
|
convertToCurrentMode,
|
||||||
} from '../support/assetUrl';
|
} from '../support/assetUrl';
|
||||||
|
import { isAllowedScriptSrc, getTrustedScriptHosts } from '../support/scriptSrcPolicy';
|
||||||
import {
|
import {
|
||||||
notifyAssetFailure,
|
notifyAssetFailure,
|
||||||
drainExternalAssetFailures,
|
drainExternalAssetFailures,
|
||||||
@@ -919,12 +920,37 @@ function initAssetUrlAPI(G7Core: any): void {
|
|||||||
* 확장이 자기 자산을 런타임에 직접 로드할 때 쓴다. 확장 번들이 코어 모듈을
|
* 확장이 자기 자산을 런타임에 직접 로드할 때 쓴다. 확장 번들이 코어 모듈을
|
||||||
* import 할 수 없어 각자 `document.createElement('script')` 를 쓰면, 코어가
|
* import 할 수 없어 각자 `document.createElement('script')` 를 쓰면, 코어가
|
||||||
* 갖춘 재시도·실패 표면화 계층이 그 경로에만 없게 된다.
|
* 갖춘 재시도·실패 표면화 계층이 그 경로에만 없게 된다.
|
||||||
|
*
|
||||||
|
* `url` 은 레이아웃 `scripts[]` 와 **같은 출처 정책**을 받는다 — same-origin 절대
|
||||||
|
* 경로이거나 확장이 manifest(`trusted_script_hosts`)로 선언한 신뢰 호스트여야 한다.
|
||||||
|
* 이 seam 만 게이트가 없으면 저장측 검증을 우회한 원격 코드 로드 통로가 된다.
|
||||||
|
*
|
||||||
|
* @since engine-v1.64.0 출처 게이트 추가 (미신뢰 URL 은 reject)
|
||||||
*/
|
*/
|
||||||
loadScript: (
|
loadScript: (
|
||||||
url: string,
|
url: string,
|
||||||
attrs?: Record<string, string>,
|
attrs?: Record<string, string>,
|
||||||
options?: Record<string, unknown>
|
options?: Record<string, unknown>
|
||||||
): Promise<void> => loadScriptWithRetry(url, attrs, options as any),
|
): Promise<void> => {
|
||||||
|
if (!isAllowedScriptSrc(url, getTrustedScriptHosts())) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error(
|
||||||
|
`Blocked untrusted script src (same-origin path or declared trusted host required): ${url}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadScriptWithRetry(url, attrs, options as any);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 스크립트 URL 이 주입 허용 대상인지 판정합니다.
|
||||||
|
*
|
||||||
|
* 로더를 쓸 수 없는 주입(iframe `document.write` 등)이 같은 판정을 재사용하는 통로다.
|
||||||
|
*
|
||||||
|
* @since engine-v1.64.0
|
||||||
|
*/
|
||||||
|
isAllowedScriptSrc: (url: string): boolean =>
|
||||||
|
isAllowedScriptSrc(url, getTrustedScriptHosts()),
|
||||||
/** 재시도 계층을 갖춘 스타일시트 로더 */
|
/** 재시도 계층을 갖춘 스타일시트 로더 */
|
||||||
loadStylesheet: (
|
loadStylesheet: (
|
||||||
url: string,
|
url: string,
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* `reloadModuleHandlers` / `reloadPluginHandlers` 자산 로드 계약 테스트.
|
||||||
|
*
|
||||||
|
* 두 핸들러는 확장 활성화 응답이 지시하는 URL 을 그대로 `<script>`/`<link>` 로 붙인다.
|
||||||
|
* 그래서 레이아웃 `scripts[]` 와 같은 출처 정책을 받아야 하고(미신뢰 URL 차단),
|
||||||
|
* script 가 이미 있다는 이유로 **CSS 로드까지 건너뛰면 안 된다**(종전 결함).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { ActionDispatcher, ActionDefinition } from '../ActionDispatcher';
|
||||||
|
|
||||||
|
vi.mock('../../auth/AuthManager', () => ({
|
||||||
|
AuthManager: {
|
||||||
|
getInstance: vi.fn(() => ({
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../api/ApiClient', () => ({
|
||||||
|
getApiClient: vi.fn(() => ({ getToken: vi.fn() })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('확장 자산 재로드 핸들러 (reloadModuleHandlers / reloadPluginHandlers)', () => {
|
||||||
|
let dispatcher: ActionDispatcher;
|
||||||
|
let created: HTMLElement[];
|
||||||
|
let originalCreateElement: typeof document.createElement;
|
||||||
|
|
||||||
|
const lastResult = (r: any) => r;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dispatcher = new ActionDispatcher({ navigate: vi.fn() });
|
||||||
|
created = [];
|
||||||
|
originalCreateElement = document.createElement.bind(document);
|
||||||
|
document.head.innerHTML = '';
|
||||||
|
|
||||||
|
(window as any).G7Config = { trustedScriptHosts: [], moduleAssets: {}, pluginAssets: {} };
|
||||||
|
|
||||||
|
vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
|
||||||
|
const el = originalCreateElement(tagName);
|
||||||
|
if (tagName === 'script' || tagName === 'link') {
|
||||||
|
created.push(el as HTMLElement);
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(document.head, 'appendChild').mockImplementation((node: Node) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = node as HTMLElement & { onload?: ((e: Event) => void) | null };
|
||||||
|
el.onload?.(new Event('load'));
|
||||||
|
}, 0);
|
||||||
|
// 실제 DOM 에도 넣어야 getElementById 로 기존재 판정이 가능하다
|
||||||
|
return HTMLHeadElement.prototype.appendChild.call(document.head, node) as Node;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
document.head.innerHTML = '';
|
||||||
|
delete (window as any).G7Config;
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = (handler: string, params: Record<string, any>) =>
|
||||||
|
dispatcher.executeAction(
|
||||||
|
{ type: 'click', handler, params } as ActionDefinition,
|
||||||
|
{} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
['reloadModuleHandlers', 'moduleInfo', 'module', 'moduleAssets'],
|
||||||
|
['reloadPluginHandlers', 'pluginInfo', 'plugin', 'pluginAssets'],
|
||||||
|
])('%s', (handlerName, infoKey, prefix, assetsKey) => {
|
||||||
|
const identifier = 'vendor-ext';
|
||||||
|
|
||||||
|
const add = (assets: Record<string, string>) =>
|
||||||
|
run(handlerName, { [infoKey]: { identifier, assets }, action: 'add' });
|
||||||
|
|
||||||
|
it('same-origin js/css 는 정상 로드된다', async () => {
|
||||||
|
const result = lastResult(await add({ js: '/api/x/bundle.js', css: '/api/x/bundle.css' }));
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(document.getElementById(`${prefix}-${identifier}`)).not.toBeNull();
|
||||||
|
expect(document.getElementById(`${prefix}-css-${identifier}`)).not.toBeNull();
|
||||||
|
expect((window as any).G7Config[assetsKey][identifier]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('미신뢰 외부 js 는 차단된다 (태그 미생성)', async () => {
|
||||||
|
const result = lastResult(await add({ js: 'https://cdn.evil.com/x.js' }));
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect((result.error as Error)?.message).toMatch(/Blocked untrusted/);
|
||||||
|
expect(created.filter(el => el.tagName === 'SCRIPT')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('미신뢰 외부 css 는 차단된다 (태그 미생성)', async () => {
|
||||||
|
const result = lastResult(
|
||||||
|
await add({ js: '/api/x/bundle.js', css: 'https://cdn.evil.com/x.css' })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect((result.error as Error)?.message).toMatch(/Blocked untrusted/);
|
||||||
|
expect(created.filter(el => el.tagName === 'LINK')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('script 가 이미 있어도 CSS 로드는 진행된다', async () => {
|
||||||
|
const existing = originalCreateElement('script');
|
||||||
|
existing.id = `${prefix}-${identifier}`;
|
||||||
|
HTMLHeadElement.prototype.appendChild.call(document.head, existing);
|
||||||
|
|
||||||
|
const result = lastResult(await add({ js: '/api/x/bundle.js', css: '/api/x/bundle.css' }));
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(document.getElementById(`${prefix}-css-${identifier}`)).not.toBeNull();
|
||||||
|
// 새 script 태그는 만들지 않는다
|
||||||
|
expect(created.filter(el => el.tagName === 'SCRIPT')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('css 만 있는 확장도 로드된다 (js 없음)', async () => {
|
||||||
|
const result = lastResult(await add({ css: '/api/x/bundle.css' }));
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(document.getElementById(`${prefix}-css-${identifier}`)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('신뢰 호스트로 선언된 외부 자산은 통과한다', async () => {
|
||||||
|
(window as any).G7Config.trustedScriptHosts = ['cdn.trusted.com'];
|
||||||
|
|
||||||
|
const result = lastResult(await add({ js: 'https://cdn.trusted.com/x.js' }));
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(document.getElementById(`${prefix}-${identifier}`)).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4503,10 +4503,13 @@ describe('ActionDispatcher', () => {
|
|||||||
|
|
||||||
// Clear loaded scripts cache
|
// Clear loaded scripts cache
|
||||||
(ActionDispatcher as any).loadedScripts = new Set();
|
(ActionDispatcher as any).loadedScripts = new Set();
|
||||||
|
(ActionDispatcher as any).loadingScripts = new Map();
|
||||||
|
(window as any).G7Config = { trustedScriptHosts: [] };
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
delete (window as any).G7Config;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load external script and execute onLoad action', async () => {
|
it('should load external script and execute onLoad action', async () => {
|
||||||
@@ -4521,7 +4524,7 @@ describe('ActionDispatcher', () => {
|
|||||||
type: 'click',
|
type: 'click',
|
||||||
handler: 'loadScript',
|
handler: 'loadScript',
|
||||||
params: {
|
params: {
|
||||||
src: '//example.com/script.js',
|
src: '/js/vendor/script.js',
|
||||||
id: 'test_script',
|
id: 'test_script',
|
||||||
},
|
},
|
||||||
onLoad: onLoadAction,
|
onLoad: onLoadAction,
|
||||||
@@ -4537,7 +4540,7 @@ describe('ActionDispatcher', () => {
|
|||||||
|
|
||||||
// Script should be created with correct attributes
|
// Script should be created with correct attributes
|
||||||
expect(appendedScripts.length).toBe(1);
|
expect(appendedScripts.length).toBe(1);
|
||||||
expect(appendedScripts[0].src).toContain('example.com/script.js');
|
expect(appendedScripts[0].src).toContain('/js/vendor/script.js');
|
||||||
expect(appendedScripts[0].id).toBe('test_script');
|
expect(appendedScripts[0].id).toBe('test_script');
|
||||||
|
|
||||||
// onLoad setState should be called
|
// onLoad setState should be called
|
||||||
@@ -4556,7 +4559,7 @@ describe('ActionDispatcher', () => {
|
|||||||
type: 'click',
|
type: 'click',
|
||||||
handler: 'loadScript',
|
handler: 'loadScript',
|
||||||
params: {
|
params: {
|
||||||
src: '//example.com/already.js',
|
src: '/js/vendor/already.js',
|
||||||
id: 'already_loaded',
|
id: 'already_loaded',
|
||||||
},
|
},
|
||||||
onLoad: {
|
onLoad: {
|
||||||
@@ -4598,6 +4601,161 @@ describe('ActionDispatcher', () => {
|
|||||||
|
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('출처 게이트 (KVE-2026-1915 B-2 후속)', () => {
|
||||||
|
const dispatch = (params: Record<string, any>) =>
|
||||||
|
dispatcher.executeAction(
|
||||||
|
{ type: 'click', handler: 'loadScript', params } as ActionDefinition,
|
||||||
|
{} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
it('미신뢰 외부 src 는 실패로 끝나고 script 태그가 만들어지지 않는다', async () => {
|
||||||
|
const result = await dispatch({ src: 'https://cdn.evil.com/lodash.js', id: 'evil' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect((result.error as Error)?.message).toMatch(/Blocked untrusted script src/);
|
||||||
|
expect(appendedScripts.length).toBe(0);
|
||||||
|
expect((ActionDispatcher as any).loadedScripts.has('evil')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authority 우회 형태(`/\\/evil.com/x.js`)도 차단된다', async () => {
|
||||||
|
const result = await dispatch({ src: '/\\/evil.com/x.js', id: 'bypass' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect((result.error as Error)?.message).toMatch(/Blocked untrusted script src/);
|
||||||
|
expect(appendedScripts.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('미신뢰 src 는 onError 액션을 발화시킨다', async () => {
|
||||||
|
const mockSetState = vi.fn();
|
||||||
|
const action: ActionDefinition = {
|
||||||
|
type: 'click',
|
||||||
|
handler: 'loadScript',
|
||||||
|
params: { src: 'https://cdn.evil.com/x.js', id: 'evil2' },
|
||||||
|
onError: { type: 'click', handler: 'setState', params: { blocked: true } },
|
||||||
|
} as ActionDefinition;
|
||||||
|
|
||||||
|
const handler = dispatcher.createHandler(action, {}, { setState: mockSetState });
|
||||||
|
const mockEvent = {
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
stopPropagation: vi.fn(),
|
||||||
|
target: null,
|
||||||
|
} as unknown as Event;
|
||||||
|
|
||||||
|
await handler(mockEvent);
|
||||||
|
|
||||||
|
expect(mockSetState).toHaveBeenCalledWith(expect.objectContaining({ blocked: true }));
|
||||||
|
expect(appendedScripts.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('G7Config.trustedScriptHosts 에 선언된 호스트는 통과한다', async () => {
|
||||||
|
(window as any).G7Config = { trustedScriptHosts: ['t1.daumcdn.net'] };
|
||||||
|
|
||||||
|
const result = await dispatch({ src: '//t1.daumcdn.net/postcode.v2.js', id: 'daum' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toBe(true);
|
||||||
|
expect(appendedScripts.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('미신뢰 src 는 캐시에 이미 있어도 차단된다 (게이트가 캐시보다 앞)', async () => {
|
||||||
|
(ActionDispatcher as any).loadedScripts.add('cached_evil');
|
||||||
|
|
||||||
|
const result = await dispatch({ src: 'https://cdn.evil.com/x.js', id: 'cached_evil' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect((result.error as Error)?.message).toMatch(/Blocked untrusted script src/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('동시 로드 Promise 공유', () => {
|
||||||
|
it('같은 id 동시 2건 → script 1개, 두 호출자 모두 onload 이후 완료', async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
const onLoadA = vi.fn(() => order.push('A'));
|
||||||
|
const onLoadB = vi.fn(() => order.push('B'));
|
||||||
|
|
||||||
|
// appendChild mock 이 10ms 뒤 onload 를 부른다 (beforeEach)
|
||||||
|
const p1 = dispatcher
|
||||||
|
.executeAction(
|
||||||
|
{
|
||||||
|
type: 'click',
|
||||||
|
handler: 'loadScript',
|
||||||
|
params: { src: '/js/shared.js', id: 'shared' },
|
||||||
|
onLoad: { type: 'click', handler: 'customA' },
|
||||||
|
} as ActionDefinition,
|
||||||
|
{} as any
|
||||||
|
);
|
||||||
|
const p2 = dispatcher
|
||||||
|
.executeAction(
|
||||||
|
{
|
||||||
|
type: 'click',
|
||||||
|
handler: 'loadScript',
|
||||||
|
params: { src: '/js/shared.js', id: 'shared' },
|
||||||
|
onLoad: { type: 'click', handler: 'customB' },
|
||||||
|
} as ActionDefinition,
|
||||||
|
{} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatcher.registerHandler('customA', async () => onLoadA());
|
||||||
|
dispatcher.registerHandler('customB', async () => onLoadB());
|
||||||
|
|
||||||
|
await Promise.all([p1, p2]);
|
||||||
|
|
||||||
|
// 태그는 1개만 만들어진다
|
||||||
|
expect(appendedScripts.length).toBe(1);
|
||||||
|
// 두 호출자 모두 자기 onLoad 를 실행한다
|
||||||
|
expect(onLoadA).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onLoadB).toHaveBeenCalledTimes(1);
|
||||||
|
expect(order).toHaveLength(2);
|
||||||
|
// 완료 후 in-flight 는 정리된다
|
||||||
|
expect((ActionDispatcher as any).loadingScripts.size).toBe(0);
|
||||||
|
expect((ActionDispatcher as any).loadedScripts.has('shared')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('동의 관리(gdpr) 차단 경계', () => {
|
||||||
|
it('data-gdpr-blocked-src 가 붙으면 append 없이 resolve(false)', async () => {
|
||||||
|
const mockSetState = vi.fn();
|
||||||
|
|
||||||
|
// src setter 를 가로채 차단 속성을 기록하는 preblocker 를 모사
|
||||||
|
vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
|
||||||
|
const el = originalCreateElement(tagName);
|
||||||
|
if (tagName === 'script') {
|
||||||
|
const script = el as HTMLScriptElement;
|
||||||
|
Object.defineProperty(script, 'src', {
|
||||||
|
set(value: string) {
|
||||||
|
script.setAttribute('data-gdpr-blocked-src', value);
|
||||||
|
},
|
||||||
|
get() {
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
appendedScripts.push(script);
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
|
||||||
|
const action: ActionDefinition = {
|
||||||
|
type: 'click',
|
||||||
|
handler: 'loadScript',
|
||||||
|
params: { src: '/js/analytics.js', id: 'gdpr_blocked' },
|
||||||
|
onLoad: { type: 'click', handler: 'setState', params: { loaded: true } },
|
||||||
|
} as ActionDefinition;
|
||||||
|
|
||||||
|
const result = await dispatcher.executeAction(action, {
|
||||||
|
setState: mockSetState,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toBe(false);
|
||||||
|
expect(document.head.appendChild).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetState).not.toHaveBeenCalled();
|
||||||
|
// 동의 후 재디스패치가 정상 로드되도록 캐시에 기록하지 않는다
|
||||||
|
expect((ActionDispatcher as any).loadedScripts.has('gdpr_blocked')).toBe(false);
|
||||||
|
expect((ActionDispatcher as any).loadingScripts.has('gdpr_blocked')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('callExternal handler', () => {
|
describe('callExternal handler', () => {
|
||||||
@@ -4635,6 +4793,131 @@ describe('ActionDispatcher', () => {
|
|||||||
delete (window as any).G7Core;
|
delete (window as any).G7Core;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('임의 코드 실행 seam 차단 (심층 방어)', () => {
|
||||||
|
const dispatch = (handler: string, params: Record<string, any>, context: any = {}) =>
|
||||||
|
dispatcher.executeAction(
|
||||||
|
{ type: 'click', handler, params } as ActionDefinition,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['Function', 'eval', 'setTimeout', 'setInterval'])(
|
||||||
|
'%s 는 참조 동일성으로 거부된다',
|
||||||
|
async name => {
|
||||||
|
const result = await dispatch('callExternal', { constructor: name, args: {} });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect((result.error as Error)?.message).toMatch(/Blocked constructor/);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('별칭 전역도 참조 동일성으로 거부된다', async () => {
|
||||||
|
(window as any).__alias = (window as any).Function;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await dispatch('callExternal', { constructor: '__alias', args: {} });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect((result.error as Error)?.message).toMatch(/Blocked constructor/);
|
||||||
|
} finally {
|
||||||
|
delete (window as any).__alias;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('callExternalEmbed 도 같은 판정을 받는다', async () => {
|
||||||
|
const result = await dispatch('callExternalEmbed', {
|
||||||
|
constructor: 'Function',
|
||||||
|
args: {},
|
||||||
|
embedTarget: 'body',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect((result.error as Error)?.message).toMatch(/Blocked constructor/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('프로토타입 체인 경로는 해석되지 않는다 (Object.constructor)', async () => {
|
||||||
|
const result = await dispatch('callExternal', {
|
||||||
|
constructor: 'Object.constructor',
|
||||||
|
args: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect((result.error as Error)?.message).toMatch(/Constructor not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('정상 생성자는 그대로 호출된다 (과차단 없음)', async () => {
|
||||||
|
const result = await dispatch('callExternal', {
|
||||||
|
constructor: 'testLib.TestClass',
|
||||||
|
args: {},
|
||||||
|
method: 'open',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('callbackSetState 프로토타입 오염 차단', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete (Object.prototype as any).polluted;
|
||||||
|
});
|
||||||
|
|
||||||
|
const runMapping = async (callbackSetState: any) => {
|
||||||
|
const mockSetState = vi.fn();
|
||||||
|
|
||||||
|
const action: ActionDefinition = {
|
||||||
|
type: 'click',
|
||||||
|
handler: 'callExternal',
|
||||||
|
params: {
|
||||||
|
constructor: 'testLib.TestClass',
|
||||||
|
args: { oncomplete: true },
|
||||||
|
callbackSetState,
|
||||||
|
},
|
||||||
|
} as ActionDefinition;
|
||||||
|
|
||||||
|
await dispatcher.executeAction(action, { setState: mockSetState, state: {} } as any);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 30));
|
||||||
|
|
||||||
|
return mockSetState;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('프로토타입 체인 매핑 키는 상태에 도달하지 않는다', async () => {
|
||||||
|
// 레이아웃 JSON 은 JSON.parse 를 거치므로 `__proto__` 도 own property 가 된다
|
||||||
|
const mockSetState = await runMapping(
|
||||||
|
JSON.parse(
|
||||||
|
'{"__proto__": {"polluted": "result"}, "constructor": "result", "prototype": "result", "safe": "result"}'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockSetState).toHaveBeenCalled();
|
||||||
|
const payload = mockSetState.mock.calls[0][0] as Record<string, any>;
|
||||||
|
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(payload, 'constructor')).toBe(false);
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(payload, 'prototype')).toBe(false);
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(payload, '__proto__')).toBe(false);
|
||||||
|
// 정상 키는 그대로 매핑된다 (과차단 없음)
|
||||||
|
expect(payload.safe).toBe('success');
|
||||||
|
expect((Object.prototype as any).polluted).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('중첩 매핑의 프로토타입 체인 키도 건너뛴다', async () => {
|
||||||
|
const mockSetState = await runMapping(
|
||||||
|
JSON.parse('{"form": {"constructor": "result", "safe": "result"}}')
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = mockSetState.mock.calls[0][0] as Record<string, any>;
|
||||||
|
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(payload.form, 'constructor')).toBe(false);
|
||||||
|
expect(payload.form.safe).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('데이터 경로가 프로토타입 체인을 타면 undefined 로 해석된다', async () => {
|
||||||
|
const mockSetState = await runMapping({ leaked: 'constructor.name' });
|
||||||
|
|
||||||
|
const payload = mockSetState.mock.calls[0][0] as Record<string, any>;
|
||||||
|
|
||||||
|
expect(payload.leaked).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should call external constructor and method', async () => {
|
it('should call external constructor and method', async () => {
|
||||||
const mockSetState = vi.fn();
|
const mockSetState = vi.fn();
|
||||||
|
|
||||||
|
|||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* PreviewCanvas — 편집 캔버스 스크립트 주입 출처 게이트 회귀 테스트.
|
||||||
|
*
|
||||||
|
* 프리뷰는 런타임 렌더의 미리보기다. 런타임(TemplateApp)이 거부하는 `scripts[].src` 를
|
||||||
|
* 캔버스가 관리자 document.head 에 그대로 주입하면, **편집 중에만** 임의 원격 코드가
|
||||||
|
* 관리자 세션에서 실행된다. 저장은 422 로 막히므로 저장 전 미리보기 단계가 유일한
|
||||||
|
* 실행 창이고, 오류도 로그도 남지 않는다.
|
||||||
|
*
|
||||||
|
* 판정·결과는 런타임과 같아야 한다 — same-origin 경로/선언된 신뢰 호스트만 주입,
|
||||||
|
* 그 밖은 skip + 경고.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { render, waitFor, cleanup } from '@testing-library/react';
|
||||||
|
import { LayoutEditorProvider } from '../../LayoutEditorContext';
|
||||||
|
import { LayoutDocumentProvider } from '../../LayoutDocumentContext';
|
||||||
|
import { PreviewCanvas } from '../../components/PreviewCanvas';
|
||||||
|
import { TranslationProvider } from '../../../TranslationContext';
|
||||||
|
import { TranslationEngine } from '../../../TranslationEngine';
|
||||||
|
import { ComponentRegistry } from '../../../ComponentRegistry';
|
||||||
|
|
||||||
|
describe('PreviewCanvas — scripts[] 출처 게이트', () => {
|
||||||
|
let originalG7Core: any;
|
||||||
|
let layoutScripts: Array<{ src: string; id?: string }> = [];
|
||||||
|
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
const injectedScriptSrcs = (): string[] =>
|
||||||
|
Array.from(window.document.querySelectorAll('script[data-g7le-canvas-script]')).map(
|
||||||
|
el => el.getAttribute('data-g7le-canvas-script') ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ComponentRegistry.resetInstance();
|
||||||
|
originalG7Core = (window as any).G7Core;
|
||||||
|
(window as any).G7Core = { t: vi.fn((key: string) => key) };
|
||||||
|
(window as any).G7Config = { trustedScriptHosts: [] };
|
||||||
|
|
||||||
|
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// 주입된 태그가 영원히 pending 되지 않도록 즉시 load 를 발화시킨다
|
||||||
|
vi.spyOn(window.document.head, 'appendChild').mockImplementation(((node: any) => {
|
||||||
|
if (node.tagName === 'SCRIPT') {
|
||||||
|
window.document.body.appendChild(node);
|
||||||
|
queueMicrotask(() => node.dispatchEvent(new Event('load')));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return HTMLHeadElement.prototype.appendChild.call(window.document.head, node);
|
||||||
|
}) as any);
|
||||||
|
|
||||||
|
(global as any).fetch = vi.fn(async (url: string) => {
|
||||||
|
if (url.includes('/editor-assets')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
identifier: 'sirsoft-basic',
|
||||||
|
js: [],
|
||||||
|
css: [],
|
||||||
|
manifest_present: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (url.includes('/components.json')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
version: '1.0.0',
|
||||||
|
templateId: 'sirsoft-basic',
|
||||||
|
components: { basic: [], composite: [], layout: [] },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (url.includes('/lang/')) {
|
||||||
|
return { ok: true, json: async () => ({}) };
|
||||||
|
}
|
||||||
|
if (url.includes('/api/layouts/')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: { components: [], scripts: layoutScripts } }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: false, status: 404, json: async () => ({}) };
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as any).SirsoftBasic = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
window.document
|
||||||
|
.querySelectorAll('script[data-g7le-canvas-script]')
|
||||||
|
.forEach(el => el.remove());
|
||||||
|
(window as any).G7Core = originalG7Core;
|
||||||
|
delete (window as any).G7Config;
|
||||||
|
delete (window as any).SirsoftBasic;
|
||||||
|
ComponentRegistry.resetInstance();
|
||||||
|
layoutScripts = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문서 컨텍스트를 직접 주입해 캔버스를 렌더한다.
|
||||||
|
*
|
||||||
|
* 라우트 선택 → routes fetch → 문서 fetch 전체를 태우지 않고도, 캔버스가 실제로
|
||||||
|
* `document.raw.scripts` 를 읽어 주입하는 그 경로를 그대로 지난다
|
||||||
|
* (LayoutDocumentProvider 가 단일 진입점이다).
|
||||||
|
*/
|
||||||
|
const renderCanvas = () => {
|
||||||
|
const engine = new TranslationEngine();
|
||||||
|
const docValue = {
|
||||||
|
document: {
|
||||||
|
layoutName: 'main',
|
||||||
|
raw: { components: [], scripts: layoutScripts },
|
||||||
|
lockVersion: 0,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
return render(
|
||||||
|
React.createElement(
|
||||||
|
TranslationProvider,
|
||||||
|
{
|
||||||
|
translationEngine: engine,
|
||||||
|
translationContext: { templateId: 'sirsoft-admin_basic', locale: 'ko' },
|
||||||
|
},
|
||||||
|
React.createElement(
|
||||||
|
LayoutEditorProvider,
|
||||||
|
{ templateIdentifier: 'sirsoft-basic', initialLocale: 'ko' },
|
||||||
|
React.createElement(
|
||||||
|
LayoutDocumentProvider,
|
||||||
|
{ value: docValue },
|
||||||
|
React.createElement(PreviewCanvas)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('미신뢰 외부 src 는 주입하지 않고 경고만 남긴다', async () => {
|
||||||
|
layoutScripts = [{ src: 'https://cdn.evil.com/x.js', id: 'evil' }];
|
||||||
|
|
||||||
|
renderCanvas();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Blocked untrusted script src')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(injectedScriptSrcs()).not.toContain('https://cdn.evil.com/x.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authority 우회 형태도 주입하지 않는다', async () => {
|
||||||
|
layoutScripts = [{ src: '/\\/evil.com/x.js', id: 'bypass' }];
|
||||||
|
|
||||||
|
renderCanvas();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Blocked untrusted script src')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(injectedScriptSrcs()).not.toContain('/\\/evil.com/x.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('same-origin 경로는 그대로 주입된다 (과차단 없음)', async () => {
|
||||||
|
layoutScripts = [{ src: '/api/templates/assets/sirsoft-basic/x.js', id: 'ok' }];
|
||||||
|
|
||||||
|
renderCanvas();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(injectedScriptSrcs()).toContain('/api/templates/assets/sirsoft-basic/x.js');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('선언된 신뢰 호스트는 주입된다', async () => {
|
||||||
|
(window as any).G7Config = { trustedScriptHosts: ['cdn.ckeditor.com'] };
|
||||||
|
layoutScripts = [{ src: 'https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.js', id: 'ck' }];
|
||||||
|
|
||||||
|
renderCanvas();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(injectedScriptSrcs()).toContain(
|
||||||
|
'https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.js'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+87
@@ -243,4 +243,91 @@ describe('useEditorTemplateAssets', () => {
|
|||||||
const langCall = fetchSpy.mock.calls.find((c) => /\/lang\/en\.json/.test(String(c[0])));
|
const langCall = fetchSpy.mock.calls.find((c) => /\/lang\/en\.json/.test(String(c[0])));
|
||||||
expect(langCall).toBeDefined();
|
expect(langCall).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 편집 자산 매니페스트가 지시하는 `js` 는 서버 응답이지만, 붙는 자리는 **관리자 document** 다.
|
||||||
|
* 확장 자산 재로드·프리뷰 캔버스와 같은 출처 게이트를 받아야 한다 — 이 경로만 게이트가
|
||||||
|
* 없으면 편집기 진입만으로 임의 원격 코드가 관리자 세션에서 실행된다.
|
||||||
|
*/
|
||||||
|
describe('스크립트 주입 출처 게이트', () => {
|
||||||
|
/**
|
||||||
|
* 매니페스트가 주어진 js 목록을 돌려주도록 fetch 를 세운다.
|
||||||
|
*
|
||||||
|
* @param js 매니페스트 js 배열
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function mockManifest(js: string[]): void {
|
||||||
|
fetchSpy.mockImplementation(async (url: string) => {
|
||||||
|
if (url.includes('/editor-assets')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { identifier: 'sirsoft-basic', js, css: [], manifest_present: true },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (url.includes('/config.json')) return { ok: true, json: async () => ({ cache_version: 1 }) };
|
||||||
|
if (url.includes('/components.json')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
version: '1.0.0',
|
||||||
|
templateId: 'sirsoft-basic',
|
||||||
|
components: { basic: [], composite: [], layout: [] },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (url.includes('/lang/')) return { ok: true, json: async () => ({}) };
|
||||||
|
return { ok: false, status: 404, json: async () => ({}) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(window as any).G7Config = { trustedScriptHosts: [] };
|
||||||
|
// 주입된 태그가 영원히 pending 되지 않도록 즉시 load 를 발화시킨다
|
||||||
|
vi.spyOn(document.head, 'appendChild').mockImplementation(((node: any) => {
|
||||||
|
if (node.tagName === 'SCRIPT' || node.tagName === 'LINK') {
|
||||||
|
document.body.appendChild(node);
|
||||||
|
queueMicrotask(() => node.dispatchEvent(new Event('load')));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return HTMLHeadElement.prototype.appendChild.call(document.head, node);
|
||||||
|
}) as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
delete (window as any).G7Config;
|
||||||
|
document.querySelectorAll('[data-g7le-asset]').forEach((n) => n.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('미신뢰 외부 src 는 주입하지 않고 오류로 끝난다', async () => {
|
||||||
|
mockManifest(['https://cdn.evil.com/x.js']);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEditorTemplateAssets('sirsoft-basic', 'en'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.error).not.toBeNull(), { timeout: 3000 });
|
||||||
|
expect(
|
||||||
|
document.querySelectorAll('script[data-g7le-asset="https://cdn.evil.com/x.js"]').length,
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('same-origin src 는 주입된다 (과차단 없음)', async () => {
|
||||||
|
mockManifest(['/api/templates/assets/sirsoft-basic/js/components.iife.js']);
|
||||||
|
|
||||||
|
renderHook(() => useEditorTemplateAssets('sirsoft-basic', 'en'));
|
||||||
|
|
||||||
|
// 게이트를 통과해 실제로 태그가 붙는지만 본다 — 그 뒤 부트스트랩(전역 확보 등)은
|
||||||
|
// 이 축의 관심사가 아니다.
|
||||||
|
await waitFor(
|
||||||
|
() =>
|
||||||
|
expect(
|
||||||
|
document.querySelectorAll(
|
||||||
|
'script[data-g7le-asset="/api/templates/assets/sirsoft-basic/js/components.iife.js"]',
|
||||||
|
).length,
|
||||||
|
).toBe(1),
|
||||||
|
{ timeout: 3000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
} from '../state/pageStateSimulator';
|
} from '../state/pageStateSimulator';
|
||||||
import { trackPageState } from '../devtools/editorTrackers';
|
import { trackPageState } from '../devtools/editorTrackers';
|
||||||
import type { EditorStateItemSpec } from '../spec/specTypes';
|
import type { EditorStateItemSpec } from '../spec/specTypes';
|
||||||
|
import { isAllowedScriptSrc, getTrustedScriptHosts } from '../../../support/scriptSrcPolicy';
|
||||||
|
|
||||||
export interface PreviewCanvasProps {
|
export interface PreviewCanvasProps {
|
||||||
/** components.json 매니페스트 — Chrome 에서 fetch 해 주입 */
|
/** components.json 매니페스트 — Chrome 에서 fetch 해 주입 */
|
||||||
@@ -614,6 +615,15 @@ export function PreviewCanvas(props: PreviewCanvasProps = {}): React.ReactElemen
|
|||||||
const loaders: Promise<void>[] = [];
|
const loaders: Promise<void>[] = [];
|
||||||
for (const entry of scripts) {
|
for (const entry of scripts) {
|
||||||
if (!entry?.src || typeof entry.src !== 'string') continue;
|
if (!entry?.src || typeof entry.src !== 'string') continue;
|
||||||
|
// 프리뷰는 런타임 렌더의 미리보기다 — 런타임(TemplateApp)이 거부할 src 를
|
||||||
|
// 관리자 document.head 에 주입하면 편집 중에만 임의 원격 코드가 실행된다.
|
||||||
|
// 같은 판정·같은 결과(skip + 경고).
|
||||||
|
if (!isAllowedScriptSrc(entry.src, getTrustedScriptHosts())) {
|
||||||
|
console.warn(
|
||||||
|
`[PreviewCanvas] Blocked untrusted script src (same-origin path or declared trusted host required): ${entry.src}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const sel = entry.id
|
const sel = entry.id
|
||||||
? `script#${CSS.escape(entry.id)}, script[data-g7le-canvas-script="${entry.src}"]`
|
? `script#${CSS.escape(entry.id)}, script[data-g7le-canvas-script="${entry.src}"]`
|
||||||
: `script[data-g7le-canvas-script="${entry.src}"], script[src="${entry.src}"]`;
|
: `script[data-g7le-canvas-script="${entry.src}"], script[src="${entry.src}"]`;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
} from '../types/editorErrors';
|
} from '../types/editorErrors';
|
||||||
import { reseedPendingIntoEngine } from './pendingCustomTranslations';
|
import { reseedPendingIntoEngine } from './pendingCustomTranslations';
|
||||||
import { suffixed } from '../../../support/assetUrl';
|
import { suffixed } from '../../../support/assetUrl';
|
||||||
|
import { isAllowedScriptSrc, getTrustedScriptHosts } from '../../../support/scriptSrcPolicy';
|
||||||
|
|
||||||
export interface EditorTemplateAssetsState {
|
export interface EditorTemplateAssetsState {
|
||||||
componentRegistry: ComponentRegistry | null;
|
componentRegistry: ComponentRegistry | null;
|
||||||
@@ -121,6 +122,17 @@ function injectScript(src: string): Promise<void> {
|
|||||||
reject(new Error('document undefined'));
|
reject(new Error('document undefined'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 출처 게이트 — 이 src 는 서버 응답(editor-assets manifest)이 지시하는 값이고,
|
||||||
|
// 붙는 자리는 관리자 document 다. 확장 자산 재로드(reloadModuleHandlers)·프리뷰 캔버스와
|
||||||
|
// 같은 판정을 받는다. 정상 템플릿 자산 URL 은 전부 `/api/...` same-origin 이다.
|
||||||
|
if (!isAllowedScriptSrc(src, getTrustedScriptHosts())) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Blocked untrusted script src (same-origin path or declared trusted host required): ${src}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const existing = document.querySelector(`script[data-g7le-asset="${src}"]`);
|
const existing = document.querySelector(`script[data-g7le-asset="${src}"]`);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// 이미 로드된 상태로 간주 (실패한 경우라도 재시도는 페이지 새로고침으로)
|
// 이미 로드된 상태로 간주 (실패한 경우라도 재시도는 페이지 새로고침으로)
|
||||||
|
|||||||
@@ -568,4 +568,213 @@ class NoExternalUrlsTest extends TestCase
|
|||||||
'UpdateLayoutRequest 의 content 배열 규칙에 NoExternalUrls 가 부착되어야 합니다'
|
'UpdateLayoutRequest 의 content 배열 규칙에 NoExternalUrls 가 부착되어야 합니다'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 순회 사각 폐쇄 (액션이 실행되는 자리 / 값이 sink 로 흐르는 자리)
|
||||||
|
// ==========================================
|
||||||
|
//
|
||||||
|
// 종전 순회는 `components[].props/actions/children` + `init_actions` 뿐이었다.
|
||||||
|
// 나머지 키는 저장 검증을 그대로 통과했고, 통과는 오류를 남기지 않는다.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최상위 키에 외부 URL 을 심은 레이아웃을 만든다.
|
||||||
|
*
|
||||||
|
* @param string $key 최상위 키
|
||||||
|
* @param mixed $payload 그 키의 값
|
||||||
|
* @return array<string, mixed> 레이아웃 배열
|
||||||
|
*/
|
||||||
|
private function layoutWith(string $key, mixed $payload): array
|
||||||
|
{
|
||||||
|
return ['version' => '1.0.0', 'components' => [], $key => $payload];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 규칙을 돌려 실패 여부를 돌려준다.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $layout 레이아웃 배열
|
||||||
|
* @return bool 차단되면 true
|
||||||
|
*/
|
||||||
|
private function blocks(array $layout): bool
|
||||||
|
{
|
||||||
|
$failed = false;
|
||||||
|
$this->rule->validate('content', $layout, function () use (&$failed) {
|
||||||
|
$failed = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신규 순회 키별 외부 URL 차단.
|
||||||
|
*
|
||||||
|
* @return array<string, array{0: array<string, mixed>}>
|
||||||
|
*/
|
||||||
|
public static function newlyTraversedKeyProvider(): array
|
||||||
|
{
|
||||||
|
$evilAction = ['handler' => 'loadScript', 'params' => ['src' => 'https://cdn.evil.com/x.js']];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'initActions (신철자)' => [
|
||||||
|
['version' => '1.0.0', 'components' => [], 'initActions' => [$evilAction]],
|
||||||
|
],
|
||||||
|
'modals 안 컴포넌트 props' => [
|
||||||
|
[
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'components' => [],
|
||||||
|
'modals' => [
|
||||||
|
'confirm' => [
|
||||||
|
'components' => [
|
||||||
|
['component' => 'Img', 'props' => ['src' => 'https://cdn.evil.com/x.png']],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'named_actions' => [
|
||||||
|
['version' => '1.0.0', 'components' => [], 'named_actions' => ['boot' => $evilAction]],
|
||||||
|
],
|
||||||
|
'errorHandling' => [
|
||||||
|
[
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'components' => [],
|
||||||
|
'errorHandling' => ['404' => ['handler' => 'navigate', 'params' => ['path' => 'https://evil.com']]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'component lifecycle.onMount' => [
|
||||||
|
[
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'components' => [
|
||||||
|
['component' => 'Div', 'lifecycle' => ['onMount' => [$evilAction]]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'component onComponentEvent' => [
|
||||||
|
[
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'components' => [
|
||||||
|
['component' => 'Div', 'onComponentEvent' => [$evilAction]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'component slots' => [
|
||||||
|
[
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'components' => [
|
||||||
|
[
|
||||||
|
'component' => 'Card',
|
||||||
|
'slots' => [
|
||||||
|
'header' => [
|
||||||
|
['component' => 'Img', 'props' => ['src' => 'https://cdn.evil.com/x.png']],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'component component_layout' => [
|
||||||
|
[
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'components' => [
|
||||||
|
[
|
||||||
|
'component' => 'Widget',
|
||||||
|
'component_layout' => [
|
||||||
|
'components' => [
|
||||||
|
['props' => ['src' => 'https://cdn.evil.com/x.png']],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'component responsive.props' => [
|
||||||
|
[
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'components' => [
|
||||||
|
[
|
||||||
|
'component' => 'Img',
|
||||||
|
'responsive' => ['md' => ['props' => ['src' => 'https://cdn.evil.com/x.png']]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'protocol-relative 우회 형태' => [
|
||||||
|
[
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'components' => [
|
||||||
|
['component' => 'Div', 'lifecycle' => ['onMount' => [['params' => ['src' => '/\\/evil.com/x.js']]]]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider newlyTraversedKeyProvider
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $layout 검사 대상 레이아웃
|
||||||
|
*/
|
||||||
|
public function test_blocks_external_url_in_newly_traversed_keys(array $layout): void
|
||||||
|
{
|
||||||
|
$this->assertTrue($this->blocks($layout), '신규 순회 키의 외부 URL 은 차단되어야 합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* same-origin 값은 신규 순회 키에서도 통과한다 (과차단 방지).
|
||||||
|
*/
|
||||||
|
public function test_passes_same_origin_values_in_newly_traversed_keys(): void
|
||||||
|
{
|
||||||
|
$ok = ['handler' => 'loadScript', 'params' => ['src' => '/api/templates/assets/x/a.js']];
|
||||||
|
|
||||||
|
$this->assertFalse($this->blocks($this->layoutWith('initActions', [$ok])));
|
||||||
|
$this->assertFalse($this->blocks($this->layoutWith('named_actions', ['boot' => $ok])));
|
||||||
|
$this->assertFalse($this->blocks([
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'components' => [
|
||||||
|
['component' => 'Div', 'lifecycle' => ['onMount' => [$ok]]],
|
||||||
|
['component' => 'Img', 'responsive' => ['md' => ['props' => ['src' => '/images/a.png']]]],
|
||||||
|
['component' => 'Card', 'slots' => ['header' => [['props' => ['src' => '/images/a.png']]]]],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
$this->assertFalse($this->blocks([
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'components' => [],
|
||||||
|
'modals' => ['confirm' => ['partial' => 'partials/confirm.json']],
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 계층은 여전히 순회하지 않는다 (예시/안내 URL 을 담는 정당한 용례 보호).
|
||||||
|
*
|
||||||
|
* 이 회귀 단언이 없으면 다음 확장 때 "이왕 넓히는 김에" 로 데이터 계층까지 순회 대상이
|
||||||
|
* 되어, 저장되던 레이아웃이 갑자기 422 가 된다.
|
||||||
|
*/
|
||||||
|
public function test_does_not_traverse_data_layer_keys(): void
|
||||||
|
{
|
||||||
|
foreach (['defines', 'state', 'computed', 'initLocal', 'initGlobal', 'initIsolated'] as $key) {
|
||||||
|
$this->assertFalse(
|
||||||
|
$this->blocks($this->layoutWith($key, ['docsUrl' => 'https://example.com/guide'])),
|
||||||
|
"$key 는 데이터 계층이므로 순회 대상이 아닙니다"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오류 경로에 modals/slots 위치가 남는다 (어느 자리인지 알 수 있어야 고칠 수 있다).
|
||||||
|
*/
|
||||||
|
public function test_reports_modal_and_slot_paths(): void
|
||||||
|
{
|
||||||
|
$paths = [];
|
||||||
|
$rule = new NoExternalUrls;
|
||||||
|
|
||||||
|
$rule->validate('content', [
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'components' => [
|
||||||
|
['component' => 'Card', 'slots' => ['header' => [['props' => ['src' => 'https://evil.com/x.png']]]]],
|
||||||
|
],
|
||||||
|
], function () use (&$paths) {
|
||||||
|
$paths[] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->assertNotEmpty($paths, 'slots 안의 외부 URL 이 차단되어야 합니다');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,18 +63,47 @@ test.describe('레이아웃 외부 스크립트 신뢰 출처 허용목록', ()
|
|||||||
return {
|
return {
|
||||||
ckeditorActive: has('sirsoft-ckeditor5'),
|
ckeditorActive: has('sirsoft-ckeditor5'),
|
||||||
daumActive: has('sirsoft-daum_postcode'),
|
daumActive: has('sirsoft-daum_postcode'),
|
||||||
|
kginicisActive: has('sirsoft-pay_kginicis'),
|
||||||
|
tossActive: has('sirsoft-tosspayments'),
|
||||||
|
niceActive: has('sirsoft-pay_nicepayments'),
|
||||||
|
kcpActive: has('sirsoft-pay_nhnkcp'),
|
||||||
hasCkeditorHost: hosts.includes('cdn.ckeditor.com'),
|
hasCkeditorHost: hosts.includes('cdn.ckeditor.com'),
|
||||||
hasDaumHost: hosts.includes('t1.daumcdn.net'),
|
hasDaumHost: hosts.includes('t1.daumcdn.net'),
|
||||||
|
hasKginicisHosts:
|
||||||
|
hosts.includes('stdpay.inicis.com') && hosts.includes('stgstdpay.inicis.com'),
|
||||||
|
hasTossHost: hosts.includes('js.tosspayments.com'),
|
||||||
|
hasNiceHost: hosts.includes('web.nicepay.co.kr'),
|
||||||
|
hasKcpHosts: hosts.includes('pay.kcp.co.kr') && hosts.includes('testpay.kcp.co.kr'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 확장이 활성일 때만 그 선언 호스트가 목록에 있어야 한다(비활성이면 스킵 — 서버 상태 독립).
|
// 확장이 활성일 때만 그 선언 호스트가 목록에 있어야 한다(비활성이면 스킵 — 서버 상태 독립).
|
||||||
if (result.ckeditorActive) {
|
|
||||||
expect(result.hasCkeditorHost).toBe(true);
|
|
||||||
}
|
|
||||||
if (result.daumActive) {
|
if (result.daumActive) {
|
||||||
expect(result.hasDaumHost).toBe(true);
|
expect(result.hasDaumHost).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 결제 플러그인의 PG SDK 호스트 — 서비스 SDK 라 자체 호스팅이 불가능해 선언 대상이다.
|
||||||
|
if (result.kginicisActive) {
|
||||||
|
expect(result.hasKginicisHosts).toBe(true);
|
||||||
|
}
|
||||||
|
if (result.tossActive) {
|
||||||
|
expect(result.hasTossHost).toBe(true);
|
||||||
|
}
|
||||||
|
if (result.niceActive) {
|
||||||
|
expect(result.hasNiceHost).toBe(true);
|
||||||
|
}
|
||||||
|
if (result.kcpActive) {
|
||||||
|
expect(result.hasKcpHosts).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CKEditor5 는 7.0.10 에서 자체 제공으로 전환해 더 이상 CDN 호스트를 선언하지 않는다.
|
||||||
|
// 이 호스트가 다시 나타나면 외부 CDN 의존으로 되돌아갔다는 뜻이다(자체 제공 원칙 회귀).
|
||||||
|
if (result.ckeditorActive) {
|
||||||
|
expect(
|
||||||
|
result.hasCkeditorHost,
|
||||||
|
'CKEditor5 가 자체 제공에서 외부 CDN 의존으로 되돌아갔다',
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// @scenario case=untrusted_external_script_not_loaded
|
// @scenario case=untrusted_external_script_not_loaded
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* E2E: loadScript 액션의 출처 게이트 (배포 번들 기준)
|
||||||
|
*
|
||||||
|
* 배경: 레이아웃 `scripts[]` 경로에는 원격 스크립트 차단 게이트가 있었는데, 같은
|
||||||
|
* `<script>` 주입인 `loadScript` **액션**에는 게이트가 없었다. 저장측 검증
|
||||||
|
* (SafeLayoutExpressions·NoExternalUrls)이 외부 URL 저장을 막아도, 그 액션을 런타임에
|
||||||
|
* 디스패치하면 임의 원격 코드가 그대로 로드됐다 — 실제 브라우저에서 미신뢰 CDN 스크립트가
|
||||||
|
* 로드되어 전역이 생성되는 것을 실측했다.
|
||||||
|
*
|
||||||
|
* 이 스펙은 배포 번들에서 그 게이트가 실제로 동작하는지를 **네트워크 레벨**로 잠근다:
|
||||||
|
* 미신뢰 src 는 요청 자체가 나가지 않고(0건) 액션이 실패해야 하며, same-origin src 는
|
||||||
|
* 종전대로 로드돼야 한다(과차단 없음).
|
||||||
|
*
|
||||||
|
* 판정 함수 자체(정규화·authority 우회·신뢰 호스트 비교)는 minify 된 번들에서 이름으로
|
||||||
|
* 호출할 수 없어 단위 스위트(scriptSrcPolicy.test.ts)가 고정한다. 여기서는 그 결정의
|
||||||
|
* 관찰 가능한 결과만 측정한다.
|
||||||
|
*
|
||||||
|
* 시나리오 축(case)·효과는 매니페스트 tests/scenarios/trusted-script-hosts.yaml 참조.
|
||||||
|
* 마커는 test 레벨에만 둔다 — 파일 레벨에 두면 test 를 지워도 효과가 "언급됨" 으로 집계돼
|
||||||
|
* 삭제가 무증상 green 이 된다.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../fixtures/auth';
|
||||||
|
|
||||||
|
/** 미신뢰 CDN — 어떤 번들 확장도 선언하지 않은 호스트 */
|
||||||
|
const UNTRUSTED_SRC = 'https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js';
|
||||||
|
|
||||||
|
/** same-origin 경로 — 코어가 항상 게시하는 번들 */
|
||||||
|
const SAME_ORIGIN_SRC = '/build/core/devtools.min.js';
|
||||||
|
|
||||||
|
test.describe('loadScript 액션 출처 게이트', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => typeof (window as any).G7Core?.dispatch === 'function',
|
||||||
|
null,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// @scenario case=loadscript-action-untrusted
|
||||||
|
// @effects loadscript_action_gate_wired, untrusted_external_script_blocked
|
||||||
|
test('미신뢰 외부 src 는 네트워크 요청 0건으로 차단되고 액션이 실패한다', async ({ page }) => {
|
||||||
|
const requested: string[] = [];
|
||||||
|
page.on('request', (req) => {
|
||||||
|
if (req.url().includes('cdn.jsdelivr.net')) {
|
||||||
|
requested.push(req.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await page.evaluate(async (src) => {
|
||||||
|
try {
|
||||||
|
const value = await (window as any).G7Core.dispatch({
|
||||||
|
handler: 'loadScript',
|
||||||
|
params: { src, id: 'e2e_untrusted_probe' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
threw: false,
|
||||||
|
success: value?.success ?? null,
|
||||||
|
message: String(value?.error?.message ?? ''),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { threw: true, success: false, message: String((e as Error)?.message ?? e) };
|
||||||
|
}
|
||||||
|
}, UNTRUSTED_SRC);
|
||||||
|
|
||||||
|
// 게이트가 살아 있으면 액션은 성공으로 끝나지 않는다
|
||||||
|
expect(result.success).not.toBe(true);
|
||||||
|
expect(result.message).toContain('Blocked untrusted script src');
|
||||||
|
|
||||||
|
// 그리고 그 도메인으로 요청 자체가 나가지 않는다
|
||||||
|
expect(requested, `미신뢰 CDN 으로 요청이 나감: ${requested.join(', ')}`).toEqual([]);
|
||||||
|
|
||||||
|
// 스크립트 태그도 만들어지지 않는다
|
||||||
|
const injected = await page.evaluate(
|
||||||
|
() => document.querySelectorAll('script#e2e_untrusted_probe').length,
|
||||||
|
);
|
||||||
|
expect(injected).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// @scenario case=loadscript-action-sameorigin
|
||||||
|
// @effects loadscript_action_gate_wired
|
||||||
|
test('same-origin src 는 종전대로 로드된다 (과차단 없음)', async ({ page }) => {
|
||||||
|
const requested: string[] = [];
|
||||||
|
page.on('request', (req) => {
|
||||||
|
if (req.resourceType() === 'script' && req.url().includes('devtools.min.js')) {
|
||||||
|
requested.push(req.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await page.evaluate(async (src) => {
|
||||||
|
try {
|
||||||
|
const value = await (window as any).G7Core.dispatch({
|
||||||
|
handler: 'loadScript',
|
||||||
|
params: { src, id: 'e2e_same_origin_probe' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: value?.success ?? null, message: String(value?.error?.message ?? '') };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, message: String((e as Error)?.message ?? e) };
|
||||||
|
}
|
||||||
|
}, SAME_ORIGIN_SRC);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.success,
|
||||||
|
`same-origin 스크립트가 차단됨(과차단): ${result.message}`,
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// 공허 통과 방지 — 실제로 태그가 붙었는지 확인한다
|
||||||
|
const injected = await page.evaluate(
|
||||||
|
() => document.querySelectorAll('script#e2e_same_origin_probe').length,
|
||||||
|
);
|
||||||
|
expect(injected).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,12 +9,18 @@ description: |
|
|||||||
이 외부 URL 저장을 422 로 차단한다. 본 E2E 는 실제 화면 로드의 모든 script 요청이 same-origin
|
이 외부 URL 저장을 422 로 차단한다. 본 E2E 는 실제 화면 로드의 모든 script 요청이 same-origin
|
||||||
또는 신뢰 호스트임을 네트워크 레벨에서 무조건 측정한다.
|
또는 신뢰 호스트임을 네트워크 레벨에서 무조건 측정한다.
|
||||||
|
|
||||||
|
같은 판정은 레이아웃 scripts[] 뿐 아니라 브라우저에 새 <script> 를 붙이는 모든 경로
|
||||||
|
(loadScript 액션 · 확장 핸들러 재로드 · 편집기 프리뷰 · G7Core.asset.loadScript · PG SDK 주입)
|
||||||
|
가 공유한다. loadScript 액션은 종전 이 게이트 밖이라 저장 검증을 우회한 임의 원격 코드가
|
||||||
|
런타임에 그대로 로드됐다 — 그 축을 loadscript-action-* 케이스가 잠근다.
|
||||||
|
|
||||||
axes:
|
axes:
|
||||||
case: [trusted_hosts_exposed_in_config, active_extension_host_reflected, untrusted_external_script_not_loaded]
|
case: [trusted_hosts_exposed_in_config, active_extension_host_reflected, untrusted_external_script_not_loaded, loadscript-action-untrusted, loadscript-action-sameorigin]
|
||||||
|
|
||||||
effects:
|
effects:
|
||||||
- trusted_script_host_allowlist_wired
|
- trusted_script_host_allowlist_wired
|
||||||
- untrusted_external_script_blocked
|
- untrusted_external_script_blocked
|
||||||
|
- loadscript_action_gate_wired
|
||||||
|
|
||||||
# 선언 주체(모듈/플러그인/템플릿)는 브라우저 관찰 축(case)과 교차하지 않는 별개 관심사다 —
|
# 선언 주체(모듈/플러그인/템플릿)는 브라우저 관찰 축(case)과 교차하지 않는 별개 관심사다 —
|
||||||
# case 는 배포 번들이 무엇을 로드하는가를, 선언 주체는 서버가 무엇을 집계하는가를 본다.
|
# case 는 배포 번들이 무엇을 로드하는가를, 선언 주체는 서버가 무엇을 집계하는가를 본다.
|
||||||
@@ -34,7 +40,10 @@ sub_flows:
|
|||||||
|
|
||||||
test_files:
|
test_files:
|
||||||
- tests/Playwright/specs/layout-script-src-trusted-host.spec.ts
|
- tests/Playwright/specs/layout-script-src-trusted-host.spec.ts
|
||||||
|
- tests/Playwright/specs/loadscript-action-gate.spec.ts
|
||||||
- tests/Unit/Support/TrustedScriptHostsTest.php
|
- tests/Unit/Support/TrustedScriptHostsTest.php
|
||||||
|
- tests/Feature/Rules/NoExternalUrlsTest.php
|
||||||
# 차단 결정 함수(isAllowedScriptSrc)는 minify 된 배포 번들에서 이름으로 호출할 수 없어
|
# 차단 결정 함수(isAllowedScriptSrc)는 minify 된 배포 번들에서 이름으로 호출할 수 없어
|
||||||
# E2E 는 그 결과만 관찰한다. 결정 자체(신뢰 판정·authority 우회 차단)는 여기서 잠근다.
|
# E2E 는 그 결과만 관찰한다. 결정 자체(신뢰 판정·authority 우회 차단)는 여기서 잠근다.
|
||||||
|
- resources/js/core/support/__tests__/scriptSrcPolicy.test.ts
|
||||||
- resources/js/core/__tests__/TemplateApp.scriptSrc.test.ts
|
- resources/js/core/__tests__/TemplateApp.scriptSrc.test.ts
|
||||||
|
|||||||
Reference in New Issue
Block a user