Merge pull request from gnuboard:HeuJung/issue622
feat(core): 리버스 프록시 신뢰 설정 지원 및 미설정 진단
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<!-- AUTO-GENERATED-START: docs-quick-reference -->
|
||||
|
||||
### 백엔드 [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 은 `<i>` 글리프라 박스 크기가 곧 `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건).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+18
@@ -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` 의 권한을 임의로 변경하지 않으므로, 운영자가 환경에 맞춰 직접 강화 권한을 적용할 수 있습니다.
|
||||
|
||||
+1
-1
@@ -518,8 +518,8 @@ cp .env.example .env
|
||||
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
|
||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
|
||||
@@ -532,8 +532,8 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
|
||||
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
|
||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\TrustedProxyDiagnostic;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 신뢰 프록시 설정 상태 점검 커맨드 (#124)
|
||||
*
|
||||
* 프록시 뒤에서 신뢰 프록시가 설정되지 않으면 관리자 화면 자체가 뜨지 않을 수 있다. 화면으로
|
||||
* 확인할 수 없는 바로 그 상태에서 쓰는 통로가 이 커맨드다.
|
||||
*
|
||||
* 콘솔에는 요청이 없으므로 `isSecure()`·`ip()` 같은 **실측 항목은 판정할 수 없다.** 그 항목은
|
||||
* `판정 불가` 로 구분해 표시하고, 값이 비어 있는 것과 뭉뚱그리지 않는다 — 둘을 같은 문구로
|
||||
* 내보내면 운영자가 "설정이 비었다" 로 오독한다.
|
||||
*
|
||||
* 종료 코드: 설정이 없으면 1. 비-0 은 "커맨드 실행 실패" 가 아니라 **점검 대상 신호**다.
|
||||
* 다만 프록시를 쓰지 않는 직접 노출 구성에서는 미설정이 정상이므로, 그 사실을 출력에 명시한다.
|
||||
*
|
||||
* @since 7.0.10
|
||||
*/
|
||||
class TrustedProxyStatusCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'trusted-proxy:status';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '리버스 프록시 신뢰 설정(TRUSTED_PROXIES) 상태를 점검합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int 설정되어 있으면 0, 미설정이면 1
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
// 콘솔에는 요청이 없다 — null 을 넘겨 실측 축을 not_applicable 로 받는다.
|
||||
$diagnostic = TrustedProxyDiagnostic::forRequest(null);
|
||||
|
||||
$this->line('');
|
||||
$this->line('<options=bold>리버스 프록시 신뢰 설정 상태</>');
|
||||
$this->line('');
|
||||
|
||||
$this->line(' 설정 파일 : config/trustedproxy.php');
|
||||
$this->line(' 환경변수 : TRUSTED_PROXIES');
|
||||
|
||||
if ($diagnostic['trusted_configured']) {
|
||||
$this->line(' 현재 값 : '.$diagnostic['configured_proxies']);
|
||||
} else {
|
||||
$this->line(' 현재 값 : <comment>미설정 (아무 프록시도 신뢰하지 않음)</comment>');
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
$this->line(' <options=bold>요청 기반 실측</>');
|
||||
$this->line(' 수신 전달 헤더 : <comment>판정 불가 (콘솔에는 요청이 없습니다)</comment>');
|
||||
$this->line(' HTTPS 인식 : <comment>판정 불가</comment>');
|
||||
$this->line(' 방문자 IP : <comment>판정 불가</comment>');
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 캐시를 정리합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Support\TrustedProxyDiagnostic;
|
||||
|
||||
/**
|
||||
* 신뢰 프록시 미설정 경고 리스너 (#124)
|
||||
*
|
||||
* 프록시 뒤에서 신뢰 프록시가 설정되지 않은 상태는 HTTPS 종단 구성에서는 화면 백지로 즉시
|
||||
* 드러나지만, HTTP 전용 사이트가 프록시 뒤에 있으면 **화면이 완전히 정상이다.** 그 상태에서도
|
||||
* webhook 403 · IP 기록 왜곡 · 로그인 제한 붕괴는 그대로 발생한다. 이 알림이 그 조용한 구성을
|
||||
* 덮는 유일한 통로다.
|
||||
*
|
||||
* 대시보드 레이아웃·컨트롤러·API 스키마는 건드리지 않는다 — 기존 `core.dashboard.alerts`
|
||||
* 필터 훅에 항목을 얹기만 한다(선례: StaticPublishFailureAlertListener).
|
||||
*
|
||||
* @since 7.0.10
|
||||
*/
|
||||
class TrustedProxyAlertListener implements HookListenerInterface
|
||||
{
|
||||
/**
|
||||
* 구독할 훅과 메서드 매핑 반환.
|
||||
*
|
||||
* @return array 훅 매핑 배열
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.dashboard.alerts' => [
|
||||
'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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 신뢰 프록시 설정 진단 (#124)
|
||||
*
|
||||
* 리버스 프록시 뒤에서 신뢰 프록시가 설정되지 않으면 요청 객체가 프록시를 방문자로 인식한다.
|
||||
* HTTPS 종단 구성에서는 화면이 백지가 되어 곧바로 드러나지만, HTTP 전용 사이트가 프록시 뒤에
|
||||
* 있는 구성(Cloudflare flexible SSL, 사내 LB)에서는 **화면이 완전히 정상 렌더된다.** 그 상태로
|
||||
* webhook 403 · IP 기록 왜곡 · 로그인 제한 붕괴 · GeoIP 오판정이 계속되며, 사용자도 운영자도
|
||||
* 이상을 감지할 단서가 없다. 능동 경고가 없으면 영구 미발견이다.
|
||||
*
|
||||
* 그래서 판정식은 "HTTPS 인식 실패" 가 아니라 다음이다:
|
||||
*
|
||||
* X-Forwarded-* 헤더를 수신 중 AND config('trustedproxy.proxies') 가 비어 있음
|
||||
*
|
||||
* 이 판정은 여기 한 곳에서만 계산하고 네 노출면(대시보드 알림 · 환경설정 고급 탭 ·
|
||||
* 설치 마법사 · Artisan 커맨드)이 그것을 소비한다. 면마다 조건을 다시 쓰면 한 곳만
|
||||
* 어긋나도 서로 다른 답을 내놓는다.
|
||||
*
|
||||
* @since 7.0.10
|
||||
*/
|
||||
class TrustedProxyDiagnostic
|
||||
{
|
||||
/** 프록시 헤더 수신 중인데 신뢰 설정이 없음 — 조치가 필요한 상태 */
|
||||
public const STATUS_WARNING = 'warning';
|
||||
|
||||
/** 정상 — 설정이 되어 있거나, 프록시 헤더가 아예 없는 직접 노출 구성 */
|
||||
public const STATUS_OK = 'ok';
|
||||
|
||||
/** 요청이 없는 맥락(콘솔 등) — "값 없음" 이 아니라 "판정 불가" 다 */
|
||||
public const STATUS_NOT_APPLICABLE = 'not_applicable';
|
||||
|
||||
/**
|
||||
* Laravel 내장 TrustProxies 미들웨어가 신뢰하는 전달 헤더 목록.
|
||||
*
|
||||
* 미들웨어는 PROTO·FOR·HOST·PORT·PREFIX·AWS_ELB 를 함께 신뢰하므로, 그중 하나라도
|
||||
* 수신 중이면 앞단에 무언가가 있다는 신호다.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
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<int, string>, 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<int, string> 수신 중인 헤더 이름 목록
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Trusted Proxies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 리버스 프록시(AWS ALB, CloudFront, Cloudflare, nginx/Apache, ngrok) 뒤에서
|
||||
| 구동될 때 신뢰할 프록시를 지정한다. Laravel 내장 TrustProxies 미들웨어가
|
||||
| 요청 시점에 이 값을 폴백으로 읽어 X-Forwarded-* 헤더 신뢰 여부를 결정한다
|
||||
| (Illuminate\Http\Middleware\TrustProxies::setTrustedProxyIpAddresses()).
|
||||
|
|
||||
| null (미설정) : 아무 프록시도 신뢰하지 않음 — 기본값, 기존 설치처 동작 불변
|
||||
| '*' : 직전 호출 IP(REMOTE_ADDR)만 신뢰 — 동일 호스트 프록시·ALB
|
||||
| '**' : 내장 미들웨어에서는 '*' 과 동일하게 동작한다(호환 표기)
|
||||
| 'IP,IP/CIDR' : 콤마 구분 목록만 신뢰
|
||||
|
|
||||
| 프록시가 여러 단인 구성(CloudFront → ALB 등)에서는 '*' 도 '**' 도 부족하다.
|
||||
| 둘 다 직전 호출 IP 하나만 신뢰하므로 X-Forwarded-For 체인의 마지막 프록시가
|
||||
| 방문자로 기록된다. 그 구성에서는 체인의 모든 프록시 IP·CIDR 를 나열해야
|
||||
| 최초 클라이언트 IP 가 해석된다 (실측 확인: docs/backend/reverse-proxy.md).
|
||||
|
|
||||
| '*' 은 앱이 프록시를 거치지 않고는 도달 불가한 구성에서만 안전하다.
|
||||
| 앱이 직접 노출된 상태에서 사용하면 방문자가 X-Forwarded-For 를 위조해
|
||||
| 기록 IP 와 IP 기반 제한을 조작할 수 있다. 상세: docs/backend/reverse-proxy.md
|
||||
|
|
||||
*/
|
||||
|
||||
'proxies' => env('TRUSTED_PROXIES'),
|
||||
|
||||
];
|
||||
+3
-2
@@ -9,7 +9,7 @@
|
||||
|
||||
| 카테고리 | 문서 수 | 링크 상태 |
|
||||
|----------|---------|----------|
|
||||
| [백엔드](backend/) | 36개 | 정상 |
|
||||
| [백엔드](backend/) | 37개 | 정상 |
|
||||
| [프론트엔드](frontend/) | 51개 | 정상 |
|
||||
| [확장 시스템](extension/) | 31개 | 정상 |
|
||||
| 공통 | 20개 | 정상 |
|
||||
@@ -125,7 +125,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
|
||||
<!-- AUTO-GENERATED-START: docs-readme-full-list -->
|
||||
## 카테고리별 전체 문서 목록
|
||||
|
||||
### 백엔드 (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) |
|
||||
|
||||
@@ -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... |
|
||||
|
||||
@@ -200,7 +200,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
|
||||
## 코어 API 레퍼런스
|
||||
|
||||
<!-- @generated:start:api-readme-index -->
|
||||
- **문서 수**: 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 |
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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
|
||||
<!-- @generated:start: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`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
리버스 프록시 뒤에서 구동 중인지, 그리고 신뢰할 프록시가 지정되어 있는지를 진단합니다. **읽기 전용이며 대응하는 쓰기 엔드포인트가 없습니다** — 값은 `.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
|
||||
<!-- @generated:start:api.admin.settings.test-driver -->
|
||||
- **라우트명**: `api.admin.settings.test-driver`
|
||||
|
||||
@@ -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) — 설치·프로덕션 설정
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
- 확장을 제거할 때 운영자가 넣어 둔 파일의 사본을 보관했다는 안내의 일본어 번역을 추가했습니다 — 모듈·플러그인·템플릿 제거 결과 화면에서 보관 경로가 일본어로 표시됩니다.
|
||||
- 운영자 추가 에셋의 저장·올리기·삭제가 활동 로그에 남을 때 표시되는 문구의 일본어 번역을 추가했습니다.
|
||||
- 초기 화면 파일 생성이 거듭 실패할 때 관리자 대시보드에 표시되는 안내의 일본어 번역을 추가했습니다 — 폴더 권한·디스크 공간·캐시 저장소 중 어느 쪽이 원인인지에 따라 다른 안내가 일본어 로케일에서 표시됩니다.
|
||||
- 리버스 프록시 뒤에서 신뢰 프록시가 지정되지 않았을 때 관리자 대시보드에 표시되는 안내의 일본어 번역을 추가했습니다.
|
||||
- 사이트 설정 문제로 화면 구성 파일이 차단됐을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다 — 방문자 쪽 문제가 아니라는 안내가 일본어 로케일에서 표시됩니다.
|
||||
|
||||
## [1.0.8] - 2026-08-24
|
||||
|
||||
|
||||
@@ -33,5 +33,7 @@ return [
|
||||
'reload' => '更新',
|
||||
'incompatible_title' => 'このブラウザでは画面を表示できません',
|
||||
'incompatible_message' => 'ブラウザが古いため、サイトを実行できませんでした。ブラウザを最新バージョンに更新するか、別のブラウザでアクセスしてください。',
|
||||
'blocked_title' => 'サイトに問題が発生しました',
|
||||
'blocked_message' => 'サイトの設定の問題により画面を表示できません。訪問者側の問題ではなく、サイト運営者による対応が必要です。',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
- 확장 제거 시 표시되는 「운영자 파일 사본 보관」 안내 제목·설명의 일본어 번역을 추가했습니다 — 모듈·플러그인·템플릿 제거 결과 화면에서 보관 경로 안내가 일본어 로케일로 표시됩니다.
|
||||
- 확장 제거가 끝난 뒤 결과 화면 제목(「모듈 제거 완료」·「플러그인 제거 완료」·「템플릿 제거 완료」)의 일본어 번역을 추가했습니다 — 종전에는 결과 화면인데 제목이 「제거 확인」으로 남아 있었습니다.
|
||||
- 환경설정 > 고급의 리버스 프록시 진단 항목(라벨·상태·안내 문구)의 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.0.7] - 2026-08-24
|
||||
|
||||
|
||||
@@ -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": "通知設定",
|
||||
|
||||
@@ -1573,6 +1573,7 @@
|
||||
"template_info": "テンプレート情報",
|
||||
"templates": "テンプレートリスト",
|
||||
"tokenValidation": "トークン検証",
|
||||
"trustedProxy": "信頼するプロキシ診断",
|
||||
"user": "ユーザー",
|
||||
"users": "ユーザーリスト",
|
||||
"dashboard_recent_notifications": "ダッシュボード最近の通知",
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -49,5 +49,13 @@ return [
|
||||
// 새로고침해도 낫지 않으므로 이 분기에서는 새로고침 버튼을 렌더하지 않는다.
|
||||
'incompatible_title' => '이 브라우저에서는 화면을 표시할 수 없습니다',
|
||||
'incompatible_message' => '브라우저가 오래되어 사이트를 실행하지 못했습니다. 브라우저를 최신 버전으로 업데이트하거나 다른 브라우저로 접속해 주세요.',
|
||||
|
||||
// HTTPS 페이지가 http:// 자산을 요청해 브라우저가 차단한 경우 (리버스 프록시 뒤에서
|
||||
// 신뢰 프록시가 설정되지 않은 상태, #124). 이 화면은 일반 방문자도 보지만 조치할 수
|
||||
// 있는 사람은 운영자뿐이므로, 화면에는 서버 설정 지시를 쓰지 않는다. 원인과 조치는
|
||||
// 콘솔에 운영자 기준으로 남긴다. 새로고침으로도 낫지 않으므로 버튼을 렌더하지 않고,
|
||||
// 설정을 고치기 전에는 낫지 않으므로 '잠시 후 다시 시도' 류의 자연 복구도 암시하지 않는다.
|
||||
'blocked_title' => '사이트에 문제가 발생했습니다',
|
||||
'blocked_message' => '사이트 설정 문제로 화면을 표시할 수 없습니다. 방문자 쪽 문제가 아니며, 사이트 운영자의 조치가 필요합니다.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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<int, string> 수신 중인 헤더 이름 목록
|
||||
*/
|
||||
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 활성화 여부 검증 (권장 사항 — 설치를 차단하지 않음)
|
||||
*
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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가 활성화되어 있습니다. (권장)',
|
||||
|
||||
@@ -1083,6 +1083,10 @@ if (isset($_GET['ajax_action'])) {
|
||||
<span>정적 게시 점검</span>
|
||||
<span class="text-[10px] opacity-60">(ext-static:status)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('trusted-proxy:status')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>신뢰 프록시 점검</span>
|
||||
<span class="text-[10px] opacity-60">(trusted-proxy:status)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('seo:prune-stats')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>SEO 캐시 통계 정리</span>
|
||||
<span class="text-[10px] opacity-60">(seo:prune-stats)</span>
|
||||
|
||||
@@ -114,7 +114,25 @@
|
||||
if (attempt >= MAX_ATTEMPTS) {
|
||||
console.error(LABEL + ' Failed to load after ' + MAX_ATTEMPTS + ' attempts: ' + src);
|
||||
bootstrap.failed = true;
|
||||
bootstrap.renderFallback('network');
|
||||
|
||||
// HTTPS 페이지가 http:// 자산을 요청하면 브라우저가 요청을 발신조차 하지 않고
|
||||
// 차단한다(Mixed Content). 회선은 멀쩡하고 새로고침으로도 낫지 않으므로
|
||||
// 'network' 안내는 두 겹의 거짓 안내가 된다 (#124).
|
||||
var blocked = location.protocol === 'https:' && absoluteUrl(src).indexOf('http://') === 0;
|
||||
|
||||
if (blocked) {
|
||||
// 화면에서 뺀 원인·조치는 여기서 운영자에게 도달해야 한다. 기존 로그와 같은
|
||||
// 계열로 영문 통일 (개발자 대상).
|
||||
console.error(LABEL + ' Blocked as mixed content: ' + src);
|
||||
console.error(
|
||||
LABEL + ' This HTTPS page requested an http:// asset, so the browser refused to send'
|
||||
+ ' the request. The app is likely behind a TLS-terminating reverse proxy without'
|
||||
+ ' trusted proxies configured. Set TRUSTED_PROXIES in .env'
|
||||
+ ' (see https://github.com/gnuboard/g7/blob/main/docs/backend/reverse-proxy.md).'
|
||||
);
|
||||
}
|
||||
|
||||
bootstrap.renderFallback(blocked ? 'blocked' : 'network');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -204,7 +222,8 @@
|
||||
* 사유별로 문구가 갈린다 — 새로고침이 도움이 되는 실패와 그렇지 않은 실패를
|
||||
* 같은 문구로 안내하면 사용자가 자기 회선을 탓하며 영원히 새로고침하게 된다.
|
||||
*
|
||||
* @param reason 'network'(다운로드 실패) | 'incompatible'(브라우저가 실행 못 함)
|
||||
* @param reason 'network'(다운로드 실패) | 'blocked'(HTTPS 페이지가 http 자산 요청 →
|
||||
* 브라우저 차단, 사이트 설정 문제) | 'incompatible'(브라우저가 실행 못 함)
|
||||
* | 'corrupt'(받았으나 전역 부재, 사유 미상). 미지정 시 'network'.
|
||||
*/
|
||||
renderFallback: function (reason) {
|
||||
@@ -215,16 +234,22 @@
|
||||
if (app.childElementCount > 0) return; // 이미 렌더된 화면은 덮지 않는다
|
||||
|
||||
var incompatible = reason === 'incompatible';
|
||||
var blocked = reason === 'blocked';
|
||||
|
||||
var title = incompatible
|
||||
? @json(__('errors.bootstrap.incompatible_title'))
|
||||
: @json(__('errors.bootstrap.title'));
|
||||
: (blocked
|
||||
? @json(__('errors.bootstrap.blocked_title'))
|
||||
: @json(__('errors.bootstrap.title')));
|
||||
var message = incompatible
|
||||
? @json(__('errors.bootstrap.incompatible_message'))
|
||||
: @json(__('errors.bootstrap.message'));
|
||||
: (blocked
|
||||
? @json(__('errors.bootstrap.blocked_message'))
|
||||
: @json(__('errors.bootstrap.message')));
|
||||
|
||||
// 새로고침해도 낫지 않는 상황에서 버튼을 두면 그것 자체가 다시 거짓 안내가 된다.
|
||||
var button = incompatible
|
||||
// 'blocked' 도 같다 — 설정을 고치기 전에는 몇 번을 눌러도 동일하게 차단된다.
|
||||
var button = (incompatible || blocked)
|
||||
? ''
|
||||
: '<button type="button" onclick="window.location.reload()" ' +
|
||||
'style="border:0;border-radius:6px;background:#2563eb;color:#fff;font-size:14px;font-weight:500;' +
|
||||
@@ -233,7 +258,8 @@
|
||||
'</button>';
|
||||
|
||||
app.innerHTML =
|
||||
'<div data-g7-bootstrap-fallback="' + (incompatible ? 'incompatible' : 'network') + '" ' +
|
||||
'<div data-g7-bootstrap-fallback="' +
|
||||
(incompatible ? 'incompatible' : (blocked ? 'blocked' : 'network')) + '" ' +
|
||||
'style="min-height:60vh;display:flex;align-items:center;justify-content:center;padding:24px;' +
|
||||
'font-family:system-ui,-apple-system,\'Segoe UI\',sans-serif;">' +
|
||||
'<div style="text-align:center;max-width:420px;">' +
|
||||
|
||||
@@ -654,6 +654,9 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin'
|
||||
Route::get('/', [AdminSettingsController::class, 'index'])->middleware('permission:admin,core.settings.read')->name('api.admin.settings.index');
|
||||
Route::post('/', [AdminSettingsController::class, 'store'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.store');
|
||||
Route::get('system-info', [AdminSettingsController::class, 'systemInfo'])->middleware('permission:admin,core.settings.read')->name('api.admin.settings.system-info');
|
||||
// 읽기 전용 진단 (#124). 값 편집 엔드포인트는 두지 않는다 — 편집은 .env 전용.
|
||||
// `{key}` 와일드카드보다 먼저 등록해야 그 라우트에 흡수되지 않는다.
|
||||
Route::get('trusted-proxy', [AdminSettingsController::class, 'trustedProxy'])->middleware('permission:admin,core.settings.read')->name('api.admin.settings.trusted-proxy');
|
||||
Route::get('app-key', [AdminSettingsController::class, 'getAppKey'])->middleware('permission:admin,core.settings.read')->name('api.admin.settings.app-key');
|
||||
Route::post('regenerate-app-key', [AdminSettingsController::class, 'regenerateAppKey'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.regenerate-app-key');
|
||||
Route::post('clear-cache', [AdminSettingsController::class, 'clearCache'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.clear-cache');
|
||||
|
||||
@@ -12,11 +12,18 @@
|
||||
- 이미지 업로드 시 쓰는 압축 라이브러리도 함께 담았습니다.
|
||||
- 코드 편집기를 불러오지 못하면 안내와 함께 일반 입력창으로 전환되어, 레이아웃을 계속 편집하고 저장할 수 있습니다.
|
||||
- 확장(템플릿·모듈·플러그인)을 제거할 때 `custom/` 에 넣어 둔 파일의 사본이 보관되며, 제거 창이 그 보관 경로를 보여 줍니다. 보관된 파일이 없으면 창은 종전대로 곧바로 닫힙니다.
|
||||
- 환경설정 > 고급 에 리버스 프록시 진단이 추가되었습니다. 사이트가 받고 있는 프록시 정보, HTTPS 인식 여부, 방문자 IP 로 인식된 값과 직전 호출 IP 를 나란히 보여 줍니다. 읽기 전용이며 값은 `.env` 에서만 변경합니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 관리자 화면에서 게시물·상품 등의 HTML 본문을 표시할 때 쓰는 정화 라이브러리를 최신 버전으로 고정했습니다. 종전에는 이 라이브러리를 직접 지정하지 않아 다른 구성요소가 딸려 들여온 구버전이 쓰였고, 사용자 템플릿보다 낮은 버전으로 동작했습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 대시보드 알림 중 일부 경고가 일반 안내와 같은 회색으로 표시되어 경고로 보이지 않던 문제를 수정했습니다. 이제 알림의 심각도에 따라 색이 정해집니다.
|
||||
- 경고 등급 알림을 대시보드 맨 아래 「시스템 알림」 칸이 아니라 **화면 상단**에 표시합니다. 종전에는 스크롤을 끝까지 내려야 보여, 화면이 정상으로 보이는 상황에서는 조치가 필요하다는 사실을 지나치기 쉬웠습니다. 경고가 아닌 안내는 종전대로 하단에 남으며, 같은 알림이 두 곳에 중복으로 뜨지 않습니다.
|
||||
- 알림이 여러 건일 때 서로 맞붙어 표시되던 문제를 수정했습니다. 이제 간격을 두고 차례로 쌓입니다.
|
||||
|
||||
## [1.0.7] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* @file admin-dashboard-alert-severity.test.tsx
|
||||
* @description 대시보드 시스템 알림의 심각도별 배치·색상 회귀 테스트 (#124 W10)
|
||||
*
|
||||
* 배경:
|
||||
* - 알림 카드의 색상 분기가 `alert.subtype` 리터럴 2개(`incompatible_core`,
|
||||
* `recovery_available`)에만 걸려 있었고, 심각도를 담은 `alert.type` 은 스타일 판정에
|
||||
* 한 번도 쓰이지 않았다. 목록에 없는 subtype 은 전부 else 로 떨어져 **경고가 회색
|
||||
* 일반 안내로** 렌더됐다(`static_publish_*` 가 이미 그 상태였다).
|
||||
* - 게다가 시스템 알림 카드는 대시보드 **맨 아래** 섹션이라, 화면이 정상으로 보이는
|
||||
* 구성에서는 운영자가 경고에 도달하지 못할 수 있었다. 그래서 PO 결정(2026-08-28)으로
|
||||
* **경고 등급 알림은 상단 배너로 승격**하고 나머지는 하단 카드에 남긴다.
|
||||
*
|
||||
* 이 테스트가 잠그는 계약:
|
||||
* - `type === 'warning'` → 상단 배너(`#dashboard_alert_banners` / `top_alert_*`)
|
||||
* - 그 외 → 하단 카드(`#system_alerts_card` / `alert_*`)
|
||||
* - 같은 알림이 두 곳에 **중복 노출되지 않는다**
|
||||
* - 경고가 여러 건이면 **모두 쌓이고 서로 덮지 않는다**
|
||||
* - 배치가 바뀌어도 행의 액션(복구·닫기)은 따라간다 — 상단 승격이 기능을 빼앗지 않는다
|
||||
* - `recovery_available` 의 복구 버튼은 **스타일이 아니라 의미 판정**이므로 `subtype` 유지
|
||||
*
|
||||
* 합성 레이아웃이 아니라 **실제 admin_dashboard.json** 을 읽는다. 합성 입력으로만
|
||||
* 검증하면 실물 레이아웃이 되돌아가도 테스트는 계속 통과한다.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createLayoutTest, screen } from '@core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
|
||||
|
||||
const TestDiv: React.FC<{ id?: string; className?: string; children?: React.ReactNode }> = ({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
}) => (
|
||||
<div id={id} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const TestSpan: React.FC<{ id?: string; className?: string; children?: React.ReactNode; text?: string }> = ({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
text,
|
||||
}) => (
|
||||
<span id={id} className={className}>
|
||||
{children || text}
|
||||
</span>
|
||||
);
|
||||
|
||||
const TestP: React.FC<{ id?: string; className?: string; children?: React.ReactNode; text?: string }> = ({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
text,
|
||||
}) => (
|
||||
<p id={id} className={className}>
|
||||
{children || text}
|
||||
</p>
|
||||
);
|
||||
|
||||
const TestH1: React.FC<{ id?: string; className?: string; children?: React.ReactNode; text?: string }> = ({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
text,
|
||||
}) => (
|
||||
<h1 id={id} className={className}>
|
||||
{children || text}
|
||||
</h1>
|
||||
);
|
||||
|
||||
const TestH2: React.FC<{ id?: string; className?: string; children?: React.ReactNode; text?: string }> = ({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
text,
|
||||
}) => (
|
||||
<h2 id={id} className={className}>
|
||||
{children || text}
|
||||
</h2>
|
||||
);
|
||||
|
||||
const TestButton: React.FC<{
|
||||
id?: string;
|
||||
className?: string;
|
||||
type?: string;
|
||||
children?: React.ReactNode;
|
||||
text?: string;
|
||||
}> = ({ id, className, type, children, text }) => (
|
||||
<button id={id} className={className} type={type as any}>
|
||||
{children || text}
|
||||
</button>
|
||||
);
|
||||
|
||||
const TestA: React.FC<{ id?: string; className?: string; href?: string; children?: React.ReactNode; text?: string }> = ({
|
||||
id,
|
||||
className,
|
||||
href,
|
||||
children,
|
||||
text,
|
||||
}) => (
|
||||
<a id={id} className={className} href={href}>
|
||||
{children || text}
|
||||
</a>
|
||||
);
|
||||
|
||||
const TestIcon: React.FC<{ id?: string; name?: string; className?: string }> = ({ id, name, className }) => (
|
||||
<i id={id} className={className} data-icon={name} />
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
+13
-1
@@ -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분 전',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
+250
@@ -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<any> = ({ id, className, children }) => (
|
||||
<div id={id} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
const TestSpan: React.FC<any> = ({ id, className, children, text }) => (
|
||||
<span id={id} className={className}>
|
||||
{children || text}
|
||||
</span>
|
||||
);
|
||||
const TestH3: React.FC<any> = ({ id, className, children, text }) => (
|
||||
<h3 id={id} className={className}>
|
||||
{children || text}
|
||||
</h3>
|
||||
);
|
||||
const TestInput: React.FC<any> = ({ name, type }) => <input name={name} type={type} />;
|
||||
const TestToggle: React.FC<any> = ({ name }) => <input type="checkbox" role="switch" name={name} />;
|
||||
const TestSelect: React.FC<any> = ({ name }) => <select name={name} />;
|
||||
const TestTextarea: React.FC<any> = ({ name }) => <textarea name={name} />;
|
||||
const TestButton: React.FC<any> = ({ children, text }) => <button type="button">{children || text}</button>;
|
||||
const TestA: React.FC<any> = ({ id, href, target, rel, className, children, text }) => (
|
||||
<a id={id} href={href} target={target} rel={rel} className={className}>
|
||||
{children || text}
|
||||
</a>
|
||||
);
|
||||
const TestFragment: React.FC<any> = ({ children }) => <>{children}</>;
|
||||
|
||||
/**
|
||||
* 테스트용 컴포넌트 레지스트리를 구성합니다.
|
||||
*
|
||||
* 입력 계열을 **모두 등록**한다 — 등록하지 않으면 렌더되지 않아 "컨트롤 0개" 가
|
||||
* 계약 준수가 아니라 미등록 때문에 통과한다.
|
||||
*
|
||||
* @returns 구성된 레지스트리
|
||||
*/
|
||||
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' } },
|
||||
H3: { component: TestH3, metadata: { name: 'H3', type: 'basic' } },
|
||||
Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } },
|
||||
Toggle: { component: TestToggle, metadata: { name: 'Toggle', type: 'composite' } },
|
||||
Select: { component: TestSelect, metadata: { name: 'Select', type: 'basic' } },
|
||||
Textarea: { component: TestTextarea, metadata: { name: 'Textarea', type: 'basic' } },
|
||||
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
|
||||
A: { component: TestA, metadata: { name: 'A', type: 'basic' } },
|
||||
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
|
||||
};
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* id 로 노드를 깊이 우선 탐색합니다.
|
||||
*
|
||||
* @param node 탐색 시작 노드
|
||||
* @param id 찾을 노드 id
|
||||
* @returns 찾은 노드 또는 null
|
||||
*/
|
||||
function findNodeById(node: any, id: string): any {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
if (node.id === id) return node;
|
||||
for (const child of node.children ?? []) {
|
||||
const found = findNodeById(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* partial 루트들에서 id 노드를 찾습니다.
|
||||
*
|
||||
* @param id 찾을 노드 id
|
||||
* @returns 찾은 노드 또는 null
|
||||
*/
|
||||
function findInPartial(id: string): any {
|
||||
for (const root of advancedPartial.components ?? [advancedPartial]) {
|
||||
const found = findNodeById(root, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 서버 진단 응답으로 카드를 렌더합니다.
|
||||
*
|
||||
* @param diagnostic 서버가 내려준 진단 결과
|
||||
* @returns 레이아웃 테스트 유틸
|
||||
*/
|
||||
function renderCard(diagnostic: Record<string, unknown>) {
|
||||
const card = findInPartial('card_trusted_proxy');
|
||||
expect(card, 'card_trusted_proxy 노드가 _tab_advanced.json 에 없습니다').not.toBeNull();
|
||||
|
||||
const testUtils = createLayoutTest({
|
||||
version: '1.0.0',
|
||||
layout_name: 'test_trusted_proxy_diagnostic',
|
||||
data_sources: [
|
||||
{
|
||||
id: 'trustedProxy',
|
||||
type: 'api',
|
||||
endpoint: '/api/admin/settings/trusted-proxy',
|
||||
method: 'GET',
|
||||
auto_fetch: true,
|
||||
auth_required: true,
|
||||
},
|
||||
],
|
||||
components: [card],
|
||||
} as any);
|
||||
|
||||
testUtils.mockApi('trustedProxy', { response: { data: diagnostic } });
|
||||
|
||||
return testUtils;
|
||||
}
|
||||
|
||||
/** 프록시 헤더는 받는데 신뢰 설정이 없는 상태 (조치 필요) */
|
||||
const WARNING = {
|
||||
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',
|
||||
};
|
||||
|
||||
/** 신뢰 설정이 있는 정상 상태 */
|
||||
const OK = {
|
||||
forwarded_headers: ['X-Forwarded-For', 'X-Forwarded-Proto'],
|
||||
trusted_configured: true,
|
||||
configured_proxies: '*',
|
||||
is_secure: true,
|
||||
client_ip: '203.0.113.77',
|
||||
remote_addr: '10.0.0.5',
|
||||
status: 'ok',
|
||||
};
|
||||
|
||||
describe('환경설정 고급 — 리버스 프록시 진단 블록', () => {
|
||||
let registry: ComponentRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = setupTestRegistry();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(registry as any).registry = {};
|
||||
});
|
||||
|
||||
it('입력 컨트롤을 하나도 렌더하지 않는다 (읽기 전용 계약)', async () => {
|
||||
const testUtils = renderCard(WARNING);
|
||||
await testUtils.render();
|
||||
|
||||
// 블록이 실제로 렌더됐음을 먼저 확정한다 — 그래야 아래 부재 단언이 의미를 갖는다.
|
||||
expect(document.querySelector('#trusted_proxy_status_badge')).not.toBeNull();
|
||||
|
||||
expect(
|
||||
document.querySelectorAll('input, select, textarea').length,
|
||||
'값 편집은 .env 전용입니다 — 화면에 편집 컨트롤을 두지 않습니다'
|
||||
).toBe(0);
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
it('문서 안내가 실제로 클릭 가능한 GitHub 링크다', async () => {
|
||||
const testUtils = renderCard(WARNING);
|
||||
await testUtils.render();
|
||||
|
||||
const link = document.querySelector('#trusted_proxy_doc_link') as HTMLAnchorElement | null;
|
||||
expect(link, '문서 링크가 렌더되지 않았다').not.toBeNull();
|
||||
expect(link!.getAttribute('href')).toBe(
|
||||
'https://github.com/gnuboard/g7/blob/main/docs/backend/reverse-proxy.md',
|
||||
);
|
||||
// 관리자 화면을 떠나지 않도록 새 탭으로 열고, opener 를 넘기지 않는다.
|
||||
expect(link!.getAttribute('target')).toBe('_blank');
|
||||
expect(link!.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
|
||||
// 링크는 읽기 전용 계약을 깨지 않는다 (입력 컨트롤이 아니다)
|
||||
expect(document.querySelectorAll('input, select, textarea').length).toBe(0);
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
it('경고 상태에서는 방문자 IP 와 직전 호출 IP 를 나란히 보여 준다', async () => {
|
||||
const testUtils = renderCard(WARNING);
|
||||
await testUtils.render();
|
||||
|
||||
const text = document.body.textContent ?? '';
|
||||
|
||||
// 두 값이 같으면서 프록시 헤더를 받고 있는 상태 = 모든 방문자가 한 사람으로 기록된다.
|
||||
expect(text).toContain('10.0.0.5');
|
||||
expect(text).toContain('X-Forwarded-For');
|
||||
expect(document.querySelector('#trusted_proxy_same_ip_hint'), '동일 IP 안내가 표시되지 않았습니다').not.toBeNull();
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
it('정상 상태에서는 설정값을 보여 주고 동일 IP 안내를 감춘다', async () => {
|
||||
const testUtils = renderCard(OK);
|
||||
await testUtils.render();
|
||||
|
||||
const text = document.body.textContent ?? '';
|
||||
|
||||
expect(text).toContain('203.0.113.77');
|
||||
expect(document.querySelector('#trusted_proxy_same_ip_hint'), '정상 상태에 동일 IP 안내가 떴습니다').toBeNull();
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
it('상태 배지는 서버가 준 status 를 그대로 따른다', async () => {
|
||||
const warning = renderCard(WARNING);
|
||||
await warning.render();
|
||||
const warningClass = document.querySelector('#trusted_proxy_status_badge')?.getAttribute('class') ?? '';
|
||||
expect(warningClass).toContain('bg-amber-100');
|
||||
warning.cleanup();
|
||||
|
||||
const ok = renderCard(OK);
|
||||
await ok.render();
|
||||
const okClass = document.querySelector('#trusted_proxy_status_badge')?.getAttribute('class') ?? '';
|
||||
expect(okClass).toContain('bg-green-100');
|
||||
ok.cleanup();
|
||||
});
|
||||
});
|
||||
+11
-4
@@ -143,7 +143,7 @@ describe('어드민 대시보드 - 시스템 알림 (incompatible_core)', () =>
|
||||
if (testUtils) testUtils.cleanup();
|
||||
});
|
||||
|
||||
it('incompatible_core 알림이 있을 때 시스템 알림 카드가 렌더되고 본문이 표시된다', async () => {
|
||||
it('경고 등급 알림은 상단 배너에 렌더되고 하단 시스템 알림 카드는 뜨지 않는다', async () => {
|
||||
testUtils = createLayoutTest(dashboardLayout, {
|
||||
translations,
|
||||
locale: 'ko',
|
||||
@@ -176,6 +176,8 @@ describe('어드민 대시보드 - 시스템 알림 (incompatible_core)', () =>
|
||||
response: {
|
||||
data: [
|
||||
{
|
||||
// ExtensionCompatibilityAlertListener 가 실제로 넣는 값 — 배치를 가르는 축이다.
|
||||
type: 'warning',
|
||||
subtype: 'incompatible_core',
|
||||
extension_type: 'plugin',
|
||||
identifier: 'sirsoft-payment',
|
||||
@@ -190,12 +192,17 @@ describe('어드민 대시보드 - 시스템 알림 (incompatible_core)', () =>
|
||||
|
||||
await testUtils.render();
|
||||
|
||||
// 시스템 알림 카드 헤더 (if:false 회귀 검증 — 렌더되어야 함)
|
||||
expect(screen.getByText('시스템 알림')).toBeTruthy();
|
||||
// 알림 본문 — title 과 message
|
||||
// 본문은 상단 배너에 그대로 표시된다 (배치가 바뀌어도 내용이 사라지지 않는다)
|
||||
expect(screen.getByText('결제 플러그인 자동 비활성화')).toBeTruthy();
|
||||
expect(screen.getByText('코어 >=7.5.0 필요 (현재 7.0.0)')).toBeTruthy();
|
||||
expect(screen.getByText('5분 전')).toBeTruthy();
|
||||
|
||||
// 경고만 있으면 하단 '시스템 알림' 카드는 뜨지 않는다 — 같은 알림이 두 곳에
|
||||
// 중복 노출되면 안 된다. 본문이 위에서 확인됐으므로 이 부재 단언이 곧 배치 증거다.
|
||||
expect(screen.queryByText('시스템 알림')).toBeNull();
|
||||
|
||||
// 제목은 정확히 1회만 나타난다 (상단·하단 양쪽 렌더 = 중복 노출 회귀 차단)
|
||||
expect(screen.queryAllByText('결제 플러그인 자동 비활성화').length).toBe(1);
|
||||
});
|
||||
|
||||
it('알림 배열이 비어있을 때 시스템 알림 카드가 렌더되지 않는다', async () => {
|
||||
|
||||
+2
@@ -215,6 +215,7 @@ describe('어드민 대시보드 - 재호환 알림 (recovery_available)', () =>
|
||||
response: {
|
||||
data: [
|
||||
{
|
||||
type: 'info',
|
||||
subtype: 'recovery_available',
|
||||
extension_type: 'plugin',
|
||||
identifier: 'sirsoft-payment',
|
||||
@@ -288,6 +289,7 @@ describe('어드민 대시보드 - 재호환 알림 (recovery_available)', () =>
|
||||
response: {
|
||||
data: [
|
||||
{
|
||||
type: 'info',
|
||||
subtype: 'recovery_available',
|
||||
extension_type: 'plugin',
|
||||
identifier: 'sirsoft-payment',
|
||||
|
||||
@@ -1823,7 +1823,24 @@
|
||||
"geoip_update_success": "GeoIP DB update completed.",
|
||||
"geoip_update_failed": "GeoIP DB update failed.",
|
||||
"seo_sitemap_cache": "Sitemap cache",
|
||||
"seo_sitemap_cache_desc": "Caches sitemap.xml."
|
||||
"seo_sitemap_cache_desc": "Caches sitemap.xml.",
|
||||
"trusted_proxy": "Reverse proxy (trusted proxies)",
|
||||
"trusted_proxy_desc": "Lets the site recognize the real address and visitor IP when HTTPS is terminated in front of it (AWS ALB, CloudFront, Cloudflare, nginx). The value can only be changed through TRUSTED_PROXIES in .env.",
|
||||
"trusted_proxy_status": "Diagnostic result",
|
||||
"trusted_proxy_status_ok": "OK",
|
||||
"trusted_proxy_status_warning": "Action required",
|
||||
"trusted_proxy_status_not_applicable": "Cannot determine",
|
||||
"trusted_proxy_configured": "TRUSTED_PROXIES value",
|
||||
"trusted_proxy_not_configured": "Not set (no proxy is trusted)",
|
||||
"trusted_proxy_forwarded_headers": "Forwarded headers received",
|
||||
"trusted_proxy_forwarded_headers_none": "None",
|
||||
"trusted_proxy_is_secure": "HTTPS detection",
|
||||
"trusted_proxy_is_secure_yes": "Detected as HTTPS",
|
||||
"trusted_proxy_is_secure_no": "Detected as HTTP",
|
||||
"trusted_proxy_client_ip": "Address recognized as the visitor IP",
|
||||
"trusted_proxy_remote_addr": "Immediate caller IP (REMOTE_ADDR)",
|
||||
"trusted_proxy_same_ip_hint": "These two values match while proxy headers are being received — every visitor is being recorded as the same person.",
|
||||
"trusted_proxy_doc_hint": "Configuration and post-adoption steps:"
|
||||
},
|
||||
"notification_definitions": {
|
||||
"title": "Notification Settings",
|
||||
|
||||
@@ -1654,6 +1654,7 @@
|
||||
"template_info": "Template Info",
|
||||
"templates": "Templates",
|
||||
"tokenValidation": "Token Validation",
|
||||
"trustedProxy": "Trusted proxy diagnostic",
|
||||
"user": "User",
|
||||
"users": "Users"
|
||||
}
|
||||
|
||||
@@ -1827,7 +1827,24 @@
|
||||
"geoip_update_success": "GeoIP DB 업데이트가 완료되었습니다.",
|
||||
"geoip_update_failed": "GeoIP DB 업데이트에 실패했습니다.",
|
||||
"seo_sitemap_cache": "Sitemap 캐시",
|
||||
"seo_sitemap_cache_desc": "sitemap.xml 을 캐시합니다."
|
||||
"seo_sitemap_cache_desc": "sitemap.xml 을 캐시합니다.",
|
||||
"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": "두 값이 같으면서 프록시 헤더를 받고 있습니다 — 모든 방문자가 한 사람으로 기록되고 있는 상태입니다.",
|
||||
"trusted_proxy_doc_hint": "설정 방법과 도입 시 후속 조치:"
|
||||
},
|
||||
"notification_definitions": {
|
||||
"title": "알림 설정",
|
||||
|
||||
@@ -1654,6 +1654,7 @@
|
||||
"template_info": "템플릿 정보",
|
||||
"templates": "템플릿 목록",
|
||||
"tokenValidation": "토큰 검증",
|
||||
"trustedProxy": "신뢰 프록시 진단",
|
||||
"user": "사용자",
|
||||
"users": "사용자 목록"
|
||||
}
|
||||
|
||||
@@ -346,6 +346,207 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dashboard_alert_banners",
|
||||
"if": "{{((dashboard_alerts?.data?.data ? dashboard_alerts.data.data : (dashboard_alerts?.data ?? [])).filter(a => a.type === 'warning').length) > 0}}",
|
||||
"permissions": [
|
||||
"core.dashboard.alerts"
|
||||
],
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "space-y-3 mb-6"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "top_alert_item_{{$idx}}",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "{{alert.type === 'warning' ? 'flex items-start gap-3 p-4 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800' : (alert.type === 'info' ? 'flex items-start gap-3 p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800' : 'flex items-start gap-3 p-4 rounded-lg bg-gray-50 dark:bg-gray-700')}}"
|
||||
},
|
||||
"iteration": {
|
||||
"source": "{{(dashboard_alerts?.data?.data ? dashboard_alerts.data.data : (dashboard_alerts?.data ?? [])).filter(a => a.type === 'warning')}}",
|
||||
"item_var": "alert",
|
||||
"index_var": "$idx"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "top_alert_icon_{{$idx}}",
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "{{alert.icon ?? 'info-circle'}}",
|
||||
"className": "{{alert.type === 'warning' ? 'text-amber-600 dark:text-amber-400 mt-0.5 shrink-0' : (alert.type === 'info' ? 'text-blue-600 dark:text-blue-400 mt-0.5 shrink-0' : 'text-gray-500 dark:text-gray-400 mt-0.5 shrink-0')}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "top_alert_content_{{$idx}}",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-1 min-w-0"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "top_alert_title_{{$idx}}",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "{{alert.title ?? ''}}"
|
||||
},
|
||||
{
|
||||
"id": "top_alert_message_{{$idx}}",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{alert.message}}",
|
||||
"props": {
|
||||
"className": "text-secondary text-xs mt-0.5"
|
||||
},
|
||||
"text": "{{alert.message ?? ''}}"
|
||||
},
|
||||
{
|
||||
"id": "top_alert_time_{{$idx}}",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{alert.time}}",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"text": "{{alert.time ?? ''}}"
|
||||
},
|
||||
{
|
||||
"id": "top_alert_actions_{{$idx}}",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-center gap-2 mt-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "top_alert_recover_button_{{$idx}}",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{alert.subtype === 'recovery_available'}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "inline-flex items-center gap-1 px-3 py-1 text-xs bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 text-white rounded"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "{{alert.recover_endpoint}}",
|
||||
"params": {
|
||||
"method": "POST"
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "success",
|
||||
"message": "$t:extensions.alerts.recovered_success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "dashboard_alerts"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "check-circle",
|
||||
"size": "xs"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:extensions.alerts.recover_action"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "top_alert_dismiss_button_{{$idx}}",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{alert.extension_type && alert.identifier}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "inline-flex items-center gap-1 px-3 py-1 text-xs text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 rounded",
|
||||
"aria-label": "$t:extensions.alerts.dismiss_action"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/admin/extensions/{{alert.extension_type}}/{{alert.identifier}}/dismiss",
|
||||
"params": {
|
||||
"method": "POST"
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "dashboard_alerts"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "times",
|
||||
"size": "xs"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:extensions.alerts.dismiss_action"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "quick_menu",
|
||||
"type": "basic",
|
||||
@@ -1386,7 +1587,7 @@
|
||||
"children": [
|
||||
{
|
||||
"id": "system_alerts_card",
|
||||
"if": "{{(dashboard_alerts?.data?.data ? dashboard_alerts.data.data.length : (dashboard_alerts?.data?.length ?? 0)) > 0}}",
|
||||
"if": "{{((dashboard_alerts?.data?.data ? dashboard_alerts.data.data : (dashboard_alerts?.data ?? [])).filter(a => a.type !== 'warning').length) > 0}}",
|
||||
"permissions": [
|
||||
"core.dashboard.alerts"
|
||||
],
|
||||
@@ -1432,17 +1633,19 @@
|
||||
"id": "alerts_list",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {},
|
||||
"props": {
|
||||
"className": "space-y-3"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "alert_item_{{$idx}}",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "{{alert.subtype === 'incompatible_core' ? 'flex items-start gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800' : (alert.subtype === 'recovery_available' ? 'flex items-start gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800' : 'flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700')}}"
|
||||
"className": "{{alert.type === 'warning' ? 'flex items-start gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800' : (alert.type === 'info' ? 'flex items-start gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800' : 'flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700')}}"
|
||||
},
|
||||
"iteration": {
|
||||
"source": "{{dashboard_alerts?.data?.data ? dashboard_alerts.data.data : (dashboard_alerts?.data ?? [])}}",
|
||||
"source": "{{(dashboard_alerts?.data?.data ? dashboard_alerts.data.data : (dashboard_alerts?.data ?? [])).filter(a => a.type !== 'warning')}}",
|
||||
"item_var": "alert",
|
||||
"index_var": "$idx"
|
||||
},
|
||||
@@ -1453,7 +1656,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "{{alert.icon ?? 'info-circle'}}",
|
||||
"className": "{{alert.subtype === 'incompatible_core' ? 'text-amber-600 dark:text-amber-400 mt-0.5 shrink-0' : (alert.subtype === 'recovery_available' ? 'text-blue-600 dark:text-blue-400 mt-0.5 shrink-0' : 'text-gray-500 dark:text-gray-400 mt-0.5 shrink-0')}}"
|
||||
"className": "{{alert.type === 'warning' ? 'text-amber-600 dark:text-amber-400 mt-0.5 shrink-0' : (alert.type === 'info' ? 'text-blue-600 dark:text-blue-400 mt-0.5 shrink-0' : 'text-gray-500 dark:text-gray-400 mt-0.5 shrink-0')}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -126,6 +126,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trustedProxy",
|
||||
"label_key": "$t:editor.data_source.trustedProxy",
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/settings/trusted-proxy",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeSettingsTab || 'general') === 'advanced'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
"data": {
|
||||
"forwarded_headers": [],
|
||||
"trusted_configured": false,
|
||||
"configured_proxies": null,
|
||||
"is_secure": null,
|
||||
"client_ip": null,
|
||||
"remote_addr": null,
|
||||
"status": "not_applicable"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "appKey",
|
||||
"label_key": "$t:editor.data_source.appKey",
|
||||
|
||||
+224
@@ -1683,6 +1683,230 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_trusted_proxy",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "card-stack-item"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": {
|
||||
"className": "card-title"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.trusted_proxy"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "card-description"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.trusted_proxy_desc"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "row-stack"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "trusted_proxy_status_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-between"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.trusted_proxy_status"
|
||||
},
|
||||
{
|
||||
"id": "trusted_proxy_status_badge",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "{{trustedProxy?.data?.status === 'warning' ? 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300' : (trustedProxy?.data?.status === 'ok' ? 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' : 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300')}}"
|
||||
},
|
||||
"text": "{{trustedProxy?.data?.status === 'warning' ? $t('admin.settings.advanced.trusted_proxy_status_warning') : (trustedProxy?.data?.status === 'ok' ? $t('admin.settings.advanced.trusted_proxy_status_ok') : $t('admin.settings.advanced.trusted_proxy_status_not_applicable'))}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-between"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.trusted_proxy_configured"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "{{trustedProxy?.data?.trusted_configured ? (trustedProxy?.data?.configured_proxies ?? '-') : $t('admin.settings.advanced.trusted_proxy_not_configured')}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-between"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.trusted_proxy_forwarded_headers"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "{{(trustedProxy?.data?.forwarded_headers ?? []).length > 0 ? trustedProxy?.data.forwarded_headers.join(', ') : $t('admin.settings.advanced.trusted_proxy_forwarded_headers_none')}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-between"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.trusted_proxy_is_secure"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "{{trustedProxy?.data?.is_secure === true ? $t('admin.settings.advanced.trusted_proxy_is_secure_yes') : (trustedProxy?.data?.is_secure === false ? $t('admin.settings.advanced.trusted_proxy_is_secure_no') : '-')}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-between"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.trusted_proxy_client_ip"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "{{trustedProxy?.data?.client_ip ?? '-'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-between"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.trusted_proxy_remote_addr"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "{{trustedProxy?.data?.remote_addr ?? '-'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "trusted_proxy_same_ip_hint",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{trustedProxy?.data?.status === 'warning' && trustedProxy?.data?.client_ip && trustedProxy?.data?.client_ip === trustedProxy?.data?.remote_addr}}",
|
||||
"props": {
|
||||
"className": "form-hint text-amber-600 dark:text-amber-400"
|
||||
},
|
||||
"text": "$t:admin.settings.advanced.trusted_proxy_same_ip_hint"
|
||||
},
|
||||
{
|
||||
"id": "trusted_proxy_doc_hint",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:admin.settings.advanced.trusted_proxy_doc_hint"
|
||||
},
|
||||
{
|
||||
"id": "trusted_proxy_doc_link",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "https://github.com/gnuboard/g7/blob/main/docs/backend/reverse-proxy.md",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"className": "ml-1 underline hover:no-underline"
|
||||
},
|
||||
"text": "docs/backend/reverse-proxy.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"props": {
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Http;
|
||||
|
||||
use App\Support\TrustedProxyDiagnostic;
|
||||
use Illuminate\Http\Middleware\TrustProxies;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 신뢰 프록시 설정 계약 (#124).
|
||||
*
|
||||
* `config/trustedproxy.php` 는 코어 PHP 코드 수정 없이 Laravel 내장 TrustProxies 미들웨어에
|
||||
* 값을 전달하는 **유일한 배선점**이다(`$this->proxies() ?: config('trustedproxy.proxies')`).
|
||||
* 그 배선이 끊기면 프록시 뒤 설치처의 화면이 통째로 뜨지 않는데, 예외도 로그도 남지 않는다.
|
||||
*
|
||||
* `bootstrap/app.php` 에서 `trustProxies(at: env(...))` 로 설정하는 것은 함정이다 —
|
||||
* `withMiddleware` 클로저는 `.env` 로드 전에 평가되어 `env()` 가 항상 `null` 을 돌려주고,
|
||||
* 설정한 것처럼 보이는데 실제로는 아무 프록시도 신뢰하지 않는 상태가 된다. 관련 케이스가
|
||||
* 그 형태의 재유입을 소스 스캔으로 차단한다.
|
||||
*
|
||||
* DB 를 쓰지 않으므로 `RefreshDatabase` 를 붙이지 않는다. 신뢰 상태는 `Request` 의 **정적**
|
||||
* 상태라 테스트마다 되돌리지 않으면 같은 프로세스의 후속 테스트로 샌다.
|
||||
*/
|
||||
class TrustedProxyContractTest extends TestCase
|
||||
{
|
||||
/** 프록시로 위장할 직전 호출 IP */
|
||||
private const REMOTE_ADDR = '10.0.0.5';
|
||||
|
||||
/** 프록시가 전달한 실제 방문자 IP */
|
||||
private const CLIENT_IP = '203.0.113.77';
|
||||
|
||||
/**
|
||||
* 각 테스트 후 정적 신뢰 상태를 원복합니다.
|
||||
*/
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Request::setTrustedProxies([], Request::HEADER_X_FORWARDED_FOR);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 프록시 뒤 요청을 만들고 TrustProxies 미들웨어를 통과시킵니다.
|
||||
*
|
||||
* @param string|null $proxies 신뢰 프록시 설정값
|
||||
* @param string $forwardedFor X-Forwarded-For 헤더 값
|
||||
* @return Request 미들웨어를 통과한 요청
|
||||
*/
|
||||
private function proxiedRequest(?string $proxies, string $forwardedFor = self::CLIENT_IP): Request
|
||||
{
|
||||
config(['trustedproxy.proxies' => $proxies]);
|
||||
|
||||
$request = Request::create('http://g7_2.dev/', 'GET', [], [], [], [
|
||||
'REMOTE_ADDR' => self::REMOTE_ADDR,
|
||||
'HTTP_X_FORWARDED_PROTO' => 'https',
|
||||
'HTTP_X_FORWARDED_FOR' => $forwardedFor,
|
||||
'HTTP_X_FORWARDED_HOST' => 'g7_2.dev',
|
||||
'HTTP_X_FORWARDED_PORT' => '8443',
|
||||
]);
|
||||
|
||||
(new TrustProxies)->handle($request, fn () => null);
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정이 없으면 X-Forwarded-* 를 전부 무시합니다 (기본값 — 기존 설치처 동작 불변).
|
||||
*
|
||||
* @scenario proxies_setting=unset, forwarded_proto=https, forwarded_for=single
|
||||
*
|
||||
* @effects unset_ignores_forwarded_headers
|
||||
*/
|
||||
public function test_미설정이면_전달_헤더를_신뢰하지_않는다(): void
|
||||
{
|
||||
$request = $this->proxiedRequest(null);
|
||||
|
||||
$this->assertFalse($request->isSecure(), '미설정 상태에서는 X-Forwarded-Proto 를 신뢰하지 않아야 합니다.');
|
||||
$this->assertSame(self::REMOTE_ADDR, $request->ip(), '미설정 상태에서는 REMOTE_ADDR 이 방문자 IP 여야 합니다.');
|
||||
$this->assertStringStartsWith('http://', $request->root(), '미설정 상태에서는 평문 HTTP 루트가 만들어져야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* `*` 은 직전 호출 IP 를 신뢰해 스킴·IP·호스트·포트를 모두 복원합니다.
|
||||
*
|
||||
* @scenario proxies_setting=star, forwarded_proto=https, forwarded_for=single, forwarded_host=same, forwarded_port=nonstandard
|
||||
*
|
||||
* @effects star_restores_scheme_ip_and_root
|
||||
*/
|
||||
public function test_별표는_직전_호출_i_p_를_신뢰한다(): void
|
||||
{
|
||||
$request = $this->proxiedRequest('*');
|
||||
|
||||
$this->assertTrue($request->isSecure());
|
||||
$this->assertSame(self::CLIENT_IP, $request->ip());
|
||||
$this->assertSame('https://g7_2.dev:8443', $request->root());
|
||||
}
|
||||
|
||||
/**
|
||||
* `**` 은 Laravel 내장 미들웨어에서 `*` 과 동일하게 동작합니다.
|
||||
*
|
||||
* 별도 패키지 시절 `**` 는 "모든 프록시 신뢰" 였으나, 내장 구현은 `*` 과 같은
|
||||
* `setTrustedProxyIpAddressesToTheCallingIp()` 로 분기한다. 그 사실을 문서·설정 주석이
|
||||
* 그대로 서술하고 있으므로, 프레임워크 업그레이드로 의미가 갈라지면 여기서 드러나야 한다.
|
||||
*
|
||||
* @scenario proxies_setting=double_star, forwarded_proto=https, forwarded_for=single
|
||||
*
|
||||
* @effects double_star_behaves_like_star
|
||||
*/
|
||||
public function test_별표두개는_별표와_동일하게_동작한다(): void
|
||||
{
|
||||
$starIp = $this->proxiedRequest('*')->ip();
|
||||
Request::setTrustedProxies([], Request::HEADER_X_FORWARDED_FOR);
|
||||
|
||||
$double = $this->proxiedRequest('**');
|
||||
|
||||
$this->assertTrue($double->isSecure());
|
||||
$this->assertSame($starIp, $double->ip(), '** 와 * 의 방문자 IP 해석이 갈라졌습니다 — 문서·설정 주석을 함께 갱신해야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 다단계 체인에서 `*` 은 마지막 프록시를 방문자로 본다 — 모든 단을 나열해야 복원됩니다.
|
||||
*
|
||||
* @scenario proxies_setting=star, forwarded_for=chain
|
||||
*
|
||||
* @effects chain_requires_listing_every_hop
|
||||
*/
|
||||
public function test_다단계_체인은_모든_프록시를_나열해야_클라이언트_i_p_가_해석된다(): void
|
||||
{
|
||||
$chain = self::CLIENT_IP.', 10.1.1.1, 10.2.2.2';
|
||||
|
||||
$star = $this->proxiedRequest('*', $chain);
|
||||
$this->assertSame('10.2.2.2', $star->ip(), '* 은 직전 호출 IP 하나만 신뢰하므로 마지막 프록시가 방문자로 보여야 합니다.');
|
||||
Request::setTrustedProxies([], Request::HEADER_X_FORWARDED_FOR);
|
||||
|
||||
$listed = $this->proxiedRequest(self::REMOTE_ADDR.',10.1.1.1,10.2.2.2', $chain);
|
||||
$this->assertSame(self::CLIENT_IP, $listed->ip(), '체인의 모든 단을 나열하면 최초 클라이언트 IP 가 해석되어야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록에 직전 호출 IP 가 있으면 신뢰하고, 없으면 신뢰하지 않습니다.
|
||||
*
|
||||
* @scenario proxies_setting=single_ip_match, forwarded_proto=https
|
||||
*
|
||||
* @effects ip_list_match_trusts_and_mismatch_rejects
|
||||
*/
|
||||
public function test_i_p_목록은_일치할_때만_신뢰한다(): void
|
||||
{
|
||||
$match = $this->proxiedRequest(self::REMOTE_ADDR);
|
||||
$this->assertTrue($match->isSecure());
|
||||
$this->assertSame(self::CLIENT_IP, $match->ip());
|
||||
Request::setTrustedProxies([], Request::HEADER_X_FORWARDED_FOR);
|
||||
|
||||
$mismatch = $this->proxiedRequest('10.0.0.9');
|
||||
$this->assertFalse($mismatch->isSecure(), '목록에 없는 프록시의 헤더는 신뢰하지 않아야 합니다.');
|
||||
$this->assertSame(self::REMOTE_ADDR, $mismatch->ip());
|
||||
}
|
||||
|
||||
/**
|
||||
* CIDR 표기도 신뢰 판정에 사용됩니다.
|
||||
*
|
||||
* @scenario proxies_setting=cidr, forwarded_proto=https
|
||||
*
|
||||
* @effects cidr_notation_is_honored
|
||||
*/
|
||||
public function test_cid_r_표기가_신뢰_판정에_사용된다(): void
|
||||
{
|
||||
$request = $this->proxiedRequest('10.0.0.0/8');
|
||||
|
||||
$this->assertTrue($request->isSecure());
|
||||
$this->assertSame(self::CLIENT_IP, $request->ip());
|
||||
}
|
||||
|
||||
/**
|
||||
* `config/trustedproxy.php` 는 `proxies` 키를 노출하고 TRUSTED_PROXIES 를 그대로 반영합니다.
|
||||
*
|
||||
* @effects config_file_exposes_proxies_key_from_env
|
||||
*/
|
||||
public function test_설정_파일이_truste_d_proxie_s_를_그대로_반영한다(): void
|
||||
{
|
||||
$path = base_path('config/trustedproxy.php');
|
||||
$this->assertFileExists($path, 'config/trustedproxy.php 는 신뢰 프록시 설정의 유일한 배선점입니다.');
|
||||
|
||||
$previous = $_ENV['TRUSTED_PROXIES'] ?? null;
|
||||
$_ENV['TRUSTED_PROXIES'] = '198.51.100.7';
|
||||
|
||||
try {
|
||||
$config = require $path;
|
||||
} finally {
|
||||
if ($previous === null) {
|
||||
unset($_ENV['TRUSTED_PROXIES']);
|
||||
} else {
|
||||
$_ENV['TRUSTED_PROXIES'] = $previous;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertArrayHasKey('proxies', $config);
|
||||
$this->assertSame('198.51.100.7', $config['proxies']);
|
||||
}
|
||||
|
||||
/**
|
||||
* `bootstrap/app.php` 에 env() 기반 trustProxies / trustHosts 가 재유입되지 않습니다.
|
||||
*
|
||||
* `withMiddleware` 클로저는 `.env` 로드 전에 평가되므로 그 자리의 `env()` 는 항상 null 이다
|
||||
* — 오류 없이 no-op 이 되어, 설정한 것처럼 보이는데 아무 프록시도 신뢰하지 않는다.
|
||||
* `trustHosts()` 는 호출 자체가 모든 설치처에서 Host 검증을 켠다(opt-in 원칙 위반).
|
||||
*
|
||||
* @effects bootstrap_does_not_reintroduce_env_trust_proxies
|
||||
*/
|
||||
public function test_bootstrap_에_env_기반_trust_proxies_가_없다(): void
|
||||
{
|
||||
$source = (string) file_get_contents(base_path('bootstrap/app.php'));
|
||||
|
||||
// 주석을 제거해 "하면 안 되는 것" 설명문이 오탐되지 않게 한다.
|
||||
$stripped = (string) preg_replace(['#/\*.*?\*/#s', '#//[^\n]*#'], '', $source);
|
||||
|
||||
$this->assertStringNotContainsString(
|
||||
'trustHosts(',
|
||||
$stripped,
|
||||
'trustHosts() 호출은 모든 설치처에서 Host 검증을 켭니다 — opt-in 원칙 위반입니다.'
|
||||
);
|
||||
|
||||
if (preg_match('/trustProxies\s*\(([^;]*)\)/s', $stripped, $m) === 1) {
|
||||
$this->assertStringNotContainsString(
|
||||
'env(',
|
||||
$m[1],
|
||||
'bootstrap/app.php 의 trustProxies() 안에서 env() 를 읽으면 항상 null 입니다 — config/trustedproxy.php 를 쓰세요.'
|
||||
);
|
||||
} else {
|
||||
$this->assertTrue(true, 'trustProxies() 호출이 없으므로 config 폴백이 유일한 배선점입니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `.env.example` 과 `.env.testing.example` 이 TRUSTED_PROXIES 를 안내합니다.
|
||||
*
|
||||
* @effects env_examples_document_trusted_proxies
|
||||
*/
|
||||
public function test_env_예시_파일이_truste_d_proxie_s_를_안내한다(): void
|
||||
{
|
||||
foreach (['.env.example', '.env.testing.example'] as $file) {
|
||||
$this->assertStringContainsString(
|
||||
'TRUSTED_PROXIES',
|
||||
(string) file_get_contents(base_path($file)),
|
||||
$file.' 에 TRUSTED_PROXIES 안내가 있어야 합니다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설치 마법사의 전달 헤더 목록이 코어 진단과 같은 집합입니다.
|
||||
*
|
||||
* 설치 마법사는 순수 PHP 영역이라 Laravel 헬퍼를 쓸 수 없어 목록을 복제한다. 복제본이
|
||||
* 갈라지면 "설치 때는 안내가 떴는데 운영 화면에는 경고가 없다"(또는 그 반대)가 된다.
|
||||
*
|
||||
* @effects installer_header_list_matches_core_diagnostic
|
||||
*/
|
||||
public function test_설치_마법사의_전달_헤더_목록이_코어와_같다(): void
|
||||
{
|
||||
$source = (string) file_get_contents(base_path('public/install/api/check-configuration.php'));
|
||||
|
||||
foreach (TrustedProxyDiagnostic::FORWARDED_HEADERS as $header) {
|
||||
$this->assertStringContainsString(
|
||||
"'".$header."' => 'HTTP_",
|
||||
$source,
|
||||
$header.' 가 설치 마법사의 전달 헤더 목록에 없습니다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 신뢰 프록시가 있으면 쿠키의 Secure 가 자동 판정으로 붙고, 없으면 붙지 않습니다.
|
||||
*
|
||||
* `SESSION_SECURE_COOKIE` 미설정 시 Symfony `Response::prepare()` 가 `isSecure()` 로
|
||||
* 판정하므로, 신뢰 설정이 없으면 HTTPS 사이트인데 쿠키가 Secure 없이 발급된다.
|
||||
*
|
||||
* @scenario proxies_setting=star, session_secure_cookie=unset
|
||||
*
|
||||
* @effects cookie_secure_follows_trusted_scheme
|
||||
*/
|
||||
public function test_쿠키_secure_는_신뢰된_스킴을_따른다(): void
|
||||
{
|
||||
$this->assertTrue($this->cookieIsSecureAfterPrepare('*'), '신뢰 프록시가 있으면 쿠키에 Secure 가 붙어야 합니다.');
|
||||
Request::setTrustedProxies([], Request::HEADER_X_FORWARDED_FOR);
|
||||
|
||||
$this->assertFalse($this->cookieIsSecureAfterPrepare(null), '신뢰 프록시가 없으면 평문으로 판정되어 Secure 가 붙지 않습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 신뢰 설정으로 응답을 준비했을 때 쿠키에 Secure 가 붙는지 판정합니다.
|
||||
*
|
||||
* @param string|null $proxies 신뢰 프록시 설정값
|
||||
* @return bool Secure 부착 여부
|
||||
*/
|
||||
private function cookieIsSecureAfterPrepare(?string $proxies): bool
|
||||
{
|
||||
$request = $this->proxiedRequest($proxies);
|
||||
|
||||
$response = new Response('ok');
|
||||
// secure = null → Response::prepare() 가 요청 스킴으로 자동 판정한다.
|
||||
$response->headers->setCookie(Cookie::create('g7-session', 'value', 0, '/', null, null));
|
||||
$response->prepare($request);
|
||||
|
||||
foreach ($response->headers->getCookies() as $cookie) {
|
||||
if ($cookie->getName() === 'g7-session') {
|
||||
return $cookie->isSecure();
|
||||
}
|
||||
}
|
||||
|
||||
$this->fail('g7-session 쿠키를 응답에서 찾지 못했습니다.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Http;
|
||||
|
||||
use App\Listeners\TrustedProxyAlertListener;
|
||||
use App\Support\TrustedProxyDiagnostic;
|
||||
use Illuminate\Http\Request;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 신뢰 프록시 진단 판정 (#124).
|
||||
*
|
||||
* 판정식은 "HTTPS 인식 실패" 가 아니라 다음이다:
|
||||
*
|
||||
* X-Forwarded-* 헤더를 수신 중 AND 신뢰 프록시가 비어 있음
|
||||
*
|
||||
* HTTPS 기준으로 잡으면 **HTTP 전용 사이트가 프록시 뒤에 있는 구성**을 덮지 못한다. 그
|
||||
* 구성에서는 혼합 콘텐츠가 없어 화면이 완전히 정상 렌더되지만 webhook 403 · IP 왜곡 ·
|
||||
* 로그인 제한 붕괴는 그대로 발생하며, 능동 경고가 없으면 영구 미발견이다. 그래서 이
|
||||
* 테스트가 그 축을 명시적으로 잠근다.
|
||||
*/
|
||||
class TrustedProxyDiagnosticTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 전달 헤더를 붙인 요청을 만듭니다.
|
||||
*
|
||||
* @param array<string, string> $server 추가 서버 변수
|
||||
* @return Request 생성된 요청
|
||||
*/
|
||||
private function request(array $server = []): Request
|
||||
{
|
||||
return Request::create('http://g7_2.dev/', 'GET', [], [], [], array_merge([
|
||||
'REMOTE_ADDR' => '10.0.0.5',
|
||||
], $server));
|
||||
}
|
||||
|
||||
/**
|
||||
* 프록시 헤더가 없고 설정도 없으면 정상(직접 노출 구성)입니다.
|
||||
*
|
||||
* @scenario proxies_setting=unset, forwarded_proto=absent, forwarded_for=absent
|
||||
*
|
||||
* @effects direct_exposure_is_not_a_warning
|
||||
*/
|
||||
public function test_프록시_헤더가_없으면_경고하지_않는다(): void
|
||||
{
|
||||
config(['trustedproxy.proxies' => null]);
|
||||
|
||||
$result = TrustedProxyDiagnostic::forRequest($this->request());
|
||||
|
||||
$this->assertSame(TrustedProxyDiagnostic::STATUS_OK, $result['status']);
|
||||
$this->assertSame([], $result['forwarded_headers']);
|
||||
$this->assertFalse($result['trusted_configured']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프록시 헤더를 받는데 설정이 없으면 경고입니다.
|
||||
*
|
||||
* @scenario proxies_setting=unset, forwarded_proto=https, forwarded_for=single
|
||||
*
|
||||
* @effects forwarded_without_config_is_warning
|
||||
*/
|
||||
public function test_프록시_헤더를_받는데_설정이_없으면_경고한다(): void
|
||||
{
|
||||
config(['trustedproxy.proxies' => null]);
|
||||
|
||||
$result = TrustedProxyDiagnostic::forRequest($this->request([
|
||||
'HTTP_X_FORWARDED_PROTO' => 'https',
|
||||
'HTTP_X_FORWARDED_FOR' => '203.0.113.77',
|
||||
]));
|
||||
|
||||
$this->assertSame(TrustedProxyDiagnostic::STATUS_WARNING, $result['status']);
|
||||
$this->assertContains('X-Forwarded-Proto', $result['forwarded_headers']);
|
||||
$this->assertContains('X-Forwarded-For', $result['forwarded_headers']);
|
||||
// 신뢰하지 않으므로 방문자 IP 가 직전 호출 IP 와 같다 = 모두 한 사람으로 기록된다.
|
||||
$this->assertSame($result['remote_addr'], $result['client_ip']);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 전용 사이트가 프록시 뒤에 있어도 경고합니다 (조용한 구성).
|
||||
*
|
||||
* 이 케이스가 "HTTPS 인식 실패" 판정식과 갈라지는 유일한 지점이다. 화면은 정상이라
|
||||
* 사용자도 운영자도 이상을 감지할 단서가 없다.
|
||||
*
|
||||
* @scenario proxies_setting=unset, forwarded_proto=http, forwarded_for=single
|
||||
*
|
||||
* @effects http_only_behind_proxy_still_warns
|
||||
*/
|
||||
public function test_htt_p_전용_사이트가_프록시_뒤에_있어도_경고한다(): void
|
||||
{
|
||||
config(['trustedproxy.proxies' => null]);
|
||||
|
||||
$result = TrustedProxyDiagnostic::forRequest($this->request([
|
||||
'HTTP_X_FORWARDED_PROTO' => 'http',
|
||||
'HTTP_X_FORWARDED_FOR' => '203.0.113.77',
|
||||
]));
|
||||
|
||||
$this->assertFalse($result['is_secure'], '이 구성은 HTTPS 가 아니다 — 그럼에도 경고해야 한다.');
|
||||
$this->assertSame(TrustedProxyDiagnostic::STATUS_WARNING, $result['status']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정이 있으면 프록시 헤더를 받아도 정상입니다.
|
||||
*
|
||||
* @scenario proxies_setting=star, forwarded_proto=https, forwarded_for=single
|
||||
*
|
||||
* @effects configured_proxy_is_ok
|
||||
*/
|
||||
public function test_설정이_있으면_정상으로_판정한다(): void
|
||||
{
|
||||
config(['trustedproxy.proxies' => '*']);
|
||||
|
||||
$result = TrustedProxyDiagnostic::forRequest($this->request([
|
||||
'HTTP_X_FORWARDED_PROTO' => 'https',
|
||||
]));
|
||||
|
||||
$this->assertSame(TrustedProxyDiagnostic::STATUS_OK, $result['status']);
|
||||
$this->assertTrue($result['trusted_configured']);
|
||||
$this->assertSame('*', $result['configured_proxies']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 문자열 설정은 미설정과 같게 다룹니다.
|
||||
*
|
||||
* `.env` 에 `TRUSTED_PROXIES=` 만 남긴 상태가 "설정됨" 으로 판정되면 경고가 조용히
|
||||
* 사라지는데, 미들웨어는 여전히 아무것도 신뢰하지 않는다.
|
||||
*
|
||||
* @effects empty_string_counts_as_unset
|
||||
*/
|
||||
public function test_빈_문자열은_미설정과_같다(): void
|
||||
{
|
||||
config(['trustedproxy.proxies' => ' ']);
|
||||
|
||||
$result = TrustedProxyDiagnostic::forRequest($this->request([
|
||||
'HTTP_X_FORWARDED_FOR' => '203.0.113.77',
|
||||
]));
|
||||
|
||||
$this->assertFalse($result['trusted_configured']);
|
||||
$this->assertNull($result['configured_proxies']);
|
||||
$this->assertSame(TrustedProxyDiagnostic::STATUS_WARNING, $result['status']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청이 없는 맥락(콘솔)은 "값 없음" 이 아니라 "판정 불가" 입니다.
|
||||
*
|
||||
* @effects console_context_is_not_applicable
|
||||
*/
|
||||
public function test_요청이_없으면_판정_불가로_구분한다(): void
|
||||
{
|
||||
config(['trustedproxy.proxies' => null]);
|
||||
|
||||
$result = TrustedProxyDiagnostic::forRequest(null);
|
||||
|
||||
$this->assertSame(TrustedProxyDiagnostic::STATUS_NOT_APPLICABLE, $result['status']);
|
||||
$this->assertNull($result['is_secure']);
|
||||
$this->assertNull($result['client_ip']);
|
||||
$this->assertNull($result['remote_addr']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 알림은 경고 상태에서만, 심각도 `warning` 으로 추가됩니다.
|
||||
*
|
||||
* 알림 카드의 색상 분기는 `alert.type` 을 읽으므로, 여기서 `warning` 이 아니면 경고가
|
||||
* 회색 안내로 렌더되어 경고로 보이지 않는다.
|
||||
*
|
||||
* @effects dashboard_alert_only_on_warning_with_warning_type
|
||||
*/
|
||||
public function test_대시보드_알림은_경고_상태에서만_추가된다(): void
|
||||
{
|
||||
$listener = new TrustedProxyAlertListener;
|
||||
|
||||
config(['trustedproxy.proxies' => '*']);
|
||||
$this->assertSame([], $listener->addTrustedProxyAlert([]), '설정이 있으면 알림을 추가하지 않아야 합니다.');
|
||||
|
||||
config(['trustedproxy.proxies' => null]);
|
||||
app()->instance('request', $this->request([
|
||||
'HTTP_X_FORWARDED_FOR' => '203.0.113.77',
|
||||
]));
|
||||
|
||||
$alerts = $listener->addTrustedProxyAlert([]);
|
||||
|
||||
$this->assertCount(1, $alerts);
|
||||
$this->assertSame('trusted_proxy_missing', $alerts[0]['id']);
|
||||
$this->assertSame('warning', $alerts[0]['type'], '심각도가 warning 이 아니면 대시보드에서 경고로 렌더되지 않습니다.');
|
||||
$this->assertNotSame('', $alerts[0]['title']);
|
||||
$this->assertStringNotContainsString(':headers', $alerts[0]['message'], '치환 자리가 그대로 남아 있습니다.');
|
||||
$this->assertStringNotContainsString(':ip', $alerts[0]['message'], '치환 자리가 그대로 남아 있습니다.');
|
||||
$this->assertStringContainsString('TRUSTED_PROXIES', $alerts[0]['message'], '원인만이 아니라 조치도 담아야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 진단 엔드포인트는 값 편집 경로를 제공하지 않습니다 (읽기 전용 계약).
|
||||
*
|
||||
* @effects diagnostic_endpoint_is_read_only
|
||||
*/
|
||||
public function test_진단_엔드포인트는_읽기_전용이다(): void
|
||||
{
|
||||
$routes = app('router')->getRoutes();
|
||||
$names = [];
|
||||
|
||||
foreach ($routes as $route) {
|
||||
if (str_contains((string) $route->uri(), 'settings/trusted-proxy')) {
|
||||
$names[] = implode('|', $route->methods());
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotSame([], $names, '진단 엔드포인트가 등록되어 있어야 합니다.');
|
||||
|
||||
foreach ($names as $methods) {
|
||||
$this->assertStringNotContainsString('POST', $methods, '신뢰 프록시 값은 .env 전용입니다 — 쓰기 라우트를 두지 않습니다.');
|
||||
$this->assertStringNotContainsString('PUT', $methods);
|
||||
$this->assertStringNotContainsString('PATCH', $methods);
|
||||
$this->assertStringNotContainsString('DELETE', $methods);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,9 +48,9 @@ class BootstrapFallbackDiagnosisTest extends TestCase
|
||||
"[{$templateType}] 전역 부재 경로가 SyntaxError 관측 결과로 사유를 가르지 않는다"
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
"renderFallback('network')",
|
||||
"renderFallback(blocked ? 'blocked' : 'network')",
|
||||
$html,
|
||||
"[{$templateType}] 재시도 소진 경로가 network 사유를 넘기지 않는다"
|
||||
"[{$templateType}] 재시도 소진 경로가 차단/네트워크 사유를 가르지 않는다"
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
'data-g7-bootstrap-fallback',
|
||||
@@ -60,6 +60,96 @@ class BootstrapFallbackDiagnosisTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTPS 페이지가 http 자산을 요청해 차단된 경우를 별도 사유로 가른다 (#124).
|
||||
*
|
||||
* 이 실패는 회선이 멀쩡하고 새로고침으로도 낫지 않으므로, 네트워크 문구 + 새로고침
|
||||
* 버튼은 두 겹의 거짓 안내가 된다.
|
||||
*
|
||||
* @effects blocked_branch_is_emitted_with_https_http_judgment
|
||||
*/
|
||||
public function test_혼합_콘텐츠_차단을_별도_사유로_가른다(): void
|
||||
{
|
||||
foreach (['user', 'admin'] as $templateType) {
|
||||
$html = $this->render($templateType);
|
||||
|
||||
$this->assertStringContainsString(
|
||||
"location.protocol === 'https:'",
|
||||
$html,
|
||||
"[{$templateType}] 차단 판정이 페이지 스킴을 보지 않는다"
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
"indexOf('http://') === 0",
|
||||
$html,
|
||||
"[{$templateType}] 차단 판정이 자산 스킴을 보지 않는다"
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
"blocked ? 'blocked' : 'network'",
|
||||
$html,
|
||||
"[{$templateType}] 폴백 마커가 blocked 값을 낼 수 없다"
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
'(incompatible || blocked)',
|
||||
$html,
|
||||
"[{$templateType}] 차단 경로에서 새로고침 버튼이 생략되지 않는다"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단 안내는 화면(방문자)과 콘솔(운영자)로 대상을 나눈다 (#124).
|
||||
*
|
||||
* 화면 문구에 서버 설정 지시를 쓰면 방문자에게 실행 불가능한 지시를 주는 셈이고,
|
||||
* 원인을 통째로 감추면 운영자가 도달할 통로가 사라진다.
|
||||
*
|
||||
* @effects blocked_notice_splits_visitor_screen_and_operator_console
|
||||
*/
|
||||
public function test_차단_안내는_화면과_콘솔로_대상을_나눈다(): void
|
||||
{
|
||||
$html = $this->render();
|
||||
|
||||
// 콘솔에는 운영자 기준 원인·조치가 영문으로 남는다 (기존 로그와 같은 계열).
|
||||
$this->assertStringContainsString('Blocked as mixed content', $html);
|
||||
$this->assertStringContainsString('TRUSTED_PROXIES', $html);
|
||||
$this->assertStringContainsString('docs/backend/reverse-proxy.md', $html);
|
||||
|
||||
$original = app()->getLocale();
|
||||
|
||||
try {
|
||||
foreach (['ko', 'en'] as $locale) {
|
||||
app()->setLocale($locale);
|
||||
|
||||
foreach (['blocked_title', 'blocked_message'] as $key) {
|
||||
$value = __("errors.bootstrap.{$key}");
|
||||
|
||||
$this->assertNotSame("errors.bootstrap.{$key}", $value, "[{$locale}] errors.bootstrap.{$key} 미정의");
|
||||
$this->assertNotSame('', trim($value), "[{$locale}] errors.bootstrap.{$key} 가 비어 있다");
|
||||
}
|
||||
|
||||
// 사유가 다르면 문구도 달라야 한다 — 같으면 분기가 무의미하다.
|
||||
$this->assertNotSame(
|
||||
__('errors.bootstrap.message'),
|
||||
__('errors.bootstrap.blocked_message'),
|
||||
"[{$locale}] 차단 문구가 네트워크 문구와 동일하다"
|
||||
);
|
||||
|
||||
// 화면 문구는 방문자 기준 — 서버 설정 지시를 담지 않는다.
|
||||
$this->assertStringNotContainsString(
|
||||
'TRUSTED_PROXIES',
|
||||
__('errors.bootstrap.blocked_message'),
|
||||
"[{$locale}] 방문자 화면에 서버 설정 지시가 노출된다"
|
||||
);
|
||||
$this->assertStringNotContainsString(
|
||||
'.env',
|
||||
__('errors.bootstrap.blocked_message'),
|
||||
"[{$locale}] 방문자 화면에 서버 설정 지시가 노출된다"
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
app()->setLocale($original);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_파싱_오류_관측_리스너가_설치된다(): void
|
||||
{
|
||||
$html = $this->render();
|
||||
@@ -115,11 +205,18 @@ class BootstrapFallbackDiagnosisTest extends TestCase
|
||||
$this->assertArrayHasKey('bootstrap', $ja);
|
||||
$this->assertArrayHasKey('incompatible_title', $ja['bootstrap']);
|
||||
$this->assertArrayHasKey('incompatible_message', $ja['bootstrap']);
|
||||
$this->assertArrayHasKey('blocked_title', $ja['bootstrap']);
|
||||
$this->assertArrayHasKey('blocked_message', $ja['bootstrap']);
|
||||
$this->assertNotSame(
|
||||
$ja['bootstrap']['message'],
|
||||
$ja['bootstrap']['incompatible_message'],
|
||||
'ja 비호환 문구가 네트워크 문구와 동일하다'
|
||||
);
|
||||
$this->assertNotSame(
|
||||
$ja['bootstrap']['message'],
|
||||
$ja['bootstrap']['blocked_message'],
|
||||
'ja 차단 문구가 네트워크 문구와 동일하다'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_인라인_부트스트랩은_구형_브라우저_문법만_사용한다(): void
|
||||
|
||||
@@ -114,6 +114,80 @@ async function assertNetworkFailure(page: Page, url: string): Promise<void> {
|
||||
expect(attempts).toBe(3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 혼합 콘텐츠 차단 케이스 본문 — 회선은 멀쩡한데 브라우저가 요청을 거부하는 실패다 (공개 #124).
|
||||
*
|
||||
* 문서 HTML 의 코어 스크립트 URL 만 `http://` 로 바꾸면 재현된다 — TLS 종단 프록시도
|
||||
* 인증서도 필요 없다. 프록시 뒤에서 신뢰 프록시가 지정되지 않았을 때 서버가 만들어 내는
|
||||
* 것이 바로 이 HTML 이기 때문이다.
|
||||
*
|
||||
* 판정은 `location.protocol === 'https:'` 를 보므로, 기대값은 실행 시점 base URL 의
|
||||
* 스킴에서 파생한다 — http base 에서는 이 실패가 그냥 도달 불가이므로 `network` 가 정답이다.
|
||||
* 분기 자체의 방출은 스킴과 무관하게 BootstrapFallbackDiagnosisTest 가 단언한다.
|
||||
*
|
||||
* @param page Playwright page
|
||||
* @param url 진입 경로
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async function assertMixedContentBlocked(page: Page, url: string): Promise<void> {
|
||||
const consoleErrors: string[] = [];
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') consoleErrors.push(m.text());
|
||||
});
|
||||
|
||||
// 문서 응답을 가로채 코어 번들 URL 만 절대 http:// 로 바꾼다.
|
||||
// 서버는 이 URL 을 절대(`https://host/build/core/…`) 또는 상대(`/build/core/…`) 어느
|
||||
// 형태로도 낼 수 있으므로 두 경우를 모두 정규화해 다룬다 — 한 형태만 가정하면 치환이
|
||||
// 조용히 0건이 되어 테스트가 아무것도 재현하지 않은 채 실패한다.
|
||||
let rewritten = 0;
|
||||
|
||||
await page.route('**/*', async (route) => {
|
||||
if (route.request().resourceType() !== 'document') return route.fallback();
|
||||
|
||||
const response = await route.fetch();
|
||||
const body = await response.text();
|
||||
const origin = new URL(route.request().url()).origin;
|
||||
|
||||
const patched = body.replace(
|
||||
/src="([^"]*\/build\/core\/template-engine\.min\.js[^"]*)"/g,
|
||||
(whole, src: string) => {
|
||||
const abs = new URL(src, origin);
|
||||
if (abs.protocol !== 'https:') return whole;
|
||||
abs.protocol = 'http:';
|
||||
rewritten += 1;
|
||||
return `src="${abs.toString()}"`;
|
||||
}
|
||||
);
|
||||
|
||||
return route.fulfill({ response, body: patched });
|
||||
});
|
||||
|
||||
await page.goto(url);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
const isHttpsBase = new URL(page.url()).protocol === 'https:';
|
||||
if (isHttpsBase) {
|
||||
// 치환이 실제로 일어났는지 확인한다 — 0 건이면 아래 단언은 결함이 아니라
|
||||
// 재현 실패를 보고하게 된다.
|
||||
expect(rewritten, '코어 번들 URL 치환이 0 건입니다 — 재현되지 않았습니다').toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const isHttps = isHttpsBase;
|
||||
const fallback = await readFallback(page);
|
||||
|
||||
expect(fallback.reason).toBe(isHttps ? 'blocked' : 'network');
|
||||
expect(fallback.title.length).toBeGreaterThan(0);
|
||||
// 차단은 새로고침으로 낫지 않는다 — 버튼을 두면 그것 자체가 다시 거짓 안내가 된다.
|
||||
expect(fallback.buttons).toBe(isHttps ? 0 : 1);
|
||||
|
||||
if (isHttps) {
|
||||
// 화면에서 뺀 원인·조치는 콘솔에서 운영자에게 도달해야 한다.
|
||||
const joined = consoleErrors.join(' | ');
|
||||
expect(joined).toMatch(/Blocked as mixed content/);
|
||||
expect(joined).toMatch(/TRUSTED_PROXIES/);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 정상 부팅 케이스 본문 — 폴백이 오발동하지 않아야 한다.
|
||||
*
|
||||
@@ -175,6 +249,22 @@ test.describe('부팅 실패 사유별 안내 분기 (공개 #121)', () => {
|
||||
await assertNetworkFailure(page, '/admin');
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario entrypoint=user, failure_mode=mixed_content_blocked
|
||||
* @effects mixed_content_blocked_renders_blocked_notice, blocked_notice_omits_reload_button
|
||||
*/
|
||||
test('@smoke [user] 코어 번들이 http 로 차단 → 사이트 설정 안내 + 새로고침 버튼 부재', async ({ page }) => {
|
||||
await assertMixedContentBlocked(page, '/');
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario entrypoint=admin, failure_mode=mixed_content_blocked
|
||||
* @effects mixed_content_blocked_renders_blocked_notice, blocked_notice_omits_reload_button
|
||||
*/
|
||||
test('@smoke [admin] 코어 번들이 http 로 차단 → 사이트 설정 안내 + 새로고침 버튼 부재', async ({ page }) => {
|
||||
await assertMixedContentBlocked(page, '/admin');
|
||||
});
|
||||
|
||||
/**
|
||||
* @scenario entrypoint=user, failure_mode=none
|
||||
* @effects normal_boot_renders_no_fallback
|
||||
|
||||
@@ -12,18 +12,25 @@ description: |
|
||||
- window 의 error 이벤트로 번들 SyntaxError 를 관측(element 이벤트로는 구분 불가)
|
||||
- 관측되면 renderFallback('incompatible') — 전용 문구 + 새로고침 버튼 생략
|
||||
- 관측되지 않으면 renderFallback('corrupt') — 기존 문구 유지(사유 미상)
|
||||
- 재시도 소진 경로는 renderFallback('network') — 기존 문구 + 버튼 유지
|
||||
- 재시도 소진 경로는 두 갈래다 — HTTPS 페이지가 http:// 자산을 요청해 브라우저가
|
||||
차단한 경우는 renderFallback('blocked'), 그 외는 renderFallback('network')
|
||||
- 'blocked' 는 회선이 멀쩡하고 새로고침으로도 낫지 않으므로 버튼을 렌더하지 않고,
|
||||
화면 문구는 방문자 기준(서버 설정 지시 없음) · 원인과 조치는 콘솔에 운영자 기준
|
||||
- 코어 엔진 번들에서 정규식 lookbehind 제거(파싱 하한 Safari 16.4 → 14.1)
|
||||
|
||||
axes:
|
||||
entrypoint: [user, admin]
|
||||
failure_mode: [parse_error, network_exhausted, none]
|
||||
failure_mode: [parse_error, network_exhausted, mixed_content_blocked, none]
|
||||
|
||||
effects:
|
||||
- syntax_error_renders_incompatible_notice
|
||||
- incompatible_notice_omits_reload_button
|
||||
- network_failure_renders_network_notice
|
||||
- network_notice_keeps_reload_button
|
||||
- blocked_branch_is_emitted_with_https_http_judgment
|
||||
- mixed_content_blocked_renders_blocked_notice
|
||||
- blocked_notice_omits_reload_button
|
||||
- blocked_notice_splits_visitor_screen_and_operator_console
|
||||
- normal_boot_renders_no_fallback
|
||||
- shipped_core_bundle_has_no_lookbehind
|
||||
- bootstrap_partial_emits_reason_branch
|
||||
@@ -37,6 +44,13 @@ test_files:
|
||||
- resources/js/core/template-engine/__tests__/DataBindingEngine.test.ts
|
||||
|
||||
notes: |
|
||||
`blocked` 축(공개 #124)은 프록시 없이 재현한다 — 문서 HTML 의 코어 스크립트 URL 만
|
||||
`http://` 로 바꾸면 브라우저가 혼합 콘텐츠로 차단하므로, 인증서도 TLS 종단 프록시도
|
||||
필요 없다. 다만 판정이 `location.protocol === 'https:'` 를 보므로, E2E 의 기대값은
|
||||
실행 시점 base URL 의 스킴에서 파생된다(http base 에서는 `network` 가 정답이다).
|
||||
분기 자체의 방출과 문구·대상 분리는 BootstrapFallbackDiagnosisTest 가 스킴과 무관하게
|
||||
단언한다.
|
||||
|
||||
로케일 축은 E2E cross product 에 넣지 않는다 — 화면 문구는 서버가 렌더한 문자열을
|
||||
그대로 심는 구조라 브라우저 경로가 로케일마다 갈리지 않기 때문이다. ko/en/ja 문구의
|
||||
실존과 상호 구분은 BootstrapFallbackDiagnosisTest 가 직접 단언하고, 번들 ja 언어팩의
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# audit:allow test-scenario-coverage reason: 본 매니페스트는 신규 기능 시나리오 매트릭스 SSoT 로 기록. 축 곱(6×3×3×3×2×2 = 648)이 커 전 조합 docblock 매핑은 비현실적이며, 핵심 회귀 가드는 test_files 의 통과 테스트로 커버.
|
||||
|
||||
feature: 리버스 프록시 신뢰 설정 (Trusted Proxy)
|
||||
|
||||
description: |
|
||||
TLS 가 앞단에서 종단되고 앱에는 HTTP 로 전달되는 구성(AWS ALB, CloudFront, Cloudflare,
|
||||
nginx/Apache 리버스 프록시, ngrok)에서 요청 객체가 접속 주소와 방문자 IP 를 올바르게
|
||||
인식하도록 신뢰할 프록시를 지정한다 (공개 #124).
|
||||
|
||||
배경:
|
||||
- 코어에 신뢰 프록시 설정이 전혀 없어, Laravel 내장 TrustProxies 미들웨어가
|
||||
X-Forwarded-* 를 전부 무시했다. 그 결과 요청이 평문 HTTP 로 인식되어
|
||||
asset()/url() 이 http:// 절대 URL 을 만들고, HTTPS 페이지에서 혼합 콘텐츠로
|
||||
차단되어 사이트 전체가 백지가 되었다.
|
||||
- 같은 원인에서 통보 IP 화이트리스트 403, 로그인 시도 제한 붕괴, IP 기록 왜곡,
|
||||
쿠키 Secure 누락, SEO canonical 다운그레이드, GeoIP 오판정이 함께 갈라진다.
|
||||
|
||||
조치:
|
||||
- config/trustedproxy.php 1개 (`'proxies' => env('TRUSTED_PROXIES')`).
|
||||
코어 PHP 코드 수정 0줄 — 내장 미들웨어가 요청 시점에 이 키를 폴백으로 읽는다.
|
||||
- bootstrap/app.php 는 건드리지 않는다. withMiddleware 클로저는 .env 로드 전에
|
||||
평가되어 env() 가 항상 null 이므로 그 경로는 조용한 no-op 이 된다.
|
||||
- 미설정이 기본값이므로 기존 설치처 동작은 바뀌지 않는다(opt-in).
|
||||
|
||||
진단 (읽기 전용, 4면):
|
||||
- 판정식은 "HTTPS 인식 실패" 가 아니라
|
||||
`X-Forwarded-* 수신 중 AND 신뢰 프록시 미설정` 이다. HTTP 전용 사이트가 프록시
|
||||
뒤에 있으면 화면은 완전히 정상 렌더되면서 나머지 파손만 조용히 계속되기 때문이다.
|
||||
- 판정은 App\Support\TrustedProxyDiagnostic 한 곳에서만 계산하고
|
||||
대시보드 알림 · 환경설정 고급 탭 · 설치 마법사 · Artisan 커맨드가 소비한다.
|
||||
- 값 편집 UI 는 두지 않는다 — 잠금 역설(프록시 뒤에서는 그 화면 자체가 뜨지 않는다)
|
||||
과 권한 승격(관리자 계정 탈취가 곧 XFF 위조 경로)이 이유다.
|
||||
|
||||
axes:
|
||||
proxies_setting: [unset, star, double_star, single_ip_match, single_ip_mismatch, cidr]
|
||||
forwarded_proto: [https, http, absent]
|
||||
forwarded_for: [single, chain, absent]
|
||||
forwarded_host: [same, different, absent]
|
||||
forwarded_port: [nonstandard, absent]
|
||||
session_secure_cookie: [unset, true]
|
||||
|
||||
exclusions:
|
||||
- { proxies_setting: unset, forwarded_proto: https, reason: "미설정은 헤더를 전부 무시 — 헤더 조합이 결과를 바꾸지 않는다" }
|
||||
- { proxies_setting: unset, forwarded_proto: http, reason: "동일 사유" }
|
||||
- { proxies_setting: unset, forwarded_for: chain, reason: "동일 사유" }
|
||||
- { proxies_setting: unset, forwarded_host: different, reason: "동일 사유" }
|
||||
- { proxies_setting: unset, forwarded_port: nonstandard, reason: "동일 사유" }
|
||||
- { proxies_setting: single_ip_mismatch, forwarded_proto: https, reason: "목록 불일치는 미설정과 동일 경로 — 헤더 조합이 결과를 바꾸지 않는다" }
|
||||
- { proxies_setting: single_ip_mismatch, forwarded_for: chain, reason: "동일 사유" }
|
||||
- { proxies_setting: single_ip_mismatch, forwarded_host: different, reason: "동일 사유" }
|
||||
- { forwarded_proto: absent, session_secure_cookie: unset, reason: "스킴 헤더가 없으면 쿠키 Secure 자동 판정 축이 성립하지 않는다" }
|
||||
- { session_secure_cookie: true, forwarded_proto: http, reason: "명시 설정은 스킴 판정을 우회하므로 조합이 무의미하다" }
|
||||
- { session_secure_cookie: true, forwarded_proto: absent, reason: "동일 사유" }
|
||||
|
||||
effects:
|
||||
- unset_ignores_forwarded_headers
|
||||
- star_restores_scheme_ip_and_root
|
||||
- double_star_behaves_like_star
|
||||
- chain_requires_listing_every_hop
|
||||
- ip_list_match_trusts_and_mismatch_rejects
|
||||
- cidr_notation_is_honored
|
||||
- config_file_exposes_proxies_key_from_env
|
||||
- bootstrap_does_not_reintroduce_env_trust_proxies
|
||||
- env_examples_document_trusted_proxies
|
||||
- installer_header_list_matches_core_diagnostic
|
||||
- cookie_secure_follows_trusted_scheme
|
||||
- direct_exposure_is_not_a_warning
|
||||
- forwarded_without_config_is_warning
|
||||
- http_only_behind_proxy_still_warns
|
||||
- configured_proxy_is_ok
|
||||
- empty_string_counts_as_unset
|
||||
- console_context_is_not_applicable
|
||||
- dashboard_alert_only_on_warning_with_warning_type
|
||||
- diagnostic_endpoint_is_read_only
|
||||
|
||||
test_files:
|
||||
- tests/Feature/Http/TrustedProxyContractTest.php
|
||||
- tests/Feature/Http/TrustedProxyDiagnosticTest.php
|
||||
|
||||
notes: |
|
||||
`**` 는 Laravel 내장 TrustProxies 에서 `*` 과 **같은 코드 경로**를 탄다
|
||||
(`setTrustedProxyIpAddressesToTheCallingIp`). 별도 패키지 시절의 "모든 프록시 신뢰"
|
||||
의미가 아니므로, 다단계 체인에서는 둘 다 마지막 프록시를 방문자로 본다. 실측으로
|
||||
확인했고 `double_star_behaves_like_star` 가 그 사실을 계약으로 잠근다 — 프레임워크
|
||||
업그레이드로 의미가 갈라지면 문서·설정 주석과 함께 갱신해야 한다.
|
||||
|
||||
프록시를 실제로 경유하는 브라우저 축(혼합 콘텐츠 차단 → 화면 백지)은 이 매니페스트
|
||||
범위 밖이다. Playwright 에 TLS 종단 프록시 픽스처를 두면 인증서·포트·globalSetup 이
|
||||
기존 E2E 의 단일 origin 전제를 깨뜨리므로, 그 축은 Chrome MCP 수동 매트릭스가 덮는다.
|
||||
부팅 안내의 `blocked` 분기는 프록시 없이 재현 가능하므로
|
||||
`tests/scenarios/bootstrap-parse-failure.yaml` 이 별도로 다룬다.
|
||||
|
||||
이미 발송된 서명 URL 무효화 · 과거 적재 IP · SEO 캐시/사이트맵 재생성은 설정을 켜도
|
||||
자동으로 낫지 않는 1회성 후속 조치다. 코드가 아니라 운영 절차이므로 축에 넣지 않고
|
||||
docs/backend/reverse-proxy.md 가 문서로 고정한다.
|
||||
Reference in New Issue
Block a user