diff --git a/backend/tests/test_gateway_runtime_cleanup.py b/backend/tests/test_gateway_runtime_cleanup.py index 0c17368f4..1ccbba11a 100644 --- a/backend/tests/test_gateway_runtime_cleanup.py +++ b/backend/tests/test_gateway_runtime_cleanup.py @@ -12,6 +12,36 @@ def _read(path: str) -> str: return (REPO_ROOT / path).read_text(encoding="utf-8") +def _extract_nginx_location_block(content: str, location: str) -> str: + needle = f"location {location} {{" + start = content.find(needle) + assert start != -1, f"missing nginx {needle}" + + brace_start = content.find("{", start) + assert brace_start != -1, f"missing nginx block opener for {needle}" + + depth = 0 + for index in range(brace_start, len(content)): + char = content[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return content[start : index + 1] + + raise AssertionError(f"missing nginx block closer for {needle}") + + +def _assert_frontend_upgrade_header_is_conditional(content: str) -> None: + frontend_block = _extract_nginx_location_block(content, "/") + + assert "map $http_upgrade $connection_upgrade" in content + assert "proxy_set_header Upgrade $http_upgrade;" in frontend_block + assert "proxy_set_header Connection $connection_upgrade;" in frontend_block + assert "proxy_set_header Connection 'upgrade';" not in frontend_block + + def test_root_makefile_no_longer_exposes_transition_gateway_targets(): makefile = _read("Makefile") @@ -104,6 +134,38 @@ def test_nginx_defers_cors_to_gateway_allowlist(): assert "if ($request_method = 'OPTIONS')" not in content +def test_nginx_frontend_upgrade_header_is_conditional(): + for path in ("docker/nginx/nginx.local.conf", "docker/nginx/nginx.conf"): + content = _read(path) + + _assert_frontend_upgrade_header_is_conditional(content) + + +def test_nginx_frontend_upgrade_check_allows_dedicated_websocket_locations(): + content = """ +http { + map $http_upgrade $connection_upgrade { + default upgrade; + '' ''; + } + + server { + location /api/threads/123/browser/stream { + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + } + + location / { + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + } +} +""" + + _assert_frontend_upgrade_header_is_conditional(content) + + def test_gateway_cors_configuration_uses_gateway_allowlist(): gateway_config = _read("backend/app/gateway/config.py") gateway_app = _read("backend/app/gateway/app.py") diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index c2fd40925..9ce9437b5 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -30,6 +30,15 @@ http { "~*^https" https; } + # Only mark frontend requests as connection upgrades when the browser + # actually requested one. Next.js dev HMR also uses long-lived HTTP + # streams, and forcing "Connection: upgrade" on those requests makes + # nginx treat the upstream response as an invalid upgrade handshake. + map $http_upgrade $connection_upgrade { + default upgrade; + '' ''; + } + # ── Main server (path-based routing) ───────────────────────────────── server { listen 2026 default_server; @@ -253,7 +262,7 @@ http { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; + proxy_set_header Connection $connection_upgrade; proxy_cache_bypass $http_upgrade; # Timeouts diff --git a/docker/nginx/nginx.local.conf b/docker/nginx/nginx.local.conf index 664a45722..c0ae52647 100644 --- a/docker/nginx/nginx.local.conf +++ b/docker/nginx/nginx.local.conf @@ -27,6 +27,15 @@ http { server 127.0.0.1:3000; } + # Only mark frontend requests as connection upgrades when the browser + # actually requested one. Next.js dev HMR also uses long-lived HTTP + # streams, and forcing "Connection: upgrade" on those requests makes + # nginx treat the upstream response as an invalid upgrade handshake. + map $http_upgrade $connection_upgrade { + default upgrade; + '' ''; + } + server { listen 2026; listen [::]:2026; @@ -262,7 +271,7 @@ http { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; + proxy_set_header Connection $connection_upgrade; proxy_cache_bypass $http_upgrade; # Disable response buffering for the frontend. Without this,