KISA 제보 3건(KVE-2026-2010/2011/2018)과 그 동일 계열 형제 결함을 전수 조치하고, 그 과정에서 드러난 두 결함군을 함께 닫는다. - 검증 시점과 연결 시점이 host 를 다르게 읽던 SSRF 통로를 정규화 SSoT 한 곳으로 모았다 - 세션을 여는 지점(2FA 완료·토큰 재발급)이 잠금 검사를 거치지 않아 계정 잠금이 우회됐다 - 인증도 서명도 없는 브라우저 리턴 콜백이 주문 상태를 바꾸던 통로를 4 PG 전부에서 닫고, 소유권을 대조하는 close-report 를 토스에도 신설했다. 그 결과 정리 주체를 잃는 결제창 미완료 주문은 만료 자동취소가 거둔다 - 저장소 A(_local)에만 쓰는 경로가 B 의 값을 조용히 덮던 회귀를 정본 writer 로 닫았다 (engine-v1.63.5). 한 방향만 보던 정적 검사에 반대 방향 축과 양방향 계약 테스트를 더했다 - 레이아웃 JSON 의 같은 객체 중복 키가 앞선 선언을 오류 없이 삼키던 결함군을 닫았다
386 lines
25 KiB
JSON
386 lines
25 KiB
JSON
{
|
|
"target_layout": "_user_base",
|
|
"injections": [
|
|
{
|
|
"target_id": "header_currency_inject_anchor",
|
|
"position": "append_child",
|
|
"components": [
|
|
{
|
|
"id": "ext_header_currency_selector",
|
|
"comment": "헤더 통화+배송국가 공용 셀렉터 (이커머스 주입, D4). slot='header_currency' 로 SlotContext 에 등록 → 템플릿 헤더(PC·모바일 SlotContainer)가 떙겨 렌더. 헤더는 이 조각을 모름(모듈 무지). 두 섹션: ① 통화(availableCurrencies) ② 배송국가(availableShippingCountries, 해외배송 활성+2개↑ 시). 선택 시 로그인 회원=영속 PUT+localStorage, 비회원=localStorage. 배송국가 변경 시 장바구니/체크아웃 데이터소스 refetch. 표시 제어: 기본 hidden(display:none) → init_actions 가 채운 파생 상태(availableCurrencies/availableShippingCountries)가 표시 조건을 만족할 때만 hidden 해제. if(마운트 게이트) 대신 className 토글이라 첫 페인트부터 무조건 숨김 → 깜빡임(떴다 사라짐) 원천 차단.",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"slot": "header_currency",
|
|
"props": { "className": "relative {{(_global.availableCurrencies ?? []).length > 0 || ((_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true) ? '' : 'hidden'}}" },
|
|
"responsive": {
|
|
"portable": {
|
|
"comment": "portable 에서는 이 조각이 사용자 템플릿 모바일 드로어(세로 블록) 안에 렌더된다. 펼친 목록이 컨테이너 폭을 채우도록 w-full. hidden 게이트 표현식은 그대로 유지(props 얕은 머지가 아니라 className 통째 교체이므로 표현식을 복제해야 함).",
|
|
"props": { "className": "relative w-full {{(_global.availableCurrencies ?? []).length > 0 || ((_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true) ? '' : 'hidden'}}" }
|
|
}
|
|
},
|
|
"children": [
|
|
{
|
|
"comment": "트리거 버튼 — 데스크톱은 absolute 드롭다운의 토글, portable(드로어 안)에서는 접기/펼치기 아코디언의 트리거. 두 경우 모두 같은 플래그(_local.showCurrencyDropdown)를 토글하므로 기본은 접힘이다. portable 에서는 드로어 폭을 꽉 채우는 행 형태(좌: '통화' 라벨, 우: 현재 통화·배송국가 요약 + chevron)로 바뀐다.",
|
|
"type": "basic",
|
|
"name": "Button",
|
|
"props": {
|
|
"className": "flex items-center gap-1.5 px-2.5 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg cursor-pointer transition-colors",
|
|
"data-testid": "currency-switcher",
|
|
"aria-haspopup": "listbox",
|
|
"aria-expanded": "{{_local.showCurrencyDropdown ?? false}}",
|
|
"aria-label": "$t:sirsoft-ecommerce.common.currency_label"
|
|
},
|
|
"responsive": {
|
|
"portable": {
|
|
"comment": "드로어 안에서는 언어 아코디언 트리거와 동일한 형태 — children 을 통째 교체해 '통화' 라벨 + 현재값 요약 + chevron 으로 구성한다(엔진은 responsive.children 을 완전 교체한다).",
|
|
"props": {
|
|
"className": "w-full flex items-center justify-between py-1 cursor-pointer",
|
|
"aria-haspopup": "listbox",
|
|
"aria-expanded": "{{_local.showCurrencyDropdown ?? false}}",
|
|
"aria-label": "$t:sirsoft-ecommerce.common.currency_label"
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" },
|
|
"text": "$t:sirsoft-ecommerce.common.currency_label"
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"props": { "className": "flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300" },
|
|
"children": [
|
|
{
|
|
"comment": "접힘 상태 요약 ① 현재 통화 코드",
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"props": { "className": "font-medium" },
|
|
"text": "{{_global.preferredCurrency ?? _global.defaultCurrency}}"
|
|
},
|
|
{
|
|
"comment": "접힘 상태 요약 ② 현재 배송국가명 (해외배송 활성 + 국가 2개 이상일 때만). 통화와 시각적으로 구분되도록 옅은 색.",
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"if": "{{(_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true}}",
|
|
"props": { "className": "text-gray-500 dark:text-gray-400" },
|
|
"text": "· {{(_global.availableShippingCountries ?? []).find(c => c.code === (_global.preferredShippingCountry ?? _global.modules?.['sirsoft-ecommerce']?.shipping?.default_country ?? 'KR'))?.name ?? (_global.preferredShippingCountry ?? 'KR')}}"
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "Icon",
|
|
"props": {
|
|
"name": "chevron-down",
|
|
"className": "{{_local.showCurrencyDropdown ? 'rotate-180' : ''}} text-xs text-gray-400 dark:text-gray-500 transition-transform duration-200"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
"children": [
|
|
{
|
|
"comment": "트리거 라벨: 해외배송 활성이면 선택 배송국가 코드 병기 (관리자 헤더와 동일하게 코드 텍스트로 표기 — flag-icons 의존 제거).",
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"if": "{{(_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true}}",
|
|
"props": { "className": "text-xs text-gray-500 dark:text-gray-400" },
|
|
"text": "{{_global.preferredShippingCountry ?? _global.modules?.['sirsoft-ecommerce']?.shipping?.default_country ?? 'KR'}}"
|
|
},
|
|
{ "type": "basic", "name": "Icon", "props": { "name": "coins", "className": "text-base" } },
|
|
{ "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{_global.preferredCurrency ?? _global.defaultCurrency}}" },
|
|
{ "type": "basic", "name": "Icon", "props": { "name": "chevron-down", "className": "text-xs" } }
|
|
],
|
|
"actions": [
|
|
{
|
|
"type": "click",
|
|
"handler": "setState",
|
|
"params": { "target": "local", "showCurrencyDropdown": "{{!_local.showCurrencyDropdown}}" }
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"comment": "드롭다운 메뉴 (통화 + 배송국가 2섹션). portable(0~1023px) 에서는 absolute 팝오버가 드로어 스크롤 경계에서 잘리므로 static 인라인 목록으로 전환한다. 표시 여부는 데스크톱과 동일하게 _local.showCurrencyDropdown 이 결정한다(responsive 에 if 를 두지 않으면 base if 를 그대로 상속) → 기본 접힘.",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"if": "{{_local.showCurrencyDropdown}}",
|
|
"props": {
|
|
"className": "absolute right-0 top-full mt-2 w-52 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 py-1 overflow-hidden",
|
|
"role": "listbox"
|
|
},
|
|
"responsive": {
|
|
"portable": {
|
|
"props": {
|
|
"className": "static w-full mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg py-1",
|
|
"role": "listbox"
|
|
}
|
|
}
|
|
},
|
|
"children": [
|
|
{
|
|
"comment": "── 통화 섹션 ──",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"if": "{{(_global.availableCurrencies ?? []).length > 0}}",
|
|
"children": [
|
|
{
|
|
"comment": "섹션 라벨 '통화'. portable 에서는 아코디언 트리거 행이 이미 '통화' 라벨을 달고 있어 중복이므로 숨긴다(배송국가 라벨은 두 번째 섹션을 구분해야 하므로 유지).",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": { "className": "px-4 py-1.5 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wide" },
|
|
"responsive": { "portable": { "if": "{{false}}" } },
|
|
"text": "$t:sirsoft-ecommerce.common.currency_label"
|
|
},
|
|
{
|
|
"comment": "옵션 래퍼. portable 에서만 가로 칩 나열(flex-wrap). 데스크톱은 무클래스 블록이라 기존 세로 목록 그대로. iteration 은 아래 Div 자신에 걸려 있어 항목 수만큼 복제되므로, 복제본들이 이 래퍼의 flex item 이 된다.",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"responsive": {
|
|
"portable": {
|
|
"props": { "className": "flex flex-wrap gap-2 px-3 pb-2" }
|
|
}
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"iteration": { "source": "{{_global.availableCurrencies ?? []}}", "item_var": "currency" },
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Button",
|
|
"props": {
|
|
"className": "w-full px-4 py-2.5 text-left text-sm flex items-center gap-3 cursor-pointer transition-colors {{_global.preferredCurrency === currency.code ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}}",
|
|
"data-testid": "currency-option-{{currency.code}}",
|
|
"role": "option",
|
|
"aria-selected": "{{_global.preferredCurrency === currency.code}}"
|
|
},
|
|
"responsive": {
|
|
"portable": {
|
|
"props": {
|
|
"className": "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-full cursor-pointer whitespace-nowrap transition-colors {{_global.preferredCurrency === currency.code ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800' : 'text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'}}",
|
|
"role": "option",
|
|
"aria-selected": "{{_global.preferredCurrency === currency.code}}"
|
|
}
|
|
}
|
|
},
|
|
"children": [
|
|
{ "type": "basic", "name": "Span", "props": { "className": "text-base" }, "text": "{{currency.flag}}" },
|
|
{
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"props": { "className": "flex-1" },
|
|
"responsive": { "portable": { "props": { "className": "" } } },
|
|
"children": [
|
|
{ "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{currency.code}}" },
|
|
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-500 dark:text-gray-400 ml-1" }, "text": "({{currency.symbol}})" }
|
|
]
|
|
},
|
|
{
|
|
"comment": "portable(칩 나열)에서는 선택 상태가 배경/테두리 색으로 이미 표현되고 aria-selected 로도 전달되므로, 칩 폭을 아끼려 체크 아이콘을 숨긴다.",
|
|
"type": "basic",
|
|
"name": "Icon",
|
|
"if": "{{_global.preferredCurrency === currency.code}}",
|
|
"responsive": { "portable": { "if": "{{false}}" } },
|
|
"props": { "name": "check", "className": "text-base" }
|
|
}
|
|
],
|
|
"actions": [
|
|
{
|
|
"type": "click",
|
|
"handler": "sequence",
|
|
"actions": [
|
|
{ "handler": "setState", "params": { "target": "global", "preferredCurrency": "{{currency.code}}" } },
|
|
{ "handler": "saveToLocalStorage", "params": { "key": "g7_preferred_currency", "value": "{{currency.code}}" } },
|
|
{ "handler": "setState", "params": { "target": "local", "showCurrencyDropdown": false } },
|
|
{
|
|
"comment": "로그인 회원이면 계정에 영속 저장(비회원은 localStorage 만). PUT 은 auth:sanctum 필요.",
|
|
"handler": "conditions",
|
|
"conditions": [
|
|
{
|
|
"if": "{{_global.currentUser?.uuid}}",
|
|
"then": {
|
|
"handler": "apiCall",
|
|
"target": "/api/modules/sirsoft-ecommerce/user/currency",
|
|
"auth_mode": "required",
|
|
"params": {
|
|
"method": "PUT",
|
|
"body": { "currency": "{{currency.code}}" }
|
|
},
|
|
"onError": [
|
|
{
|
|
"handler": "toast",
|
|
"params": {
|
|
"type": "error",
|
|
"message": "{{error.message}}"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"comment": "── 구분선 (두 섹션 모두 표시될 때) ──",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"if": "{{(_global.availableCurrencies ?? []).length > 0 && (_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true}}",
|
|
"props": { "className": "my-1 border-t border-gray-200 dark:border-gray-700" }
|
|
},
|
|
{
|
|
"comment": "── 배송국가 섹션 ── (해외배송 활성 + 활성 국가 2개 이상)",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"if": "{{(_global.availableShippingCountries ?? []).length > 1 && _global.modules?.['sirsoft-ecommerce']?.shipping?.international_shipping_enabled === true}}",
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": { "className": "px-4 py-1.5 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wide" },
|
|
"responsive": { "portable": { "props": { "className": "px-3 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" } } },
|
|
"text": "$t:sirsoft-ecommerce.common.shipping_country_label"
|
|
},
|
|
{
|
|
"comment": "옵션 래퍼. portable 에서만 가로 칩 나열(flex-wrap). 데스크톱은 무클래스 블록.",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"responsive": {
|
|
"portable": {
|
|
"props": { "className": "flex flex-wrap gap-2 px-3 pb-2" }
|
|
}
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"iteration": { "source": "{{_global.availableShippingCountries ?? []}}", "item_var": "shipCountry" },
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Button",
|
|
"props": {
|
|
"className": "w-full px-4 py-2.5 text-left text-sm flex items-center gap-3 cursor-pointer transition-colors {{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}}",
|
|
"role": "option",
|
|
"aria-selected": "{{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code}}"
|
|
},
|
|
"responsive": {
|
|
"portable": {
|
|
"props": {
|
|
"className": "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-full cursor-pointer whitespace-nowrap transition-colors {{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800' : 'text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'}}",
|
|
"role": "option",
|
|
"aria-selected": "{{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code}}"
|
|
}
|
|
}
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"props": { "className": "flex-1 font-medium" },
|
|
"responsive": { "portable": { "props": { "className": "font-medium" } } },
|
|
"text": "{{shipCountry.name}}"
|
|
},
|
|
{
|
|
"comment": "portable(칩 나열)에서는 선택 상태를 배경/테두리 색 + aria-selected 로 전달하므로 체크 아이콘을 숨긴다.",
|
|
"type": "basic",
|
|
"name": "Icon",
|
|
"if": "{{(_global.preferredShippingCountry ?? 'KR') === shipCountry.code}}",
|
|
"responsive": { "portable": { "if": "{{false}}" } },
|
|
"props": { "name": "check", "className": "text-base" }
|
|
}
|
|
],
|
|
"actions": [
|
|
{
|
|
"type": "click",
|
|
"handler": "sequence",
|
|
"actions": [
|
|
{ "handler": "setState", "params": { "target": "global", "preferredShippingCountry": "{{shipCountry.code}}" } },
|
|
{ "handler": "saveToLocalStorage", "params": { "key": "g7_preferred_shipping_country", "value": "{{shipCountry.code}}" } },
|
|
{ "handler": "setState", "params": { "target": "local", "showCurrencyDropdown": false } },
|
|
{
|
|
"comment": "로그인 회원이면 계정에 영속 저장(비회원은 localStorage+X-Shipping-Country 헤더로 반영).",
|
|
"handler": "conditions",
|
|
"conditions": [
|
|
{
|
|
"if": "{{_global.currentUser?.uuid}}",
|
|
"then": {
|
|
"handler": "apiCall",
|
|
"target": "/api/modules/sirsoft-ecommerce/user/shipping-country",
|
|
"auth_mode": "required",
|
|
"params": {
|
|
"method": "PUT",
|
|
"body": { "shipping_country": "{{shipCountry.code}}" }
|
|
},
|
|
"onError": [
|
|
{
|
|
"handler": "toast",
|
|
"params": {
|
|
"type": "error",
|
|
"message": "{{error.message}}"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"comment": "현재 장바구니/체크아웃/상품상세이면 해당 데이터소스 refetch (국가 변경 → 배송가능/배송비 재계산). 없는 페이지에선 no-op.",
|
|
"handler": "refetchDataSource",
|
|
"params": { "dataSourceId": "cartItems" }
|
|
},
|
|
{
|
|
"handler": "refetchDataSource",
|
|
"params": { "dataSourceId": "checkoutData" }
|
|
},
|
|
{
|
|
"comment": "상품 상세 페이지 — 국가 변경 시 배송가능 경고/구매버튼 disable 즉시 재계산 (B8). 상품 목록은 productList no-op.",
|
|
"handler": "refetchDataSource",
|
|
"params": { "dataSourceId": "product" }
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"comment": "배경 클릭 시 닫기 — 데스크톱 팝오버 전용. portable 에서는 패널이 드로어 안 인라인 아코디언이라 배경 개념이 없다. 여기서 if 를 끄지 않으면 모바일에서 펼치는 순간 fixed inset-0 오버레이가 드로어 전체를 덮어 모든 클릭을 가로챈다.",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"if": "{{_local.showCurrencyDropdown}}",
|
|
"responsive": { "portable": { "if": "{{false}}" } },
|
|
"props": { "className": "fixed inset-0 z-40" },
|
|
"actions": [
|
|
{
|
|
"type": "click",
|
|
"handler": "setState",
|
|
"params": { "target": "local", "showCurrencyDropdown": false }
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"priority": 320
|
|
}
|