Files
Gnuboard7/app/Support/SiteAssetHosts.php
T
HeuJung 72f46a6468 fix(core): 레이아웃 첨부 URL 을 사이트 상대 경로로 발급하고 저장 게이트가 자기 주소를 외부로 보지 않게 수정
편집기 헤더 「로고 이미지」에 파일을 올린 뒤 저장하면 「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 래핑을 읽도록 보정
2026-09-09 09:20:29 +09:00

116 lines
4.8 KiB
PHP

<?php
namespace App\Support;
use App\Extension\Storage\CoreStorageDriver;
use Illuminate\Support\Facades\Log;
/**
* 사이트 자산 host 해석기
*
* "이 사이트가 스스로 자산을 내보내는 host" 를 한 지점에서 해석합니다. 저장측 규칙
* (`App\Rules\NoExternalUrls`)이 절대 URL 을 외부로 차단할 때, 서버가 스스로 발급하는
* 주소 — 레이아웃 첨부 API 의 프록시 서빙 URL·운영자가 선언한 공개 자산 디스크의 직접
* URL — 까지 외부로 오판하면 업로드는 되는데 저장이 422 로 끝납니다. 그 오판을 막는
* 근거가 되는 host 목록입니다.
*
* 근거는 둘뿐입니다:
* 1. `config('app.url')` 의 host — 운영자가 설정한 사이트 주소
* 2. `PublicAssetDisk::resolve()` 가 돌려주는 디스크의 기준 URL host — 운영자가
* "익명 읽기가 되는 저장소" 라고 명시 선언한 곳
*
* 요청의 `Host` 헤더는 근거로 쓰지 않습니다 — 신뢰 프록시 밖에서는 위조 가능하고, 위조된
* host 가 허용되면 레이아웃에 임의 외부 주소를 실을 수 있습니다. 디스크의 `url` 설정
* 유무도 근거가 아닙니다(`PublicAssetDisk` 와 같은 이유 — "URL 을 만들 수 있다" 는
* "공개다" 가 아닙니다).
*
* 판정은 접두 문자열 비교가 아니라 브라우저와 같은 정규화(`TrustedScriptHosts::
* normalizeForOriginCheck`) 뒤의 host 등가 비교입니다. 접두 비교는
* `https://shop.example.test.evil.com` · `https://shop.example.test@evil.com` 을 통과시킵니다.
*/
class SiteAssetHosts
{
/**
* 사이트 자산 host 목록을 해석합니다.
*
* memoize 하지 않습니다 — `app.url`·`core.storage.public_asset_disk` 는 런타임에
* 바뀔 수 있습니다(관리자 환경설정 저장, 테스트의 Config::set). 한 검증 안에서의
* 반복 호출은 호출측이 캐시합니다.
*
* @return array<int, string> 소문자 host 목록 (중복 제거, 해석 불가면 빈 배열)
*/
public static function hosts(): array
{
$hosts = [];
$appHost = TrustedScriptHosts::hostOf((string) config('app.url', ''));
if ($appHost !== null) {
$hosts[] = $appHost;
}
$disk = PublicAssetDisk::resolve();
if ($disk !== null) {
$diskHost = self::publicAssetDiskHost($disk);
if ($diskHost !== null && ! in_array($diskHost, $hosts, true)) {
$hosts[] = $diskHost;
}
}
return $hosts;
}
/**
* http/https 절대 URL 이고 그 host 가 사이트 자산 host 이면 true 를 돌려줍니다.
*
* 경로만 있는 값(`/api/...`)은 host 가 없어 이 판정의 대상이 아니고(호출측이 경로
* 규칙으로 다룹니다), protocol-relative(`//host/...`)와 http/https 밖의 스킴은 host 가
* 같아도 false 입니다 — 그 축은 종전 판정을 그대로 둡니다.
*
* @param string $url 검사 대상 URL
* @param array<int, string>|null $hosts host 목록 (미지정 시 self::hosts())
* @return bool 사이트 자산 host 의 http(s) 절대 URL 이면 true
*/
public static function isSiteAssetUrl(string $url, ?array $hosts = null): bool
{
$normalized = TrustedScriptHosts::normalizeForOriginCheck(trim($url));
if (preg_match('#^https?://#i', $normalized) !== 1) {
return false;
}
$host = TrustedScriptHosts::hostOf($normalized);
if ($host === null) {
return false;
}
$hosts ??= self::hosts();
return in_array($host, $hosts, true);
}
/**
* 공개 자산 디스크의 기준 URL host 를 해석합니다.
*
* 첨부 API 가 직접 URL 을 만드는 것과 같은 경로(`CoreStorageDriver::url()` — 디스크 url
* 설정과 `core.storage.filter_url` 훅을 모두 거친다)로 빈 경로의 URL 을 만들어 host 만
* 취합니다. 설정값을 직접 읽으면 훅이 바꾼 host 를 놓칩니다.
*
* @param string $disk PublicAssetDisk::resolve() 가 돌려준 디스크
* @return string|null 소문자 host (직접 URL 을 만들 수 없는 디스크면 null)
*/
private static function publicAssetDiskHost(string $disk): ?string
{
try {
// StorageInterface 는 컨텍스트 바인딩만 있어 컨테이너로 못 받는다 (ExtensionBundleService 와 같은 선례).
$base = (new CoreStorageDriver($disk))->url('', '');
} catch (\Throwable $e) {
Log::warning('SiteAssetHosts: 공개 자산 디스크 기준 URL 해석 실패 - '.$e->getMessage(), ['disk' => $disk]);
return null;
}
return is_string($base) ? TrustedScriptHosts::hostOf($base) : null;
}
}