From 8dc77f87ab7b5f718cc94354461b32b5a02ff658 Mon Sep 17 00:00:00 2001 From: devmrko Date: Sun, 28 Jun 2026 23:03:32 +0900 Subject: [PATCH] fix #547: secure VM deployment behind HTTPS proxy --- .env.example | 5 + deploy/caddy/Caddyfile.template | 17 ++ deploy/caddy/install-caddy-config.sh | 53 ++++ docs/design/547-vm-https-hardening/README.md | 125 ++++++++ docs/runbooks/547-vm-https-operations.md | 109 +++++++ scripts/configure-backoffice-https-vm.sh | 282 ++++++++++++++++++ scripts/deploy-backoffice-vm.sh | 13 +- scripts/test-backoffice-https-config.sh | 52 ++++ .../config/BackofficeProperties.java | 2 +- .../vpdbackoffice/config/SecurityConfig.java | 12 +- src/main/resources/application.yml | 9 + .../config/TransportSecurityTest.java | 142 +++++++++ 12 files changed, 818 insertions(+), 3 deletions(-) create mode 100644 deploy/caddy/Caddyfile.template create mode 100755 deploy/caddy/install-caddy-config.sh create mode 100644 docs/design/547-vm-https-hardening/README.md create mode 100644 docs/runbooks/547-vm-https-operations.md create mode 100755 scripts/configure-backoffice-https-vm.sh create mode 100755 scripts/test-backoffice-https-config.sh create mode 100644 src/test/java/com/cloudhandson/vpdbackoffice/config/TransportSecurityTest.java diff --git a/.env.example b/.env.example index 07ddf89..c03ad15 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,11 @@ export BACKOFFICE_DB_PASSWORD="${ADB_PASSWORD}" # --- (2b) Spring Boot 백오피스 --- export BACKOFFICE_ADMIN_USER="admin" export BACKOFFICE_ADMIN_PASSWORD="admin" +# 로컬 HTTP 개발 기본값. 외부 VM 배포 스크립트는 loopback/HTTPS 안전값으로 덮어씁니다. +export BACKOFFICE_BIND_ADDRESS="0.0.0.0" +export BACKOFFICE_FORWARD_HEADERS_STRATEGY="none" +export BACKOFFICE_REQUIRE_HTTPS="false" +export BACKOFFICE_SESSION_COOKIE_SECURE="false" export BACKOFFICE_ORDS_BASE_URL="https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords" export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10" # ORDS metadata 생성/수정 전용 계정. 비워두면 BACKOFFICE_DB_* 연결을 사용하므로 diff --git a/deploy/caddy/Caddyfile.template b/deploy/caddy/Caddyfile.template new file mode 100644 index 0000000..ad5820f --- /dev/null +++ b/deploy/caddy/Caddyfile.template @@ -0,0 +1,17 @@ +{ + email {{TLS_EMAIL}} +} + +{{PUBLIC_HOST}} { + encode zstd gzip + {{TLS_OPTIONS}} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "same-origin" + -Server + } + + reverse_proxy 127.0.0.1:{{APP_PORT}} +} diff --git a/deploy/caddy/install-caddy-config.sh b/deploy/caddy/install-caddy-config.sh new file mode 100755 index 0000000..6f51de1 --- /dev/null +++ b/deploy/caddy/install-caddy-config.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Runs on the target VM after a candidate Caddyfile has been uploaded. +set -Eeuo pipefail + +CANDIDATE="${1:?candidate Caddyfile is required}" +APP_DIR="${2:?application directory is required}" +TARGET="/etc/caddy/Caddyfile" + +[[ -f "$CANDIDATE" ]] || { echo "candidate Caddyfile not found: $CANDIDATE" >&2; exit 1; } +command -v caddy >/dev/null 2>&1 || { echo "caddy is not installed" >&2; exit 1; } +command -v systemctl >/dev/null 2>&1 || { echo "systemctl is not installed" >&2; exit 1; } +sudo -n true || { echo "passwordless sudo is required for Caddy configuration" >&2; exit 1; } +sudo test -f "$TARGET" || { echo "existing $TARGET not found; install the official Caddy package first" >&2; exit 1; } + +BACKUP_DIR="$APP_DIR/caddy-backups" +mkdir -p "$BACKUP_DIR" +BACKUP="$BACKUP_DIR/Caddyfile.$(date -u +%Y%m%dT%H%M%SZ)" +sudo cat "$TARGET" > "$BACKUP" +chmod 600 "$BACKUP" + +was_active="N" +if sudo systemctl is-active --quiet caddy; then + was_active="Y" +fi + +rollback() { + status=$? + trap - ERR + echo "Caddy 적용 실패, 이전 설정 복원: $BACKUP" >&2 + sudo install -o root -g root -m 644 "$BACKUP" "$TARGET" + if [[ "$was_active" == "Y" ]]; then + sudo systemctl reload caddy || true + else + sudo systemctl stop caddy || true + fi + exit "$status" +} +trap rollback ERR + +sudo caddy validate --config "$CANDIDATE" --adapter caddyfile +sudo install -o root -g root -m 644 "$CANDIDATE" "$TARGET" + +if [[ "$was_active" == "Y" ]]; then + sudo systemctl reload caddy +else + sudo systemctl enable --now caddy +fi + +sudo systemctl is-active --quiet caddy +sudo systemctl is-enabled --quiet caddy +trap - ERR + +printf 'caddy active; backup=%s\n' "$BACKUP" diff --git a/docs/design/547-vm-https-hardening/README.md b/docs/design/547-vm-https-hardening/README.md new file mode 100644 index 0000000..8a1fbc7 --- /dev/null +++ b/docs/design/547-vm-https-hardening/README.md @@ -0,0 +1,125 @@ +# 설계서: 외부 VM HTTPS 및 직접 포트 차단 (#547) + +> **상태**: Approved +> **작성**: [AI] Architect · **최종수정**: 2026-06-28 +> **추적성** — Redmine: #547 · 관련 ADR: 없음 +> · 구현 파일: `src/main/resources/application.yml`, `SecurityConfig.java`, `scripts/deploy-backoffice-vm.sh`, `scripts/configure-backoffice-https-vm.sh`, `deploy/caddy/Caddyfile.template` +> · 테스트: `TransportSecurityTest.java`, `scripts/test-backoffice-https-config.sh` + +## 1. 목적 (Why) + +외부 VM에서 관리자 자격증명과 세션 쿠키가 평문 HTTP로 전송되고 애플리케이션 포트 `8082`가 직접 노출되는 경로를 제거한다. + +## 2. 범위 (Scope) + +- **포함**: 공개 DNS 이름 또는 통제 가능한 public IPv4 기반 HTTPS, HTTP→HTTPS 전환, Caddy TLS termination과 인증서 자동 갱신, 애플리케이션 루프백 바인딩, forwarded header 처리, Secure/HttpOnly/SameSite 세션 쿠키, HSTS, 배포·검증·롤백 절차. +- **제외**: OCI NSG와 VM firewalld의 실제 변경, DNS 레코드 생성, Caddy 패키지 설치, 기본 `admin/admin` 제거(#548), CSP/CDN 공급망 강화(#549). + +## 3. 인수조건 (Acceptance Criteria) + +- [ ] 외부 HTTP 요청은 HTTPS로 전환되고 로그인 폼은 HTTPS에서만 제출된다. +- [ ] 애플리케이션은 VM의 `127.0.0.1:8082`에만 바인딩되어 외부 직접 접속이 실패한다. +- [ ] HTTPS 로그인 응답의 `JSESSIONID`에 `Secure`, `HttpOnly`, `SameSite=Lax`가 있다. +- [ ] HTTPS 응답에 1년 HSTS가 있고, 전달된 scheme/host가 redirect 생성에 반영된다. +- [ ] 공개 프록시는 클라이언트가 보낸 `X-Forwarded-*`를 신뢰하지 않고 직접 다시 설정한다. +- [ ] 인증서 갱신·기동·헬스체크·롤백 절차가 자동화 스크립트와 런북에 있다. + +## 4. 컨텍스트 & 제약 + +- Caddy automatic HTTPS는 외부 80/443 접근과 지속 가능한 인증서 저장소를 전제로 인증서를 발급·갱신하고 HTTP를 HTTPS로 전환한다. +- Let’s Encrypt public IPv4 인증서는 2026년부터 정식 지원되며 `shortlived` ACME profile과 약 6일 수명을 사용한다. Caddy가 profile과 갱신을 지속적으로 관리한다. +- FQDN이 있으면 일반 공개 인증서를 우선하고, DNS가 준비되지 않은 단일 VM은 통제 중인 고정 public IPv4 인증서를 사용할 수 있다. Caddy 로컬 CA 인증서는 운영에 사용하지 않는다. +- 애플리케이션의 forwarded header 처리는 연결 원본 CIDR을 자체 판별하지 않는다. 따라서 `127.0.0.1` 바인딩을 보안 경계로 삼아 같은 VM의 Caddy만 접근시킨다. +- Caddy는 인터넷에 직접 연결되는 첫 프록시다. CDN/로드밸런서를 추가할 때만 명시적인 `trusted_proxies` CIDR 검토가 필요하다. +- 방화벽/NSG 변경은 서비스 영향과 잠금 위험이 있어 스크립트가 자동 수행하지 않는다. + +## 5. 아키텍처 개요 + +```text +Internet client + ├─ HTTP :80 ───────> Caddy ── 308 HTTPS redirect + └─ HTTPS :443 ─TLS─> Caddy ── X-Forwarded-* 재생성 + │ + └─ HTTP 127.0.0.1:8082 + Spring Boot + ├─ require-https + ├─ HSTS + └─ Secure/HttpOnly/SameSite cookie + +Internet client ── HTTP :8082 ──X (loopback bind + firewalld/NSG deny) +``` + +- `deploy/caddy/Caddyfile.template`: TLS/redirect/HSTS/reverse proxy의 선언적 설정. +- `scripts/configure-backoffice-https-vm.sh`: 입력 검증, 원격 설정 검증·백업·적용·확인. +- `scripts/deploy-backoffice-vm.sh`: 앱 배포 시 운영 보안 환경값을 강제하고 루프백 헬스체크. +- Spring 설정: proxy가 전달한 HTTPS scheme을 인식하고 직접 HTTP 요청을 거부한다. +- 순수 경계 검증(호스트명, 이메일, 포트, 템플릿 렌더링)은 로컬 dry-run 테스트로 검증하고, SSH/systemd/ACME는 명시적 운영 단계에서 수행한다. + +## 6. 설정 모델 + +| 설정 | 운영값 | 검증 | +|---|---|---| +| `BACKOFFICE_BIND_ADDRESS` | `127.0.0.1` | HTTPS 배포 스크립트가 고정 | +| `BACKOFFICE_FORWARD_HEADERS_STRATEGY` | `framework` | 배포 환경 override | +| `BACKOFFICE_REQUIRE_HTTPS` | `true` | 배포 환경 override | +| `BACKOFFICE_SESSION_COOKIE_SECURE` | `true` | 배포 환경 override | +| public host | 소유한 FQDN 또는 고정 public IPv4 | FQDN/IP 문법과 expected address 일치 검증 | +| TLS email | ACME 운영 이메일 | 공백/개행/비정상 형식 거부 | +| app port | `8082` | 1–65535 숫자 | + +로컬 개발은 기존 HTTP 흐름을 보존하기 위해 HTTPS 강제와 Secure cookie의 기본값을 `false`로 둔다. VM 배포 산출물에서만 안전한 운영값으로 덮어쓴다. + +## 7. 함수/스크립트 명세 + +| 함수/스크립트 | 책임 | 입력 | 출력 | 실패 | +|---|---|---|---|---| +| `securityFilterChain` | 인증, HTTPS channel, HSTS 구성 | 보안 설정 | servlet filter chain | 구성 오류 시 기동 실패 | +| `deploy-backoffice-vm.sh` | jar/env/wallet 배포와 내부 헬스체크 | SSH 대상, 포트 | 루프백 앱 | SSH/build/start 실패 | +| `configure-backoffice-https-vm.sh` | Caddy 설정 적용·검증 | FQDN, TLS email, SSH 대상 | HTTPS endpoint | DNS/Caddy/sudo/TLS 검증 실패 | +| `test-backoffice-https-config.sh` | 입력 거부와 Caddy 템플릿 검증 | 로컬 저장소 | PASS/FAIL | assertion 실패 | + +## 8. 적용 흐름 + +1. 소유한 FQDN의 A/AAAA 레코드를 VM에 연결하거나, 고정 public IPv4를 endpoint로 확정한다. +2. 개별 승인으로 NSG/firewalld에서 80/443을 열고 8082 ingress를 제거한다. +3. VM에 공식 Caddy 패키지와 systemd service가 준비됐는지 확인한다. +4. 앱을 재배포해 `127.0.0.1:8082`, forwarded headers, HTTPS 강제, Secure cookie를 활성화한다. +5. Caddy 설정을 문법 검증한 후 기존 설정을 timestamp backup하고 원자적으로 설치한다. +6. HTTP redirect, TLS 신뢰, HSTS, cookie attributes, `:8082` 차단을 외부에서 검증한다. + +## 9. 엣지케이스 & 에러 처리 + +- FQDN DNS가 아직 VM을 가리키지 않으면 Caddy 설정을 적용하지 않는다. +- IPv4 endpoint는 expected address와 같아야 하며 `shortlived` profile 없는 설정을 허용하지 않는다. +- wildcard hostname은 이 단일 VM 스크립트에서 거부한다. +- Caddy 설정 검증 또는 reload가 실패하면 직전 Caddyfile을 복원하고 reload한다. +- 외부 HTTPS 검증이 실패해도 앱의 루프백 프로세스는 유지하며 로그와 복구 명령을 출력한다. +- Caddy 장애 시 `8082`를 공개하는 방식으로 우회하지 않는다. 직전 Caddyfile 복원 또는 앱/프록시 동시 롤백만 허용한다. + +## 10. 테스트 계획 + +- Maven 통합 테스트: + - 직접 HTTP 요청이 HTTPS로 redirect되는지 확인. + - `X-Forwarded-Proto=https`, `X-Forwarded-Host`를 적용한 요청이 정상 처리되고 HSTS가 있는지 확인. + - 운영 환경변수가 Secure/HttpOnly/SameSite cookie 설정에 결합되는지 확인. +- 셸 테스트: + - FQDN/email/port 입력 검증. + - 렌더링된 Caddyfile에 public host, HSTS, 루프백 upstream이 있는지 확인. + - 배포 dry-run이 루프백/HTTPS 운영 override를 표시하는지 확인. +- 운영 검증: + - `curl -I http:///login` + - `curl -I https:///login` + - HTTPS GET의 `Set-Cookie` + - 외부 `curl http://:8082/login` 실패 + - `systemctl is-active caddy`와 Caddy journal의 갱신 오류 확인. + +## 11. 리스크 & 대안 검토 + +- Caddy를 선택한 이유는 HTTP redirect, ACME 발급/갱신, reverse proxy를 하나의 짧은 선언으로 운영할 수 있기 때문이다. +- Nginx+Certbot은 가능하지만 인증서 갱신 hook과 설정 검증 경로가 분리되어 이 단일 VM PoC의 운영 표면이 더 넓다. +- Spring Boot 직접 TLS는 애플리케이션 재기동과 인증서 교체가 결합되고 80→443 처리 및 권한 분리가 불리해 제외한다. + +## 12. 미해결 질문 (배포 전 필수 입력) + +- 사용할 공개 FQDN 또는 `130.162.134.59` 고정 IP와 ACME 알림 이메일. +- DNS 변경 완료 여부와 80/443 NSG/firewalld 변경 승인. diff --git a/docs/runbooks/547-vm-https-operations.md b/docs/runbooks/547-vm-https-operations.md new file mode 100644 index 0000000..c7d9db5 --- /dev/null +++ b/docs/runbooks/547-vm-https-operations.md @@ -0,0 +1,109 @@ +# Redmine #547 - 외부 VM HTTPS 운영 런북 + +## 운영 구조 + +- 공개 endpoint: `https://<소유 FQDN>` 또는 `https://<고정 public IPv4>` +- Caddy: VM의 80/443에서 TLS termination, HTTP redirect, HSTS, 인증서 자동 발급·갱신 +- Spring Boot: `127.0.0.1:8082`에서만 수신 +- 외부 `8082/tcp`: OCI NSG와 VM firewalld 모두 deny + +FQDN을 쓰면 A 레코드는 VM public IP를 가리켜야 한다. DNS가 없는 고정 public IPv4는 Let’s Encrypt `shortlived` profile 기반 약 6일 인증서를 사용하며 Caddy 자동 갱신이 정상인지 반드시 모니터링한다. 임시 공개 DNS 서비스와 Caddy 로컬 CA 인증서는 운영 endpoint로 사용하지 않는다. + +## 최초 준비 + +아래 작업은 시스템 패키지와 방화벽을 바꾸므로 개별 승인을 받은 뒤 수행한다. + +1. 소유 FQDN의 A 레코드를 VM public IP로 설정하거나 고정 public IPv4 사용을 확정한다. +2. OCI NSG와 VM firewalld에서 80/443 ingress를 허용한다. +3. 기존 8082 ingress를 OCI NSG와 firewalld에서 제거한다. +4. Oracle Linux/RHEL 계열 VM에 공식 Caddy 패키지를 설치한다. + +```bash +sudo dnf install -y dnf-plugins-core +sudo dnf copr enable -y @caddy/caddy +sudo dnf install -y caddy +``` + +공식 패키지는 `caddy.service`와 `/etc/caddy/Caddyfile`을 제공한다. 설정 스크립트가 service enable/start를 처리한다. + +## 적용 + +먼저 로컬 검증과 앱 배포를 수행한다. + +```bash +mvn test +scripts/test-backoffice-https-config.sh +scripts/deploy-backoffice-vm.sh --host hermes +``` + +HTTPS 설정을 dry-run으로 확인한 다음 적용한다. + +```bash +scripts/configure-backoffice-https-vm.sh \ + --host hermes \ + --public-host admin.example.com \ + --tls-email ops@example.com \ + --expected-address 130.162.134.59 \ + --dry-run + +scripts/configure-backoffice-https-vm.sh \ + --host hermes \ + --public-host admin.example.com \ + --tls-email ops@example.com \ + --expected-address 130.162.134.59 +``` + +실제 endpoint, 이메일, 주소로 바꿔 실행한다. FQDN 대신 IP를 쓰는 현재 hermes 예시는 `--public-host 130.162.134.59 --expected-address 130.162.134.59`다. 스크립트는 DNS/IP 일치, Caddy/sudo, Caddyfile 문법, service 상태를 확인하고 기존 설정을 `~/apps/vpd-backoffice/caddy-backups`에 저장한다. + +## 반복 검증 + +설정을 바꾸지 않고 외부 검증만 다시 수행할 수 있다. + +```bash +scripts/configure-backoffice-https-vm.sh \ + --host hermes \ + --public-host admin.example.com \ + --tls-email ops@example.com \ + --expected-address 130.162.134.59 \ + --verify-only +``` + +검증 항목: + +- HTTP `/login`이 동일 host의 HTTPS로 전환됨 +- HTTPS 인증서가 공개 신뢰됨 +- HSTS 1년 +- `JSESSIONID`의 Secure/HttpOnly/SameSite=Lax +- 외부 `:8082` 직접 연결 실패 + +## 인증서 갱신과 모니터링 + +Caddy는 공개 DNS 이름의 인증서를 자동 갱신하며, 공식 systemd service의 인증서 상태는 `/var/lib/caddy/.local/share/caddy`에 유지된다. 별도 cron이나 certbot hook을 추가하지 않는다. + +```bash +ssh hermes 'systemctl is-active caddy && systemctl is-enabled caddy' +ssh hermes 'sudo journalctl -u caddy --since "24 hours ago" --no-pager' +ssh hermes 'sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile' +``` + +ACME 오류, 인증서 만료 경고, 반복 reload 실패를 알림 대상으로 삼는다. VM 백업에서 Caddy data directory와 앱의 Caddyfile backup을 함께 보존한다. + +## 장애와 롤백 + +1. 앱이 살아 있는지 VM 내부에서 확인한다. + +```bash +ssh hermes 'curl -sS -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: admin.example.com" -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8082/login' +``` + +2. Caddy 로그와 설정을 확인한다. +3. 설정 변경 직후 장애라면 가장 최근 backup을 복원한다. + +```bash +ssh hermes 'ls -1t ~/apps/vpd-backoffice/caddy-backups/Caddyfile.* | head -n 3' +ssh hermes 'sudo install -o root -g root -m 644 ~/apps/vpd-backoffice/caddy-backups/Caddyfile. /etc/caddy/Caddyfile && sudo systemctl reload caddy' +``` + +4. 앱 jar 롤백이 필요하면 직전 승인된 artifact를 배포하고 앱과 Caddy를 모두 재검증한다. + +장애 우회를 위해 `8082`를 다시 공개하지 않는다. 서비스 중단이나 방화벽/NSG 롤백은 개별 승인을 받는다. diff --git a/scripts/configure-backoffice-https-vm.sh b/scripts/configure-backoffice-https-vm.sh new file mode 100755 index 0000000..40a37bc --- /dev/null +++ b/scripts/configure-backoffice-https-vm.sh @@ -0,0 +1,282 @@ +#!/usr/bin/env bash +# Configure and verify the HTTPS reverse proxy for VPD Backoffice. +set -Eeuo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# shellcheck disable=SC1091 +. "$ROOT/scripts/lib/common.sh" + +HOST="hermes" +PUBLIC_HOST="" +TLS_EMAIL="" +EXPECTED_ADDRESS="" +REMOTE_DIR='$HOME/apps/vpd-backoffice' +APP_PORT="8082" +IDENTITY_FILE="" +KNOWN_HOSTS_FILE="" +ACCEPT_HOST_KEY="N" +DRY_RUN="N" +VERIFY_ONLY="N" +INSPECT_ONLY="N" + +usage() { + cat <<'USAGE' +Usage: scripts/configure-backoffice-https-vm.sh \ + --public-host FQDN_OR_IPV4 --tls-email EMAIL --expected-address IPV4 [options] + +Options: + --host SSH_ALIAS SSH target (default: hermes) + --public-host FQDN_OR_IPV4 Owned public DNS name or controlled public IPv4 + --tls-email EMAIL ACME expiry/security notification address + --expected-address IPV4 Address that the public DNS name must resolve to + --remote-dir PATH Application directory (default: $HOME/apps/vpd-backoffice) + --app-port PORT Loopback Spring Boot port (default: 8082) + --identity-file KEY SSH private key + --known-hosts-file FILE Dedicated SSH known_hosts file + --accept-host-key Accept a new host key (not recommended for routine use) + --dry-run Validate and render locally without network access + --inspect Read-only VM listener/Caddy/firewalld preflight + --verify-only Skip configuration and run external checks only + +This script does not change DNS, OCI NSG, or firewalld rules. +USAGE +} + +validate_public_host() { + local value="${1:-}" + if [[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + validate_ipv4 "$value" + return + fi + [[ "$value" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]] \ + || die "공개 host는 소유한 FQDN 또는 public IPv4여야 합니다: $value" + [[ "$value" != *".."* && "$value" != *"*"* ]] \ + || die "wildcard/빈 label host는 허용되지 않습니다: $value" +} + +validate_tls_email() { + local value="${1:-}" + [[ "$value" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]] \ + || die "유효한 TLS 운영 이메일이 필요합니다: $value" +} + +validate_ssh_host() { + local value="${1:-}" + [[ "$value" =~ ^[A-Za-z0-9._-]+$ && "$value" != -* ]] \ + || die "SSH host alias에 허용되지 않은 문자가 있습니다: $value" +} + +validate_ipv4() { + local value="${1:-}" part octet + local -a parts + [[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] \ + || die "expected address는 IPv4 형식이어야 합니다: $value" + IFS=. read -r -a parts <<< "$value" + for part in "${parts[@]}"; do + octet=$((10#$part)) + (( octet >= 0 && octet <= 255 )) || die "IPv4 octet 범위가 잘못됐습니다: $value" + done +} + +validate_port() { + local value="${1:-}" port + [[ "$value" =~ ^[0-9]+$ ]] || die "port는 1..65535 숫자여야 합니다: $value" + port=$((10#$value)) + (( port >= 1 && port <= 65535 )) \ + || die "port는 1..65535 숫자여야 합니다: $value" +} + +validate_remote_dir() { + local value="${1:-}" + [[ "$value" =~ ^(\$HOME|~|/)[A-Za-z0-9._/-]+$ ]] \ + || die "remote-dir에 허용되지 않은 문자가 있습니다: $value" + [[ "$value" != *".."* && "$value" != *"//"* ]] \ + || die "remote-dir의 상위/빈 경로 segment는 허용되지 않습니다: $value" +} + +render_caddyfile() { + local output="${1:?output path is required}" tls_options="" + if [[ "$PUBLIC_HOST" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + tls_options=$'tls {\n\t\tissuer acme {\n\t\t\tprofile shortlived\n\t\t}\n\t}' + fi + awk \ + -v public_host="$PUBLIC_HOST" \ + -v tls_email="$TLS_EMAIL" \ + -v app_port="$APP_PORT" \ + -v tls_options="$tls_options" ' + { + gsub(/{{PUBLIC_HOST}}/, public_host) + gsub(/{{TLS_EMAIL}}/, tls_email) + gsub(/{{APP_PORT}}/, app_port) + gsub(/{{TLS_OPTIONS}}/, tls_options) + print + } + ' "$ROOT/deploy/caddy/Caddyfile.template" > "$output" +} + +build_ssh_args() { + SSH_ARGS=( + -o BatchMode=yes + -o ConnectTimeout=8 + -o ConnectionAttempts=1 + -o ServerAliveInterval=5 + -o ServerAliveCountMax=2 + ) + if [[ -n "$IDENTITY_FILE" ]]; then + [[ -f "$IDENTITY_FILE" ]] || die "identity file을 찾을 수 없습니다: $IDENTITY_FILE" + SSH_ARGS+=(-i "$IDENTITY_FILE") + fi + if [[ -n "$KNOWN_HOSTS_FILE" ]]; then + SSH_ARGS+=(-o "UserKnownHostsFile=$KNOWN_HOSTS_FILE") + fi + if [[ "$ACCEPT_HOST_KEY" == "Y" ]]; then + SSH_ARGS+=(-o StrictHostKeyChecking=accept-new) + else + SSH_ARGS+=(-o StrictHostKeyChecking=yes) + fi +} + +verify_dns() { + local addresses + if [[ "$PUBLIC_HOST" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + [[ "$PUBLIC_HOST" == "$EXPECTED_ADDRESS" ]] \ + || die "public IPv4와 expected address가 다릅니다: $PUBLIC_HOST != $EXPECTED_ADDRESS" + return + fi + addresses="$(getent ahostsv4 "$PUBLIC_HOST" | awk '{print $1}' | sort -u)" + [[ -n "$addresses" ]] || die "DNS A record를 조회할 수 없습니다: $PUBLIC_HOST" + grep -Fxq "$EXPECTED_ADDRESS" <<< "$addresses" \ + || die "$PUBLIC_HOST DNS가 expected address $EXPECTED_ADDRESS 를 가리키지 않습니다 (actual: ${addresses//$'\n'/,})" +} + +verify_external() { + local tmp_dir http_headers https_headers cookie_line location + tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/vpd-backoffice-https-verify.XXXXXX")" + http_headers="$tmp_dir/http.headers" + https_headers="$tmp_dir/https.headers" + + curl -sS --connect-timeout 8 --max-time 20 -D "$http_headers" -o /dev/null \ + "http://$PUBLIC_HOST/login" + location="$(awk 'BEGIN{IGNORECASE=1} /^Location:/{sub(/\r$/, "", $2); print $2; exit}' "$http_headers")" + [[ "$location" == "https://$PUBLIC_HOST/login" ]] \ + || die "HTTP→HTTPS redirect가 올바르지 않습니다: ${location:-missing}" + + curl -sS --connect-timeout 8 --max-time 20 -D "$https_headers" -o /dev/null \ + "https://$PUBLIC_HOST/login" + grep -Eiq '^Strict-Transport-Security:.*max-age=31536000' "$https_headers" \ + || die "HTTPS 응답에 1년 HSTS가 없습니다." + cookie_line="$(grep -Ei '^Set-Cookie:[[:space:]]*JSESSIONID=' "$https_headers" | head -n 1 || true)" + [[ "$cookie_line" == *"Secure"* && "$cookie_line" == *"HttpOnly"* ]] \ + || die "JSESSIONID Secure/HttpOnly 속성을 확인할 수 없습니다." + grep -Eiq '^Set-Cookie:.*JSESSIONID=.*SameSite=Lax' <<< "$cookie_line" \ + || die "JSESSIONID SameSite=Lax 속성을 확인할 수 없습니다." + + if curl -sS --connect-timeout 3 --max-time 5 -o /dev/null \ + "http://$PUBLIC_HOST:$APP_PORT/login" 2>/dev/null; then + die "외부에서 애플리케이션 포트 $APP_PORT 에 직접 접근할 수 있습니다." + fi + + rm -rf "$tmp_dir" + ok "HTTPS 검증 통과: redirect, TLS, HSTS, session cookie, direct-port deny" +} + +main() { + while [[ $# -gt 0 ]]; do + case "$1" in + --host) HOST="${2:?--host requires a value}"; shift 2 ;; + --public-host) PUBLIC_HOST="${2:?--public-host requires a value}"; shift 2 ;; + --tls-email) TLS_EMAIL="${2:?--tls-email requires a value}"; shift 2 ;; + --expected-address) EXPECTED_ADDRESS="${2:?--expected-address requires a value}"; shift 2 ;; + --remote-dir) REMOTE_DIR="${2:?--remote-dir requires a value}"; shift 2 ;; + --app-port) APP_PORT="${2:?--app-port requires a value}"; shift 2 ;; + --identity-file) IDENTITY_FILE="${2:?--identity-file requires a value}"; shift 2 ;; + --known-hosts-file) KNOWN_HOSTS_FILE="${2:?--known-hosts-file requires a value}"; shift 2 ;; + --accept-host-key) ACCEPT_HOST_KEY="Y"; shift ;; + --dry-run) DRY_RUN="Y"; shift ;; + --inspect) INSPECT_ONLY="Y"; shift ;; + --verify-only) VERIFY_ONLY="Y"; shift ;; + -h|--help) usage; exit 0 ;; + *) die "알 수 없는 옵션: $1" ;; + esac + done + + if [[ "$INSPECT_ONLY" == "Y" ]]; then + validate_ssh_host "$HOST" + need_cmd ssh "OpenSSH client" + need_cmd timeout "bounded SSH preflight" + build_ssh_args + timeout 30s ssh "${SSH_ARGS[@]}" "$HOST" 'bash -s' <<'INSPECT' +set -Eeuo pipefail +printf 'host=%s user=%s\n' "$(hostname)" "$(whoami)" +printf '%s\n' 'listeners:' +ss -ltn | awk 'NR == 1 || $4 ~ /:(80|443|8082)$/' +if command -v caddy >/dev/null 2>&1; then + printf 'caddy=%s\n' "$(timeout 5s caddy version || true)" + printf 'caddy_active=%s\n' "$(timeout 5s systemctl is-active caddy 2>/dev/null || true)" + printf 'caddy_enabled=%s\n' "$(timeout 5s systemctl is-enabled caddy 2>/dev/null || true)" +else + printf '%s\n' 'caddy=not-installed' +fi +if sudo -n true >/dev/null 2>&1 && command -v firewall-cmd >/dev/null 2>&1; then + printf 'firewalld_ports=%s\n' "$(timeout 5s sudo firewall-cmd --list-ports 2>/dev/null || true)" +fi +printf 'app_loopback_http_status=' +curl -sS --connect-timeout 3 -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8082/login || true +INSPECT + exit 0 + fi + + validate_public_host "$PUBLIC_HOST" + validate_tls_email "$TLS_EMAIL" + validate_ssh_host "$HOST" + validate_ipv4 "$EXPECTED_ADDRESS" + validate_port "$APP_PORT" + validate_remote_dir "$REMOTE_DIR" + + local tmp_dir candidate remote_stage + tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/vpd-backoffice-caddy.XXXXXX")" + candidate="$tmp_dir/Caddyfile" + render_caddyfile "$candidate" + + if [[ "$DRY_RUN" == "Y" ]]; then + log "dry-run: host=$HOST" + log "dry-run: public_host=$PUBLIC_HOST" + log "dry-run: expected_address=$EXPECTED_ADDRESS" + log "dry-run: app_upstream=127.0.0.1:$APP_PORT" + log "dry-run: remote_dir=$REMOTE_DIR" + cat "$candidate" + rm -rf "$tmp_dir" + exit 0 + fi + + need_cmd ssh "OpenSSH client" + need_cmd curl "HTTPS 검증" + need_cmd getent "DNS 검증" + verify_dns + build_ssh_args + + if [[ "$VERIFY_ONLY" != "Y" ]]; then + remote_stage="${REMOTE_DIR%/}/.https-stage" + log "Caddy 및 sudo 사전 점검: $HOST" + ssh "${SSH_ARGS[@]}" "$HOST" \ + 'command -v caddy >/dev/null && command -v systemctl >/dev/null && sudo -n true' + + log "Caddy candidate 업로드" + ssh "${SSH_ARGS[@]}" "$HOST" "mkdir -p \"$remote_stage\"" + cp "$ROOT/deploy/caddy/install-caddy-config.sh" "$tmp_dir/install-caddy-config.sh" + tar -C "$tmp_dir" -czf - Caddyfile install-caddy-config.sh \ + | ssh "${SSH_ARGS[@]}" "$HOST" "tar -C \"$remote_stage\" -xzf - && chmod 700 \"$remote_stage/install-caddy-config.sh\"" + + log "Caddy 설정 검증·백업·적용" + ssh "${SSH_ARGS[@]}" "$HOST" \ + "\"$remote_stage/install-caddy-config.sh\" \"$remote_stage/Caddyfile\" \"$REMOTE_DIR\"" + fi + + rm -rf "$tmp_dir" + verify_external +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/deploy-backoffice-vm.sh b/scripts/deploy-backoffice-vm.sh index 15c6c69..02ebc5c 100755 --- a/scripts/deploy-backoffice-vm.sh +++ b/scripts/deploy-backoffice-vm.sh @@ -27,6 +27,10 @@ Defaults: --remote-dir \$HOME/apps/vpd-backoffice --port 8082 +Production transport defaults are always applied on the VM: + bind address 127.0.0.1, forwarded headers enabled, + HTTPS required, Secure/HttpOnly/SameSite session cookie. + The script uploads: - target/*.jar as app.jar - .env rewritten for the VM wallet path @@ -111,6 +115,9 @@ if [[ "$DRY_RUN" == "Y" ]]; then log "dry-run: remote_dir=$REMOTE_DIR" log "dry-run: remote_wallet_dir=$REMOTE_WALLET_DIR" log "dry-run: port=$REMOTE_PORT" + log "dry-run: bind_address=127.0.0.1" + log "dry-run: require_https=true" + log "dry-run: session_cookie_secure=true" log "dry-run: local_wallet=$TNS_ADMIN" exit 0 fi @@ -144,6 +151,10 @@ export TNS_ADMIN="${REMOTE_WALLET_DIR}" export BACKOFFICE_DB_URL="jdbc:oracle:thin:@\${ADB_TNS}?TNS_ADMIN=\${TNS_ADMIN}" export BACKOFFICE_ORDS_DB_URL="\${BACKOFFICE_DB_URL}" export BACKOFFICE_PORT="${REMOTE_PORT}" +export BACKOFFICE_BIND_ADDRESS="127.0.0.1" +export BACKOFFICE_FORWARD_HEADERS_STRATEGY="framework" +export BACKOFFICE_REQUIRE_HTTPS="true" +export BACKOFFICE_SESSION_COOKIE_SECURE="true" ENVEOF chmod 600 "$TMP_DIR/.env" @@ -222,7 +233,7 @@ if [[ "$START_APP" == "Y" ]]; then ssh "${SSH_ARGS[@]}" "$HOST" "\"$REMOTE_DIR\"/stop.sh || true; \"$REMOTE_DIR\"/start.sh; sleep 8; \"$REMOTE_DIR\"/status.sh" log "원격 헬스체크" - ssh "${SSH_ARGS[@]}" "$HOST" "curl -sS -o /tmp/vpd-backoffice-login.html -w '%{http_code}\n' http://127.0.0.1:${REMOTE_PORT}/login" + ssh "${SSH_ARGS[@]}" "$HOST" "curl -sS -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: localhost' -o /tmp/vpd-backoffice-login.html -w '%{http_code}\n' http://127.0.0.1:${REMOTE_PORT}/login" fi ok "배포 완료: $HOST:$REMOTE_DIR" diff --git a/scripts/test-backoffice-https-config.sh b/scripts/test-backoffice-https-config.sh new file mode 100755 index 0000000..f85f184 --- /dev/null +++ b/scripts/test-backoffice-https-config.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# shellcheck disable=SC1091 +. "$ROOT/scripts/configure-backoffice-https-vm.sh" + +assert_rejected() { + local label="${1:?label}" function_name="${2:?function}" value="${3:-}" + if ("$function_name" "$value") >/dev/null 2>&1; then + echo "FAIL: $label was accepted: $value" >&2 + exit 1 + fi +} + +validate_public_host "admin.example.com" +validate_public_host "130.162.134.59" +validate_tls_email "ops@example.com" +validate_ssh_host "hermes" +validate_ipv4 "130.162.134.59" +validate_port "8082" +validate_remote_dir '$HOME/apps/vpd-backoffice' + +assert_rejected "invalid IPv4 host" validate_public_host "130.162.134.999" +assert_rejected "wildcard host" validate_public_host "*.example.com" +assert_rejected "single-label host" validate_public_host "hermes" +assert_rejected "invalid email" validate_tls_email "ops at example.com" +assert_rejected "SSH option injection" validate_ssh_host "-oProxyCommand=id" +assert_rejected "invalid IPv4" validate_ipv4 "130.162.134.999" +assert_rejected "privileged overflow port" validate_port "65536" +assert_rejected "remote command injection" validate_remote_dir '$HOME/apps/x;id' +assert_rejected "remote path traversal" validate_remote_dir '$HOME/apps/../etc' + +tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/vpd-backoffice-https-test.XXXXXX")" +trap 'rm -rf "$tmp_dir"' EXIT +PUBLIC_HOST="admin.example.com" +TLS_EMAIL="ops@example.com" +APP_PORT="8082" +render_caddyfile "$tmp_dir/Caddyfile" + +grep -Fq 'admin.example.com {' "$tmp_dir/Caddyfile" +grep -Fq 'email ops@example.com' "$tmp_dir/Caddyfile" +grep -Fq 'reverse_proxy 127.0.0.1:8082' "$tmp_dir/Caddyfile" +grep -Fq 'Strict-Transport-Security "max-age=31536000; includeSubDomains"' "$tmp_dir/Caddyfile" + +PUBLIC_HOST="130.162.134.59" +render_caddyfile "$tmp_dir/Caddyfile.ip" +grep -Fq '130.162.134.59 {' "$tmp_dir/Caddyfile.ip" +grep -Fq 'profile shortlived' "$tmp_dir/Caddyfile.ip" + +echo "PASS: HTTPS configuration validation and rendering" diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java index 402f85b..bcf74fc 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -11,7 +11,7 @@ public record BackofficeProperties( Ai ai ) { - public record Security(String adminUser, String adminPassword) { + public record Security(String adminUser, String adminPassword, boolean requireHttps) { } public record Token(int maxDays) { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java index 06ffc8f..bebbf2f 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java @@ -14,9 +14,19 @@ import org.springframework.security.web.SecurityFilterChain; public class SecurityConfig { @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + SecurityFilterChain securityFilterChain( + HttpSecurity http, + BackofficeProperties properties + ) throws Exception { + if (properties.security().requireHttps()) { + http.requiresChannel(channel -> channel.anyRequest().requiresSecure()); + } + return http .csrf(csrf -> csrf.ignoringRequestMatchers("/mcp/messages", "/mcp/*/messages")) + .headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts + .includeSubDomains(true) + .maxAgeInSeconds(31_536_000))) .authorizeHttpRequests(auth -> auth .requestMatchers("/css/**", "/js/**", "/webjars/**").permitAll() .anyRequest().authenticated()) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 45462e0..fcab828 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,12 +22,21 @@ mybatis: map-underscore-to-camel-case: true server: + address: ${BACKOFFICE_BIND_ADDRESS:0.0.0.0} port: ${BACKOFFICE_PORT:8080} + forward-headers-strategy: ${BACKOFFICE_FORWARD_HEADERS_STRATEGY:none} + servlet: + session: + cookie: + secure: ${BACKOFFICE_SESSION_COOKIE_SECURE:false} + http-only: true + same-site: lax backoffice: security: admin-user: ${BACKOFFICE_ADMIN_USER:admin} admin-password: ${BACKOFFICE_ADMIN_PASSWORD:admin} + require-https: ${BACKOFFICE_REQUIRE_HTTPS:false} token: max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365} ords: diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/config/TransportSecurityTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/config/TransportSecurityTest.java new file mode 100644 index 0000000..ada8551 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/config/TransportSecurityTest.java @@ -0,0 +1,142 @@ +package com.cloudhandson.vpdbackoffice.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockServletContext; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.filter.ForwardedHeaderFilter; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +class TransportSecurityTest { + + private AnnotationConfigWebApplicationContext context; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + context = new AnnotationConfigWebApplicationContext(); + context.setServletContext(new MockServletContext()); + TestPropertyValues.of( + "backoffice.security.admin-user=admin", + "backoffice.security.admin-password=test-password", + "backoffice.security.require-https=true", + "backoffice.token.max-days=365", + "backoffice.ords.base-url=https://ords.example.test", + "backoffice.ords.timeout=10s", + "backoffice.ai.enabled=false", + "backoffice.ai.timeout=30s" + ).applyTo(context); + context.register(TestWebConfig.class); + context.refresh(); + + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .addFilters(new ForwardedHeaderFilter()) + .apply(springSecurity()) + .build(); + } + + @AfterEach + void tearDown() { + context.close(); + } + + @Test + void directHttpRequestRedirectsToHttps() throws Exception { + mockMvc.perform(get("/login")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("https://localhost/login")); + } + + @Test + void forwardedHttpsRequestIsAcceptedAndReturnsHsts() throws Exception { + mockMvc.perform(get("/login") + .header("X-Forwarded-Proto", "https") + .header("X-Forwarded-Host", "admin.example.test")) + .andExpect(status().isOk()) + .andExpect(header().string( + "Strict-Transport-Security", + containsString("max-age=31536000") + )); + } + + @Test + void forwardedHostIsUsedForAuthenticationRedirect() throws Exception { + mockMvc.perform(get("/") + .accept(MediaType.TEXT_HTML) + .header("X-Forwarded-Proto", "https") + .header("X-Forwarded-Host", "admin.example.test")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("https://admin.example.test/login")); + } + + @Test + void productionSessionCookieSettingsAreBound() throws Exception { + var environment = new StandardEnvironment(); + environment.getPropertySources().addFirst(new MapPropertySource("test-overrides", Map.of( + "BACKOFFICE_FORWARD_HEADERS_STRATEGY", "framework", + "BACKOFFICE_SESSION_COOKIE_SECURE", "true" + ))); + var loader = new YamlPropertySourceLoader(); + for (var source : loader.load("application", new ClassPathResource("application.yml"))) { + environment.getPropertySources().addLast(source); + } + + ServerProperties properties = Binder.get(environment) + .bind("server", ServerProperties.class) + .orElseThrow(() -> new AssertionError("server properties were not bound")); + var cookie = properties.getServlet().getSession().getCookie(); + + assertThat(cookie.getSecure()).isTrue(); + assertThat(cookie.getHttpOnly()).isTrue(); + assertThat(cookie.getSameSite().attributeValue()).isEqualTo("Lax"); + assertThat(properties.getForwardHeadersStrategy().name()).isEqualTo("FRAMEWORK"); + } + + @Configuration(proxyBeanMethods = false) + @EnableWebMvc + @EnableWebSecurity + @EnableConfigurationProperties(BackofficeProperties.class) + @Import({SecurityConfig.class, TestController.class}) + static class TestWebConfig { + } + + @RestController + static class TestController { + + @GetMapping("/login") + String login() { + return "login"; + } + + @GetMapping("/") + String home() { + return "home"; + } + } +}