From 74d8185693a51755dddafb488ccf1b2cae3a46c3 Mon Sep 17 00:00:00 2001 From: frankbria Date: Mon, 6 Jul 2026 21:02:43 -0700 Subject: [PATCH 1/3] fix(deploy): terminate TLS in front of services via Caddy reverse proxy (#747) App processes now bind loopback (127.0.0.1); a TLS-terminating Caddy reverse proxy is the sole public listener. JWT bearer tokens and API keys no longer travel in cleartext on shared-VPS interfaces. - ecosystem.{staging,production}.config.js: frontend binds 127.0.0.1, not 0.0.0.0 - .env.{staging,production}.example: HOST=127.0.0.1; public origins are https/wss (same-origin behind the proxy); add .env.production.example - deploy/Caddyfile.example: auto-TLS (Let's Encrypt), path-routes /api,/auth,/ws to the backend and everything else to Next.js; transparent WS upgrades - scripts/remote-setup.sh: install Caddy; firewall exposes only 80/443 and denies the loopback-only app ports (14100/14200) - deploy/README.md: TLS reverse-proxy setup + routing - tests/test_deploy_config.py: assert loopback binding, https/wss origins, Caddyfile routing, and proxy provisioning --- .env.production.example | 50 ++++++++++++++++++++ .env.staging.example | 34 ++++++-------- deploy/Caddyfile.example | 36 +++++++++++++++ deploy/README.md | 51 +++++++++++++++++++++ ecosystem.production.config.js | 4 +- ecosystem.staging.config.js | 4 +- scripts/remote-setup.sh | 84 ++++++++++++++++++++++------------ tests/test_deploy_config.py | 75 ++++++++++++++++++++++++++++++ 8 files changed, 288 insertions(+), 50 deletions(-) create mode 100644 .env.production.example create mode 100644 deploy/Caddyfile.example create mode 100644 deploy/README.md diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 00000000..aa2cccb7 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,50 @@ +# CodeFRAME Production Environment Configuration +# Copy this file to .env.production and add your actual values. +# ecosystem.production.config.js reads these values. + +# Anthropic API Key (required for Lead Agent functionality) +ANTHROPIC_API_KEY=your-anthropic-api-key-here + +# Authentication secret (REQUIRED) +# The backend hard-fails on startup if this is unset in production/hosted mode +# (see issue #643). Generate with: +# openssl rand -hex 32 +AUTH_SECRET=your-secure-random-secret-here + +# Database Path (production database location) +DATABASE_PATH=/home/frankbria/projects/codeframe/.codeframe/state.db + +# Server Configuration +# Both app processes bind loopback and sit behind the Caddy reverse proxy that +# terminates TLS (#747). HOST pins the backend to 127.0.0.1; the frontend is +# pinned in ecosystem.production.config.js. Never bind 0.0.0.0 on a shared VPS. +BACKEND_PORT=8000 +FRONTEND_PORT=3000 +HOST=127.0.0.1 + +# PM2 app names (optional — override the ecosystem defaults, #727) +# PM2_BACKEND_NAME=codeframe-production-backend +# PM2_FRONTEND_NAME=codeframe-production-frontend + +# Public origins (NEXT_PUBLIC_* variables are exposed to the browser). These are +# your domain served by Caddy over TLS — the browser only ever speaks https/wss. +# Caddy path-routes /api, /auth and /ws to the backend, so this is same-origin. +# See deploy/README.md and deploy/Caddyfile.example. +NEXT_PUBLIC_API_URL=https://your-domain + +# WebSocket URL for real-time updates — wss (TLS) via the reverse proxy. +NEXT_PUBLIC_WS_URL=wss://your-domain + +# CORS Configuration +# With the reverse proxy the browser hits a single https origin (same-origin), +# so this is just that domain. +CORS_ALLOWED_ORIGINS=https://your-domain + +# Environment +# NODE_ENV must be 'production' for Next.js to work correctly. +NODE_ENV=production +CODEFRAME_ENV=production +PYTHON_ENV=production + +# Logging +LOG_LEVEL=INFO diff --git a/.env.staging.example b/.env.staging.example index e1eebc29..93239483 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -14,32 +14,26 @@ AUTH_SECRET=your-secure-random-secret-here DATABASE_PATH=/home/frankbria/projects/codeframe/staging/.codeframe/state.db # Server Configuration +# Both app processes bind loopback and sit behind the Caddy reverse proxy that +# terminates TLS (#747). HOST pins the backend to 127.0.0.1; the frontend is +# pinned in ecosystem.staging.config.js. Never bind 0.0.0.0 on a shared VPS. BACKEND_PORT=14200 FRONTEND_PORT=14100 -HOST=0.0.0.0 +HOST=127.0.0.1 -# Frontend API URL (NEXT_PUBLIC_* variables are exposed to the browser) -# This tells the Next.js frontend where the backend API is located -NEXT_PUBLIC_API_URL=http://localhost:14200 +# Public origins (NEXT_PUBLIC_* variables are exposed to the browser). These are +# your domain served by Caddy over TLS — the browser only ever speaks https/wss. +# Caddy path-routes /api, /auth and /ws to the backend, so this is same-origin. +# See deploy/README.md and deploy/Caddyfile.example. +NEXT_PUBLIC_API_URL=https://your-domain -# WebSocket URL for real-time updates -# Use ws:// for HTTP or wss:// for HTTPS -# Development: ws://localhost:8080/ws -# Staging: ws://your-hostname/ws (if nginx proxies WebSocket) -# Production: wss://your-domain/ws -NEXT_PUBLIC_WS_URL=ws://localhost:14200/ws +# WebSocket URL for real-time updates — wss (TLS) via the reverse proxy. +NEXT_PUBLIC_WS_URL=wss://your-domain # CORS Configuration -# Comma-separated list of allowed origins for CORS -# IMPORTANT: Add your server's hostname/IP if accessing via network -# Development: http://localhost:3000,http://localhost:5173 -# Staging (localhost): http://localhost:14100,http://0.0.0.0:14100 -# Staging (network): http://your-server-hostname:14100 -# Production: https://yourdomain.com -# -# Example for network access via hostname: -# CORS_ALLOWED_ORIGINS=http://localhost:14100,http://frankbria-inspiron-7586:14100,http://codeframe.home.frankbria.net -CORS_ALLOWED_ORIGINS=http://localhost:14100,http://0.0.0.0:14100 +# Comma-separated list of allowed origins. With the reverse proxy the browser +# hits a single https origin (same-origin), so this is just that domain. +CORS_ALLOWED_ORIGINS=https://your-domain # Environment # NODE_ENV should be 'production' for Next.js to work correctly diff --git a/deploy/Caddyfile.example b/deploy/Caddyfile.example new file mode 100644 index 00000000..6122df52 --- /dev/null +++ b/deploy/Caddyfile.example @@ -0,0 +1,36 @@ +# CodeFRAME reverse proxy — terminates TLS in front of the app processes (#747). +# +# The backend (127.0.0.1:14200) and frontend (127.0.0.1:14100) bind loopback +# only; this proxy is the sole public listener. Caddy automatically provisions +# and renews Let's Encrypt certificates for a real domain, so JWT bearer tokens +# and API keys never travel in cleartext. +# +# Setup on the VPS: +# 1. Point your domain's A/AAAA record at this server. +# 2. Replace codeframe.example.com below with your domain. +# 3. sudo cp deploy/Caddyfile.example /etc/caddy/Caddyfile +# 4. sudo systemctl reload caddy +# +# No public domain (IP-only / internal host)? Replace the site address with the +# IP and add `tls internal` to serve with Caddy's local CA: +# +# 203.0.113.10 { +# tls internal +# ... +# } +# +# Ports below match the staging deploy (14100 frontend / 14200 backend). For the +# production ecosystem, set them to your FRONTEND_PORT / BACKEND_PORT +# (defaults 3000 / 8000). + +codeframe.example.com { + encode zstd gzip + + # Backend: REST API, auth, WebSockets, health, and API docs. Caddy proxies + # WebSocket upgrades (/ws/*) transparently — no extra config needed. + @backend path /api/* /auth/* /ws/* /health /docs /redoc /openapi.json + reverse_proxy @backend 127.0.0.1:14200 + + # Everything else → Next.js frontend. + reverse_proxy 127.0.0.1:14100 +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 00000000..5be9c0dc --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,51 @@ +# Deployment: TLS reverse proxy + +CodeFRAME runs two processes behind a **TLS-terminating reverse proxy** (issue #747): + +| Process | Binds | Public? | +|----------|------------------|---------| +| Backend | `127.0.0.1:14200` | no — loopback only | +| Frontend | `127.0.0.1:14100` | no — loopback only | +| Caddy | `:80`, `:443` | **yes** — the only public listener | + +Because the app processes bind loopback, JWT bearer tokens and API keys only +ever travel over HTTPS/WSS between the browser and Caddy. Plaintext +`http://`/`ws://` is confined to the loopback hop, which never leaves the host. + +## Setup + +`scripts/remote-setup.sh` installs Caddy and points you here. Manually: + +1. Point your domain's DNS `A`/`AAAA` record at the server. +2. Copy the config and set your domain: + ```bash + sudo cp deploy/Caddyfile.example /etc/caddy/Caddyfile + sudo $EDITOR /etc/caddy/Caddyfile # replace codeframe.example.com + sudo systemctl reload caddy + ``` + Caddy provisions and renews the Let's Encrypt certificate automatically. +3. Set the public origins in `.env.staging` / `.env.production`: + ``` + NEXT_PUBLIC_API_URL=https://your-domain + NEXT_PUBLIC_WS_URL=wss://your-domain + CORS_ALLOWED_ORIGINS=https://your-domain + HOST=127.0.0.1 + ``` +4. Firewall: allow `80`/`443` only. The app ports (`14100`/`14200`) must **not** + be reachable from the network — Caddy reaches them over loopback. + +## Routing + +Caddy path-routes a single public origin (no CORS needed — same origin): + +- `/api/*`, `/auth/*`, `/ws/*`, `/health`, `/docs`, `/redoc`, `/openapi.json` + → backend `127.0.0.1:14200` +- everything else → Next.js frontend `127.0.0.1:14100` + +WebSocket upgrades are handled transparently by Caddy's `reverse_proxy`. + +## No public domain? + +For an IP-only or internal host, use the IP as the site address and add +`tls internal` (Caddy's local CA) — see the commented example in +`Caddyfile.example`. diff --git a/ecosystem.production.config.js b/ecosystem.production.config.js index 8e381b91..bac55a56 100644 --- a/ecosystem.production.config.js +++ b/ecosystem.production.config.js @@ -42,7 +42,9 @@ module.exports = { { name: FRONTEND_NAME, script: path.join(PROJECT_ROOT, 'web-ui/node_modules/.bin/next'), - args: `start -H 0.0.0.0 -p ${FRONTEND_PORT}`, + // Bind loopback only — the Caddy reverse proxy terminates TLS and is the + // sole public listener (issue #747). + args: `start -H 127.0.0.1 -p ${FRONTEND_PORT}`, cwd: path.join(PROJECT_ROOT, 'web-ui'), env: { ...envConfig diff --git a/ecosystem.staging.config.js b/ecosystem.staging.config.js index a6aba92e..54a06f49 100644 --- a/ecosystem.staging.config.js +++ b/ecosystem.staging.config.js @@ -31,7 +31,9 @@ module.exports = { { name: 'codeframe-staging-frontend', script: path.join(PROJECT_ROOT, 'web-ui/node_modules/.bin/next'), - args: 'start -H 0.0.0.0 -p 14100', + // Bind loopback only — the Caddy reverse proxy terminates TLS and is the + // sole public listener (issue #747). + args: 'start -H 127.0.0.1 -p 14100', cwd: path.join(PROJECT_ROOT, 'web-ui'), env: { ...envConfig diff --git a/scripts/remote-setup.sh b/scripts/remote-setup.sh index 797ff560..26099a20 100755 --- a/scripts/remote-setup.sh +++ b/scripts/remote-setup.sh @@ -100,6 +100,27 @@ else echo -e "${GREEN}✓ lsof already installed${NC}" fi +# Install Caddy — the TLS-terminating reverse proxy that fronts the app (#747). +# The app processes bind 127.0.0.1; Caddy is the only public listener and +# auto-provisions/renews Let's Encrypt certificates. +if ! command -v caddy &> /dev/null; then + echo "Installing Caddy (reverse proxy for TLS termination)..." + sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ + | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + | sudo tee /etc/apt/sources.list.d/caddy-stable.list + sudo apt update + sudo apt install -y caddy + echo -e "${GREEN}✓ Caddy installed${NC}" +else + echo -e "${GREEN}✓ Caddy already installed${NC}" +fi + +# Verify Caddy installation +CADDY_VERSION=$(caddy version 2>/dev/null || echo "not found") +echo " Caddy version: $CADDY_VERSION" + # ============================================================================ # PHASE 2: PROJECT DIRECTORY SETUP # ============================================================================ @@ -195,34 +216,35 @@ echo "" echo -e "${BLUE}[4/5] Firewall Configuration${NC}" echo "" -# Check if ufw is active +# Only 80/443 (Caddy) are exposed. The app ports 14100/14200 stay loopback-only +# — Caddy reaches them over 127.0.0.1, so they must NOT be opened to the network +# (#747). If a legacy rule exposed them, deny it. if sudo ufw status | grep -q "Status: active"; then echo "UFW firewall is active." - # Check if ports are already allowed - if ! sudo ufw status | grep -q "14100/tcp"; then - read -p "Allow port 14100 (frontend) through firewall? (Y/n): " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Nn]$ ]]; then - sudo ufw allow 14100/tcp comment 'CodeFRAME Frontend' - echo -e "${GREEN}✓ Port 14100 allowed${NC}" + for port in 80 443; do + if ! sudo ufw status | grep -q "${port}/tcp"; then + read -p "Allow port ${port} (Caddy reverse proxy) through firewall? (Y/n): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Nn]$ ]]; then + sudo ufw allow ${port}/tcp comment 'CodeFRAME Caddy proxy' + echo -e "${GREEN}✓ Port ${port} allowed${NC}" + fi + else + echo -e "${GREEN}✓ Port ${port} already allowed${NC}" fi - else - echo -e "${GREEN}✓ Port 14100 already allowed${NC}" - fi + done - if ! sudo ufw status | grep -q "14200/tcp"; then - read -p "Allow port 14200 (backend) through firewall? (Y/n): " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Nn]$ ]]; then - sudo ufw allow 14200/tcp comment 'CodeFRAME Backend API' - echo -e "${GREEN}✓ Port 14200 allowed${NC}" + # App ports must never be public — Caddy proxies them over loopback. + for port in 14100 14200; do + if sudo ufw status | grep -q "${port}/tcp.*ALLOW"; then + echo -e "${YELLOW}⚠ Port ${port} is exposed — denying (must be loopback-only)${NC}" + sudo ufw delete allow ${port}/tcp 2>/dev/null || true fi - else - echo -e "${GREEN}✓ Port 14200 already allowed${NC}" - fi + done else - echo "UFW firewall is not active. Skipping firewall configuration." + echo "UFW firewall is not active." + echo -e "${YELLOW}⚠ Expose only 80/443. The app ports 14100/14200 must stay loopback-only.${NC}" fi # ============================================================================ @@ -252,18 +274,24 @@ echo "" echo -e "${BLUE}Next Steps:${NC}" echo "" -echo "1. Verify your .env.staging configuration:" -echo " cat .env.staging | grep ANTHROPIC_API_KEY" +echo "1. Verify your .env.staging configuration (loopback host + https origins):" +echo " grep -E 'ANTHROPIC_API_KEY|HOST|NEXT_PUBLIC_' .env.staging" +echo "" +echo "2. Configure the Caddy reverse proxy (TLS termination):" +echo " sudo cp deploy/Caddyfile.example /etc/caddy/Caddyfile" +echo " sudo \$EDITOR /etc/caddy/Caddyfile # set your domain" +echo " sudo systemctl reload caddy" +echo " (See deploy/README.md for details.)" echo "" -echo "2. Run the deployment script:" +echo "3. Run the deployment script:" echo " cd ~/projects/codeframe" echo " ./scripts/deploy-staging.sh" echo "" -echo "3. After deployment, access the dashboard:" -echo " Frontend: http://YOUR_STAGING_SERVER:14100" -echo " Backend: http://YOUR_STAGING_SERVER:14200" +echo "4. After deployment, access the dashboard over TLS via Caddy:" +echo " https://your-domain" +echo " (The app ports 14100/14200 are loopback-only — not public.)" echo "" -echo "4. (Optional) Enable auto-start on boot:" +echo "5. (Optional) Enable auto-start on boot:" echo " pm2 save" echo " pm2 startup" echo "" diff --git a/tests/test_deploy_config.py b/tests/test_deploy_config.py index d07476de..ed4f5bb2 100644 --- a/tests/test_deploy_config.py +++ b/tests/test_deploy_config.py @@ -17,6 +17,10 @@ REPO = Path(__file__).resolve().parents[1] DEPLOY_YML = REPO / ".github" / "workflows" / "deploy.yml" PROD_ECOSYSTEM = REPO / "ecosystem.production.config.js" +STAGING_ECOSYSTEM = REPO / "ecosystem.staging.config.js" +CADDYFILE = REPO / "deploy" / "Caddyfile.example" +STAGING_ENV = REPO / ".env.staging.example" +PROD_ENV = REPO / ".env.production.example" def test_production_ecosystem_config_exists(): @@ -62,3 +66,74 @@ def test_production_config_defines_two_apps(): assert ".venv/bin/python" in text # backend app assert "web-ui/node_modules/.bin/next" in text # frontend app assert text.count("name:") == 2 # exactly two pm2 apps + + +# --------------------------------------------------------------------------- +# TLS reverse proxy (#747 / P1.20): app processes bind loopback; a +# TLS-terminating proxy is the sole public listener; documented origins are +# https/wss. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("cfg", [PROD_ECOSYSTEM, STAGING_ECOSYSTEM], ids=["prod", "staging"]) +def test_frontend_binds_loopback(cfg): + """The Next.js frontend must bind 127.0.0.1, never 0.0.0.0 — the Caddy + reverse proxy is the only process allowed to listen on public interfaces.""" + text = cfg.read_text() + assert "-H 127.0.0.1" in text, f"{cfg.name}: frontend must bind 127.0.0.1" + assert "-H 0.0.0.0" not in text, f"{cfg.name}: frontend must not bind 0.0.0.0" + + +def test_env_examples_bind_backend_loopback(): + """HOST in the deploy env templates must pin the backend to loopback.""" + for env in (STAGING_ENV, PROD_ENV): + text = env.read_text() + assert re.search(r"^HOST=127\.0\.0\.1$", text, re.MULTILINE), ( + f"{env.name}: HOST must be 127.0.0.1 (backend behind the proxy)" + ) + assert "HOST=0.0.0.0" not in text, f"{env.name}: HOST must not be 0.0.0.0" + + +def test_env_examples_document_tls_origins(): + """Public-facing origins in the deploy templates must be https/wss, not + plaintext http/ws (the whole point of #747).""" + for env in (STAGING_ENV, PROD_ENV): + text = env.read_text() + for line in text.splitlines(): + if line.startswith("NEXT_PUBLIC_API_URL="): + assert line.split("=", 1)[1].startswith("https://"), f"{env.name}: {line}" + if line.startswith("NEXT_PUBLIC_WS_URL="): + assert line.split("=", 1)[1].startswith("wss://"), f"{env.name}: {line}" + + +def test_caddyfile_example_exists_and_proxies_both_services(): + """A reverse-proxy config must ship and route to both loopback services.""" + assert CADDYFILE.is_file(), "deploy/Caddyfile.example must exist" + text = CADDYFILE.read_text() + assert "reverse_proxy" in text + assert "127.0.0.1:14200" in text, "Caddyfile must proxy the backend" + assert "127.0.0.1:14100" in text, "Caddyfile must proxy the frontend" + # Backend paths (API/auth/websockets) must be routed to the backend. + assert "/api/*" in text and "/auth/*" in text and "/ws/*" in text + + +@pytest.mark.skipif(shutil.which("caddy") is None, reason="caddy not available") +def test_caddyfile_example_is_valid(): + """If caddy is installed, the shipped config must pass `caddy validate`.""" + result = subprocess.run( + ["caddy", "validate", "--config", str(CADDYFILE), "--adapter", "caddyfile"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + +def test_remote_setup_installs_proxy_and_binds_loopback_firewall(): + """Provisioning must install the proxy and stop exposing the app ports + directly (only 80/443 should be public).""" + text = (REPO / "scripts" / "remote-setup.sh").read_text() + assert "caddy" in text.lower(), "remote-setup.sh must provision the reverse proxy" + assert "for port in 80 443" in text, "remote-setup.sh firewall must allow HTTP/HTTPS (80/443)" + # The raw app ports must no longer be opened to the world. + assert "ufw allow 14100/tcp" not in text + assert "ufw allow 14200/tcp" not in text From 5d8ee708f54262f536a39ebe19a901fd3f611782 Mon Sep 17 00:00:00 2001 From: frankbria Date: Mon, 6 Jul 2026 21:16:04 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(deploy):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20robust=20backend=20loopback=20bind=20+=20trusted-proxy=20rat?= =?UTF-8?q?e=20limiting=20(#747)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party review (opencode/GLM) findings: - Major: backend was started without --host, so loopback binding depended on an operator-maintained HOST env var — a stale HOST=0.0.0.0 in an existing deploy would leave the backend exposed. Pin --host explicitly in both ecosystems (staging literal 127.0.0.1; production HOST const defaulting to 127.0.0.1). - Major: behind TLS termination the app sees client 127.0.0.1. The app has its own trusted-proxy mechanism (RATE_LIMIT_TRUSTED_PROXIES), so document RATE_LIMIT_TRUSTED_PROXIES=127.0.0.1 in the env examples rather than enabling uvicorn proxy_headers (which would bypass the app's spoof protection). - Minor: note that 'tls internal' needs the local CA trusted (caddy trust). - Minor: clarify CORS is same-origin but keep CORS_ALLOWED_ORIGINS set. - Close test gap: assert the ecosystem backend binds loopback, not just .env. --- .env.production.example | 6 ++++++ .env.staging.example | 6 ++++++ deploy/Caddyfile.example | 3 +++ deploy/README.md | 9 +++++++-- ecosystem.production.config.js | 6 +++++- ecosystem.staging.config.js | 5 ++++- tests/test_deploy_config.py | 13 +++++++++++++ 7 files changed, 44 insertions(+), 4 deletions(-) diff --git a/.env.production.example b/.env.production.example index aa2cccb7..64e9b16d 100644 --- a/.env.production.example +++ b/.env.production.example @@ -40,6 +40,12 @@ NEXT_PUBLIC_WS_URL=wss://your-domain # so this is just that domain. CORS_ALLOWED_ORIGINS=https://your-domain +# Trusted proxy for rate limiting — Caddy connects over loopback, so trust +# 127.0.0.1 to read the real client IP from X-Forwarded-For. Without this the +# rate limiter keys every request to the proxy IP, collapsing per-client and +# auth brute-force limits into one shared bucket (#747). +RATE_LIMIT_TRUSTED_PROXIES=127.0.0.1 + # Environment # NODE_ENV must be 'production' for Next.js to work correctly. NODE_ENV=production diff --git a/.env.staging.example b/.env.staging.example index 93239483..48023fb7 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -35,6 +35,12 @@ NEXT_PUBLIC_WS_URL=wss://your-domain # hits a single https origin (same-origin), so this is just that domain. CORS_ALLOWED_ORIGINS=https://your-domain +# Trusted proxy for rate limiting — Caddy connects over loopback, so trust +# 127.0.0.1 to read the real client IP from X-Forwarded-For. Without this the +# rate limiter keys every request to the proxy IP, collapsing per-client and +# auth brute-force limits into one shared bucket (#747). +RATE_LIMIT_TRUSTED_PROXIES=127.0.0.1 + # Environment # NODE_ENV should be 'production' for Next.js to work correctly # Use CODEFRAME_ENV for environment-specific configuration diff --git a/deploy/Caddyfile.example b/deploy/Caddyfile.example index 6122df52..65c4b312 100644 --- a/deploy/Caddyfile.example +++ b/deploy/Caddyfile.example @@ -19,6 +19,9 @@ # ... # } # +# Note: browsers reject Caddy's local CA (NET::ERR_CERT_AUTHORITY_INVALID) until +# you install its root cert on the client (`caddy trust`) or accept the warning. +# # Ports below match the staging deploy (14100 frontend / 14200 backend). For the # production ecosystem, set them to your FRONTEND_PORT / BACKEND_PORT # (defaults 3000 / 8000). diff --git a/deploy/README.md b/deploy/README.md index 5be9c0dc..0b10a112 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -36,7 +36,10 @@ ever travel over HTTPS/WSS between the browser and Caddy. Plaintext ## Routing -Caddy path-routes a single public origin (no CORS needed — same origin): +Caddy path-routes a single public origin, so browser traffic is same-origin and +CORS pre-flight never fires. Keep `CORS_ALLOWED_ORIGINS` set to that domain +anyway — the backend's CORS middleware still validates it, and it's the fallback +if you later split the API onto a separate subdomain. - `/api/*`, `/auth/*`, `/ws/*`, `/health`, `/docs`, `/redoc`, `/openapi.json` → backend `127.0.0.1:14200` @@ -48,4 +51,6 @@ WebSocket upgrades are handled transparently by Caddy's `reverse_proxy`. For an IP-only or internal host, use the IP as the site address and add `tls internal` (Caddy's local CA) — see the commented example in -`Caddyfile.example`. +`Caddyfile.example`. Browsers reject the local CA +(`NET::ERR_CERT_AUTHORITY_INVALID`) until its root cert is trusted on the client +(`caddy trust`) or the warning is accepted — this is expected, not a broken deploy. diff --git a/ecosystem.production.config.js b/ecosystem.production.config.js index bac55a56..e2a8b21f 100644 --- a/ecosystem.production.config.js +++ b/ecosystem.production.config.js @@ -17,13 +17,17 @@ const FRONTEND_NAME = process.env.PM2_FRONTEND_NAME || envConfig.PM2_FRONTEND_NAME || 'codeframe-production-frontend'; const BACKEND_PORT = process.env.BACKEND_PORT || envConfig.BACKEND_PORT || '8000'; const FRONTEND_PORT = process.env.FRONTEND_PORT || envConfig.FRONTEND_PORT || '3000'; +// Bind loopback by default — the Caddy reverse proxy is the sole public listener +// (#747). Defaults to 127.0.0.1 so a stale HOST in .env.production can't expose +// the backend; override only for a proxy on a separate host. +const HOST = process.env.HOST || envConfig.HOST || '127.0.0.1'; module.exports = { apps: [ { name: BACKEND_NAME, script: path.join(PROJECT_ROOT, '.venv/bin/python'), - args: `-m codeframe.ui.server --port ${BACKEND_PORT}`, + args: `-m codeframe.ui.server --host ${HOST} --port ${BACKEND_PORT}`, cwd: PROJECT_ROOT, env: { ...envConfig, diff --git a/ecosystem.staging.config.js b/ecosystem.staging.config.js index 54a06f49..584a53d8 100644 --- a/ecosystem.staging.config.js +++ b/ecosystem.staging.config.js @@ -12,7 +12,10 @@ module.exports = { { name: 'codeframe-staging-backend', script: path.join(PROJECT_ROOT, '.venv/bin/python'), - args: '-m codeframe.ui.server --port 14200', + // Bind loopback explicitly — do not rely on HOST in .env.staging, so an + // existing deploy with a stale HOST=0.0.0.0 can't leave the backend + // exposed behind the TLS proxy (issue #747). + args: '-m codeframe.ui.server --host 127.0.0.1 --port 14200', cwd: PROJECT_ROOT, env: { ...envConfig, diff --git a/tests/test_deploy_config.py b/tests/test_deploy_config.py index ed4f5bb2..251cc248 100644 --- a/tests/test_deploy_config.py +++ b/tests/test_deploy_config.py @@ -84,6 +84,19 @@ def test_frontend_binds_loopback(cfg): assert "-H 0.0.0.0" not in text, f"{cfg.name}: frontend must not bind 0.0.0.0" +@pytest.mark.parametrize("cfg", [PROD_ECOSYSTEM, STAGING_ECOSYSTEM], ids=["prod", "staging"]) +def test_backend_binds_loopback(cfg): + """The backend must be started with an explicit loopback --host, so binding + doesn't silently depend on an operator's (possibly stale) HOST env var.""" + text = cfg.read_text() + assert "--host 127.0.0.1" in text or "--host ${HOST}" in text, ( + f"{cfg.name}: backend must pass an explicit loopback --host" + ) + # If it parametrizes HOST, the default must be loopback. + if "--host ${HOST}" in text: + assert "|| '127.0.0.1'" in text, f"{cfg.name}: HOST must default to 127.0.0.1" + + def test_env_examples_bind_backend_loopback(): """HOST in the deploy env templates must pin the backend to loopback.""" for env in (STAGING_ENV, PROD_ENV): From 53b35292a7d17bb04f482b3941a1c06816da417e Mon Sep 17 00:00:00 2001 From: frankbria Date: Mon, 6 Jul 2026 21:24:17 -0700 Subject: [PATCH 3/3] docs(deploy): point remote-setup.sh at deploy/README.md (was dangling ref) (#747) --- scripts/remote-setup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/remote-setup.sh b/scripts/remote-setup.sh index 26099a20..5d8a21f4 100755 --- a/scripts/remote-setup.sh +++ b/scripts/remote-setup.sh @@ -297,5 +297,5 @@ echo " pm2 startup" echo "" echo -e "${BLUE}Documentation:${NC}" -echo " See docs/REMOTE_STAGING_DEPLOYMENT.md for full guide" +echo " See deploy/README.md for the TLS reverse-proxy setup guide" echo ""