편집기 헤더 「로고 이미지」에 파일을 올린 뒤 저장하면 「HTTPS 프로토콜 URL은 허용되지 않습니다」 422 로 거부되던 결함. 프록시 서빙 URL 이 절대 형태로 발급되어 NoExternalUrls 가 서버 자신의 주소를 외부로 차단했고, 배경 이미지는 style 로 들어가 스캔 밖이어서 로고(propValue)에서만 드러났다. - proxyUrl 을 사이트 상대 경로로 발급 (발급 시점 도메인·스킴에 묶이지 않는다) - SiteAssetHosts 신설: app.url host + 운영자가 선언한 공개 자산 디스크 host 를 브라우저와 같은 정규화 뒤 등가 비교. 요청 Host 헤더는 근거로 쓰지 않고, 흉내 host·protocol-relative·위험 스킴은 종전대로 차단 - 회귀 테스트 4클래스(71 passed) + 시나리오 매니페스트 + Playwright 2케이스, 공개 문서(SECURITY·security·storage-driver·api)와 규정 표 갱신 함께 담긴 릴리즈 정비: - 7.0.11 미출시 섹션의 릴리즈 날짜를 09-09 로 갱신 (코어·board·page·admin_basic·basic·ja 언어팩 3종) - 배포본에 실리는 주석·독블록·시나리오의 내부 호칭을 중립 표현으로 정리하고, 동봉 vendor 자산의 우연한 토큰은 누출 검사에서 경로 규칙으로만 제외 - 버전 bump 검사: github_url 없는 번들을 언어팩과 같은 origin/release 페이로드 기준선으로 판정(상시 경고 제거), 코어 버전 판독이 env 래핑을 읽도록 보정
280 lines
12 KiB
PHP
280 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Rules;
|
|
|
|
use App\Support\SiteAssetHosts;
|
|
use Closure;
|
|
use Illuminate\Contracts\Validation\ValidationRule;
|
|
|
|
/**
|
|
* 레이아웃 JSON에서 외부 URL을 차단하는 Custom Rule
|
|
*
|
|
* 컴포넌트 props·actions·lifecycle·onComponentEvent·slots·component_layout·responsive 와
|
|
* 최상위 init_actions/initActions·modals·named_actions·errorHandling 내의 http://, https://,
|
|
* data:, javascript: 등 위험한 URI 스킴을 감지하여 차단합니다.
|
|
*
|
|
* 순회 대상은 "액션이 실행되거나 값이 sink(컴포넌트 prop)로 흘러 들어가는 자리" 다.
|
|
* 한 자리만 빠져도 그 키가 그대로 저장 우회로가 되며, 우회는 오류를 남기지 않는다.
|
|
*
|
|
* 검사 대상 구분(신뢰 경계): init_actions 는 로드 시 자동 실행되는 액션이라 외부
|
|
* navigate/apiCall URL 이 곧 자동 리다이렉트·데이터 유출 경로가 되므로 실행 지점에서
|
|
* 차단합니다. 반면 state/computed 는 데이터 값이며, 실제 위험은 그 값이 바인딩되는
|
|
* sink(컴포넌트 prop = img src 등)에서 발생하고 그 sink 는 이미 여기서 검사됩니다 —
|
|
* 예시/안내용 URL 을 담는 정당한 용례를 깨지 않기 위해 데이터 계층은 재차단하지 않습니다.
|
|
*
|
|
* 외부의 기준: 사이트 자기 host(`app.url`)와 운영자가 선언한 공개 자산 디스크의 host
|
|
* (`SiteAssetHosts`)는 외부가 아닙니다. 레이아웃 첨부 API 가 스스로 발급하는 주소(프록시
|
|
* 서빙 URL·직접 URL)가 그 host 를 쓰므로, 여기서 차단하면 image 위젯으로 올린 파일이
|
|
* 업로드는 되고 저장은 422 가 됩니다. 판정은 정규화 뒤 host 등가 비교이며 protocol-relative
|
|
* 와 http/https 밖의 스킴은 host 가 같아도 종전대로 차단합니다.
|
|
*/
|
|
class NoExternalUrls implements ValidationRule
|
|
{
|
|
/**
|
|
* 사이트 자산 host 목록 (검증 1회당 1회 해석 후 캐시)
|
|
*
|
|
* @var array<int, string>|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<int, string> 소문자 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));
|
|
}
|
|
}
|
|
}
|