From 72f46a64680a546a31b3a2182cf0ada523eb85cf Mon Sep 17 00:00:00 2001 From: HeuJung Date: Wed, 9 Sep 2026 09:20:29 +0900 Subject: [PATCH] =?UTF-8?q?fix(core):=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=B2=A8=EB=B6=80=20URL=20=EC=9D=84=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=83=81=EB=8C=80=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=9C=EA=B8=89=ED=95=98=EA=B3=A0=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=EA=B0=80=20=EC=9E=90?= =?UTF-8?q?=EA=B8=B0=20=EC=A3=BC=EC=86=8C=EB=A5=BC=20=EC=99=B8=EB=B6=80?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B4=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 편집기 헤더 「로고 이미지」에 파일을 올린 뒤 저장하면 「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 래핑을 읽도록 보정 --- AGENTS.md | 2 + CHANGELOG.md | 3 +- app/Rules/NoExternalUrls.php | 30 ++++ .../TemplateLayoutAttachmentService.php | 10 +- .../ExtensionDoc/ExtensionDocScaffolder.php | 4 +- .../ExtensionDoc/ExtensionInventory.php | 2 +- app/Support/SiteAssetHosts.php | 115 +++++++++++++ docs/SECURITY.md | 12 +- docs/backend/api/templates.md | 4 +- docs/extension/storage-driver.md | 7 + docs/extension/template-security.md | 2 +- docs/frontend/security.md | 2 +- lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 2 +- .../CHANGELOG.md | 2 +- .../g7-template-sirsoft-basic-ja/CHANGELOG.md | 2 +- modules/_bundled/sirsoft-board/CHANGELOG.md | 2 +- modules/_bundled/sirsoft-page/CHANGELOG.md | 2 +- .../necessary-allowlist-settings.spec.ts | 2 +- .../_bundled/sirsoft-admin_basic/CHANGELOG.md | 2 +- .../admin-dashboard-alert-severity.test.tsx | 2 +- templates/_bundled/sirsoft-basic/CHANGELOG.md | 2 +- ...TemplateLayoutAttachmentControllerTest.php | 107 ++++++++++++ .../DevTools/DevtoolsDebugGateTest.php | 2 +- .../ExtensionDocContractTest.php | 4 +- .../Installer/DevVendorDetectionTest.php | 2 +- tests/Feature/Rules/NoExternalUrlsTest.php | 155 +++++++++++++++++ .../layout-attachment-url-mode.spec.ts | 156 +++++++++++++++++- .../RequirementsVendorDevPackagesTest.php | 2 +- ...plateLayoutAttachmentUrlResolutionTest.php | 20 +++ .../ExtensionDoc/ExtensionInventoryTest.php | 2 +- tests/Unit/Support/SiteAssetHostsTest.php | 133 +++++++++++++++ .../installer-composer-skip-dev-vendor.yaml | 2 +- .../layout-attachment-url-storage-gate.yaml | 43 +++++ 33 files changed, 811 insertions(+), 28 deletions(-) create mode 100644 app/Support/SiteAssetHosts.php create mode 100644 tests/Unit/Support/SiteAssetHostsTest.php create mode 100644 tests/scenarios/layout-attachment-url-storage-gate.yaml diff --git a/AGENTS.md b/AGENTS.md index ae6c844c..729d98f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -575,6 +575,8 @@ catch-all shadow 는 보호처럼 보인다는 점이 위험하다. 가려진 | 공개 판정을 `PublicAssetDisk::resolve()` 밖에서 사본으로 재작성 | 판정은 그 단일 지점 경유. `''`·`'none'`·config 에 없는 고아 디스크가 등가 비교만으로 통과하는 것을 막는다 | | 행 disk 로 `withDisk()` 를 무검증 호출 | 존재 확인 후 폴백 — 공개 자산 디스크가 플러그인 등록 디스크일 수 있고, 그 플러그인 비활성화 시 무인증 공개 서빙 라우트가 **500** 이 된다 | | 직접 URL 로 전환하면서 "행 삭제 = 접근 차단" 전제를 그대로 둠 | 직접 URL 은 행과 무관하게 파일을 가리킨다 — 회수 수단(파일 삭제)과 그 한계를 문서에 남긴다 | +| 서버가 스스로 발급하는 자산 주소(프록시 서빙 URL)를 절대 URL 로 만듦 | 사이트 상대 경로 — 절대면 저장 규칙(외부 URL 차단)이 자기 주소를 외부로 차단해 image 위젯의 업로드 → 저장이 422 가 되고(배경은 `style` 이라 스캔되지 않아 로고에서만 드러났다), 저장된 레이아웃이 발급 시점 도메인·스킴에 묶인다 | +| 저장 규칙의 "외부" 판정을 스킴 접두 문자열로만 두고 자기 주소를 예외 없이 차단 | 사이트 자기 host(`app.url`)·선언된 공개 자산 디스크 host 는 외부가 아니다 — `SiteAssetHosts` 단일 판정(정규화 후 host 등가 비교). 접두 비교는 `host.evil.com`·`host@evil.com` 을 통과시키고, 요청 `Host` 헤더는 위조 가능해 근거가 아니다 | 이 결함군의 유일한 증상은 화면이다: 직접 URL 이 403 을 돌려주면 그 이미지들만 깨지고, 게이트가 우회되면 정상 200 이 나간다. 서버 로그에는 어느 쪽도 흔적이 없다. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bdd716a..396a4442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [7.0.11] - 2026-09-07 +## [7.0.11] - 2026-09-09 ### Added @@ -47,6 +47,7 @@ - 화면 편집기가 열리자마자 빈 화면이 되는 경우가 있던 문제를 수정했습니다. 편집기가 잠깐 보였다가 사라졌고, 새로고침에 따라 나타났다 사라졌다 했습니다. - 화면 편집기에서 환경설정과 연결된 항목(예: 헤더 로고)을 무심코 조작하면 그 연결이 소리 없이 끊기던 문제를 수정했습니다. 이제 그런 항목은 연결된 값이라는 안내와 원래 값이 표시되고, 「직접 지정으로 바꾸기」를 눌러야 편집할 수 있으며 「되돌리기」로 언제든 원래 연결로 복구할 수 있습니다. - `vendor-bundle:build-all --check` 가 설치되지 않은 번들 확장을 「소스 경로 없음」으로 건너뛰어, 그 확장의 vendor 번들이 갱신되지 않은 상태가 점검을 통과하던 문제를 수정했습니다. 이제 설치 여부와 무관하게 배포 원본 기준으로 판정합니다. +- 레이아웃 편집기의 헤더 「로고 이미지」에서 파일을 올린 뒤 저장하면 「HTTPS 프로토콜 URL은 허용되지 않습니다」로 거부되던 문제를 수정했습니다. 업로드한 이미지의 주소가 사이트 전체 주소 형태로 발급되어 레이아웃 저장의 외부 주소 차단에 걸리고 있었으며, 배경 이미지는 검사 대상이 아니라 증상이 없었습니다. 이제 업로드 주소는 사이트 상대 경로로 발급되고, 사이트 자신의 주소와 「공개 자산 스토리지」로 지정한 저장소의 주소는 외부로 취급하지 않습니다. ## [7.0.10] - 2026-09-06 diff --git a/app/Rules/NoExternalUrls.php b/app/Rules/NoExternalUrls.php index 891da8ce..802df1f3 100644 --- a/app/Rules/NoExternalUrls.php +++ b/app/Rules/NoExternalUrls.php @@ -2,6 +2,7 @@ namespace App\Rules; +use App\Support\SiteAssetHosts; use Closure; use Illuminate\Contracts\Validation\ValidationRule; @@ -20,9 +21,22 @@ use Illuminate\Contracts\Validation\ValidationRule; * 차단합니다. 반면 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|null + */ + private ?array $siteAssetHosts = null; + /** * 차단할 위험 URI 스킴 목록 */ @@ -203,6 +217,12 @@ class NoExternalUrls implements ValidationRule */ 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) { @@ -225,6 +245,16 @@ class NoExternalUrls implements ValidationRule } } + /** + * 사이트 자산 host 목록을 반환합니다 (검증 1회당 1회 해석 후 캐시). + * + * @return array 소문자 host 목록 + */ + private function siteAssetHosts(): array + { + return $this->siteAssetHosts ??= SiteAssetHosts::hosts(); + } + /** * 스킴별 에러 메시지 출력 */ diff --git a/app/Services/TemplateLayoutAttachmentService.php b/app/Services/TemplateLayoutAttachmentService.php index b3007812..b17e2104 100644 --- a/app/Services/TemplateLayoutAttachmentService.php +++ b/app/Services/TemplateLayoutAttachmentService.php @@ -190,8 +190,14 @@ class TemplateLayoutAttachmentService /** * 공개 서빙 라우트(프록시) URL 을 생성합니다. * + * 사이트 상대 경로(`/api/templates/...`)로 발급한다. 절대 URL 로 발급하면 ① 저장 + * 게이트(`NoExternalUrls`)가 서버 자신이 발급한 주소를 외부로 차단해 image 위젯의 + * 업로드 → 저장이 422 로 끝나고(배경은 style 로 들어가 스캔되지 않아 드러나지 않았다), + * ② 저장된 레이아웃이 발급 시점의 도메인·스킴에 묶여 주소가 바뀌면 그 이미지가 전부 + * 깨진다. 직접 URL(CDN)은 저장소가 정하는 절대 주소이므로 이 규칙의 대상이 아니다. + * * @param TemplateLayoutAttachment $attachment 첨부 파일 - * @return string 공개 서빙 URL + * @return string 공개 서빙 URL (사이트 상대 경로) */ private function proxyUrl(TemplateLayoutAttachment $attachment): string { @@ -201,7 +207,7 @@ class TemplateLayoutAttachmentService return route('api.public.templates.layout-attachment-file', [ 'identifier' => $identifier, 'attachment' => $attachment->id, - ]); + ], false); } /** diff --git a/app/Support/ExtensionDoc/ExtensionDocScaffolder.php b/app/Support/ExtensionDoc/ExtensionDocScaffolder.php index 3f630050..20cbe5cb 100644 --- a/app/Support/ExtensionDoc/ExtensionDocScaffolder.php +++ b/app/Support/ExtensionDoc/ExtensionDocScaffolder.php @@ -2303,11 +2303,11 @@ class ExtensionDocScaffolder $sections, ); - // 확장명은 히어로 이미지가 아니라 평범한 H1 이다 (PO 결정 2026-08-31). 확장 20개는 + // 확장명은 히어로 이미지가 아니라 평범한 H1 이다 (설계 결정 2026-08-31). 확장 20개는 // 대등하게 병렬로 존재하는 구성요소이고, 각자가 코어와 같은 히어로 브랜딩을 받으면 // "이 확장이 곧 독립 프로젝트" 라는 착시를 준다. `@generated:badges` 블록의 // flat-square 정보 배지는 manifest 에서 오는 것이라 그대로 둔다. - // 제목은 확장명만이 아니라 「그누보드7 {확장명} {유형}」 이다 (PO 결정 2026-09-04) — + // 제목은 확장명만이 아니라 「그누보드7 {확장명} {유형}」 이다 (설계 결정 2026-09-04) — // 이 문서만 연 사람이 그누보드7의 어떤 종류 확장인지 제목에서 알 수 있어야 한다. $lines = []; $lines[] = '# '.ExtensionInventory::docTitle($name, $record['type']); diff --git a/app/Support/ExtensionDoc/ExtensionInventory.php b/app/Support/ExtensionDoc/ExtensionInventory.php index 28847371..36f757a1 100644 --- a/app/Support/ExtensionDoc/ExtensionInventory.php +++ b/app/Support/ExtensionDoc/ExtensionInventory.php @@ -281,7 +281,7 @@ class ExtensionInventory * 진입 문서(README · AGENTS.md · docs/README.md)의 제목을 만듭니다. * * 확장명만 제목으로 두면(`# 게시판`) 제3자가 그 문서만 열었을 때 이것이 그누보드7의 - * 확장인지, 모듈인지 템플릿인지 알 수 없다(PO 결정 2026-09-04). 그래서 제목은 + * 확장인지, 모듈인지 템플릿인지 알 수 없다(설계 결정 2026-09-04). 그래서 제목은 * 「그누보드7 {확장명} {유형}」 으로 조립한다 — 「그누보드7 게시판 모듈」. * * 확장명이 이미 유형으로 끝나면(「Hello 모듈」·「Hello User Template」) 유형을 겹쳐 diff --git a/app/Support/SiteAssetHosts.php b/app/Support/SiteAssetHosts.php new file mode 100644 index 00000000..cd8d3562 --- /dev/null +++ b/app/Support/SiteAssetHosts.php @@ -0,0 +1,115 @@ + 소문자 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|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; + } +} diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 0194ce92..713057c8 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -225,7 +225,17 @@ G7은 레이아웃 JSON 보안을 위해 **10개의 Custom Validation Rule**을 **추가 차단**: `//`로 시작하는 프로토콜 상대 URL -**검증 범위**: `components[]` → `props`, `actions` 내 모든 문자열 값을 재귀적으로 스캔 +**외부의 기준**: 사이트 자기 host(`app.url`)와 운영자가 「공개 자산 스토리지」로 선언한 디스크의 host 는 +외부가 아닙니다. 레이아웃 첨부 API 가 스스로 발급하는 주소(공개 서빙 URL·직접 URL)가 그 host 를 +쓰므로, 여기서 차단하면 image 위젯으로 올린 파일이 업로드는 되고 저장은 422 가 됩니다. 판정은 +접두 문자열 비교가 아니라 브라우저와 같은 정규화 뒤의 host 등가 비교입니다(`App\Support\SiteAssetHosts`). +`host.evil.com`·`host@evil.com`·`evil.com\@host` 같은 흉내 host 는 통과하지 않으며, 요청의 `Host` 헤더는 +위조 가능하므로 근거로 쓰지 않습니다. 프로토콜 상대 URL 과 http/https 밖의 스킴은 host 가 같아도 +차단됩니다. + +**검증 범위**: `components[]` → `props`, `actions`, `lifecycle`, `onComponentEvent`, `slots`, `component_layout`, +`responsive` 와 최상위 `init_actions`/`modals`/`named_actions`/`errorHandling` 내 모든 문자열 값을 재귀적으로 스캔. +`style` 은 스캔 대상이 아닙니다 — 배경 이미지(`backgroundImage`)가 절대 URL 이어도 통과하는 이유입니다. ### 5. ValidParentLayout diff --git a/docs/backend/api/templates.md b/docs/backend/api/templates.md index 77050411..7aff6c82 100644 --- a/docs/backend/api/templates.md +++ b/docs/backend/api/templates.md @@ -94667,7 +94667,7 @@ _목록 응답: `data` 는 첨부 항목 배열입니다 (페이지네이션 없 | original_name | string | `hero-bg.png` | 업로드 당시 원본 파일명 | | mime_type | string | `image/png` | 파일 MIME 타입 | | size | integer | `204800` | 파일 크기 (바이트) | -| url | string | `/api/templates/sirsoft-basic/layout-attachments/1/file` | 첨부 파일 접근 URL. 기본은 공개 서빙 라우트(프록시)이고, 관리자 환경설정의 공개 자산 스토리지를 켠 뒤 그 디스크에 올라간 첨부만 직접 URL(CDN 절대 주소)로 내려온다 | +| url | string | `/api/templates/sirsoft-basic/layout-attachments/1/file` | 첨부 파일 접근 URL. 기본은 공개 서빙 라우트(프록시)의 **사이트 상대 경로**(`/api/...`)이고, 관리자 환경설정의 공개 자산 스토리지를 켠 뒤 그 디스크에 올라간 첨부만 직접 URL(CDN 절대 주소)로 내려온다. 어느 형태든 레이아웃 저장의 외부 URL 차단 규칙을 통과한다(사이트 자기 host 와 선언된 공개 자산 디스크 host 는 외부가 아니다) | | created_at | string | `2026-07-14T10:00:00+09:00` | 업로드 일시 (ISO 8601) | **응답 예시** @@ -94748,7 +94748,7 @@ _단건 응답: `data` 객체의 필드._ | original_name | string | `hero-bg.png` | 업로드된 원본 파일명 | | mime_type | string | `image/png` | 파일 MIME 타입 | | size | integer | `204800` | 파일 크기 (바이트) | -| url | string | `/api/templates/sirsoft-basic/layout-attachments/1/file` | 업로드된 파일의 접근 URL (편집기 ImagePickerControl 이 사용). 기본은 공개 서빙 라우트(프록시)이고, 공개 자산 스토리지를 켜 그 디스크에 저장된 경우에만 직접 URL(CDN 절대 주소)이다 | +| url | string | `/api/templates/sirsoft-basic/layout-attachments/1/file` | 업로드된 파일의 접근 URL (편집기 ImagePickerControl 이 값으로 그대로 쓴다). 기본은 공개 서빙 라우트(프록시)의 **사이트 상대 경로**(`/api/...`)이고, 공개 자산 스토리지를 켜 그 디스크에 저장된 경우에만 직접 URL(CDN 절대 주소)이다. 어느 형태든 레이아웃 저장의 외부 URL 차단 규칙을 통과한다 — 절대 프록시 URL 이던 시절 로고처럼 props 에 들어가는 값은 저장 시 422 였다 | **응답 예시** diff --git a/docs/extension/storage-driver.md b/docs/extension/storage-driver.md index 6ad078c1..fcdca739 100644 --- a/docs/extension/storage-driver.md +++ b/docs/extension/storage-driver.md @@ -1616,6 +1616,13 @@ class AttachmentServiceTest extends TestCase 발급됩니다. 디스크에 공개 URL(`url`) 설정이 있다는 사실만으로는 직접 URL 을 발급하지 않습니다 — 그 설정은 "URL 문자열을 만들 수 있는가" 일 뿐 "익명 읽기가 되는가" 가 아니어서, 비공개 버킷에 공개 URL 을 적어 둔 구성에서는 발급된 주소가 403 이 됩니다. +- 발급된 주소는 **레이아웃 저장의 외부 URL 차단 규칙을 통과해야** 합니다. 편집기 image 위젯은 + 그 주소를 props 에 그대로 넣고, 헤더 「로고 이미지」처럼 값 슬롯이 하나뿐인 컨트롤은 + `style` 이 아니라 `props` 에 쓰므로 규칙의 스캔 대상입니다. 그래서 공개 서빙(프록시) URL 은 + 사이트 상대 경로(`/api/templates/.../file`)로 발급하고, 직접 URL 의 host(선언된 공개 자산 + 디스크)와 사이트 자기 host 는 규칙이 외부로 보지 않습니다(`App\Support\SiteAssetHosts`). + 프록시 URL 을 절대 형태로 발급하면 그 저장이 422 로 거부될 뿐 아니라, 저장된 레이아웃이 + 발급 시점의 도메인·스킴에 묶여 주소가 바뀌면 그 이미지가 전부 깨집니다. ### 혼재 운용 diff --git a/docs/extension/template-security.md b/docs/extension/template-security.md index 6afe8f6d..b13572a3 100644 --- a/docs/extension/template-security.md +++ b/docs/extension/template-security.md @@ -37,7 +37,7 @@ Custom Rule: ComponentExists | JSON 구조 유효성 | 올바른 JSON 형식인지 검증 | | 최대 중첩 깊이 | 10단계 제한 | | 엔드포인트 화이트리스트 | `/api/(admin\|auth\|public)/` 패턴만 허용 | -| 외부 URL 금지 | 외부 도메인 URL 차단 | +| 외부 URL 금지 | 외부 도메인 URL 차단 (사이트 자기 host 와 「공개 자산 스토리지」로 선언한 디스크의 host 는 외부가 아니다) | | 컴포넌트 존재 여부 | components.json 기준으로 검증 | --- diff --git a/docs/frontend/security.md b/docs/frontend/security.md index 72d666e4..859992de 100644 --- a/docs/frontend/security.md +++ b/docs/frontend/security.md @@ -55,7 +55,7 @@ | JSON 구조 | ValidLayoutStructure | 필수 필드, 깊이 10단계 제한, 타입 검증 | | 컴포넌트 | ComponentExists | components.json 매니페스트 대조 | | API 엔드포인트 | WhitelistedEndpoint | `/api/(admin\|auth\|public)/` 패턴만 허용 | -| 외부 URL | NoExternalUrls | http, data, javascript 등 7개 위험 스킴 차단 | +| 외부 URL | NoExternalUrls | http, data, javascript 등 7개 위험 스킴 차단. 사이트 자기 host·선언된 공개 자산 디스크 host 의 http(s) 절대 URL 은 외부가 아니다(서버가 발급하는 첨부 주소) | | 상속 | ValidParentLayout | 순환 참조 방지, 상속 깊이 10 제한 | | 슬롯 | ValidSlotStructure | 부모에서 정의된 슬롯만 허용 | | 데이터소스 | ValidDataSourceMerge | 상속 체인 ID 고유성 | diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 31d3cbee..74c6c842 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.10] - 2026-09-07 +## [1.0.10] - 2026-09-09 ### Added diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 6af8a5c4..abcf6c04 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.9] - 2026-09-07 +## [1.0.9] - 2026-09-09 ### Added diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md index c83ab1ce..bbb8281c 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.1.3] - 2026-09-07 +## [1.1.3] - 2026-09-09 ### Added diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 9c487cfb..c962ea53 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.1.2] - 2026-09-08 +## [1.1.2] - 2026-09-09 ### Changed diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 3d01378b..c6622855 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.1.2] - 2026-09-08 +## [1.1.2] - 2026-09-09 ### Changed diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/necessary-allowlist-settings.spec.ts b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/necessary-allowlist-settings.spec.ts index d7b8e0eb..e7916e76 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/necessary-allowlist-settings.spec.ts +++ b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/necessary-allowlist-settings.spec.ts @@ -18,7 +18,7 @@ * 3. 저장된 목록이 인라인 페이로드에 실려 브라우저에 도달한다 (+ 잠금 집합 동반) * 4. 형식 위반 항목은 422 로 거부되고 그 카드에 에러가 붙는다 * - * 종료 시 추가한 항목을 되돌린다 — PO 와 브라우저를 공유하므로 원상 복구는 의무다. + * 종료 시 추가한 항목을 되돌린다 — 사람이 쓰는 브라우저를 공유하므로 원상 복구는 의무다. */ import { test, expect, authenticatePage } from '../../fixtures/gdpr-auth'; import type { Page } from '@playwright/test'; diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index 37c7326f..65113c3c 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.9] - 2026-09-07 +## [1.0.9] - 2026-09-09 ### Added diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-dashboard-alert-severity.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-dashboard-alert-severity.test.tsx index ffe943c7..51eb4475 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-dashboard-alert-severity.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-dashboard-alert-severity.test.tsx @@ -8,7 +8,7 @@ * 한 번도 쓰이지 않았다. 목록에 없는 subtype 은 전부 else 로 떨어져 **경고가 회색 * 일반 안내로** 렌더됐다(`static_publish_*` 가 이미 그 상태였다). * - 게다가 시스템 알림 카드는 대시보드 **맨 아래** 섹션이라, 화면이 정상으로 보이는 - * 구성에서는 운영자가 경고에 도달하지 못할 수 있었다. 그래서 PO 결정(2026-08-28)으로 + * 구성에서는 운영자가 경고에 도달하지 못할 수 있었다. 그래서 설계 결정(2026-08-28)으로 * **경고 등급 알림은 상단 배너로 승격**하고 나머지는 하단 카드에 남긴다. * * 이 테스트가 잠그는 계약: diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md index 87fa4642..9bba7c74 100644 --- a/templates/_bundled/sirsoft-basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.1.4] - 2026-09-07 +## [1.1.4] - 2026-09-09 ### Added diff --git a/tests/Feature/Api/Admin/TemplateLayoutAttachmentControllerTest.php b/tests/Feature/Api/Admin/TemplateLayoutAttachmentControllerTest.php index 92c0e0f8..9b0485c6 100644 --- a/tests/Feature/Api/Admin/TemplateLayoutAttachmentControllerTest.php +++ b/tests/Feature/Api/Admin/TemplateLayoutAttachmentControllerTest.php @@ -7,11 +7,13 @@ use App\Extension\HookManager; use App\Models\Permission; use App\Models\Role; use App\Models\Template; +use App\Models\TemplateLayout; use App\Models\TemplateLayoutAttachment; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; +use Illuminate\Testing\TestResponse; use Tests\TestCase; /** @@ -422,4 +424,109 @@ class TemplateLayoutAttachmentControllerTest extends TestCase $this->assertStringContainsString("/layout-attachments/{$legacy->id}/file", $url); $this->assertStringNotContainsString('cdn.test', $url); } + // ───────────────────────────────────────────────────────────────────────── + // 업로드 응답 url → 레이아웃 저장 게이트 왕복 + // + // 헤더 「로고 이미지」처럼 값 슬롯이 하나뿐인 컨트롤은 업로드 응답 url 을 props 에 그대로 + // 넣는다. 그 값이 저장 요청의 NoExternalUrls 를 통과하지 못하면 운영자는 "업로드는 + // 됐는데 저장은 422" 를 본다. 배경 이미지는 style 로 들어가 스캔되지 않아 드러나지 않았다. + // ───────────────────────────────────────────────────────────────────────── + + /** + * 업로드 응답 url 을 props 에 넣은 레이아웃을 PUT 합니다. + * + * @param string $url 업로드 응답 url + * @return TestResponse + */ + private function saveLayoutWithLogo(string $url) + { + $layout = TemplateLayout::factory()->create([ + 'template_id' => $this->template->id, + 'name' => 'home', + ]); + + $content = $layout->content; + // 팩토리 기본 endpoint(`/api/{단어}`)는 WhitelistedEndpoint 에 걸린다 — 이 테스트의 축이 아니다. + $content['endpoint'] = '/api/public/home'; + $content['components'] = [[ + 'id' => 'header', + 'type' => 'composite', + 'name' => 'Header', + 'props' => ['logo' => $url], + ]]; + + return $this->withHeaders($this->authHeaders()) + ->putJson("/api/admin/templates/{$this->template->identifier}/layouts/home", [ + 'expected_lock_version' => (int) ($layout->lock_version ?? 0), + 'content' => $content, + ]); + } + + /** + * 프록시 변종 — 업로드 응답 url 이 저장 요청을 통과해야 합니다. + * + * @scenario url_host=site_relative_path + * + * @effects proxy_url_is_site_relative, issued_asset_url_passes_storage_gate + */ + public function test_uploaded_proxy_url_is_accepted_by_layout_save(): void + { + $upload = $this->withHeaders($this->authHeaders()) + ->postJson("/api/admin/templates/{$this->template->identifier}/layout-attachments", [ + 'file' => UploadedFile::fake()->image('logo.png', 48, 24), + 'layout_name' => 'home', + ]); + $upload->assertStatus(200); + $url = (string) $upload->json('data.url'); + + $this->assertStringStartsWith('/api/', $url, '프록시 URL 은 사이트 상대 경로여야 합니다'); + + $save = $this->saveLayoutWithLogo($url); + + $save->assertStatus(200); + $this->assertSame($url, TemplateLayout::where('name', 'home')->first()->content['components'][0]['props']['logo']); + } + + /** + * 직접 URL(CDN) 변종 — 운영자가 선언한 공개 자산 디스크의 절대 URL 도 저장 요청을 통과해야 합니다. + * + * @scenario url_host=public_asset_disk + * + * @effects issued_asset_url_passes_storage_gate + */ + public function test_uploaded_direct_url_is_accepted_by_layout_save(): void + { + $this->registerFakeCdnDisk(); + config(['core.storage.public_asset_disk' => 'fake_cdn']); + + $upload = $this->withHeaders($this->authHeaders()) + ->postJson("/api/admin/templates/{$this->template->identifier}/layout-attachments", [ + 'file' => UploadedFile::fake()->image('logo.png', 48, 24), + 'layout_name' => 'home', + ]); + $upload->assertStatus(200); + $url = (string) $upload->json('data.url'); + + $this->assertStringStartsWith('https://cdn.test/assets', $url); + + $save = $this->saveLayoutWithLogo($url); + + $save->assertStatus(200); + $this->assertSame($url, TemplateLayout::where('name', 'home')->first()->content['components'][0]['props']['logo']); + } + + /** + * 선언되지 않은 외부 host 는 여전히 차단되어야 합니다 — 허용 범위가 "서버가 발급하는 주소" 를 + * 넘어 넓어지지 않았는지 고정한다. + * + * @scenario url_host=external_undeclared + * + * @effects external_host_still_rejected + */ + public function test_external_host_url_is_still_rejected_by_layout_save(): void + { + $save = $this->saveLayoutWithLogo('https://attacker.example/template-layout-attachments/x.png'); + + $save->assertStatus(422); + } } diff --git a/tests/Feature/DevTools/DevtoolsDebugGateTest.php b/tests/Feature/DevTools/DevtoolsDebugGateTest.php index b17d0cd0..7625658d 100644 --- a/tests/Feature/DevTools/DevtoolsDebugGateTest.php +++ b/tests/Feature/DevTools/DevtoolsDebugGateTest.php @@ -229,7 +229,7 @@ class DevtoolsDebugGateTest extends TestCase } /** - * GET 4종은 디버그 ON 에서 정상 응답한다 (게이트만 걸고 기능은 유지 — PO 결정). + * GET 4종은 디버그 ON 에서 정상 응답한다 (게이트만 걸고 기능은 유지 — 설계 결정). */ public function test_get_endpoints_respond_when_debug_enabled(): void { diff --git a/tests/Feature/Documentation/ExtensionDocContractTest.php b/tests/Feature/Documentation/ExtensionDocContractTest.php index 199e28ea..6256fd33 100644 --- a/tests/Feature/Documentation/ExtensionDocContractTest.php +++ b/tests/Feature/Documentation/ExtensionDocContractTest.php @@ -1043,7 +1043,7 @@ class ExtensionDocContractTest extends TestCase /** * 확장 README 의 첫 화면이 이미지 배지가 아니라 H1 제목인지 단언합니다. * - * PO 결정(2026-08-31): 확장 README 상단의 확장명은 shields.io 이미지 배지가 아니라 평범한 + * 설계 결정(2026-08-31): 확장 README 상단의 확장명은 shields.io 이미지 배지가 아니라 평범한 * H1 마크다운 제목이다. 확장은 20개가 병렬로 존재하는 대등한 구성요소이고, 각자가 코어와 * 같은 히어로 브랜딩을 받으면 "이 확장이 곧 독립 프로젝트" 라는 착시를 준다. * @@ -1146,7 +1146,7 @@ class ExtensionDocContractTest extends TestCase /** * 진입 문서 3종의 제목이 「그누보드7 {확장명} {유형}」 을 담는지 단언합니다. * - * PO 결정(2026-09-04): 확장 README 제목이 확장명뿐(`# 게시판`)이면 그 문서만 연 사람이 + * 설계 결정(2026-09-04): 확장 README 제목이 확장명뿐(`# 게시판`)이면 그 문서만 연 사람이 * 이것이 그누보드7의 확장인지, 모듈인지 템플릿인지 알 수 없다. README · AGENTS.md · * docs/README.md 는 제3자가 처음 여는 진입 문서이므로 셋 다 같은 제목을 쓴다. * diff --git a/tests/Feature/Installer/DevVendorDetectionTest.php b/tests/Feature/Installer/DevVendorDetectionTest.php index c2f2a5f4..b2592bda 100644 --- a/tests/Feature/Installer/DevVendorDetectionTest.php +++ b/tests/Feature/Installer/DevVendorDetectionTest.php @@ -115,7 +115,7 @@ class DevVendorDetectionTest extends TestCase /** * 재사용 분기가 dev 를 감지해 경고하되 설치를 계속한다 (소스 계약). * - * 감지·경고·"설치 계속" 셋이 한 분기 안에 있어야 한다 — 차단으로 바뀌면 PO 결정(D1) 위반이다. + * 감지·경고·"설치 계속" 셋이 한 분기 안에 있어야 한다 — 차단으로 바뀌면 설계 결정(D1) 위반이다. * * @effects installer_warns_and_keeps_dev_vendor */ diff --git a/tests/Feature/Rules/NoExternalUrlsTest.php b/tests/Feature/Rules/NoExternalUrlsTest.php index e6e3d063..b23c8c30 100644 --- a/tests/Feature/Rules/NoExternalUrlsTest.php +++ b/tests/Feature/Rules/NoExternalUrlsTest.php @@ -7,6 +7,7 @@ use App\Http\Requests\Layout\UpdateLayoutContentRequest; use App\Http\Requests\Layout\UpdateLayoutExtensionContentRequest; use App\Http\Requests\Layout\UpdateLayoutRequest; use App\Rules\NoExternalUrls; +use Illuminate\Support\Facades\Config; use Tests\TestCase; class NoExternalUrlsTest extends TestCase @@ -495,6 +496,160 @@ class NoExternalUrlsTest extends TestCase // 트리에 이 규칙이 붙어 있지 않으면, init_actions·props·actions 의 외부 URL 차단이 // 그 경로에서만 조용히 발화하지 않는다. + // ───────────────────────────────────────────────────────────────────────── + // 사이트 자산 host — 서버가 스스로 발급한 주소는 외부가 아니다 + // + // 레이아웃 첨부 API 가 돌려준 URL(프록시 경로 또는 운영자가 선언한 공개 자산 + // 디스크의 직접 URL)을 image 위젯이 props 에 넣는데, 그 값을 이 규칙이 "외부" 로 + // 차단하면 업로드 → 저장이 422 로 끝난다. 배경 이미지는 style 로 들어가 스캔되지 + // 않아 같은 URL 이 통과했으므로, 증상은 로고(propValue) 에서만 나타났다. + // ───────────────────────────────────────────────────────────────────────── + + /** + * props.logo 하나를 가진 최소 레이아웃을 만듭니다. + * + * @param string $url logo 값 + * @return array + */ + private function layoutWithLogo(string $url): array + { + return [ + 'version' => '1.0.0', + 'layout_name' => 'test', + 'components' => [ + [ + 'id' => 'header', + 'type' => 'composite', + 'name' => 'Header', + 'props' => ['logo' => $url], + 'children' => [], + ], + ], + ]; + } + + /** + * 규칙 통과 여부를 돌려줍니다. + * + * @param array $layout 검사 대상 + * @return string|null 실패 메시지 (통과면 null) + */ + private function failureOf(array $layout): ?string + { + $message = null; + $this->rule->validate('content', $layout, function ($m) use (&$message) { + $message = (string) $m; + }); + + return $message; + } + + /** + * 사이트 자기 host(app.url)의 절대 URL 은 통과해야 합니다 — 프록시 서빙 라우트가 절대 + * 형태로 발급되던 배포본(7.0.10 이하)에서 저장된 값과, 운영자가 자기 사이트 주소를 + * 직접 적은 값이 여기에 해당한다. + * + * @scenario url_host=own_site_absolute + * + * @effects issued_asset_url_passes_storage_gate + */ + public function test_passes_with_own_site_absolute_url_in_props(): void + { + Config::set('app.url', 'https://shop.example.test'); + + foreach ([ + 'https://shop.example.test/api/templates/sirsoft-basic/layout-attachments/4/file', + 'http://shop.example.test/storage/template-layout-attachments/sirsoft-basic/logo.png', + 'https://SHOP.example.TEST/api/templates/sirsoft-basic/layout-attachments/4/file', + ] as $url) { + $this->assertNull($this->failureOf($this->layoutWithLogo($url)), "자기 host 절대 URL 은 통과해야 합니다: {$url}"); + } + } + + /** + * 운영자가 선언한 공개 자산 디스크의 host 는 통과해야 합니다 — 직접 URL(CDN) 변종에서 + * 첨부 API 가 돌려주는 값이다. + * + * @scenario url_host=public_asset_disk + * + * @effects issued_asset_url_passes_storage_gate + */ + public function test_passes_with_public_asset_disk_host_in_props(): void + { + Config::set('app.url', 'https://shop.example.test'); + Config::set('core.storage.public_asset_disk', 'public'); + Config::set('filesystems.disks.public.url', 'https://cdn.example.test/assets'); + + $url = 'https://cdn.example.test/assets/template-layout-attachments/sirsoft-basic/bg.png'; + + $this->assertNull($this->failureOf($this->layoutWithLogo($url)), '공개 자산 디스크 host 는 통과해야 합니다'); + } + + /** + * 같은 host 라도 공개 자산 디스크로 선언되지 않았으면 여전히 외부입니다 — 허용의 근거는 + * "URL 을 만들 수 있는 디스크가 있다" 가 아니라 운영자 선언이다. + * + * @scenario url_host=external_undeclared + * + * @effects external_host_still_rejected + */ + public function test_fails_with_cdn_host_when_public_asset_disk_is_not_declared(): void + { + Config::set('app.url', 'https://shop.example.test'); + Config::set('core.storage.public_asset_disk', ''); + Config::set('filesystems.disks.public.url', 'https://cdn.example.test/assets'); + + $url = 'https://cdn.example.test/assets/template-layout-attachments/sirsoft-basic/bg.png'; + + $this->assertNotNull($this->failureOf($this->layoutWithLogo($url)), '선언되지 않은 디스크 host 는 차단되어야 합니다'); + } + + /** + * 자기 host 를 흉내 낸 URL 은 전부 차단되어야 합니다 — 접두 문자열 비교로 허용하면 + * 이 형태들이 통과한다. 판정은 브라우저와 같은 정규화 뒤의 host 등가 비교여야 한다. + * + * @scenario url_host=lookalike + * + * @effects external_host_still_rejected + */ + public function test_fails_with_lookalike_of_own_host(): void + { + Config::set('app.url', 'https://shop.example.test'); + Config::set('core.storage.public_asset_disk', ''); + + foreach ([ + 'https://shop.example.test.evil.com/x.png', + 'https://shop.example.test@evil.com/x.png', + 'https://evil.com\\@shop.example.test/x.png', + 'https://evil.com/shop.example.test/x.png', + 'https://evil.com/?u=https://shop.example.test/x.png', + 'https://shop.example.test%2eevil.com/x.png', + // protocol-relative 는 자기 host 라도 종전대로 차단한다 (별도 판정 축) + '//shop.example.test/x.png', + ] as $url) { + $this->assertNotNull($this->failureOf($this->layoutWithLogo($url)), "자기 host 흉내는 차단되어야 합니다: {$url}"); + } + } + + /** + * 자기 host 허용은 http/https 에만 적용됩니다 — 위험 스킴은 host 가 같아도 차단한다. + * + * @scenario url_host=dangerous_scheme + * + * @effects external_host_still_rejected + */ + public function test_fails_with_dangerous_scheme_even_on_own_host(): void + { + Config::set('app.url', 'https://shop.example.test'); + + foreach ([ + 'ftp://shop.example.test/x.png', + 'file://shop.example.test/x.png', + ] as $url) { + $this->assertNotNull($this->failureOf($this->layoutWithLogo($url)), "위험 스킴은 host 와 무관하게 차단되어야 합니다: {$url}"); + } + } + /** * FormRequest 의 content 배열 규칙에서 NoExternalUrls 인스턴스를 찾는다. * diff --git a/tests/Playwright/specs/layout-editor/layout-attachment-url-mode.spec.ts b/tests/Playwright/specs/layout-editor/layout-attachment-url-mode.spec.ts index 1c6d4c23..d61a26b3 100644 --- a/tests/Playwright/specs/layout-editor/layout-attachment-url-mode.spec.ts +++ b/tests/Playwright/specs/layout-editor/layout-attachment-url-mode.spec.ts @@ -13,6 +13,10 @@ * * 축 요약(마커 아님 — 평문): public_asset_disk=unset, row_disk=attachments, * filter_url_hook=absent. + * + * 뒤의 두 테스트는 별도 매니페스트(layout-attachment-url-storage-gate.yaml)의 축이다 — + * 발급 url 이 사이트 상대 경로여야 헤더 「로고 이미지」 같은 propValue 컨트롤의 저장이 + * 외부 URL 차단 규칙에 걸리지 않는다. 절대 URL 이던 시절 그 저장은 422 였다. */ import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth'; import { bodyRootPath } from '../../fixtures/layout-editor'; @@ -72,6 +76,16 @@ async function openStyleTab(page: Page): Promise { await page.getByTestId('g7le-property-tab-style').click(); } +/** ⓘ → 속성 편집 → props 탭 (헤더 「로고 이미지」 컨트롤이 있는 탭). */ +async function openPropsTab(page: Page): Promise { + await page.waitForSelector('[data-testid="g7le-overlay-info-button"]', { timeout: 10_000 }); + await page.getByTestId('g7le-overlay-info-button').click(); + await page.waitForSelector('[data-testid="g7le-context-menu-edit-props"]', { timeout: 5_000 }); + await page.getByTestId('g7le-context-menu-edit-props').click(); + await page.waitForSelector('[data-testid="g7le-property-modal"]', { timeout: 10_000 }); + await page.getByTestId('g7le-property-tab-props').click(); +} + async function enterEditor(page: Page): Promise { await page.goto('/admin/layout-editor/sirsoft-basic?route=%2F'); await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); @@ -143,6 +157,8 @@ test.describe('@layout-editor 레이아웃 첨부 URL 발급 모드', () => { // 공개 자산 디스크 미설정 = 기본 설치 → 공개 서빙 라우트(프록시) 형태 expect(uploaded.url as string).toContain(`/layout-attachments/${uploaded.id}/file`); + // 사이트 상대 경로 — 절대 URL 이면 저장 게이트(NoExternalUrls)가 서버 자신의 주소를 외부로 차단한다 + expect(uploaded.url as string).toMatch(/^\/api\//); // 그 주소는 인증 없이 이미지로 열려야 한다 (발행 배경은 방문자에게 로드된다) const served = await page.evaluate(async (url: string) => { @@ -264,9 +280,11 @@ test.describe('@layout-editor 레이아웃 첨부 URL 발급 모드', () => { // 방문자 화면 — 저장된 문자열이 실제 요청으로 나가는지 본다. const visitor = await page.context().newPage(); + // 발급 url 은 사이트 상대 경로다 — 응답 URL(절대)과는 pathname 으로 비교한다. + const uploadedPath = new URL(uploaded.url as string, 'https://placeholder.invalid').pathname; const attachmentRequests: number[] = []; visitor.on('response', (res) => { - if (res.url() === uploaded.url) attachmentRequests.push(res.status()); + if (new URL(res.url()).pathname === uploadedPath) attachmentRequests.push(res.status()); }); await visitor.goto(SANDBOX_ROUTE['sirsoft-basic']); await visitor.waitForLoadState('domcontentloaded', { timeout: 30_000 }); @@ -279,4 +297,140 @@ test.describe('@layout-editor 레이아웃 첨부 URL 발급 모드', () => { // 시드 화면은 다음 실행에서 fixture 로 덮이므로 첨부만 정리한다. await deleteAttachment(page, token, uploaded.id); }); + /** + * 헤더 「로고 이미지」는 값 슬롯이 하나뿐인 propValue 컨트롤이라 업로드 응답 url 이 그대로 + * props 에 들어간다. 그 값이 사이트 상대 경로여야 저장 게이트를 통과한다 — 절대 URL 이던 + * 시절 이 흐름은 "업로드는 되는데 저장은 422" 였다. + * + * 공통 레이아웃(_user_base)은 제품 화면이므로 여기서는 저장하지 않는다 — 위젯 값과 캔버스 + * src 까지만 본다. 저장 왕복은 다음 테스트가 샌드박스 화면에서 잠근다. + */ + // @scenario url_host=site_relative_path + // @effects editor_logo_control_receives_site_relative_url, proxy_url_is_site_relative + test('헤더 로고 컨트롤에서 파일 업로드 → 위젯 값·캔버스 src 가 사이트 상대 경로다', async ({ page }) => { + test.setTimeout(90_000); + const token = issueToken('core.templates.layouts.edit'); + await authenticatePage(page, token); + + await page.goto('/admin/layout-editor/sirsoft-basic?route=%2F&edit=__base__%2F_user_base'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 30_000 }); + await page.waitForFunction(() => document.querySelectorAll('[data-editor-path]').length > 0, { + timeout: 20_000, + }); + + const headerPath = await page.evaluate( + () => + document + .querySelector('[data-editor-path][data-editor-name="Header"]') + ?.getAttribute('data-editor-path') ?? null, + ); + expect(headerPath, '공통 레이아웃에 Header 노드가 있어야 합니다').not.toBeNull(); + await selectNode(page, headerPath as string); + await openPropsTab(page); + + // 로고 값이 환경설정과 연결된 표현식이면 「직접 지정으로 바꾸기」로 열어야 업로드가 가능하다. + const replace = page.getByTestId('g7le-image-expression-replace'); + if (await replace.isVisible().catch(() => false)) { + await replace.click(); + } + + const uploadPromise = page.waitForResponse( + (r) => + r.request().method() === 'POST' && /\/layout-attachments$/.test(new URL(r.url()).pathname), + { timeout: 20_000 }, + ); + await page + .locator('[data-testid="g7le-property-modal"] input[type="file"]') + .setInputFiles({ name: 'e2e-logo.png', mimeType: 'image/png', buffer: Buffer.from(PNG_BASE64, 'base64') }); + const uploadRes = await uploadPromise; + expect(uploadRes.status()).toBe(200); + const body = (await uploadRes.json()) as { data?: { id?: number; url?: string } }; + const url = body?.data?.url ?? null; + const id = body?.data?.id ?? null; + expect(url).not.toBeNull(); + expect(url as string).toMatch(/^\/api\//); + + // 위젯 값 = 발급 url (가공 없음), 캔버스 Header 도 같은 값 + await expect(page.getByTestId('g7le-image-url')).toHaveValue(url as string); + await expect + .poll( + () => + page.evaluate( + (p) => document.querySelector(`[data-editor-path="${p}"] img`)?.getAttribute('src') ?? null, + headerPath, + ), + { timeout: 8_000 }, + ) + .toBe(url); + + await deleteAttachment(page, token, id); + }); + + /** + * 발급 url 을 이미지 prop(`Img.src` — 로고와 같은 props sink)에 넣고 저장하면 200 이어야 하고, + * 그 화면을 연 방문자가 같은 주소를 200 으로 받아야 한다. 절대 URL 이던 시절 이 PUT 은 + * 「HTTPS 프로토콜 URL은 허용되지 않습니다」 422 였다. + * + * 저장은 E2E 전용 시드 화면(e2e_sandbox)에 한다 — globalSetup 이 매 실행 fixture 로 덮는다. + */ + // @scenario url_host=site_relative_path + // @effects issued_asset_url_passes_storage_gate, saved_logo_renders_for_visitor + test('발급 url 을 이미지 prop 에 넣고 저장 → 200, 방문자 화면이 그 주소를 200 으로 받는다', async ({ page }) => { + test.setTimeout(90_000); + const token = issueToken('core.templates.layouts.edit'); + await authenticatePage(page, token); + await page.goto('/'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + const uploaded = await uploadAttachment(page, token, 'e2e-prop-gate.png'); + expect(uploaded.status).toBe(200); + expect(uploaded.url as string).toMatch(/^\/api\//); + + const saved = await page.evaluate( + async ({ bearer, url }) => { + const headers = { Authorization: `Bearer ${bearer}`, Accept: 'application/json' }; + const show = await fetch('/api/admin/templates/sirsoft-basic/layouts/e2e_sandbox', { headers }); + const showBody = await show.json().catch(() => null); + const data = showBody?.data ?? {}; + // show 응답의 content 는 편집 페이지용 JSON 문자열이다 (LayoutResource). + const content = typeof data.content === 'string' ? JSON.parse(data.content) : data.content; + if (!content?.slots?.content?.[0]) return { status: -1, reason: 'sandbox content shape' }; + const root = content.slots.content[0]; + root.children = Array.isArray(root.children) ? root.children : []; + root.children.push({ + id: 'e2e_prop_gate_img', + type: 'basic', + name: 'Img', + props: { src: url, alt: 'e2e prop gate', className: 'h-6' }, + }); + const put = await fetch('/api/admin/templates/sirsoft-basic/layouts/e2e_sandbox', { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ expected_lock_version: data.lock_version ?? 0, content }), + credentials: 'same-origin', + }); + const putBody = await put.json().catch(() => null); + return { status: put.status, message: putBody?.message ?? null }; + }, + { bearer: token, url: uploaded.url as string }, + ); + expect(saved, saved.message ?? '').toMatchObject({ status: 200 }); + + // 방문자 — 저장된 문자열이 실제 요청으로 나가고 200 으로 응답하는지 + const visitor = await page.context().newPage(); + const uploadedPath = new URL(uploaded.url as string, 'https://placeholder.invalid').pathname; + const statuses: number[] = []; + visitor.on('response', (res) => { + if (new URL(res.url()).pathname === uploadedPath) statuses.push(res.status()); + }); + await visitor.goto(SANDBOX_ROUTE['sirsoft-basic']); + await visitor.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect.poll(() => statuses.length, { timeout: 15_000 }).toBeGreaterThan(0); + expect(statuses.every((s) => s === 200)).toBe(true); + await expect(visitor.locator(`img[src="${uploaded.url}"]`)).toHaveCount(1); + await visitor.close(); + + await deleteAttachment(page, token, uploaded.id); + }); }); diff --git a/tests/Unit/Installer/RequirementsVendorDevPackagesTest.php b/tests/Unit/Installer/RequirementsVendorDevPackagesTest.php index 12fa04f4..9b8f3669 100644 --- a/tests/Unit/Installer/RequirementsVendorDevPackagesTest.php +++ b/tests/Unit/Installer/RequirementsVendorDevPackagesTest.php @@ -10,7 +10,7 @@ use PHPUnit\Framework\TestCase; /** * 설치 2단계(설치 환경 확인)의 Composer 의존성 구성 카드 회귀 테스트 (dev-g7 #658). * - * 이 항목은 **선택 항목**이다 — 개발용 패키지가 섞여 있어도 설치는 진행되어야 한다(PO 결정 D1). + * 이 항목은 **선택 항목**이다 — 개발용 패키지가 섞여 있어도 설치는 진행되어야 한다(설계 결정 D1). * 게이트로 승격되면 이미 dev vendor 로 준비해 둔 운영자가 설치를 시작조차 못 한다. * * `check-configuration.php` 는 `CHECK_CONFIGURATION_LIBRARY` 모드로 로드해 요청 처리를 막고, diff --git a/tests/Unit/Services/TemplateLayoutAttachmentUrlResolutionTest.php b/tests/Unit/Services/TemplateLayoutAttachmentUrlResolutionTest.php index 09cccb0e..9b803e77 100644 --- a/tests/Unit/Services/TemplateLayoutAttachmentUrlResolutionTest.php +++ b/tests/Unit/Services/TemplateLayoutAttachmentUrlResolutionTest.php @@ -255,4 +255,24 @@ class TemplateLayoutAttachmentUrlResolutionTest extends TestCase $this->assertProxyUrl($this->service->resolveUrl($this->makeAttachment('s3'))); } + + /** + * 프록시 URL 은 사이트 상대 경로여야 합니다. + * + * 절대 URL 로 발급하면 두 가지가 어긋난다 — ① 저장 게이트(NoExternalUrls)가 서버가 + * 스스로 발급한 주소를 외부로 차단해 업로드 → 저장이 422 로 끝나고, ② 저장된 레이아웃이 + * 발급 시점의 도메인·스킴에 묶여 주소가 바뀌면 그 이미지가 전부 깨진다. + * + * @scenario url_host=site_relative_path + * + * @effects proxy_url_is_site_relative, issued_asset_url_passes_storage_gate + */ + public function test_proxy_url_is_site_relative_path(): void + { + $url = $this->service->resolveUrl($this->makeAttachment('attachments')); + + $this->assertStringStartsWith('/api/', $url); + $this->assertDoesNotMatchRegularExpression('#^[a-z][a-z0-9+.\-]*:#i', $url, '프록시 URL 에 스킴이 있으면 안 됩니다'); + $this->assertStringNotContainsString('//', $url); + } } diff --git a/tests/Unit/Support/ExtensionDoc/ExtensionInventoryTest.php b/tests/Unit/Support/ExtensionDoc/ExtensionInventoryTest.php index 7649524e..1c3f6ff8 100644 --- a/tests/Unit/Support/ExtensionDoc/ExtensionInventoryTest.php +++ b/tests/Unit/Support/ExtensionDoc/ExtensionInventoryTest.php @@ -10,7 +10,7 @@ use PHPUnit\Framework\TestCase; /** * 진입 문서 제목 조립 규칙(`ExtensionInventory::docTitle`)을 고정합니다. * - * 제목은 「그누보드7 {확장명} {유형}」 이다 (PO 결정 2026-09-04). 확장명이 이미 유형으로 + * 제목은 「그누보드7 {확장명} {유형}」 이다 (설계 결정 2026-09-04). 확장명이 이미 유형으로 * 끝나면 겹쳐 붙이지 않는다 — 이 예외가 깨지면 「그누보드7 Hello 모듈 모듈」 이 되는데, * 계약 테스트는 같은 헬퍼로 기대값을 만들므로 그 형태도 초록으로 통과한다. 그래서 규칙 * 자체는 여기서 리터럴로 고정한다. diff --git a/tests/Unit/Support/SiteAssetHostsTest.php b/tests/Unit/Support/SiteAssetHostsTest.php new file mode 100644 index 00000000..ce775789 --- /dev/null +++ b/tests/Unit/Support/SiteAssetHostsTest.php @@ -0,0 +1,133 @@ +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')); + } + } +} diff --git a/tests/scenarios/installer-composer-skip-dev-vendor.yaml b/tests/scenarios/installer-composer-skip-dev-vendor.yaml index 9a5a1dad..94f6e28b 100644 --- a/tests/scenarios/installer-composer-skip-dev-vendor.yaml +++ b/tests/scenarios/installer-composer-skip-dev-vendor.yaml @@ -16,7 +16,7 @@ description: | 재개 설치에서는 인스톨러 어느 지점에서도 정리가 일어나지 않아, 이전 환경의 캐시가 그대로 남았다. - 처치 (PO 결정 D1 — 설치를 차단하지 않는다): + 처치 (설계 결정 D1 — 설치를 차단하지 않는다): 1. 설치 환경 확인(Step 2) 에 「Composer 의존성 구성」 카드를 **선택 항목**으로 추가. 경고 상태여도 `isAllRequiredPassed()` 는 이를 보지 않고 `다음` 버튼도 활성이다. 2. 설치 로그(Step 5) 에 감지 사실과 정리 명령 안내를 남긴다. diff --git a/tests/scenarios/layout-attachment-url-storage-gate.yaml b/tests/scenarios/layout-attachment-url-storage-gate.yaml new file mode 100644 index 00000000..10ab0390 --- /dev/null +++ b/tests/scenarios/layout-attachment-url-storage-gate.yaml @@ -0,0 +1,43 @@ +feature: 레이아웃 첨부 URL 저장 게이트 통과 (서버가 발급한 주소는 외부가 아니다) + +description: | + 레이아웃 편집기의 image 위젯으로 파일을 올리면 첨부 API 가 돌려준 url 이 props 에 들어간다. + 그 url 이 절대 형태(`https://<사이트>/api/templates/.../file`)라 저장 요청의 외부 URL + 차단 규칙(NoExternalUrls)이 서버 자신이 발급한 주소를 외부로 차단해, 헤더 「로고 이미지」 + 처럼 값 슬롯이 하나뿐인 컨트롤(propValue)에서 업로드는 되고 저장은 422 로 끝났다. 배경 + 이미지는 style 로 들어가 스캔되지 않아 같은 주소가 통과했으므로 증상이 로고에서만 났다. + + 결정 사슬: + - 생산자: 프록시(공개 서빙 라우트) URL 은 사이트 상대 경로(`/api/...`)로 발급한다. 절대 URL 은 + 저장 게이트 충돌뿐 아니라 저장된 레이아웃을 발급 시점 도메인·스킴에 묶는다. + - 게이트: 사이트 자기 host(app.url)와 운영자가 선언한 공개 자산 디스크(PublicAssetDisk:: + resolve())의 host 는 외부가 아니다 (SiteAssetHosts 단일 판정). 요청 Host 헤더는 위조 + 가능하므로 근거로 쓰지 않는다. + - 판정은 접두 문자열 비교가 아니라 브라우저와 같은 정규화 뒤 host 등가 비교 — 흉내 host + (`host.evil.com`·`host@evil.com`·`evil.com\@host`)를 통과시키지 않는다. + - protocol-relative(`//host/...`)와 http/https 밖의 스킴은 host 가 같아도 종전대로 차단한다. + +coverage_strategy: full + +axes: + url_host: [site_relative_path, own_site_absolute, public_asset_disk, external_undeclared, lookalike, dangerous_scheme] + +effects: + - proxy_url_is_site_relative + - issued_asset_url_passes_storage_gate + - external_host_still_rejected + # 편집기 로고 컨트롤(propValue)이 파일 업로드 후 받는 값이 사이트 상대 경로다 (E2E) + - editor_logo_control_receives_site_relative_url + # 발급 url 을 prop 에 넣어 저장한 화면을 방문자가 열면 그 주소가 200 으로 응답한다 (E2E) + - saved_logo_renders_for_visitor + +test_files: + - tests/Feature/Rules/NoExternalUrlsTest.php + - tests/Unit/Support/SiteAssetHostsTest.php + - tests/Unit/Services/TemplateLayoutAttachmentUrlResolutionTest.php + - tests/Feature/Api/Admin/TemplateLayoutAttachmentControllerTest.php + - tests/Playwright/specs/layout-editor/layout-attachment-url-mode.spec.ts + +rules_layer_coverage: + - rule: no-storage-disk-direct + coverage: 공개 자산 디스크 host 해석은 CoreStorageDriver::url() 경유 — Storage facade 직접 호출 도입 없음