diff --git a/.env.example b/.env.example index 9139cdda..8ded747f 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,16 @@ APP_DEBUG=false APP_URL=http://localhost APP_VERSION=7.0.10 +# 리버스 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 HTTPS 를 인식하려면 +# 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작). +# * = 직전 호출 IP 만 신뢰 (동일 호스트 프록시·ALB. ** 도 동일하게 동작합니다) +# IP,IP/CIDR = 지정 목록만 신뢰 (프록시가 여러 단이면 모든 단을 나열해야 합니다) +# * 은 앱이 프록시 없이는 도달 불가한 구성에서만 사용하세요 — docs/backend/reverse-proxy.md +# TRUSTED_PROXIES=* + +# HTTPS 사이트에서 세션 쿠키에 Secure 속성을 강제합니다(미설정 시 요청 스킴으로 자동 판정). +# SESSION_SECURE_COOKIE=true + APP_LOCALE=ko APP_FALLBACK_LOCALE=ko APP_FAKER_LOCALE=ko_KR diff --git a/.env.testing.example b/.env.testing.example index 7cd187ef..ed0e9ae5 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -5,6 +5,16 @@ APP_DEBUG=false APP_URL=http://localhost APP_VERSION=7.0.10 +# 리버스 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 HTTPS 를 인식하려면 +# 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작). +# * = 직전 호출 IP 만 신뢰 (동일 호스트 프록시·ALB. ** 도 동일하게 동작합니다) +# IP,IP/CIDR = 지정 목록만 신뢰 (프록시가 여러 단이면 모든 단을 나열해야 합니다) +# * 은 앱이 프록시 없이는 도달 불가한 구성에서만 사용하세요 — docs/backend/reverse-proxy.md +# TRUSTED_PROXIES=* + +# HTTPS 사이트에서 세션 쿠키에 Secure 속성을 강제합니다(미설정 시 요청 스킴으로 자동 판정). +# SESSION_SECURE_COOKIE=true + APP_LOCALE=ko APP_FALLBACK_LOCALE=ko APP_FAKER_LOCALE=ko_KR diff --git a/AGENTS.md b/AGENTS.md index a61be1bd..25edc708 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ -### 백엔드 [backend/](docs/backend/) (35개) +### 백엔드 [backend/](docs/backend/) (36개) | 문서 | 설명 | TL;DR 핵심 | |------|------|-----------| @@ -35,6 +35,7 @@ | [notification-system.md](docs/backend/notification-system.md) | 알림 시스템 (Notification System) | GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스 불필요) | | [pagination.md](docs/backend/pagination.md) | 대용량 목록 페이지네이션 (Pagination) | 총 건수만 상한을 받는다 — 상한 이하면 정확, 초과면 "이상"(total_relation=at_least) | | [response-helper.md](docs/backend/response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 | +| [reverse-proxy.md](docs/backend/reverse-proxy.md) | 리버스 프록시 환경 (Reverse Proxy) | 프록시 뒤에서는 요청이 스스로 스킴·IP 를 증명하지 못한다 — 신뢰할 프록시를 지정해야 한다 | | [routing.md](docs/backend/routing.md) | 라우트 네이밍 및 경로 | 모든 라우트는 name() 필수: ->name('api.users.index') | | [search-system.md](docs/backend/search-system.md) | Scout 검색 엔진 시스템 (Search System) | Laravel Scout + DatabaseFulltextEngine: MySQL FULLTEXT + ... | | [seo-system.md](docs/backend/seo-system.md) | SEO 페이지 생성기 시스템 (SEO Page Generator) | SeoMiddleware: 봇 요청 감지 → ?locale= 파라미터 해석 → SeoRenderer가 ... | @@ -153,7 +154,7 @@ | 대상 | 진입점 | 문서/엔드포인트 | |------|--------|----------------| -| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 324 | +| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 325 | ### 확장 API 레퍼런스 (14개 확장, 자동 스캔) @@ -460,6 +461,28 @@ Icon 은 `` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 > 상세: [module-assets.md](docs/extension/module-assets.md) "사용자 추가 에셋", [static-asset-publishing.md](docs/backend/static-asset-publishing.md) > 정적 검사가 외부 자산 URL 과 번들 확장의 `custom/` 배포를 차단한다. 서술자 형태와 교체 2경로 보존은 테스트가 잠근다. +### 프록시 뒤 요청은 스킴·IP 를 스스로 증명하지 않는다 + +TLS 가 앞단에서 종단되고 앱에는 HTTP 로 전달되는 구성(AWS ALB, CloudFront, Cloudflare, nginx/Apache 리버스 프록시, ngrok)에서 신뢰할 프록시가 지정되지 않으면 Laravel 은 `X-Forwarded-*` 를 전부 무시한다. 요청 객체가 평문 HTTP 로 인식되어 `asset()`/`url()` 이 `http://` 를 만들고(HTTPS 페이지에서 혼합 콘텐츠로 차단 → **사이트 전체 백지**), `$request->ip()` 가 프록시 IP 가 되어 통보 IP 화이트리스트·rate limit·IP 기록·GeoIP 가 동시에 무너진다. + +| ❌ 금지 | ✅ 올바른 사용 | +|--------|---------------| +| `bootstrap/app.php` 에서 `trustProxies(at: env('TRUSTED_PROXIES'))` | `config/trustedproxy.php` 의 `'proxies' => env('TRUSTED_PROXIES')` — `withMiddleware` 클로저는 `.env` 로드 전에 평가되어 `env()` 가 항상 `null` 이다(오류 없이 no-op) | +| 신뢰 프록시를 `'*'` 로 하드코딩 | env opt-in — 앱이 직접 노출된 환경에서 `X-Forwarded-For` 위조로 기록 IP·IP 제한이 조작된다 | +| `$middleware->trustHosts()` 호출 | 호출 자체가 모든 설치처에서 Host 검증을 켠다(미등록 호스트 400) — opt-in 원칙 위반 | +| IP 화이트리스트·rate limit·IP 기록을 `$request->ip()` 로 두면서 프록시 구성을 문서화하지 않음 | 그 기능이 프록시 신뢰 설정에 의존한다는 사실을 문서에 남긴다 | +| 절대 URL 이 필요한 곳에서 요청 스킴 의존(`url()`/`asset()`)과 설정 앵커(`config('app.url')`)를 혼용 | 외부 시스템에 등록·전송되는 URL(PG 콜백·webhook 안내)은 설정 앵커, 화면 자산은 요청 기준 | +| 진단 판정을 "HTTPS 인식 실패" 로 세움 | `X-Forwarded-* 수신 중 AND 신뢰 프록시 미설정` — HTTP 전용 사이트가 프록시 뒤에 있으면 **화면은 완전히 정상 렌더되면서** webhook 403·IP 왜곡만 계속된다. HTTPS 기준 판정은 그 구성에서 침묵한다 | +| 같은 판정을 노출면(대시보드·환경설정·설치 마법사·커맨드)마다 다시 작성 | `App\Support\TrustedProxyDiagnostic` 단일 판정 — 면마다 조건을 복제하면 한 곳만 어긋나도 서로 다른 답을 내놓는다 | +| 신뢰 프록시 값을 관리자 화면에서 편집 가능하게 제공 | 읽기 전용 진단만. ① 프록시 뒤에서는 그 화면 자체가 뜨지 않는 것이 이 결함이라 정작 필요한 순간에 도달 불가(잠금 역설) ② 웹 편집이 가능해지면 관리자 계정 탈취가 곧 XFF 위조 경로가 된다 | + +이 결함군은 서버 로그에 흔적을 남기지 않는다. 브라우저 콘솔의 차단 로그와 "모든 방문자가 같은 IP" 라는 데이터 상태만이 증상이며, 설치 마법사는 `X-Forwarded-Proto` 를 읽어 "HTTPS 정상" 이라고 보고하므로 운영자에게는 원인 추적 단서가 없다. + +`**` 는 Laravel 내장 미들웨어에서 `*` 과 같은 코드 경로를 타므로 "모든 프록시 신뢰" 가 아니다. 프록시가 여러 단인 구성에서는 체인의 모든 프록시 IP·CIDR 를 나열해야 최초 클라이언트 IP 가 해석된다. + +> 상세: [reverse-proxy.md](docs/backend/reverse-proxy.md) +> `config/trustedproxy.php` 의 키 계약, 신뢰/미신뢰 분기, 쿠키 Secure 자동 판정, `bootstrap/app.php` 로의 `env()` 함정 재유입 차단은 테스트가 잠근다. `url()`/`asset()`/`$request->ip()` 는 정상 사용례가 다수라 정적 금지 규칙을 두지 않는다. + ### 목록 응답의 하위 컬렉션 목록은 화면이 그 행에서 **실제로 그리는 것**만 싣는다. 행마다 하위 컬렉션을 통째로 직렬화하면 한 페이지를 여는 것만으로 수백~수천 행이 응답에 실린다 (공개 #76 — 상품 100건 × 옵션 20건). diff --git a/CHANGELOG.md b/CHANGELOG.md index fcd5bad9..8f1e286f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ - 템플릿·모듈·플러그인의 `custom/` 파일이 다른 확장 자산과 같은 방식으로 정적 파일로 게시됩니다. CSS 안에서 글꼴·이미지를 상대 경로(`url('./font.woff2')`)로 참조할 수 있게 되었고, 파일을 고치면 자동으로 다시 게시됩니다. - 초기 화면에 필요한 다국어·컴포넌트 정의·라우트 정보·확장 번들·템플릿 에셋을 정적 파일로 미리 만들어 웹서버가 직접 전달합니다. 확장 설치/활성화나 레이아웃 편집 시 자동으로 다시 생성되며, 파일이 없으면 기존 방식으로 동작합니다. 초기 화면 표시가 빨라집니다. (#122 @glitter-gim 님께서 건의해주셨습니다.) - 관리자 대시보드에 초기 화면 파일 생성 실패 알림이 추가되었습니다. 원인(폴더 권한·디스크 공간·캐시)에 따라 다른 안내가 표시되며, 서버에서 `php artisan ext-static:status` 로 더 자세한 상태를 확인할 수 있습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.) +- AWS ALB·CloudFront·Cloudflare·nginx 처럼 HTTPS 를 앞단에서 처리하고 사이트에는 HTTP 로 전달하는 구성을 지원합니다. `.env` 에 `TRUSTED_PROXIES` 를 지정하면 사이트가 접속 주소를 올바르게 인식해 화면이 정상 표시되고, 게시글 작성 IP·로그인 시도 제한·결제 통보 수신도 실제 방문자 기준으로 동작합니다. 지정하지 않으면 종전과 동일하게 동작합니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.) +- 프록시 뒤에서 구동 중인데 신뢰 프록시가 지정되지 않았으면 관리자 대시보드가 그 사실을 알립니다. 환경설정 > 고급 에서 사이트가 인식한 접속 방식과 방문자 IP 를 확인할 수 있고, 서버에서 `php artisan trusted-proxy:status` 로도 확인할 수 있으며, 설치 마법사도 설치 단계에서 함께 안내합니다. HTTPS 를 쓰지 않는 사이트도 대상입니다 — 이 경우 화면은 정상이지만 방문자 IP 기록과 결제 통보 수신이 어긋나 있어도 드러나지 않기 때문입니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.) +- 사이트 설정 문제로 화면 구성 파일이 브라우저에 차단된 경우, 네트워크 오류와 구분되는 안내를 표시합니다. 새로고침해도 낫지 않는 상황이므로 [새로고침] 버튼을 두지 않으며, 원인과 조치 방법은 운영자가 확인할 수 있도록 브라우저 콘솔에 남깁니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.) ### Changed diff --git a/INSTALL.md b/INSTALL.md index 6f0df40b..6d3d7c47 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -557,6 +557,24 @@ sudo php artisan hotfix:rollback-stale-files --prune 프로덕션 환경에서는 HTTPS를 사용해야 합니다. `.env` 파일에서 `APP_URL`을 `https://`로 설정하세요. +`APP_URL` 은 명령줄 실행·큐 워커·메일 발송처럼 **요청이 없는 맥락**에서 절대 URL 을 만들 때의 +기준입니다. 웹 요청에서 만들어지는 절대 URL(화면 자산, 콜백 주소 등)은 `APP_URL` 이 아니라 +**요청 자체의 스킴과 호스트**를 따릅니다. + +따라서 TLS 를 앞단에서 처리하고 사이트에는 HTTP 로 전달하는 구성(AWS ALB, CloudFront, +Cloudflare, nginx 리버스 프록시, ngrok)에서는 `APP_URL` 만 `https://` 로 두어서는 부족합니다. +사이트가 접속 주소와 방문자 IP 를 올바르게 인식하려면 `.env` 에 `TRUSTED_PROXIES` 를 함께 +지정해야 합니다. + +```dotenv +# 프록시 뒤에서 구동하는 경우에만 지정합니다 (미설정이 기본값). +TRUSTED_PROXIES=* +``` + +지정하지 않으면 화면이 표시되지 않거나(혼합 콘텐츠 차단), 결제 통보가 수신되지 않고, 모든 +방문자가 같은 IP 로 기록됩니다. 값 선택 기준과 도입 시 후속 조치는 +[docs/backend/reverse-proxy.md](docs/backend/reverse-proxy.md) 를 참고하세요. + ### `.env` 권한 강화 (선택) `.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함합니다. 인스톨러는 설치 직후 `.env` 의 권한을 임의로 변경하지 않으므로, 운영자가 환경에 맞춰 직접 강화 권한을 적용할 수 있습니다. diff --git a/README.ko.md b/README.ko.md index ee23d363..38398263 100644 --- a/README.ko.md +++ b/README.ko.md @@ -518,8 +518,8 @@ cp .env.example .env jiwonpapa glitter-gim Tuwasduliebst - jordy-bitree lyg-kaban + jordy-bitree laelbe bigmsg abc101 diff --git a/README.md b/README.md index 86ccd657..94b08c18 100644 --- a/README.md +++ b/README.md @@ -532,8 +532,8 @@ Thanks to everyone who reported an issue or suggested a feature that shipped — jiwonpapa glitter-gim Tuwasduliebst - jordy-bitree lyg-kaban + jordy-bitree laelbe bigmsg abc101 diff --git a/app/Console/Commands/TrustedProxyStatusCommand.php b/app/Console/Commands/TrustedProxyStatusCommand.php new file mode 100644 index 00000000..8cb1414a --- /dev/null +++ b/app/Console/Commands/TrustedProxyStatusCommand.php @@ -0,0 +1,87 @@ +line(''); + $this->line('리버스 프록시 신뢰 설정 상태'); + $this->line(''); + + $this->line(' 설정 파일 : config/trustedproxy.php'); + $this->line(' 환경변수 : TRUSTED_PROXIES'); + + if ($diagnostic['trusted_configured']) { + $this->line(' 현재 값 : '.$diagnostic['configured_proxies']); + } else { + $this->line(' 현재 값 : 미설정 (아무 프록시도 신뢰하지 않음)'); + } + + $this->line(''); + $this->line(' 요청 기반 실측'); + $this->line(' 수신 전달 헤더 : 판정 불가 (콘솔에는 요청이 없습니다)'); + $this->line(' HTTPS 인식 : 판정 불가'); + $this->line(' 방문자 IP : 판정 불가'); + $this->line(''); + $this->comment(' 실측 축은 웹 요청에서만 판정됩니다 — 관리자 대시보드 알림 또는'); + $this->comment(' 환경설정 > 고급 의 진단 블록에서 확인하세요.'); + + $this->line(''); + + if ($diagnostic['trusted_configured']) { + $this->info('신뢰 프록시가 설정되어 있습니다. X-Forwarded-* 헤더를 신뢰합니다.'); + $this->line(''); + + return self::SUCCESS; + } + + $this->warn('• 신뢰 프록시가 설정되어 있지 않습니다.'); + $this->line(''); + $this->line(' 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 구동 중이라면'); + $this->line(' .env 에 TRUSTED_PROXIES 를 지정하세요. 미설정 시 접속 주소·방문자 IP 가'); + $this->line(' 프록시 기준으로 인식되어 화면 표시·IP 기록·결제 통보 수신이 어긋납니다.'); + $this->line(' 프록시를 쓰지 않는 직접 노출 구성이라면 미설정이 정상입니다.'); + $this->line(' 상세: https://github.com/gnuboard/g7/blob/main/docs/backend/reverse-proxy.md'); + $this->line(''); + + return self::FAILURE; + } +} diff --git a/app/Http/Controllers/Api/Admin/SettingsController.php b/app/Http/Controllers/Api/Admin/SettingsController.php index dd00c684..1479e724 100644 --- a/app/Http/Controllers/Api/Admin/SettingsController.php +++ b/app/Http/Controllers/Api/Admin/SettingsController.php @@ -15,6 +15,7 @@ use App\Services\DriverConnectionTester; use App\Services\DriverRegistryService; use App\Services\OutboundProxyTester; use App\Services\SettingsService; +use App\Support\TrustedProxyDiagnostic; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; @@ -150,6 +151,23 @@ class SettingsController extends AdminBaseController } } + /** + * 신뢰 프록시(리버스 프록시) 설정 진단 결과를 조회합니다 (#124). + * + * 읽기 전용이다 — 값 편집 엔드포인트는 두지 않는다. 이 값은 "앱이 프록시 없이 도달 + * 가능한가" 라는 배포 구조 지식이 있어야 정할 수 있고, 웹에서 편집 가능해지면 관리자 + * 계정 탈취가 곧 X-Forwarded-For 위조 경로가 된다. 편집은 `.env` 전용이다. + * + * 판정 대상은 관리자 브라우저의 **실제 요청**이므로 현재 요청을 그대로 쓴다. + * 입력을 받지 않는 읽기 전용 조회라 FormRequest 를 두지 않는다. + * + * @return JsonResponse 진단 결과 JSON 응답 + */ + public function trustedProxy(): JsonResponse + { + return $this->success('common.success', TrustedProxyDiagnostic::forRequest(request())); + } + /** * 시스템 캐시를 정리합니다. * diff --git a/app/Listeners/TrustedProxyAlertListener.php b/app/Listeners/TrustedProxyAlertListener.php new file mode 100644 index 00000000..4bd070ad --- /dev/null +++ b/app/Listeners/TrustedProxyAlertListener.php @@ -0,0 +1,80 @@ + [ + 'method' => 'addTrustedProxyAlert', + 'priority' => 15, + 'type' => 'filter', + ], + ]; + } + + /** + * 훅 이벤트 처리 (기본 핸들러). + * + * @param mixed ...$args 훅에서 전달된 인수들 + */ + public function handle(...$args): void + { + // 기본 핸들러는 사용하지 않음 + } + + /** + * 신뢰 프록시 미설정 경고를 대시보드에 추가합니다. + * + * @param array $alerts 기존 알림 배열 + * @return array 알림이 추가된 배열 + */ + public function addTrustedProxyAlert(array $alerts): array + { + $diagnostic = TrustedProxyDiagnostic::forRequest(request()); + + if ($diagnostic['status'] !== TrustedProxyDiagnostic::STATUS_WARNING) { + return $alerts; + } + + $alerts[] = [ + 'id' => 'trusted_proxy_missing', + 'type' => 'warning', + 'subtype' => 'trusted_proxy_missing', + 'icon' => 'exclamation-triangle', + 'title' => __('settings.trusted_proxy.alert_title'), + // 원인과 조치를 함께 담는다 — 원인만 알려 주면 운영자가 어디를 고쳐야 할지 모른다. + 'message' => __('settings.trusted_proxy.alert_message', [ + 'headers' => implode(', ', $diagnostic['forwarded_headers']), + 'ip' => (string) ($diagnostic['client_ip'] ?? '-'), + ]), + 'time' => null, + 'read' => false, + ]; + + return $alerts; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 149f6007..f2b3c6e4 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -13,6 +13,7 @@ use App\Http\View\Composers\TemplateComposer; use App\Http\View\Composers\UserTemplateComposer; use App\Listeners\ExtensionCompatibilityAlertListener; use App\Listeners\StaticPublishFailureAlertListener; +use App\Listeners\TrustedProxyAlertListener; use App\Notifications\NotificationChannelManager; use App\Services\ChannelReadinessService; use App\Services\GeoIpService; @@ -145,12 +146,14 @@ class AppServiceProvider extends ServiceProvider * 등록 대상: * - `ExtensionCompatibilityAlertListener` — 코어 호환성으로 자동 비활성화/재호환된 확장 * - `StaticPublishFailureAlertListener` — 부트스트랩 리소스 정적 게시 실패 (#122) + * - `TrustedProxyAlertListener` — 프록시 헤더 수신 중 신뢰 프록시 미설정 (#124) */ private function registerCoreHookListeners(): void { $listenerClasses = [ ExtensionCompatibilityAlertListener::class, StaticPublishFailureAlertListener::class, + TrustedProxyAlertListener::class, ]; foreach ($listenerClasses as $listenerClass) { diff --git a/app/Support/TrustedProxyDiagnostic.php b/app/Support/TrustedProxyDiagnostic.php new file mode 100644 index 00000000..4ff8d496 --- /dev/null +++ b/app/Support/TrustedProxyDiagnostic.php @@ -0,0 +1,146 @@ + + */ + public const FORWARDED_HEADERS = [ + 'X-Forwarded-For', + 'X-Forwarded-Proto', + 'X-Forwarded-Host', + 'X-Forwarded-Port', + 'X-Forwarded-Prefix', + 'X-Forwarded-Aws-Elb', + 'Forwarded', + ]; + + /** + * 요청 1건에 대한 신뢰 프록시 진단 결과를 반환합니다. + * + * @param Request|null $request 진단 대상 요청 (콘솔 등 요청이 없는 맥락이면 null) + * @return array{forwarded_headers: array, trusted_configured: bool, configured_proxies: string|null, is_secure: bool|null, client_ip: string|null, remote_addr: string|null, status: string} 진단 결과 + */ + public static function forRequest(?Request $request): array + { + $configured = self::configuredProxies(); + $trustedConfigured = self::isConfigured(); + + if ($request === null) { + return [ + 'forwarded_headers' => [], + 'trusted_configured' => $trustedConfigured, + 'configured_proxies' => $configured, + 'is_secure' => null, + 'client_ip' => null, + 'remote_addr' => null, + 'status' => self::STATUS_NOT_APPLICABLE, + ]; + } + + $forwarded = self::forwardedHeaders($request); + + return [ + 'forwarded_headers' => $forwarded, + 'trusted_configured' => $trustedConfigured, + 'configured_proxies' => $configured, + 'is_secure' => $request->isSecure(), + 'client_ip' => $request->ip(), + 'remote_addr' => $request->server('REMOTE_ADDR'), + 'status' => ($forwarded !== [] && ! $trustedConfigured) + ? self::STATUS_WARNING + : self::STATUS_OK, + ]; + } + + /** + * 요청이 수신 중인 전달 헤더의 이름 목록을 반환합니다. + * + * @param Request $request 대상 요청 + * @return array 수신 중인 헤더 이름 목록 + */ + public static function forwardedHeaders(Request $request): array + { + $present = []; + + foreach (self::FORWARDED_HEADERS as $header) { + if ($request->headers->has($header)) { + $present[] = $header; + } + } + + return $present; + } + + /** + * 신뢰 프록시가 설정되어 있는지 반환합니다. + * + * 빈 문자열은 미설정과 같게 다룬다 — `.env` 에 `TRUSTED_PROXIES=` 만 남겨 둔 상태가 + * "설정됨" 으로 판정되면 경고가 조용히 사라지는데, 미들웨어는 여전히 아무것도 신뢰하지 않는다. + * + * @return bool 설정 여부 + */ + public static function isConfigured(): bool + { + return self::configuredProxies() !== null; + } + + /** + * 설정된 신뢰 프록시 값을 반환합니다 (미설정이면 null). + * + * @return string|null 설정값 + */ + public static function configuredProxies(): ?string + { + $proxies = config('trustedproxy.proxies'); + + if (is_array($proxies)) { + $proxies = implode(',', $proxies); + } + + if (! is_string($proxies)) { + return null; + } + + $proxies = trim($proxies); + + return $proxies === '' ? null : $proxies; + } +} diff --git a/config/trustedproxy.php b/config/trustedproxy.php new file mode 100644 index 00000000..1d1284ba --- /dev/null +++ b/config/trustedproxy.php @@ -0,0 +1,33 @@ + env('TRUSTED_PROXIES'), + +]; diff --git a/docs/README.md b/docs/README.md index 82f31186..46c7cc68 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ | 카테고리 | 문서 수 | 링크 상태 | |----------|---------|----------| -| [백엔드](backend/) | 36개 | 정상 | +| [백엔드](backend/) | 37개 | 정상 | | [프론트엔드](frontend/) | 51개 | 정상 | | [확장 시스템](extension/) | 31개 | 정상 | | 공통 | 20개 | 정상 | @@ -125,7 +125,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답 ## 카테고리별 전체 문서 목록 -### 백엔드 (36개) +### 백엔드 (37개) | 문서 | 제목 | |------|------| @@ -155,6 +155,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답 | [pagination.md](backend/pagination.md) | 대용량 목록 페이지네이션 (Pagination) | | [README.md](backend/README.md) | 백엔드 개발 가이드 | | [response-helper.md](backend/response-helper.md) | API 응답 규칙 (ResponseHelper) | +| [reverse-proxy.md](backend/reverse-proxy.md) | 리버스 프록시 환경 (Reverse Proxy) | | [routing.md](backend/routing.md) | 라우트 네이밍 및 경로 | | [search-system.md](backend/search-system.md) | Scout 검색 엔진 시스템 (Search System) | | [seo-system.md](backend/seo-system.md) | SEO 페이지 생성기 시스템 (SEO Page Generator) | diff --git a/docs/backend/README.md b/docs/backend/README.md index ac55534b..728af961 100644 --- a/docs/backend/README.md +++ b/docs/backend/README.md @@ -55,6 +55,7 @@ | [notification-system.md](notification-system.md) | 알림 시스템 (Notification System) | GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스... | | [pagination.md](pagination.md) | 대용량 목록 페이지네이션 (Pagination) | 총 건수만 상한을 받는다 — 상한 이하면 정확, 초과면 "이상"(total_relat... | | [response-helper.md](response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 | +| [reverse-proxy.md](reverse-proxy.md) | 리버스 프록시 환경 (Reverse Proxy) | 프록시 뒤에서는 요청이 스스로 스킴·IP 를 증명하지 못한다 — 신뢰할 프록시를 지정... | | [routing.md](routing.md) | 라우트 네이밍 및 경로 | 모든 라우트는 name() 필수: ->name('api.users.index') | | [search-system.md](search-system.md) | Scout 검색 엔진 시스템 (Search System) | Laravel Scout + DatabaseFulltextEngine: MySQL F... | | [seo-system.md](seo-system.md) | SEO 페이지 생성기 시스템 (SEO Page Generator) | SeoMiddleware: 봇 요청 감지 → ?locale= 파라미터 해석 → Seo... | diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index 8014597c..b2d1a87e 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -200,7 +200,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } ## 코어 API 레퍼런스 -- **문서 수**: 36 · **엔드포인트 수**: 324 +- **문서 수**: 36 · **엔드포인트 수**: 325 | 문서 | 도메인 | 엔드포인트 | | --- | --- | --- | @@ -235,7 +235,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } | [schedules.md](schedules.md) | `schedules` | 12 | | [search.md](search.md) | `search` | 1 | | [seo.md](seo.md) | `seo` | 5 | -| [settings.md](settings.md) | `settings` | 15 | +| [settings.md](settings.md) | `settings` | 16 | | [system.md](system.md) | `system` | 2 | | [templates.md](templates.md) | `templates` | 57 | | [users.md](users.md) | `users` | 12 | diff --git a/docs/backend/api/dashboard.md b/docs/backend/api/dashboard.md index 56b8a414..c360def4 100644 --- a/docs/backend/api/dashboard.md +++ b/docs/backend/api/dashboard.md @@ -141,13 +141,13 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_목록 응답: `data` 배열 항목의 필드. `data` 는 `core.dashboard.alerts` 필터 훅의 결과이며, 알릴 항목이 없으면 빈 배열(`[]`)입니다. 코어 기본 리스너(`ExtensionCompatibilityAlertListener`, `StaticPublishFailureAlertListener`)가 주입하는 항목의 필드는 다음과 같습니다._ +_목록 응답: `data` 배열 항목의 필드. `data` 는 `core.dashboard.alerts` 필터 훅의 결과이며, 알릴 항목이 없으면 빈 배열(`[]`)입니다. 코어 기본 리스너(`ExtensionCompatibilityAlertListener`, `StaticPublishFailureAlertListener`, `TrustedProxyAlertListener`)가 주입하는 항목의 필드는 다음과 같습니다._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | | id | string | `compat_plugins_sirsoft-gdpr` | 알림 식별자 (`compat_{type}_{identifier}` = 자동 비활성화, `recover_{type}_{identifier}` = 재호환. 알림 닫기(dismiss) 상태 판정 키) | -| type | string | `warning` | 알림 등급 (`warning`: 코어 비호환 자동 비활성화, `info`: 재호환 복구 가능) | -| subtype | string | `incompatible_core` | 알림 세부 분류 (`incompatible_core`: 코어 버전 비호환으로 자동 비활성화됨, `recovery_available`: 코어 업그레이드 후 다시 활성화 가능, `static_publish_parent_not_writable` · `static_publish_write_failed` · `static_publish_lock_unavailable`: 초기 화면 파일 생성이 2회 이상 연속 실패 — 각각 폴더 권한 / 디스크 공간 / 캐시 저장소가 원인) | +| type | string | `warning` | 알림 등급. **화면 배치를 결정합니다** — `warning` 은 관리자 대시보드 **상단 배너**로, 그 외(`info` 등)는 하단 「시스템 알림」 카드로 렌더됩니다. 같은 알림이 두 곳에 중복 노출되지 않습니다 | +| subtype | string | `incompatible_core` | 알림 세부 분류 (`incompatible_core`: 코어 버전 비호환으로 자동 비활성화됨, `recovery_available`: 코어 업그레이드 후 다시 활성화 가능, `static_publish_parent_not_writable` · `static_publish_write_failed` · `static_publish_lock_unavailable`: 초기 화면 파일 생성이 2회 이상 연속 실패 — 각각 폴더 권한 / 디스크 공간 / 캐시 저장소가 원인, `trusted_proxy_missing`: 리버스 프록시 헤더를 수신 중인데 신뢰 프록시가 설정되지 않음) | | icon | string | `exclamation-triangle` | 아이콘 식별자 (warning: `exclamation-triangle`, info: `check-circle`) | | title | string | `플러그인 "sirsoft-gdpr" 자동 비활성화됨` | 알림 제목 (다국어 문구 — `extensions.alerts.incompatible_deactivated` / `recovered_title`) | | message | string | `필요 버전: 7.0.0-beta.9, 현재 설치됨: 7.0.0-beta.8` | 알림 본문 (다국어 문구 — `extensions.alerts.incompatible_message` / `recovered_body`) | diff --git a/docs/backend/api/settings.md b/docs/backend/api/settings.md index b71f9cdc..30516947 100644 --- a/docs/backend/api/settings.md +++ b/docs/backend/api/settings.md @@ -1124,6 +1124,82 @@ HTTP/1.1 200 서버 실행 환경 정보를 한 번에 조회합니다. OS/웹서버/PHP/DB/Laravel/코어 버전, CPU·메모리·디스크 사용량, PHP 주요 설정값(memory_limit·max_execution_time·upload_max_filesize), 주요 경로, PHP 확장 로드 상태, DB 연결 구성 요약 등을 포함합니다. 관리자 시스템 정보 화면과 요구사항 점검용 진단 데이터로 사용됩니다. +### GET /api/admin/settings/trusted-proxy + +- **라우트명**: `api.admin.settings.trusted-proxy` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@trustedProxy` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/settings/trusted-proxy HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| forwarded_headers | array | `["X-Forwarded-For","X-Forwarded-Proto"]` | 이 요청이 수신 중인 `X-Forwarded-*` 계열 헤더 이름 목록. 비어 있으면 앞단에 프록시가 없는 직접 노출 구성이다 | +| trusted_configured | boolean | `false` | `TRUSTED_PROXIES` 가 지정되어 있는지. 빈 문자열은 미설정과 같게 판정된다 | +| configured_proxies | string\|null | `null` | 지정된 신뢰 프록시 값 (미설정이면 `null`) | +| is_secure | boolean\|null | `false` | 요청이 HTTPS 로 인식되었는지. 요청이 없는 맥락에서는 `null` | +| client_ip | string\|null | `10.0.0.5` | 방문자 IP 로 인식된 값. 신뢰 프록시가 없으면 프록시 자신의 주소가 된다 | +| remote_addr | string\|null | `10.0.0.5` | 직전 호출 IP(`REMOTE_ADDR`). `client_ip` 와 같으면서 `forwarded_headers` 가 비어 있지 않으면 모든 방문자가 한 사람으로 기록되고 있는 상태다 | +| status | string | `warning` | 진단 결과. `warning`(프록시 헤더 수신 중 + 신뢰 프록시 미설정) / `ok` / `not_applicable`(요청이 없는 맥락) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "forwarded_headers": [ + "X-Forwarded-For", + "X-Forwarded-Proto" + ], + "trusted_configured": false, + "configured_proxies": null, + "is_secure": false, + "client_ip": "10.0.0.5", + "remote_addr": "10.0.0.5", + "status": "warning" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | + + + +**설명** + +리버스 프록시 뒤에서 구동 중인지, 그리고 신뢰할 프록시가 지정되어 있는지를 진단합니다. **읽기 전용이며 대응하는 쓰기 엔드포인트가 없습니다** — 값은 `.env` 의 `TRUSTED_PROXIES` 로만 변경합니다. + +판정식은 "HTTPS 인식 실패" 가 아니라 `forwarded_headers 가 비어 있지 않음 AND trusted_configured 가 거짓` 입니다. HTTP 전용 사이트가 프록시 뒤에 있는 구성에서는 혼합 콘텐츠 차단이 없어 화면이 정상으로 보이지만, 방문자 IP 기록·통보 IP 화이트리스트·로그인 시도 제한은 그대로 어긋나기 때문입니다. + +같은 판정을 관리자 대시보드 알림, 환경설정 > 고급 화면, 설치 마법사, `php artisan trusted-proxy:status` 가 공유합니다. 설정 방법과 도입 시 후속 조치는 [reverse-proxy.md](../reverse-proxy.md) 를 참고하세요. + + ### POST /api/admin/settings/test-driver - **라우트명**: `api.admin.settings.test-driver` diff --git a/docs/backend/reverse-proxy.md b/docs/backend/reverse-proxy.md new file mode 100644 index 00000000..c039e23b --- /dev/null +++ b/docs/backend/reverse-proxy.md @@ -0,0 +1,209 @@ +# 리버스 프록시 환경 (Reverse Proxy) + +TLS 를 앞단에서 종단하고 앱에는 HTTP 로 전달하는 구성(AWS ALB, CloudFront, Cloudflare, +nginx/Apache 리버스 프록시, ngrok)에서 G7 이 접속 주소와 방문자 IP 를 올바르게 인식하도록 +신뢰할 프록시를 지정하는 방법을 설명한다. + +## TL;DR (5초 요약) + +```text +1. 프록시 뒤에서는 요청이 스스로 스킴·IP 를 증명하지 못한다 — 신뢰할 프록시를 지정해야 한다 +2. .env 에 TRUSTED_PROXIES 한 줄. 미설정이 기본값이고 기존 설치처 동작은 바뀌지 않는다 +3. * 은 앱이 프록시 없이는 도달 불가할 때만 안전하다 (XFF 위조). ** 은 * 과 동일 동작 +4. 미설정 시 증상: 화면 백지(Mixed Content) · webhook 403 · 방문자 IP 전원 동일 · 로그인 제한 붕괴 +5. 도입 직후 1회성 후속 조치가 있다 — 기발송 서명 URL 무효화 / SEO 캐시·사이트맵 재생성 +``` + +## 목차 + +- [1. 증상](#1-증상) +- [2. 설정](#2-설정) +- [3. `*` 과 `**` 의 전제](#3--과--의-전제) +- [4. 프록시측에서 함께 해야 할 것](#4-프록시측에서-함께-해야-할-것) +- [5. 세션 쿠키의 Secure 속성](#5-세션-쿠키의-secure-속성) +- [6. 도입 시 1회성 후속 조치](#6-도입-시-1회성-후속-조치) +- [7. 하면 안 되는 것](#7-하면-안-되는-것) +- [8. 확인 방법](#8-확인-방법) + +--- + +## 1. 증상 + +신뢰할 프록시가 하나도 지정되지 않으면 Laravel 은 `X-Forwarded-*` 헤더를 **전부 무시**한다. +그 결과 요청 객체가 평문 HTTP 요청으로 인식되고, 아래가 동시에 발생한다. + +| 증상 | 원인 | +|---|---| +| **사이트가 아예 뜨지 않는다** (백지 + 콘솔에 Mixed Content 차단) | `asset()`/`url()` 이 `http://` 절대 URL 을 만들어, HTTPS 페이지가 요청하는 순간 브라우저가 차단한다. 코어 엔진이 로드되지 못해 사용자·관리자 화면 양쪽이 백지가 된다 | +| 결제·메시지 **webhook 이 전량 403** | 통보 수신 IP 화이트리스트가 프록시 IP 와 대조된다. 입금 통보·발송 리포트를 받지 못해 주문 상태가 영구히 갱신되지 않는다 | +| 본인인증·에스크로 **복귀 실패** | 콜백 절대 URL 이 `http://` 로 만들어져 부모 창과 origin 이 어긋난다 | +| **로그인 시도 제한 붕괴** | 전 방문자가 한 버킷으로 묶여, 한 사람이 한도를 채우면 나머지 전원의 로그인이 막힌다 | +| 활동 로그·약관 동의·주문자 **IP 가 전부 같은 값** | 법적 증빙 가치가 사라지며, 이미 적재된 값은 사후 복구할 수 없다 | +| canonical·og:url·sitemap 이 **전부 `http://`** | 검색엔진에 HTTP 주소가 등록되고, 사이트맵은 데이터베이스에 그대로 저장된다 | +| 세션·영수증·동의 **쿠키에 `Secure` 누락** | HTTPS 사이트인데 쿠키가 평문 전송 가능한 상태로 발급된다 | +| 배송 국가·타임존이 **전 방문자 동일** | IP 기반 판정이 프록시 소재지를 기준으로 삼는다 | + +HTTPS 종단 구성에서는 첫 번째 증상 때문에 곧바로 드러난다. 그러나 **HTTP 전용 사이트가 +프록시 뒤에 있는 구성**(Cloudflare flexible SSL, 사내 로드밸런서)에서는 혼합 콘텐츠가 없어 +**화면이 완전히 정상으로 보이면서** 나머지 증상만 조용히 계속된다. + +--- + +## 2. 설정 + +`.env` 에 `TRUSTED_PROXIES` 를 지정한다. 코어 코드 수정은 필요 없다. + +```dotenv +TRUSTED_PROXIES=* +``` + +| 값 | 의미 | 적합한 구성 | +|---|---|---| +| (미설정) | 아무 프록시도 신뢰하지 않는다 — **기본값** | 앱이 인터넷에 직접 노출된 구성 | +| `*` | 직전 호출 IP(`REMOTE_ADDR`)만 신뢰 | 같은 호스트의 nginx/Apache 프록시, AWS ALB, ngrok | +| `**` | Laravel 내장 미들웨어에서는 `*` 과 **동일하게 동작**한다 | 호환 표기 — 새로 설정할 때 굳이 고를 이유는 없다 | +| `10.0.0.0/8,192.168.1.5` | 콤마로 구분한 IP·CIDR 목록만 신뢰 | 프록시 주소가 고정되어 있는 구성 — **가장 안전** | + +설정값은 `config/trustedproxy.php` 가 읽으며, Laravel 내장 `TrustProxies` 미들웨어가 요청 +시점에 그 값을 참조한다. 미설정 상태가 기본값이므로, 값을 넣지 않은 기존 설치처의 동작은 +바뀌지 않는다. + +### 프록시가 여러 단인 구성 + +`*` 과 `**` 은 **둘 다 직전 호출 IP 하나만** 신뢰한다. 그래서 CloudFront → ALB → 앱 처럼 +프록시가 여러 단이면 `X-Forwarded-For` 체인의 마지막 프록시가 방문자로 기록된다. 실측은 +다음과 같다 (`REMOTE_ADDR = 10.0.0.5`, `X-Forwarded-For: 203.0.113.77, 10.1.1.1, 10.2.2.2`). + +| 설정 | HTTPS 인식 | `$request->ip()` | +|---|---|---| +| 미설정 | `false` | `10.0.0.5` (프록시) | +| `*` | `true` | `10.2.2.2` (마지막 프록시) | +| `**` | `true` | `10.2.2.2` (`*` 과 동일) | +| `10.0.0.5,10.1.1.1,10.2.2.2` | `true` | `203.0.113.77` (실제 방문자) | + +즉 다단계 구성에서는 **체인의 모든 프록시 IP·CIDR 를 나열**해야 최초 클라이언트 IP 가 +해석된다. 스킴 인식(화면 표시)만 필요하다면 `*` 으로 충분하지만, IP 기록·IP 기반 제한·통보 +화이트리스트가 정확해야 한다면 목록 방식을 쓴다. + +--- + +## 3. `*` 과 `**` 의 전제 + +`*` 과 `**` 은 **앱이 프록시를 거치지 않고는 도달할 수 없는 구성에서만** 안전하다. + +앱이 직접 노출된 상태에서 `*` 을 쓰면, 방문자가 요청에 `X-Forwarded-For` 를 직접 붙이는 +것만으로 자기 IP 를 원하는 값으로 바꿀 수 있다. 실측상 직접 접속 클라이언트가 +`X-Forwarded-For: 8.8.8.8` 을 보내면 활동 로그에 `8.8.8.8` 이 그대로 기록된다. 그러면 +IP 기록·IP 기반 차단·통보 화이트리스트가 전부 위조 가능해진다. + +따라서 다음 중 하나를 만족해야 한다. + +- 앱이 사설망에만 있고 프록시만 공인망에 있다 +- 방화벽/보안그룹이 프록시 주소에서 오는 요청만 앱에 도달시킨다 +- 위 둘 다 아니라면 `*` 대신 **IP·CIDR 목록**을 쓴다 + +`**` 는 Laravel 내장 미들웨어에서 `*` 과 같은 코드 경로를 타므로 전제도 같다. 과거 별도 +패키지에서 "모든 프록시 신뢰" 를 뜻했던 표기라 그렇게 이해되기 쉬우나, 현재 동작은 +직전 호출 IP 만 신뢰하는 것이다. + +--- + +## 4. 프록시측에서 함께 해야 할 것 + +신뢰 설정을 켜면 `X-Forwarded-Proto` 뿐 아니라 `X-Forwarded-For`, `X-Forwarded-Host`, +`X-Forwarded-Port`, `X-Forwarded-Prefix`, `X-Forwarded-Aws-Elb` 도 함께 신뢰된다. 그중 `X-Forwarded-Host` 는 +절대 URL 의 호스트를 바꾸므로, 클라이언트가 보낸 값을 프록시가 그대로 흘려보내면 안 된다. + +- 프록시에서 **클라이언트가 보낸 `X-Forwarded-*` 를 제거**하고 프록시가 직접 설정한다 +- 웹서버의 `server_name`(Apache 는 `ServerName`)을 실제 서비스 호스트로 엄격히 둔다 +- 가능하면 `*` 대신 프록시의 IP·CIDR 목록을 지정한다 + +--- + +## 5. 세션 쿠키의 Secure 속성 + +세션 쿠키의 `Secure` 속성은 `SESSION_SECURE_COOKIE` 가 설정되어 있지 않으면 요청 스킴으로 +자동 판정된다. 신뢰 프록시를 지정하면 이 자동 판정이 정상 동작하므로 별도 설정 없이도 +`Secure` 가 붙는다. + +HTTPS 로만 서비스하는 사이트라면 스킴 판정과 무관하게 명시하는 편이 안전하다. + +```dotenv +SESSION_SECURE_COOKIE=true +``` + +--- + +## 6. 도입 시 1회성 후속 조치 + +`TRUSTED_PROXIES` 를 켜면 절대 URL 의 스킴·호스트가 바뀐다. 그 결과 아래는 **자동으로 +낫지 않으므로** 도입 직후에 한 번 처리한다. + +| 항목 | 조치 | +|---|---| +| 이미 발송된 서명 URL (메일 인증 링크, 첨부 다운로드 링크 등) | 서명은 URL 문자열 전체에 대해 이루어지므로 **기존 링크는 전부 만료된 것으로 처리된다.** 재발송이 필요하다 | +| SEO 캐시에 박제된 `http://` canonical | `php artisan seo:clear` | +| 데이터베이스에 저장된 사이트맵의 `http://` 주소 | `php artisan seo:generate-sitemap --sync` | +| 이미 적재된 프록시 IP (활동 로그, 동의 IP, 주문자 IP 등) | **복구 불가.** 원본 방문자 IP 가 남아 있지 않다 | + +--- + +## 7. 하면 안 되는 것 + +`bootstrap/app.php` 의 `withMiddleware` 클로저에서 `env()` 로 값을 읽지 않는다. + +```php +// ❌ 조용히 아무 일도 하지 않는다 +->withMiddleware(function (Middleware $middleware) { + $middleware->trustProxies(at: env('TRUSTED_PROXIES')); +}) +``` + +이 클로저는 `.env` 가 로드되기 전에 평가되므로 `env()` 가 항상 `null` 을 돌려준다. 오류도 +경고도 나지 않고, 설정한 것처럼 보이는데 실제로는 아무 프록시도 신뢰하지 않는 상태가 된다. +`config/trustedproxy.php` 의 `'proxies' => env('TRUSTED_PROXIES')` 만 사용한다. + +`$middleware->trustHosts()` 도 호출하지 않는다. 호출 자체가 모든 설치처에서 Host 검증을 +켜서, 목록에 없는 호스트로 들어온 요청이 400 이 된다. + +--- + +## 8. 확인 방법 + +### 관리자 화면 + +- **대시보드** — 프록시 헤더를 받고 있는데 신뢰 프록시가 설정되지 않았으면 경고 알림이 + 표시된다. HTTP 전용 사이트가 프록시 뒤에 있는 조용한 구성도 이 경고로 드러난다 +- **환경설정 > 고급** — 수신 중인 프록시 헤더, HTTPS 인식 여부, 방문자 IP 로 인식된 값과 + 직전 호출 IP 를 나란히 보여 준다. 두 IP 가 같으면서 프록시 헤더를 받고 있다면 모든 + 방문자가 한 사람으로 기록되고 있는 상태다. 이 화면은 **읽기 전용**이며 값 편집은 `.env` + 로만 한다 + +### 명령줄 + +관리자 화면 자체가 뜨지 않는 상태에서 쓰는 통로다. + +```bash +php artisan trusted-proxy:status +``` + +콘솔에는 요청이 없으므로 설정값만 판정하고, 요청 기반 실측 항목은 `판정 불가` 로 구분해 +표시한다. + +### 직접 확인 + +프록시를 거친 요청의 HTML 에서 코어 엔진 스크립트의 스킴을 본다. + +```bash +curl -s -H 'X-Forwarded-Proto: https' http://127.0.0.1/ | grep 'template-engine' +``` + +`https://` 로 시작하면 정상이고, `http://` 로 시작하면 신뢰 프록시가 적용되지 않은 상태다. + +--- + +## 관련 문서 + +- [routing.md](routing.md) — 자산 URL 생성 규칙 +- [../requirements.md](../requirements.md) — SSL/TLS 요구사항 +- [../../INSTALL.md](../../INSTALL.md) — 설치·프로덕션 설정 diff --git a/docs/requirements.md b/docs/requirements.md index 949eb9df..f08a59d9 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -293,11 +293,16 @@ Composer 설치 방식을 선택할 때만 필요하다. | 환경 | 요구사항 | |------|---------| | 프로덕션 | **HTTPS 필수** | +| 앞단 종단 구성 (ALB·CloudFront·Cloudflare·nginx·ngrok) | HTTPS + `.env` 의 `TRUSTED_PROXIES` 지정 | - 설치 단계에서는 HTTPS 여부를 경고로 안내하며 설치를 차단하지 않는다. 정책상 필수라는 선언은 유지하되, 자동으로 강제되지는 않으므로 운영자가 직접 확인해야 한다 - Laravel Reverb WebSocket도 `wss://` 프로토콜 사용 (`REVERB_SCHEME=https`) -- Sanctum 세션 인증 시 `SESSION_SECURE_COOKIE=true` 설정 권장 +- Sanctum 세션 인증 시 `SESSION_SECURE_COOKIE=true` 설정 권장. 미설정 시 요청 스킴으로 + 자동 판정되므로, 앞단 종단 구성에서는 `TRUSTED_PROXIES` 가 지정되어야 그 판정이 맞는다 +- TLS 를 앞단에서 종단하고 앱에는 HTTP 로 전달하는 구성에서는 `APP_URL` 을 `https://` 로 + 두는 것만으로 부족하다. 신뢰할 프록시를 지정하지 않으면 화면 표시·결제 통보 수신·IP 기록이 + 어긋난다 — [backend/reverse-proxy.md](backend/reverse-proxy.md) --- diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 02686918..87cec833 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -14,6 +14,8 @@ - 확장을 제거할 때 운영자가 넣어 둔 파일의 사본을 보관했다는 안내의 일본어 번역을 추가했습니다 — 모듈·플러그인·템플릿 제거 결과 화면에서 보관 경로가 일본어로 표시됩니다. - 운영자 추가 에셋의 저장·올리기·삭제가 활동 로그에 남을 때 표시되는 문구의 일본어 번역을 추가했습니다. - 초기 화면 파일 생성이 거듭 실패할 때 관리자 대시보드에 표시되는 안내의 일본어 번역을 추가했습니다 — 폴더 권한·디스크 공간·캐시 저장소 중 어느 쪽이 원인인지에 따라 다른 안내가 일본어 로케일에서 표시됩니다. +- 리버스 프록시 뒤에서 신뢰 프록시가 지정되지 않았을 때 관리자 대시보드에 표시되는 안내의 일본어 번역을 추가했습니다. +- 사이트 설정 문제로 화면 구성 파일이 차단됐을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다 — 방문자 쪽 문제가 아니라는 안내가 일본어 로케일에서 표시됩니다. ## [1.0.8] - 2026-08-24 diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php b/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php index 5b6912be..1d26179c 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php @@ -33,5 +33,7 @@ return [ 'reload' => '更新', 'incompatible_title' => 'このブラウザでは画面を表示できません', 'incompatible_message' => 'ブラウザが古いため、サイトを実行できませんでした。ブラウザを最新バージョンに更新するか、別のブラウザでアクセスしてください。', + 'blocked_title' => 'サイトに問題が発生しました', + 'blocked_message' => 'サイトの設定の問題により画面を表示できません。訪問者側の問題ではなく、サイト運営者による対応が必要です。', ], ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php index 9c2832bc..2590d44c 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php @@ -174,4 +174,10 @@ return [ 'source_vendor_missing' => 'ソースディレクトリに vendor がありません。composer install が実行されていない可能性があります。', 'composer_failed_with_output' => 'composer install 実行に失敗しました。:output', ], + + 'trusted_proxy' => [ + 'alert_title' => '信頼するプロキシが設定されていません', + 'alert_message' => 'プロキシヘッダー(:headers)を受信していますが、信頼するプロキシが設定されていないため、すべての訪問者が同じアドレス(:ip)として記録されています。.env に TRUSTED_PROXIES を指定してください。詳細: https://github.com/gnuboard/g7/blob/main/docs/backend/reverse-proxy.md', + ], + ]; 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 af09df17..ef651f75 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 @@ -10,6 +10,7 @@ - 확장 제거 시 표시되는 「운영자 파일 사본 보관」 안내 제목·설명의 일본어 번역을 추가했습니다 — 모듈·플러그인·템플릿 제거 결과 화면에서 보관 경로 안내가 일본어 로케일로 표시됩니다. - 확장 제거가 끝난 뒤 결과 화면 제목(「모듈 제거 완료」·「플러그인 제거 완료」·「템플릿 제거 완료」)의 일본어 번역을 추가했습니다 — 종전에는 결과 화면인데 제목이 「제거 확인」으로 남아 있었습니다. +- 환경설정 > 고급의 리버스 프록시 진단 항목(라벨·상태·안내 문구)의 일본어 번역을 추가했습니다. ## [1.0.7] - 2026-08-24 diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index 1db0d589..6ee538e6 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -1827,7 +1827,24 @@ "pagination_result_cap": "総件数集計の上限", "pagination_result_cap_desc": "この件数までのみ正確に集計します。0の場合は常にすべてを集計します(大規模リストの場合、リストが遅くなる可能性があります)。", "pagination_max_page": "ページ番号の上限", - "pagination_max_page_desc": "アドレスから直接リクエスト可能な最大ページ番号です。0の場合は制限しません。通常の次ページへの移動はこの値に関わらず引き続き可能です。" + "pagination_max_page_desc": "アドレスから直接リクエスト可能な最大ページ番号です。0の場合は制限しません。通常の次ページへの移動はこの値に関わらず引き続き可能です。", + "trusted_proxy": "リバースプロキシ (信頼するプロキシ)", + "trusted_proxy_desc": "HTTPS を前段で処理する構成 (AWS ALB, CloudFront, Cloudflare, nginx) で、接続アドレスと訪問者 IP を正しく認識するための設定です。値は .env の TRUSTED_PROXIES でのみ変更できます。", + "trusted_proxy_status": "診断結果", + "trusted_proxy_status_ok": "正常", + "trusted_proxy_status_warning": "対応が必要", + "trusted_proxy_status_not_applicable": "判定不可", + "trusted_proxy_configured": "TRUSTED_PROXIES の設定値", + "trusted_proxy_not_configured": "未設定 (どのプロキシも信頼しません)", + "trusted_proxy_forwarded_headers": "受信中のプロキシヘッダー", + "trusted_proxy_forwarded_headers_none": "なし", + "trusted_proxy_is_secure": "HTTPS の認識", + "trusted_proxy_is_secure_yes": "HTTPS として認識", + "trusted_proxy_is_secure_no": "HTTP として認識", + "trusted_proxy_client_ip": "訪問者 IP として認識された値", + "trusted_proxy_remote_addr": "直前の呼び出し元 IP (REMOTE_ADDR)", + "trusted_proxy_same_ip_hint": "2 つの値が同じでプロキシヘッダーを受信しています — すべての訪問者が同一人物として記録されている状態です。", + "trusted_proxy_doc_hint": "設定方法と導入時の後続対応:" }, "notification_definitions": { "title": "通知設定", diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json index 12fbc873..7a313e6e 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json @@ -1573,6 +1573,7 @@ "template_info": "テンプレート情報", "templates": "テンプレートリスト", "tokenValidation": "トークン検証", + "trustedProxy": "信頼するプロキシ診断", "user": "ユーザー", "users": "ユーザーリスト", "dashboard_recent_notifications": "ダッシュボード最近の通知", diff --git a/lang/en/errors.php b/lang/en/errors.php index 6b105c96..5e1d4ab9 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -49,5 +49,13 @@ return [ // 새로고침해도 낫지 않으므로 이 분기에서는 새로고침 버튼을 렌더하지 않는다. 'incompatible_title' => 'This browser cannot display the page', 'incompatible_message' => 'Your browser is too old to run this site. Please update it to the latest version, or try a different browser.', + + // The HTTPS page requested an http:// asset and the browser blocked it (running behind a + // reverse proxy without trusted proxies configured, #124). Ordinary visitors see this + // screen, but only the operator can act on it, so the screen carries no server-side + // instructions — the cause and the fix go to the console for the operator. Reloading does + // not help, so no button is rendered and no natural recovery is implied. + 'blocked_title' => 'Something went wrong with this site', + 'blocked_message' => 'The site cannot be displayed because of a configuration problem. This is not caused by anything on your side — the site operator needs to fix it.', ], ]; diff --git a/lang/en/settings.php b/lang/en/settings.php index 51d403ae..be9b258a 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -206,4 +206,19 @@ return [ 'source_vendor_missing' => 'No vendor directory in source. composer install may not have been executed.', 'composer_failed_with_output' => 'composer install failed.:output', ], + + /* + |-------------------------------------------------------------------------- + | Trusted Proxy Diagnostic (#124) + |-------------------------------------------------------------------------- + | + | The value is edited in .env only — the screen shows a read-only diagnostic. + | + */ + + 'trusted_proxy' => [ + 'alert_title' => 'Trusted proxies are not configured', + 'alert_message' => 'Proxy headers (:headers) are being received but no trusted proxy is configured, so every visitor is recorded with the same address (:ip). Set TRUSTED_PROXIES in .env. Details: https://github.com/gnuboard/g7/blob/main/docs/backend/reverse-proxy.md', + ], + ]; diff --git a/lang/ko/errors.php b/lang/ko/errors.php index a38ab33e..2ef8b334 100644 --- a/lang/ko/errors.php +++ b/lang/ko/errors.php @@ -49,5 +49,13 @@ return [ // 새로고침해도 낫지 않으므로 이 분기에서는 새로고침 버튼을 렌더하지 않는다. 'incompatible_title' => '이 브라우저에서는 화면을 표시할 수 없습니다', 'incompatible_message' => '브라우저가 오래되어 사이트를 실행하지 못했습니다. 브라우저를 최신 버전으로 업데이트하거나 다른 브라우저로 접속해 주세요.', + + // HTTPS 페이지가 http:// 자산을 요청해 브라우저가 차단한 경우 (리버스 프록시 뒤에서 + // 신뢰 프록시가 설정되지 않은 상태, #124). 이 화면은 일반 방문자도 보지만 조치할 수 + // 있는 사람은 운영자뿐이므로, 화면에는 서버 설정 지시를 쓰지 않는다. 원인과 조치는 + // 콘솔에 운영자 기준으로 남긴다. 새로고침으로도 낫지 않으므로 버튼을 렌더하지 않고, + // 설정을 고치기 전에는 낫지 않으므로 '잠시 후 다시 시도' 류의 자연 복구도 암시하지 않는다. + 'blocked_title' => '사이트에 문제가 발생했습니다', + 'blocked_message' => '사이트 설정 문제로 화면을 표시할 수 없습니다. 방문자 쪽 문제가 아니며, 사이트 운영자의 조치가 필요합니다.', ], ]; diff --git a/lang/ko/settings.php b/lang/ko/settings.php index 4f884136..209ed5a1 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -206,4 +206,19 @@ return [ 'source_vendor_missing' => '소스 디렉토리에 vendor가 없습니다. composer install이 실행되지 않았을 수 있습니다.', 'composer_failed_with_output' => 'composer install 실행에 실패했습니다.:output', ], + + /* + |-------------------------------------------------------------------------- + | 신뢰 프록시 진단 (#124) + |-------------------------------------------------------------------------- + | + | 값 편집은 .env 전용이다 — 화면에는 읽기 전용 진단만 노출한다. + | + */ + + 'trusted_proxy' => [ + 'alert_title' => '신뢰 프록시가 설정되지 않았습니다', + 'alert_message' => '프록시 헤더(:headers)를 받고 있으나 신뢰 프록시가 설정되지 않아, 모든 방문자가 같은 주소(:ip)로 기록되고 있습니다. .env 에 TRUSTED_PROXIES 를 지정하세요. 상세: https://github.com/gnuboard/g7/blob/main/docs/backend/reverse-proxy.md', + ], + ]; diff --git a/public/install/api/check-configuration.php b/public/install/api/check-configuration.php index c85c5e7c..0918ab07 100644 --- a/public/install/api/check-configuration.php +++ b/public/install/api/check-configuration.php @@ -504,6 +504,13 @@ class ValidationApi /** * HTTPS 사용 여부 확인 + * + * 이 검사는 저장소에서 유일하게 X-Forwarded-Proto 를 실제로 읽는 지점이었다. 그래서 + * 설치 마법사는 "HTTPS 정상" 이라고 보고하는데 그 직후 앱은 http:// 절대 URL 을 만드는 + * 비대칭이 있었다 — 운영자 입장에서 원인 추적이 사실상 불가능한 조합이다 (#124). + * + * 프록시 헤더가 감지되면 신뢰 프록시 설정이 필요하다는 안내를 결과에 덧붙인다. + * 설치를 차단하지는 않는다 (HTTPS 항목이 `required = false` 인 기존 정책과 동일). */ private function checkHttps(): array { @@ -514,15 +521,59 @@ class ValidationApi $isHttps = strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https'; } + $forwardedHeaders = $this->detectForwardedHeaders(); + $behindProxy = $forwardedHeaders !== []; + + $message = $isHttps + ? lang('https_enabled') + : lang('https_disabled'); + + // HTTP 전용 사이트가 프록시 뒤에 있는 구성도 대상이다 — 화면은 정상 렌더되지만 + // 방문자 IP·결제 통보 수신은 그대로 어긋난다. HTTPS 여부로 가르지 않는다. + if ($behindProxy) { + $message .= ' '.lang('https_behind_proxy'); + } + return [ 'required' => false, // HTTPS는 선택 사항 'enabled' => $isHttps, - 'message' => $isHttps - ? lang('https_enabled') - : lang('https_disabled'), + 'behind_proxy' => $behindProxy, + 'forwarded_headers' => $forwardedHeaders, + 'message' => $message, ]; } + /** + * 수신 중인 X-Forwarded-* 계열 헤더 이름 목록을 반환합니다 (#124). + * + * 설치 마법사는 순수 PHP 영역이라 Laravel 헬퍼를 쓸 수 없다. 목록은 + * App\Support\TrustedProxyDiagnostic::FORWARDED_HEADERS 와 같은 집합을 유지한다. + * + * @return array 수신 중인 헤더 이름 목록 + */ + private function detectForwardedHeaders(): array + { + $headers = [ + 'X-Forwarded-For' => 'HTTP_X_FORWARDED_FOR', + 'X-Forwarded-Proto' => 'HTTP_X_FORWARDED_PROTO', + 'X-Forwarded-Host' => 'HTTP_X_FORWARDED_HOST', + 'X-Forwarded-Port' => 'HTTP_X_FORWARDED_PORT', + 'X-Forwarded-Prefix' => 'HTTP_X_FORWARDED_PREFIX', + 'X-Forwarded-Aws-Elb' => 'HTTP_X_FORWARDED_AWS_ELB', + 'Forwarded' => 'HTTP_FORWARDED', + ]; + + $present = []; + + foreach ($headers as $name => $serverKey) { + if (isset($_SERVER[$serverKey])) { + $present[] = $name; + } + } + + return $present; + } + /** * OPcache 활성화 여부 검증 (권장 사항 — 설치를 차단하지 않음) * diff --git a/public/install/lang/en.php b/public/install/lang/en.php index 3418821d..1644139a 100644 --- a/public/install/lang/en.php +++ b/public/install/lang/en.php @@ -235,6 +235,7 @@ return [ // HTTPS Messages 'https_enabled' => 'HTTPS is enabled (recommended)', 'https_disabled' => 'HTTPS is disabled. We recommend using HTTPS for security.', + 'https_behind_proxy' => 'This site appears to be running behind a reverse proxy. Unless TRUSTED_PROXIES is set in .env after installation, the site address and visitor IP will be recognized from the proxy instead of the real visitor. (https://github.com/gnuboard/g7/blob/main/docs/backend/reverse-proxy.md)', // OPcache Messages 'opcache_enabled' => 'OPcache is enabled (recommended)', diff --git a/public/install/lang/ko.php b/public/install/lang/ko.php index 9ec01048..2ba086dd 100644 --- a/public/install/lang/ko.php +++ b/public/install/lang/ko.php @@ -235,6 +235,7 @@ return [ // HTTPS 메시지 'https_enabled' => 'HTTPS가 활성화되어 있습니다. (권장)', 'https_disabled' => 'HTTPS가 비활성화되어 있습니다. 보안을 위해 HTTPS 사용을 권장합니다.', + 'https_behind_proxy' => '리버스 프록시 뒤에서 구동 중인 것으로 보입니다. 설치 후 .env 에 TRUSTED_PROXIES 를 지정하지 않으면 접속 주소와 방문자 IP 가 프록시 기준으로 인식됩니다. (https://github.com/gnuboard/g7/blob/main/docs/backend/reverse-proxy.md)', // OPcache 메시지 'opcache_enabled' => 'OPcache가 활성화되어 있습니다. (권장)', diff --git a/resources/views/dev-dashboard.blade.php b/resources/views/dev-dashboard.blade.php index ac07827e..a33988b2 100644 --- a/resources/views/dev-dashboard.blade.php +++ b/resources/views/dev-dashboard.blade.php @@ -1083,6 +1083,10 @@ if (isset($_GET['ajax_action'])) { 정적 게시 점검 (ext-static:status) + +); + +const TestA: React.FC<{ id?: string; className?: string; href?: string; children?: React.ReactNode; text?: string }> = ({ + id, + className, + href, + children, + text, +}) => ( + + {children || text} + +); + +const TestIcon: React.FC<{ id?: string; name?: string; className?: string }> = ({ id, name, className }) => ( + +); + +const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}; + +const TestToast: React.FC = () => null; +const TestModalRoot: React.FC = () => null; + +function setupTestRegistry(): ComponentRegistry { + const registry = ComponentRegistry.getInstance(); + + (registry as any).registry = { + Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } }, + Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } }, + P: { component: TestP, metadata: { name: 'P', type: 'basic' } }, + H1: { component: TestH1, metadata: { name: 'H1', type: 'basic' } }, + H2: { component: TestH2, metadata: { name: 'H2', type: 'basic' } }, + Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } }, + A: { component: TestA, metadata: { name: 'A', type: 'basic' } }, + Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } }, + Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } }, + Toast: { component: TestToast, metadata: { name: 'Toast', type: 'composite' } }, + ModalRoot: { component: TestModalRoot, metadata: { name: 'ModalRoot', type: 'composite' } }, + }; + + return registry; +} + +function stripPermissions(nodes: any): void { + if (Array.isArray(nodes)) { + nodes.forEach(stripPermissions); + return; + } + if (!nodes || typeof nodes !== 'object') return; + delete nodes.permissions; + for (const key of Object.keys(nodes)) { + if (key === 'permissions') continue; + stripPermissions(nodes[key]); + } +} + +/** 실제 레이아웃 파일 로드 (extends 제거 — 독립 렌더) */ +function loadDashboardLayout(): any { + const file = path.resolve(__dirname, '../../layouts/admin_dashboard.json'); + const layout = JSON.parse(fs.readFileSync(file, 'utf8')); + + delete layout.extends; + layout.components = layout.slots?.content ?? []; + delete layout.slots; + stripPermissions(layout.components); + + return layout; +} + +/** + * 알림 4종 — 심각도(type)가 **배치**를, 의미(subtype)가 **액션**을 각각 가른다. + * + * 배열 순서를 섞어 둔다: 필터링 후 인덱스는 버킷별로 다시 매겨지므로, 원본 순서를 그대로 + * 가정하는 구현이면 여기서 드러난다. + */ +const ALERTS = [ + // (1) 기존 warning — 상단, amber 유지 (회귀 없음) + { type: 'warning', subtype: 'incompatible_core', icon: 'exclamation', title: '비호환', message: 'm1' }, + // (2) info + 복구 액션 — 하단, blue 유지 + 버튼 유지 + { + type: 'info', + subtype: 'recovery_available', + icon: 'check-circle', + title: '복구 가능', + message: 'm2', + recover_endpoint: '/api/admin/x/1/recover', + extension_type: 'module', + identifier: 'm1', + }, + // (3) subtype 목록에 없던 warning — 종전에는 하단에 회색으로 묻혀 있었다 + { type: 'warning', subtype: 'static_publish_write_failed', icon: 'exclamation-triangle', title: '게시 실패', message: 'm3' }, + // (4) 신규 warning — (3) 과 같은 경로 + { type: 'warning', subtype: 'trusted_proxy_missing', icon: 'exclamation-triangle', title: '신뢰 프록시 미설정', message: 'm4' }, +]; + +/** 알림 목록만 채우고 나머지 데이터소스는 비워 렌더한다 */ +async function renderAlerts(alerts: any[]) { + const layout = loadDashboardLayout(); + const testUtils = createLayoutTest(layout, { componentRegistry: setupTestRegistry() }); + const empty = { data: { data: [], current_page: 1, last_page: 1, per_page: 5, total: 0 } }; + + testUtils.mockApi('dashboard_stats', { response: { data: {} } }); + testUtils.mockApi('dashboard_activities', { response: { data: [] } }); + testUtils.mockApi('dashboard_modules', { response: empty }); + testUtils.mockApi('dashboard_plugins', { response: empty }); + testUtils.mockApi('dashboard_templates', { response: empty }); + testUtils.mockApi('dashboard_recent_notifications', { response: { data: [] } }); + testUtils.mockApi('dashboard_alerts', { response: { data: alerts } }); + + await testUtils.render(); + + return testUtils; +} + +/** + * 지정 영역의 인덱스 i 알림 행과 아이콘 className 을 읽는다. + * + * @param region 'top'(상단 배너) 또는 'bottom'(하단 시스템 알림 카드) + * @param i 그 영역 안에서의 인덱스 (필터링 후 다시 매겨진다) + * @returns 행·아이콘의 className + */ +function readAlertClasses(region: 'top' | 'bottom', i: number): { row: string; icon: string } { + const prefix = region === 'top' ? 'top_alert' : 'alert'; + const row = document.querySelector('#' + prefix + '_item_' + i); + const icon = document.querySelector('#' + prefix + '_icon_' + i); + + return { + row: row?.getAttribute('class') ?? '', + icon: icon?.getAttribute('class') ?? '', + }; +} + +describe('대시보드 알림 심각도별 배치와 색상', () => { + beforeEach(() => { + setupTestRegistry(); + }); + + it('경고 등급 알림은 subtype 과 무관하게 상단 배너에 amber + 테두리로 렌더된다', async () => { + const testUtils = await renderAlerts(ALERTS); + + // 경고 3건이 상단에 모두 쌓인다 — 하나가 다른 하나를 덮지 않는다 + const banner = document.querySelector('#dashboard_alert_banners'); + expect(banner, '상단 배너 영역이 렌더되지 않았다').not.toBeNull(); + expect(banner!.querySelectorAll('[id^="top_alert_item_"]').length).toBe(3); + + for (const i of [0, 1, 2]) { + const { row, icon } = readAlertClasses('top', i); + + expect(row, 'top_alert_item_' + i + ' 행 배경').toContain('bg-amber-50'); + expect(row, 'top_alert_item_' + i + ' 행 테두리').toContain('border-amber-200'); + expect(row, 'top_alert_item_' + i + ' 는 회색으로 떨어지면 안 된다').not.toContain('bg-gray-50'); + expect(icon, 'top_alert_icon_' + i + ' 아이콘 색').toContain('text-amber-600'); + } + + // 제목 3건이 모두 살아 있다 (덮어쓰기 없음) + for (const title of ['비호환', '게시 실패', '신뢰 프록시 미설정']) { + expect(screen.getByText(title), title + ' 이 사라졌다').toBeTruthy(); + } + + testUtils.cleanup(); + }); + + it('경고가 아닌 알림만 하단 카드에 남는다 — 같은 알림이 두 곳에 중복 노출되지 않는다', async () => { + const testUtils = await renderAlerts(ALERTS); + + // 하단에는 info 1건만 (인덱스는 필터 후 0 부터 다시 매겨진다) + const list = document.querySelector('#alerts_list'); + expect(list).not.toBeNull(); + expect(list!.querySelectorAll('[id^="alert_item_"]').length).toBe(1); + + const { row, icon } = readAlertClasses('bottom', 0); + expect(row).toContain('bg-blue-50'); + expect(row).toContain('border-blue-200'); + expect(icon).toContain('text-blue-600'); + + // 상단에 올라간 경고가 하단에 다시 나타나지 않는다 + expect(screen.queryAllByText('신뢰 프록시 미설정').length).toBe(1); + + testUtils.cleanup(); + }); + + it('경고만 있으면 하단 카드 자체가 뜨지 않는다', async () => { + const testUtils = await renderAlerts(ALERTS.filter((a) => a.type === 'warning')); + + expect(document.querySelector('#dashboard_alert_banners')).not.toBeNull(); + expect(document.querySelector('#system_alerts_card'), '빈 시스템 알림 카드가 남았다').toBeNull(); + + testUtils.cleanup(); + }); + + it('경고가 없으면 상단 배너 영역이 뜨지 않는다', async () => { + const testUtils = await renderAlerts(ALERTS.filter((a) => a.type !== 'warning')); + + expect(document.querySelector('#system_alerts_card')).not.toBeNull(); + expect(document.querySelector('#dashboard_alert_banners'), '빈 배너 영역이 남았다').toBeNull(); + + testUtils.cleanup(); + }); + + it('심각도가 없는 알림은 하단에 회색으로 떨어진다', async () => { + const testUtils = await renderAlerts([ + { subtype: 'unknown_thing', icon: 'info-circle', title: '미지정', message: 'm' }, + ]); + + const { row, icon } = readAlertClasses('bottom', 0); + + expect(row).toContain('bg-gray-50'); + expect(row).not.toContain('bg-amber-50'); + expect(icon).toContain('text-gray-500'); + expect(document.querySelector('#dashboard_alert_banners')).toBeNull(); + + testUtils.cleanup(); + }); + + it('복구 버튼은 subtype 판정을 유지한다 — info 라고 모두 뜨지 않는다', async () => { + const testUtils = await renderAlerts([ + // recovery_available 이 아닌 info — 복구 버튼이 뜨면 안 된다 + { type: 'info', subtype: 'something_else', icon: 'info-circle', title: '일반 안내', message: 'm' }, + // recovery_available — 복구 버튼이 떠야 한다 + { + type: 'info', + subtype: 'recovery_available', + icon: 'check-circle', + title: '복구 가능', + message: 'm', + recover_endpoint: '/api/admin/x/1/recover', + extension_type: 'module', + identifier: 'm1', + }, + ]); + + expect(document.querySelector('#alert_recover_button_0'), 'recovery_available 이 아닌 info 에 복구 버튼이 떴다').toBeNull(); + expect(document.querySelector('#alert_recover_button_1'), 'recovery_available 의 복구 버튼이 사라졌다').not.toBeNull(); + + testUtils.cleanup(); + }); + + it('상단으로 올라간 경고도 닫기 버튼을 그대로 갖는다 — 배치가 기능을 빼앗지 않는다', async () => { + const testUtils = await renderAlerts([ + { + type: 'warning', + subtype: 'incompatible_core', + icon: 'exclamation', + title: '비호환', + message: 'm', + extension_type: 'plugin', + identifier: 'p1', + }, + ]); + + const dismiss = document.querySelector('#top_alert_dismiss_button_0'); + expect(dismiss, '상단 배너에서 닫기 버튼이 사라졌다').not.toBeNull(); + + testUtils.cleanup(); + }); +}); diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-dashboard-iteration-id-unique.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-dashboard-iteration-id-unique.test.tsx index 1c820c53..d74618e7 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-dashboard-iteration-id-unique.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-dashboard-iteration-id-unique.test.tsx @@ -13,7 +13,8 @@ * - 플러그인 카드 (plugin_item / info / name / version / badge) * - 템플릿 카드 (template_item / info / name / version / badge) * - 최근 알림 (recent_notification_item / dot / content / subject / recipient / time) - * - 시스템 알림 (alert_item / icon / content / title / message / time / actions / buttons) + * - 시스템 알림 — 하단 카드 (alert_item / icon / content / title / message / time / actions / buttons) + * - 시스템 알림 — 상단 경고 배너 (top_alert_item / ... , 경고 2건 이상 적재) * * 수정 전(정적 id): 각 영역 row 2개 이상 mock → 동일 id 가 2회 이상 → 테스트 fail. * 수정 후(동적 id): id 에 {{$idx}} 접미 → row 별 고유 → 중복 0 → green. @@ -239,6 +240,7 @@ describe('대시보드 iteration HTML id 유일성', () => { response: { data: [ { + type: 'info', subtype: 'recovery_available', icon: 'check-circle', title: '복구 가능 1', @@ -248,7 +250,9 @@ describe('대시보드 iteration HTML id 유일성', () => { extension_type: 'module', identifier: 'm1', }, + // 경고 2건 — 상단 배너 영역이 여러 건을 쌓을 때도 id 가 겹치지 않아야 한다. { + type: 'warning', subtype: 'incompatible_core', icon: 'exclamation', title: '비호환 1', @@ -257,6 +261,14 @@ describe('대시보드 iteration HTML id 유일성', () => { extension_type: 'plugin', identifier: 'p1', }, + { + type: 'warning', + subtype: 'trusted_proxy_missing', + icon: 'exclamation-triangle', + title: '신뢰 프록시 미설정', + message: '메시지 3', + time: '1분 전', + }, ], }, }); diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-trusted-proxy-diagnostic.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-trusted-proxy-diagnostic.test.tsx new file mode 100644 index 00000000..1160cf31 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-trusted-proxy-diagnostic.test.tsx @@ -0,0 +1,250 @@ +/** + * @file admin-settings-trusted-proxy-diagnostic.test.tsx + * @description 환경설정 > 고급 의 리버스 프록시 진단 블록 (#124 W8 ②) + * + * 이 블록은 **읽기 전용**이다. 값 편집을 화면에 두지 않는 이유는 둘이다 — + * ① 프록시 뒤에서는 관리자 화면 자체가 뜨지 않는 것이 이 결함이므로 정작 필요한 + * 순간에 그 화면에 도달할 수 없다(잠금 역설), + * ② 웹에서 편집 가능해지면 관리자 계정 탈취가 곧 X-Forwarded-For 위조 경로가 된다. + * + * 그래서 입력 컨트롤이 0개라는 사실이 계약이고, 이 테스트가 그것을 잠근다. 값 자체는 + * 서버 진단(App\Support\TrustedProxyDiagnostic)이 유일한 판정자이므로 화면은 그것을 + * 표시만 한다 — 화면에서 조건을 다시 쓰면 서버와 다른 답을 내놓게 된다. + * + * 합성 레이아웃이 아니라 **실제 _tab_advanced.json** 을 읽는다. + */ + +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { createLayoutTest } from '@core/template-engine/__tests__/utils/layoutTestUtils'; +import { ComponentRegistry } from '@core/template-engine/ComponentRegistry'; + +const advancedPartial = JSON.parse( + readFileSync(resolve(__dirname, '../../layouts/partials/admin_settings/_tab_advanced.json'), 'utf-8') +); + +const TestDiv: React.FC = ({ id, className, children }) => ( +
+ {children} +
+); +const TestSpan: React.FC = ({ id, className, children, text }) => ( + + {children || text} + +); +const TestH3: React.FC = ({ id, className, children, text }) => ( +

+ {children || text} +

+); +const TestInput: React.FC = ({ name, type }) => ; +const TestToggle: React.FC = ({ name }) => ; +const TestSelect: React.FC = ({ name }) =>