|null */ private ?array $siteAssetHosts = null; /** * 차단할 위험 URI 스킴 목록 */ private const DANGEROUS_SCHEMES = [ 'http://', 'https://', 'data:', 'javascript:', 'vbscript:', 'file:', 'ftp:', ]; /** * 검증 수행 */ public function validate(string $attribute, mixed $value, Closure $fail): void { // 문자열 스칼라 필드에도 부착되므로(`content.endpoint` · // `content.data_sources.*.endpoint`) 그 값을 직접 검사한다. 배열만 처리하고 // 반환하면 그 부착이 조용한 no-op 이 된다. if (is_string($value)) { $this->checkForDangerousUrl($value, $attribute, $fail); return; } if (! is_array($value)) { return; } // components 배열을 재귀적으로 검사 if (isset($value['components']) && is_array($value['components'])) { $this->validateComponents($value['components'], $fail); } // init_actions / initActions: 로드 시 자동 실행되는 액션 — 외부 navigate/apiCall URL 은 // 로드 시점 자동 리다이렉트/데이터 유출 경로가 되므로 컴포넌트 actions 와 동일하게 // 검사한다. 엔진(LayoutLoader)이 두 철자를 모두 소비하므로 두 철자 모두 검사한다 — // 한쪽만 보면 다른 철자가 그대로 우회로가 된다. foreach (['init_actions', 'initActions'] as $initKey) { if (isset($value[$initKey]) && is_array($value[$initKey])) { foreach ($value[$initKey] as $i => $action) { if (is_array($action)) { $this->validateObject($action, "{$initKey}[$i]", $fail); } } } } // modals: 각 항목이 컴포넌트 정의다 — 모달 안의 props/actions 도 같은 sink 이므로 // 컴포넌트와 동일하게 재귀 검사한다. if (isset($value['modals']) && is_array($value['modals'])) { foreach ($value['modals'] as $modalKey => $modal) { if (! is_array($modal)) { continue; } $this->validateComponents([$modal], $fail, "modals.$modalKey"); if (isset($modal['components']) && is_array($modal['components'])) { $this->validateComponents($modal['components'], $fail, "modals.$modalKey.components"); } } } // named_actions: 이름으로 호출되는 액션 정의 — 실행 시 컴포넌트 actions 와 동일한 sink. if (isset($value['named_actions']) && is_array($value['named_actions'])) { foreach ($value['named_actions'] as $name => $named) { if (is_array($named)) { $this->validateObject($named, "named_actions.$name", $fail); } } } // errorHandling: 오류 시 실행되는 액션(navigate/apiCall)을 담는다. if (isset($value['errorHandling']) && is_array($value['errorHandling'])) { $this->validateObject($value['errorHandling'], 'errorHandling', $fail); } } /** * components 배열을 재귀적으로 검증 * * @param array $components 컴포넌트 정의 배열 * @param Closure $fail 실패 콜백 * @param string $basePath 오류 메시지에 실을 경로 접두 (modals/slots 경로 보존) */ private function validateComponents(array $components, Closure $fail, string $basePath = 'components'): void { foreach ($components as $index => $component) { if (! is_array($component)) { continue; } $path = "{$basePath}[$index]"; // props 검사 if (isset($component['props']) && is_array($component['props'])) { $this->validateObject($component['props'], "$path.props", $fail); } // actions 검사 if (isset($component['actions']) && is_array($component['actions'])) { $this->validateActions($component['actions'], $path, $fail); } // lifecycle: 마운트/언마운트 시 자동 실행되는 액션 — init_actions 와 같은 성격이다. if (isset($component['lifecycle']) && is_array($component['lifecycle'])) { $this->validateObject($component['lifecycle'], "$path.lifecycle", $fail); } // onComponentEvent: 컴포넌트 이벤트로 발화되는 액션 배열. if (isset($component['onComponentEvent']) && is_array($component['onComponentEvent'])) { $this->validateObject($component['onComponentEvent'], "$path.onComponentEvent", $fail); } // slots: 슬롯 이름별 컴포넌트 배열 — 슬롯 안의 컴포넌트도 같은 sink 다. if (isset($component['slots']) && is_array($component['slots'])) { foreach ($component['slots'] as $slotName => $slotComponents) { if (is_array($slotComponents)) { $this->validateComponents($slotComponents, $fail, "$path.slots.$slotName"); } } } // component_layout: 컴포넌트가 품는 하위 레이아웃 정의. if (isset($component['component_layout']) && is_array($component['component_layout'])) { $this->validateObject($component['component_layout'], "$path.component_layout", $fail); } // responsive: breakpoint 별 props/children 오버라이드 — 그 안의 값도 같은 sink 다. if (isset($component['responsive']) && is_array($component['responsive'])) { $this->validateObject($component['responsive'], "$path.responsive", $fail); } // children 재귀 검사 if (isset($component['children']) && is_array($component['children'])) { $this->validateComponents($component['children'], $fail, "$path.children"); } } } /** * actions 배열 검증 * * @param array $actions 액션 정의 배열 * @param string $componentPath 컴포넌트 경로 (오류 메시지용) * @param Closure $fail 실패 콜백 */ private function validateActions(array $actions, string $componentPath, Closure $fail): void { foreach ($actions as $actionIndex => $action) { if (! is_array($action)) { continue; } $this->validateObject($action, "{$componentPath}.actions[$actionIndex]", $fail); } } /** * 객체(배열)의 모든 값을 재귀적으로 검사 */ private function validateObject(array $object, string $path, Closure $fail): void { foreach ($object as $key => $value) { if (is_string($value)) { $this->checkForDangerousUrl($value, "$path.$key", $fail); } elseif (is_array($value)) { $this->validateObject($value, "$path.$key", $fail); } } } /** * 문자열에서 위험한 URL 패턴 검사 */ private function checkForDangerousUrl(string $value, string $path, Closure $fail): void { // 사이트 자기 host·선언된 공개 자산 디스크 host 의 http(s) 절대 URL 은 외부가 아니다. // 위험 스킴·protocol-relative 는 이 판정의 대상이 아니므로 아래 검사가 그대로 적용된다. if (SiteAssetHosts::isSiteAssetUrl($value, $this->siteAssetHosts())) { return; } $lowerValue = strtolower(trim($value)); foreach (self::DANGEROUS_SCHEMES as $scheme) { if (str_starts_with($lowerValue, $scheme)) { $this->failWithScheme($scheme, $value, $path, $fail); return; } } // 추가 패턴 검사: //로 시작 (프로토콜 상대 URL) // // 브라우저 URL 파서는 파싱 전에 ASCII tab·개행을 제거하고 백슬래시를 슬래시와 // 동등하게 처리하므로, `/\/evil.com` · `/{tab}/evil.com` 도 실제로는 외부 // origin 이 된다. 접두 검사 전에 동일하게 정규화한다(SafeLayoutExpressions 와 동형). if (str_starts_with(SafeLayoutExpressions::normalizeForOriginCheck($lowerValue), '//')) { $fail(__('validation.external_url.detected_in_props', ['url' => $value])); return; } } /** * 사이트 자산 host 목록을 반환합니다 (검증 1회당 1회 해석 후 캐시). * * @return array 소문자 host 목록 */ private function siteAssetHosts(): array { return $this->siteAssetHosts ??= SiteAssetHosts::hosts(); } /** * 스킴별 에러 메시지 출력 */ private function failWithScheme(string $scheme, string $url, string $path, Closure $fail): void { $scheme = rtrim($scheme, ':/'); $messageKey = match ($scheme) { 'http' => 'validation.external_url.http_not_allowed', 'https' => 'validation.external_url.https_not_allowed', 'data' => 'validation.external_url.data_uri_not_allowed', 'javascript' => 'validation.external_url.javascript_uri_not_allowed', default => 'validation.external_url.dangerous_scheme_detected', }; if ($messageKey === 'validation.external_url.dangerous_scheme_detected') { $fail(__($messageKey, ['scheme' => $scheme])); } else { $fail(__($messageKey)); } } }