Files
Gnuboard7/tests/Unit/Support/SiteAssetHostsTest.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

134 lines
5.2 KiB
PHP

<?php
namespace Tests\Unit\Support;
use App\Support\SiteAssetHosts;
use Illuminate\Support\Facades\Config;
use Tests\TestCase;
/**
* 사이트 자산 host 판정 단위 테스트
*
* 저장 게이트(NoExternalUrls)가 "서버가 스스로 발급하는 자산 주소" 를 외부로 오판하지
* 않도록, 사이트 자기 host(app.url)와 운영자가 선언한 공개 자산 디스크의 host 를 한
* 지점에서 해석한다. 요청 Host 헤더는 근거로 쓰지 않는다 — 위조 가능하다.
*/
class SiteAssetHostsTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Config::set('app.url', 'https://shop.example.test');
Config::set('core.storage.public_asset_disk', '');
}
/**
* app.url 의 host 는 항상 포함되어야 합니다 (소문자 정규화).
*
* @scenario url_host=own_site_absolute
*
* @effects issued_asset_url_passes_storage_gate
*/
public function test_hosts_include_app_url_host(): void
{
Config::set('app.url', 'https://Shop.Example.TEST:8443/sub');
$this->assertContains('shop.example.test', SiteAssetHosts::hosts());
}
/**
* 공개 자산 디스크가 선언되어 있으면 그 디스크의 기준 URL host 가 포함되어야 합니다.
*
* @scenario url_host=public_asset_disk
*
* @effects issued_asset_url_passes_storage_gate
*/
public function test_hosts_include_declared_public_asset_disk_host(): void
{
Config::set('core.storage.public_asset_disk', 'public');
Config::set('filesystems.disks.public.url', 'https://cdn.example.test/assets');
$this->assertContains('cdn.example.test', SiteAssetHosts::hosts());
}
/**
* 미선언·'none'·고아 디스크는 host 를 보태지 않아야 합니다 — 판정은 PublicAssetDisk::resolve()
* 를 경유하므로 디스크에 url 이 설정돼 있어도 선언 없이는 외부다.
*
* @scenario url_host=external_undeclared
*
* @effects external_host_still_rejected
*/
public function test_undeclared_disk_host_is_not_included(): void
{
Config::set('filesystems.disks.public.url', 'https://cdn.example.test/assets');
foreach (['', 'none', 'vanished_plugin_disk'] as $declared) {
Config::set('core.storage.public_asset_disk', $declared);
$this->assertNotContains('cdn.example.test', SiteAssetHosts::hosts(), "선언값 '{$declared}' 에서 host 가 새면 안 됩니다");
}
}
/**
* http/https 절대 URL 이고 host 가 목록에 있을 때만 참이어야 합니다.
*
* @scenario url_host=own_site_absolute
*
* @effects issued_asset_url_passes_storage_gate
*/
public function test_is_site_asset_url_accepts_only_http_urls_on_listed_hosts(): void
{
$this->assertTrue(SiteAssetHosts::isSiteAssetUrl('https://shop.example.test/api/x'));
$this->assertTrue(SiteAssetHosts::isSiteAssetUrl('http://shop.example.test/storage/x.png'));
$this->assertTrue(SiteAssetHosts::isSiteAssetUrl(" https://shop.example.test/x\n"));
// 경로만 있는 값은 host 가 없으므로 이 판정의 대상이 아니다 (호출측이 경로 규칙으로 다룬다)
$this->assertFalse(SiteAssetHosts::isSiteAssetUrl('/api/x'));
// protocol-relative 는 별도 판정 축 — 자기 host 라도 여기서는 참이 아니다
$this->assertFalse(SiteAssetHosts::isSiteAssetUrl('//shop.example.test/x'));
// 위험 스킴은 host 와 무관
$this->assertFalse(SiteAssetHosts::isSiteAssetUrl('ftp://shop.example.test/x'));
$this->assertFalse(SiteAssetHosts::isSiteAssetUrl('javascript://shop.example.test/%0aalert(1)'));
}
/**
* 자기 host 를 흉내 낸 URL 은 거짓이어야 합니다 — 브라우저와 같은 정규화 뒤 host 를 비교한다.
*
* @scenario url_host=lookalike
*
* @effects external_host_still_rejected
*/
public function test_is_site_asset_url_rejects_lookalikes(): void
{
foreach ([
'https://shop.example.test.evil.com/x',
'https://shop.example.test@evil.com/x',
'https://evil.com\\@shop.example.test/x',
'https://evil.com/shop.example.test/x',
'https://evil.com#shop.example.test',
"https://evil.com\t.shop.example.test/x",
] as $url) {
$this->assertFalse(SiteAssetHosts::isSiteAssetUrl($url), "흉내 URL 은 거짓이어야 합니다: {$url}");
}
}
/**
* app.url 이 비어 있거나 host 를 못 읽는 값이면 빈 목록이어야 합니다 (예외 없음).
*
* @scenario url_host=external_undeclared
*
* @effects external_host_still_rejected
*/
public function test_blank_app_url_yields_no_host(): void
{
foreach (['', 'not a url', '/relative'] as $value) {
Config::set('app.url', $value);
$this->assertSame([], SiteAssetHosts::hosts(), "app.url='{$value}' 에서 host 가 생기면 안 됩니다");
$this->assertFalse(SiteAssetHosts::isSiteAssetUrl('https://shop.example.test/x'));
}
}
}