Skip to content

fix(infra): nginx 하드닝 — AI 서버 공개 노출 제거 + 보안 헤더·TLS·rate limit - #252

Merged
i3months merged 1 commit into
devfrom
fix/nginx-hardening
Sep 27, 2026
Merged

i3months merged 1 commit into
devfrom
fix/nginx-hardening

Conversation

@i3months

Copy link
Copy Markdown
Member

🔴 가장 큰 것 — AI 서버가 인터넷에 열려 있었습니다

location /ai/ { proxy_pass http://127.0.0.1:38030/; }

외부에서 실제로 접근됩니다:

경로 응답
https://stack-up.shop/ai/health 200 {"status":"ok"}
https://stack-up.shop/ai/docs 200 — FastAPI 대화형 문서
https://stack-up.shop/ai/openapi.json 200 — 전체 스펙
https://stack-up.shop/ai/redoc 200

ai/CLAUDE.md 는 이 서버를 이렇게 설계했다고 적고 있습니다:

❌ 사용자 인증 (내부 통신만) — api/ 는 헬스체크 / 내부 도구

즉 인증을 전제하지 않고 만든 서버가 인터넷에 노출돼 있었습니다. 내부 전용 WS(/internal/voice/stream)도 인터넷에서 닿고, 유일한 방어선이 X-Internal-API-Key 하나뿐이었습니다. 원래 설계는 "네트워크 경계 + 키" 이중이었는데 경계가 없었던 셈입니다.

쓰는 데도 없습니다:

  • 프론트 운영 번들에 /ai/ 참조 0건
  • RealTime 은 도커 네트워크로 직접 연결 (REALTIME_AI_WS_URL=ws://ai:8000/internal/voice/stream)

순수한 잉여 노출이라 제거했습니다.

나머지 발견

항목 이전 이후
TLS 1.0/1.1 전역 설정이 허용, 서버가 실제로 수락 이 사이트만 TLSv1.2/1.3
보안 헤더 전무 HSTS·nosniff·X-Frame-Options·Referrer-Policy·Permissions-Policy
rate limit 없음 API 20r/s(burst 60), 인증 2r/s(burst 20)
버전 노출 nginx/1.18.0 (Ubuntu) server_tokens off
설정 자체가 버전 관리 밖 서버에만 존재 infra/nginx/ 로 이관

TLS 1.0/1.1 은 처음에 "미지원"으로 잘못 읽었습니다 — 제 로컬 OpenSSL 3.6 이 아예 제안하지 않아서였습니다. -cipher DEFAULT@SECLEVEL=0 으로 다시 확인하니 서버가 수락했습니다. RFC 8996(2021) 폐기 권고, PCI DSS 금지 대상입니다.

rate limit 은 2026-09-17 에 실제로 게이트웨이 크레딧이 소진돼 운영이 402 로 죽은 전례가 근거입니다. /realtime/ 에는 걸지 않았습니다 — 재연결 폭주가 정상 동작인 경우가 있어 끊으면 오히려 나빠집니다.

⚠️ 전역 설정은 건드리지 않았습니다

이 호스트의 /etc/nginx/nginx.conf 는 다른 사이트 4개와 공유됩니다 (beontteuk.com, udangtang.site, udangtangames.cloud, warurulab.site). 전역을 고치면 남의 프로젝트가 같이 바뀝니다.

그래서 하드닝을 전부 server 블록 안으로 한정했습니다 — ssl_protocols, server_tokens, 보안 헤더는 server 컨텍스트에서 재정의됩니다. limit_req_zone 만 http 컨텍스트가 필요해 conf.d 로 뺐는데, zone 선언은 공유 메모리만 잡고 실제 제한은 limit_req 를 붙인 곳에만 걸리므로 다른 사이트에 영향이 없습니다.

바꾸지 않은 것 (이미 올바름)

  • client_max_body_size 25m — 백엔드 VoiceAnswerSubmitService.MAX_BYTES 와 일치. 더 작았으면 백엔드 검증에 닿기도 전에 413 이 났을 겁니다.
  • /realtime/ 의 proxy_buffering off + 1h 타임아웃 — SSE·WS 가 이게 없으면 깨집니다.

검증

운영과 같은 nginx 1.18 컨테이너로 실제 파싱시켰습니다:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

⚠️ 적용은 수동입니다 (sudo 필요)

deploy-app.yml 은 /etc/nginx 를 건드리지 않습니다(sudo 필요 + 다른 사이트 공유). 제가 적용하려 했으나 sudo 가 비밀번호를 요구해 실행하지 못했습니다.

ts=$(date +%Y%m%d-%H%M%S)
sudo cp /etc/nginx/sites-available/stack-up.shop /etc/nginx/sites-available/stack-up.shop.bak-$ts
sudo cp ~/stackup/infra/nginx/conf.d-stackup-ratelimit.conf /etc/nginx/conf.d/stackup-ratelimit.conf
sudo cp ~/stackup/infra/nginx/stack-up.shop.conf /etc/nginx/sites-available/stack-up.shop
sudo nginx -t && sudo systemctl reload nginx

nginx -t 가 실패하면 reload 하지 말고 백업으로 되돌립니다. 적용 후 제가 결과를 확인하겠습니다(헤더·TLS·/ai/ 차단·정상 동작).

리뷰어 체크포인트

  • rate limit 수치 — API 20r/s(burst 60) 가 넉넉한지. 실부하로 검증하지 못했습니다. 너무 빡빡하면 429 가 납니다.
  • HSTS — preload 없이 시작했습니다. preload 는 등록 후 되돌리기가 매우 어렵습니다.
  • /ai/ 제거 — 정말 아무도 안 쓰는지 한 번 더 봐주세요. 제가 확인한 건 프론트 번들과 RealTime 설정입니다.

🤖 Generated with Claude Code

nginx 설정은 지금까지 **서버에만** 있었다. 레포에 없으니 누가 언제 뭘 바꿨는지
추적할 수 없고 서버가 날아가면 복원 근거도 없다. 원본을 레포로 옮기면서 점검에서
나온 것들을 같이 고친다.

가장 큰 것: **AI 서버(FastAPI)가 인터넷에 열려 있었다.** location /ai/ 가
127.0.0.1:38030 으로 프록시해서 /ai/docs · /ai/redoc · /ai/openapi.json 이 공개로
열렸고 내부 전용 WS(/internal/voice/stream)도 인터넷에서 닿았다. ai/CLAUDE.md 는
이 서버를 "내부 통신만, 사용자 인증 없음"으로 설계했다고 적고 있다.

쓰는 데도 없었다 — 프론트 운영 번들에 /ai/ 참조 0건, RealTime 은 도커 네트워크로
직접 연결(REALTIME_AI_WS_URL=ws://ai:8000/...). 순수한 잉여 노출이라 제거했다.

나머지:
- TLS: 전역 nginx.conf 가 TLSv1·1.1 을 허용하고 **서버가 실제로 수락**하는 것을
  확인했다(SECLEVEL=0 클라이언트로 재현). RFC 8996 폐기 권고·PCI DSS 금지 대상.
  이 사이트만 TLSv1.2/1.3 으로 올린다.
- 보안 헤더 전무 → HSTS(preload 없이)·nosniff·X-Frame-Options·Referrer-Policy·
  Permissions-Policy(camera/microphone=self — 면접 녹화·음성 답변에 필요).
- rate limit 없음 → API 20r/s(burst 60), 인증 2r/s(burst 20). 2026-09-17 에 실제로
  게이트웨이 크레딧이 소진돼 운영이 402 로 죽은 전례가 있다. /realtime/ 에는 걸지
  않는다 — 재연결 폭주가 정상인 경우가 있어 끊으면 더 나빠진다.
- server_tokens off (버전 노출).

**전역 nginx.conf 는 건드리지 않았다** — 이 호스트는 다른 사이트 4개와 공유한다.
하드닝을 전부 server 블록으로 한정하고, http 컨텍스트가 필요한 rate limit zone 만
conf.d 로 뺐다(zone 선언은 메모리만 잡고 제한은 limit_req 붙은 곳에만 걸린다).

바꾸지 않은 것(이미 올바름): client_max_body_size 25m(백엔드 상한과 일치),
/realtime/ 의 proxy_buffering off + 1h 타임아웃(SSE·WS 가 이게 없으면 깨진다).

검증: 운영과 같은 nginx 1.18 컨테이너로 문법 검증 통과. 적용은 sudo 가 필요해
자동 배포에 넣지 않았다 — README 에 수동 절차와 롤백을 적었다.
@i3months
i3months merged commit db10b05 into dev Sep 27, 2026
5 checks passed
i3months added a commit to i3months/stackup that referenced this pull request Oct 6, 2026
PR Team-StackUp#252 본문과 설정 주석에 틀린 내용이 있어 바로잡는다.

**정정 1 — "사이트 블록 변경 후에도 TLS 1.0/1.1 수락"은 오판이었다.**
`openssl s_client` 출력의 `Protocol : TLSv1` 줄을 "수락"으로 읽었는데, 그 줄은
클라이언트가 **시도한** 버전이라 실패해도 찍힌다. 올바른 신호는 협상된 암호
(`Cipher is (NONE)` + `alert 70`)다. 같은 버그로 두 번 오판했다.

**정정 2 — 다만 "원래 수락했다"는 맞았다.** 변경 전 설정(TLSv1 TLSv1.1 허용)을
격리 컨테이너에 그대로 재현해 확인했다: `New, TLSv1.0, Cipher is
ECDHE-RSA-AES256-SHA` 로 실제 협상됐다.

**전역 변경 기록.** ssl_protocols 는 SNI 이전에 결정돼 같은 443 소켓을 공유하는
server 들 중 default server 값이 전체에 적용된다. 이 호스트는 443 에 4개 사이트가
물려 있어 server 블록만으로는 효과가 없었고 /etc/nginx/nginx.conf 를 바꿨다
(백업 nginx.conf.bak-20260927-132844). **5개 사이트 전부에 적용된다.**
적용 후 확인: TLS1.0/1.1 거절, 1.2/1.3 정상, 5개 사이트 응답 기준선과 동일.

그 외:
- TLS 버전 확인 방법을 README 에 적었다. 최신 OpenSSL 은 TLS1.0/1.1 을 제안조차
  하지 않으므로 `-cipher DEFAULT@SECLEVEL=0` 없이는 서버 설정과 무관하게 실패한다.
- `location /ai/` 문자열을 주석에서 뺐다 — grep 으로 제거 여부를 확인하려는 사람이
  주석을 세게 된다(실제로 한 번 헷갈렸다).
- 파일 머리말의 "전역은 건드리지 않았다" 를 사실에 맞게 고쳤다.
- infra/CLAUDE.md 에 backup/·nginx/ 디렉토리와 운영 백업 구현 사실을 반영했다.
i3months added a commit to i3months/stackup that referenced this pull request Oct 6, 2026
Team-StackUp#252 에서 nginx 의 /ai/ 프록시를 지우며 "AI 서버를 인터넷에 노출하고 있었다" 를
고쳤다고 적었다. 앞문만 닫았다 — **AI 서버는 38030 에 그대로 publish 돼 있었다.**

점검해 보니 AI 만이 아니었다. 스택 포트 8개가 전부 0.0.0.0 이다:

  38010 Core      38040 PostgreSQL
  38020 RealTime  38050 RabbitMQ      38051 RabbitMQ 관리 UI
  38030 AI        38060 MinIO API     38061 MinIO 콘솔

docker-compose 가 짧은 형식("PORT:PORT")을 써서 모든 인터페이스에 열렸다.
ufw 가 설치돼 있어도 소용없다 — 도커는 제 iptables 체인을 ufw 앞에 끼워 넣어
publish 한 포트를 그냥 통과시킨다(잘 알려진 함정).

## 측정한 것만 적는다

서버가 **자기 공인 IP** 로 부르면:

  curl http://<public>:38010/api/system/health  → 200

호스트 방화벽은 막지 않는다.

외부(다른 네트워크)에서는 TCP 핸드셰이크만 간헐적으로 성립하고 HTTP 응답은
오지 않았다. 상류(학교망)가 걸러 주는 것으로 보인다. 그건 우리 통제 밖이고
바뀔 수 있으므로, 거기에 기대는 상태를 정상으로 두지 않는다.

## 필요한 곳이 하나도 없었다

  nginx        → 127.0.0.1:38010 / 127.0.0.1:38020 로만 proxy_pass
  서비스 간     → 도커 네트워크 이름 (backend:38010, minio:38060, rabbitmq:38050)

BIND_ADDR(기본 127.0.0.1)로 전부 루프백에 묶었다. 관리 콘솔은 SSH 터널로 계속
볼 수 있다(-L 38061:127.0.0.1:38061). LAN 노출이 필요하면 BIND_ADDR=0.0.0.0 으로
명시적으로 켠다 — 기본값이 아니라 선택이어야 한다.

## CI 가드

test_published_ports_bind_loopback_by_default. 이 한 줄은 지워져도 컴파일·테스트가
통과하고 **운영에서도 조용하다.** 가드가 실제로 회귀를 잡는지 확인했다 —
BACKEND_PORT 매핑 하나를 짧은 형식으로 되돌리니 테스트가 실패했다.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant