Repository navigation
fix(infra): nginx 하드닝 — AI 서버 공개 노출 제거 + 보안 헤더·TLS·rate limit - #252
Merged
Merged
Conversation
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 에 수동 절차와 롤백을 적었다.
This was referenced Sep 27, 2026
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 매핑 하나를 짧은 형식으로 되돌리니 테스트가 실패했다.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🔴 가장 큰 것 — AI 서버가 인터넷에 열려 있었습니다
외부에서 실제로 접근됩니다:
https://stack-up.shop/ai/health{"status":"ok"}https://stack-up.shop/ai/docshttps://stack-up.shop/ai/openapi.jsonhttps://stack-up.shop/ai/redocai/CLAUDE.md는 이 서버를 이렇게 설계했다고 적고 있습니다:즉 인증을 전제하지 않고 만든 서버가 인터넷에 노출돼 있었습니다. 내부 전용 WS(
/internal/voice/stream)도 인터넷에서 닿고, 유일한 방어선이X-Internal-API-Key하나뿐이었습니다. 원래 설계는 "네트워크 경계 + 키" 이중이었는데 경계가 없었던 셈입니다.쓰는 데도 없습니다:
/ai/참조 0건REALTIME_AI_WS_URL=ws://ai:8000/internal/voice/stream)순수한 잉여 노출이라 제거했습니다.
나머지 발견
nginx/1.18.0 (Ubuntu)server_tokens offinfra/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 컨테이너로 실제 파싱시켰습니다:
deploy-app.yml은/etc/nginx를 건드리지 않습니다(sudo 필요 + 다른 사이트 공유). 제가 적용하려 했으나sudo가 비밀번호를 요구해 실행하지 못했습니다.nginx -t가 실패하면 reload 하지 말고 백업으로 되돌립니다. 적용 후 제가 결과를 확인하겠습니다(헤더·TLS·/ai/차단·정상 동작).리뷰어 체크포인트
preload없이 시작했습니다. preload 는 등록 후 되돌리기가 매우 어렵습니다./ai/제거 — 정말 아무도 안 쓰는지 한 번 더 봐주세요. 제가 확인한 건 프론트 번들과 RealTime 설정입니다.🤖 Generated with Claude Code