diff --git a/.env.example b/.env.example index efcc7766..bcfec93d 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,11 @@ STORAGE_LOCAL_ROOT=/var/lib/stackup-ai/storage REALTIME_PORT=38020 REALTIME_LOG_LEVEL=info +# 호스트에 공개하는 포트의 바인드 주소. 기본 127.0.0.1 — nginx 와 SSH 터널만 닿으면 되고, +# 서비스끼리는 도커 네트워크로 통신한다. LAN 에 열어야 할 때만 0.0.0.0 으로 바꾼다. +# (도커는 ufw 를 우회해 publish 한 포트를 그대로 연다 — 0.0.0.0 은 곧 인터넷 공개다.) +# BIND_ADDR=127.0.0.1 + # Backend Core (Spring) BACKEND_PORT=38010 SPRING_PROFILES_ACTIVE=local diff --git a/ai/tests/test_compose_defaults.py b/ai/tests/test_compose_defaults.py index 4d74adb1..23a6defe 100644 --- a/ai/tests/test_compose_defaults.py +++ b/ai/tests/test_compose_defaults.py @@ -98,3 +98,43 @@ def test_no_empty_string_fallbacks_for_settings_keys() -> None: if key in settings_fields and re.fullmatch(r"\$\{[A-Z_0-9]+:-\}", value) ) assert not bad, f"빈 문자열 fallback 이 코드 기본값을 덮는다: {bad}" + + +def test_published_ports_bind_loopback_by_default(): + """호스트 공개 포트는 전부 루프백에 묶여야 한다. + + 2026-09-28 점검에서 스택 포트 8개가 전부 0.0.0.0 이었다 — Core·RealTime·AI 뿐 아니라 + PostgreSQL·RabbitMQ·MinIO 까지. 서버가 자기 공인 IP 로 38010 을 부르면 200 이 왔다 + (호스트 방화벽은 막지 않는다 — 도커가 제 iptables 체인을 ufw 앞에 끼워 넣는다). + + 필요한 곳이 없다: nginx 는 127.0.0.1 로만 프록시하고 서비스끼리는 도커 네트워크를 쓴다. + "0.0.0.0" 은 기본값이 아니라 **명시적 선택**(BIND_ADDR)이어야 한다. 이 한 줄은 지워져도 + 컴파일·테스트가 통과하고 운영에서도 조용하므로 CI 가 대신 본다. + """ + text = COMPOSE.read_text(encoding="utf-8") + offenders = [] + in_ports = False + for raw in text.splitlines(): + line = raw.strip() + if line.startswith("#"): + continue + if line == "ports:": + in_ports = True + continue + if in_ports: + if not line.startswith("- "): + in_ports = False + continue + entry = line[2:].strip().strip('"').strip("'") + # "HOST:CONTAINER" 는 모든 인터페이스, "ADDR:HOST:CONTAINER" 만 허용한다. + if entry.count(":") < 2 or not ( + entry.startswith("${BIND_ADDR:-127.0.0.1}") + or entry.startswith("127.0.0.1") + ): + offenders.append(entry) + + assert ( + not offenders + ), "포트가 모든 인터페이스에 열린다 — BIND_ADDR 를 앞에 붙일 것: " + ", ".join( + offenders + ) diff --git a/docker-compose.yml b/docker-compose.yml index 4aa7ea3b..56450ac6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,19 @@ +# 호스트에 공개하는 포트는 **루프백에만** 묶는다 (BIND_ADDR, 기본 127.0.0.1). +# +# 2026-09-28 점검에서 스택 포트 8개가 전부 0.0.0.0 에 묶여 있었다 — Core·RealTime·AI 뿐 +# 아니라 PostgreSQL(38040)·RabbitMQ(38050/38051)·MinIO(38060/38061)까지. 확인한 사실: +# · 서버가 **자기 공인 IP** 로 http://:38010/api/system/health 를 부르면 200 이다 +# → 호스트 방화벽이 막지 않는다. ufw 가 있어도 도커는 제 iptables 체인을 앞에 끼워 넣어 +# publish 한 포트를 그냥 통과시킨다(잘 알려진 함정). +# · 외부에서는 TCP 핸드셰이크만 간헐적으로 성립하고 HTTP 응답은 오지 않는다 +# → 상류(학교망) 어딘가가 걸러 주고 있는 것으로 보인다. **그건 우리 것이 아니다.** +# +# 필요한 곳이 하나도 없다: nginx 는 127.0.0.1:38010 / 127.0.0.1:38020 으로만 프록시하고, +# 서비스끼리는 도커 네트워크 이름(backend:38010, minio:38060, rabbitmq:38050)으로 통신한다. +# 38051/38061 콘솔은 SSH 터널로 계속 볼 수 있다(-L 38061:127.0.0.1:38061). +# +# LAN 에 열어야 하면 BIND_ADDR=0.0.0.0 으로 명시적으로 켠다 — 기본값이 아니라 선택이어야 한다. + name: stackup services: @@ -6,7 +22,7 @@ services: container_name: stackup-postgres command: ["postgres", "-c", "port=38040"] ports: - - "${POSTGRES_PORT:-38040}:38040" + - "${BIND_ADDR:-127.0.0.1}:${POSTGRES_PORT:-38040}:38040" environment: POSTGRES_DB: ${POSTGRES_DB:-stackup} POSTGRES_USER: ${POSTGRES_USER:-stackup} @@ -24,8 +40,8 @@ services: build: ./infra/rabbitmq container_name: stackup-rabbitmq ports: - - "${RABBITMQ_PORT:-38050}:38050" - - "${RABBITMQ_MANAGEMENT_PORT:-38051}:38051" + - "${BIND_ADDR:-127.0.0.1}:${RABBITMQ_PORT:-38050}:38050" + - "${BIND_ADDR:-127.0.0.1}:${RABBITMQ_MANAGEMENT_PORT:-38051}:38051" environment: RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-stackup} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-stackup} @@ -41,8 +57,8 @@ services: build: ./infra/minio container_name: stackup-minio ports: - - "${MINIO_API_PORT:-38060}:38060" - - "${MINIO_CONSOLE_PORT:-38061}:38061" + - "${BIND_ADDR:-127.0.0.1}:${MINIO_API_PORT:-38060}:38060" + - "${BIND_ADDR:-127.0.0.1}:${MINIO_CONSOLE_PORT:-38061}:38061" environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} @@ -74,7 +90,7 @@ services: build: ./ai container_name: stackup-ai ports: - - "${AI_PORT:-38030}:38030" + - "${BIND_ADDR:-127.0.0.1}:${AI_PORT:-38030}:38030" # 값이 붙은 항목만 compose 가 소유한다 — 컨테이너 토폴로지(서비스 호스트명)와 # MINIO_* → S3_* 이름 변환처럼 settings.py 가 알 수 없는 것들. # @@ -147,7 +163,7 @@ services: build: ./realtime container_name: stackup-realtime ports: - - "${REALTIME_PORT:-38020}:38020" + - "${BIND_ADDR:-127.0.0.1}:${REALTIME_PORT:-38020}:38020" environment: REALTIME_LISTEN_ADDR: :38020 REALTIME_RABBITMQ_URL: amqp://${RABBITMQ_USER:-stackup}:${RABBITMQ_PASSWORD:-stackup}@rabbitmq:38050/ @@ -170,7 +186,7 @@ services: context: ./backend container_name: stackup-backend ports: - - "${BACKEND_PORT:-38010}:38010" + - "${BIND_ADDR:-127.0.0.1}:${BACKEND_PORT:-38010}:38010" environment: SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-local} SERVER_PORT: "38010" diff --git a/docs/security.md b/docs/security.md index 264f974f..b5cc7898 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,5 +1,24 @@ # 보안 가이드라인 +> **2026-09-28 — 호스트 포트 바인딩 정정.** 스택 포트 8개가 전부 `0.0.0.0` 에 묶여 있었다: +> Core(38010)·RealTime(38020)·AI(38030) 뿐 아니라 **PostgreSQL(38040)·RabbitMQ(38050/38051)· +> MinIO(38060/38061)** 까지. `docker-compose.yml` 이 짧은 형식(`"PORT:PORT"`)을 써서 모든 +> 인터페이스에 열렸고, **ufw 가 있어도 도커가 제 iptables 체인을 앞에 끼워 넣어 그냥 통과**한다. +> +> 측정한 사실만 적는다: +> - 서버가 **자기 공인 IP** 로 `http://:38010/api/system/health` 를 부르면 **200**. +> → 호스트 방화벽은 막지 않는다. +> - 외부(다른 네트워크)에서는 TCP 핸드셰이크만 간헐 성립하고 **HTTP 응답은 오지 않았다**. +> → 상류(학교망)가 걸러 주는 것으로 보인다. **그건 우리 통제 밖이고 바뀔 수 있다.** +> +> 필요한 곳은 하나도 없었다 — nginx 는 `127.0.0.1:38010`/`127.0.0.1:38020` 로만 프록시하고, +> 서비스끼리는 도커 네트워크 이름으로 통신한다. `BIND_ADDR`(기본 `127.0.0.1`)로 전부 루프백에 +> 묶었고, LAN 노출이 필요하면 명시적으로 켠다. CI 가드: +> `ai/tests/test_compose_defaults.py::test_published_ports_bind_loopback_by_default`. +> +> 관리 콘솔은 SSH 터널로 계속 볼 수 있다: `ssh -L 38061:127.0.0.1:38061 stackup@stack-up.shop` + + > 인증·인가·암호화·개인정보 처리 규약. 모든 신규 기능은 이 문서 체크리스트를 통과해야 한다. ---