Skip to content

Commit 9d18a3c

Browse files
committed
refactor: Centralize CORS business logic into Tangle middleware
1 parent 26456ad commit 9d18a3c

File tree

4 files changed

+363
-0
lines changed

4 files changed

+363
-0
lines changed

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,22 @@ This backend consists of the API Server and the Orchestrator.
145145
The API Server receives API requests and accesses the database to fulfill them.
146146
The API documentation can be accessed at [http://localhost:8000/docs](http://localhost:8000/docs).
147147

148+
## CORS Middleware
149+
150+
### Purpose
151+
152+
The CORS (Cross-Origin Resource Sharing) middleware allows web browsers to make requests to the Tangle API from different origins (domains). This is essential for:
153+
- Local development (frontend on `localhost:3000`, backend on `localhost:8000`)
154+
- Production deployments where the frontend and backend are on different domains
155+
- Multi-tenant deployments with multiple frontend URLs
156+
157+
### How It Works
158+
159+
1. **Environment Variable Configuration**: Origins are specified in the `TANGLE_CORS_ALLOWED_ORIGINS` environment variable as a comma-separated list
160+
2. **Request Validation**: For each incoming request, the middleware checks the `Origin` header from the browser
161+
3. **Dynamic Response**: If the origin is in the allowed list, the server responds with `Access-Control-Allow-Origin` set to that specific origin
162+
4. **Security**: Only pre-approved origins receive CORS headers, preventing unauthorized cross-origin access
163+
148164
### Orchestrator
149165

150166
The Orchestrator works independently from the API Server.

cloud_pipelines_backend/api_router.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from . import component_library_api_server as components_api
1616
from . import database_ops
1717
from . import errors
18+
from . import middleware
1819

1920
if typing.TYPE_CHECKING:
2021
from .launchers import interfaces as launcher_interfaces
@@ -39,6 +40,9 @@ def setup_routes(
3940
container_launcher_for_log_streaming: "launcher_interfaces.ContainerTaskLauncher[launcher_interfaces.LaunchedContainer] | None" = None,
4041
default_component_library_owner_username: str = "admin",
4142
):
43+
# Setup global middleware (CORS, etc.) - must be called before routes are added
44+
middleware.setup_cors_middleware(app)
45+
4246
def get_session():
4347
with orm.Session(autocommit=False, autoflush=False, bind=db_engine) as session:
4448
yield session
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""
2+
Global middleware configuration for Tangle API servers.
3+
4+
This module provides reusable middleware setup functions that should be used
5+
by all entry points to ensure consistent behavior across deployments.
6+
"""
7+
8+
import logging
9+
import os
10+
from urllib.parse import urlparse
11+
12+
import fastapi
13+
from fastapi.middleware.cors import CORSMiddleware
14+
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
def _is_valid_origin(origin: str) -> bool:
20+
"""
21+
Validate that an origin string is a valid URL format.
22+
23+
Args:
24+
origin: The origin URL to validate
25+
26+
Returns:
27+
True if valid, False otherwise
28+
"""
29+
try:
30+
parsed = urlparse(origin)
31+
# Must have a scheme (http/https) and a netloc (domain/host)
32+
if not parsed.scheme or not parsed.netloc:
33+
return False
34+
# Scheme must be http or https
35+
if parsed.scheme not in ("http", "https"):
36+
return False
37+
# Should not have a path beyond '/'
38+
if parsed.path and parsed.path != "/":
39+
return False
40+
return True
41+
except Exception:
42+
return False
43+
44+
45+
def setup_cors_middleware(app: fastapi.FastAPI) -> None:
46+
"""
47+
Configure CORS middleware for the FastAPI application.
48+
49+
Environment Variables:
50+
TANGLE_CORS_ALLOWED_ORIGINS: Comma-separated list of allowed origins.
51+
Default: "http://localhost:3000,http://127.0.0.1:3000" if not set.
52+
"""
53+
cors_origins_str = os.environ.get("TANGLE_CORS_ALLOWED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000")
54+
55+
# Parse the comma-separated list and strip whitespace from each origin
56+
raw_origins = [
57+
origin.strip()
58+
for origin in cors_origins_str.split(",")
59+
if origin.strip()
60+
]
61+
62+
# Validate each origin
63+
allowed_origins = []
64+
invalid_origins = []
65+
66+
for origin in raw_origins:
67+
if _is_valid_origin(origin):
68+
allowed_origins.append(origin)
69+
else:
70+
invalid_origins.append(origin)
71+
72+
# Log warnings for invalid origins
73+
if invalid_origins:
74+
logger.warning(
75+
f"Invalid CORS origins found and ignored: {', '.join(invalid_origins)}. "
76+
f"Origins must be valid URLs with http:// or https:// scheme."
77+
)
78+
79+
if not allowed_origins:
80+
logger.warning(
81+
"No valid CORS origins found. CORS middleware not configured."
82+
)
83+
return
84+
85+
app.add_middleware(
86+
CORSMiddleware,
87+
allow_origins=allowed_origins,
88+
allow_credentials=True,
89+
allow_methods=["*"],
90+
allow_headers=["*"],
91+
)
92+
93+
logger.info(
94+
f"CORS middleware configured for {len(allowed_origins)} origin(s): "
95+
f"{', '.join(allowed_origins)}"
96+
)
97+

tests/test_middleware.py

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
"""Tests for the middleware module, including CORS configuration."""
2+
3+
import os
4+
import pytest
5+
from fastapi import FastAPI
6+
from fastapi.testclient import TestClient
7+
8+
from cloud_pipelines_backend import middleware
9+
10+
11+
@pytest.fixture
12+
def app():
13+
"""Create a minimal FastAPI app for testing."""
14+
app = FastAPI()
15+
16+
@app.get("/test")
17+
def test_endpoint():
18+
return {"message": "success"}
19+
20+
return app
21+
22+
23+
@pytest.fixture
24+
def clean_env(monkeypatch):
25+
"""Ensure clean environment for each test."""
26+
monkeypatch.delenv("TANGLE_CORS_ALLOWED_ORIGINS", raising=False)
27+
28+
29+
def test_cors_middleware_with_default_origins(app, clean_env, monkeypatch):
30+
"""Test that CORS middleware uses default origins when env var is not set."""
31+
# Don't set TANGLE_CORS_ALLOWED_ORIGINS - should use defaults
32+
middleware.setup_cors_middleware(app)
33+
client = TestClient(app)
34+
35+
# Test request from default origin (localhost:3000)
36+
response = client.get("/test", headers={"Origin": "http://localhost:3000"})
37+
assert response.status_code == 200
38+
assert response.headers.get("access-control-allow-origin") == "http://localhost:3000"
39+
40+
# Test request from default origin (127.0.0.1:3000)
41+
response = client.get("/test", headers={"Origin": "http://127.0.0.1:3000"})
42+
assert response.status_code == 200
43+
assert response.headers.get("access-control-allow-origin") == "http://127.0.0.1:3000"
44+
45+
46+
def test_cors_middleware_with_custom_single_origin(app, clean_env, monkeypatch):
47+
"""Test CORS middleware with a single custom origin."""
48+
monkeypatch.setenv("TANGLE_CORS_ALLOWED_ORIGINS", "https://app.example.com")
49+
middleware.setup_cors_middleware(app)
50+
client = TestClient(app)
51+
52+
# Test request from allowed origin
53+
response = client.get("/test", headers={"Origin": "https://app.example.com"})
54+
assert response.status_code == 200
55+
assert response.headers.get("access-control-allow-origin") == "https://app.example.com"
56+
57+
# Test request from disallowed origin
58+
response = client.get("/test", headers={"Origin": "http://localhost:3000"})
59+
assert response.status_code == 200
60+
# Should not match the disallowed origin
61+
assert response.headers.get("access-control-allow-origin") != "http://localhost:3000"
62+
63+
64+
def test_cors_middleware_with_multiple_origins(app, clean_env, monkeypatch):
65+
"""Test CORS middleware with multiple comma-separated origins."""
66+
origins = "http://localhost:3000,https://staging.example.com,https://app.example.com"
67+
monkeypatch.setenv("TANGLE_CORS_ALLOWED_ORIGINS", origins)
68+
middleware.setup_cors_middleware(app)
69+
client = TestClient(app)
70+
71+
# Test each allowed origin
72+
for origin in ["http://localhost:3000", "https://staging.example.com", "https://app.example.com"]:
73+
response = client.get("/test", headers={"Origin": origin})
74+
assert response.status_code == 200
75+
assert response.headers.get("access-control-allow-origin") == origin
76+
77+
# Test disallowed origin
78+
response = client.get("/test", headers={"Origin": "http://evil.com"})
79+
assert response.status_code == 200
80+
assert response.headers.get("access-control-allow-origin") != "http://evil.com"
81+
82+
83+
def test_cors_middleware_with_whitespace_in_origins(app, clean_env, monkeypatch):
84+
"""Test that whitespace around origins is properly handled."""
85+
origins = " http://localhost:3000 , https://app.example.com , http://127.0.0.1:3000 "
86+
monkeypatch.setenv("TANGLE_CORS_ALLOWED_ORIGINS", origins)
87+
middleware.setup_cors_middleware(app)
88+
client = TestClient(app)
89+
90+
# Test that whitespace is properly stripped
91+
response = client.get("/test", headers={"Origin": "http://localhost:3000"})
92+
assert response.status_code == 200
93+
assert response.headers.get("access-control-allow-origin") == "http://localhost:3000"
94+
95+
96+
def test_cors_middleware_without_origin_header(app, clean_env, monkeypatch):
97+
"""Test that requests without Origin header work normally."""
98+
monkeypatch.setenv("TANGLE_CORS_ALLOWED_ORIGINS", "http://localhost:3000")
99+
middleware.setup_cors_middleware(app)
100+
client = TestClient(app)
101+
102+
# Request without Origin header should still work
103+
response = client.get("/test")
104+
assert response.status_code == 200
105+
assert response.json() == {"message": "success"}
106+
107+
108+
def test_cors_middleware_rejects_invalid_url_formats(app, clean_env, monkeypatch, caplog):
109+
"""Test that invalid URL formats are rejected and logged."""
110+
# Mix of valid and invalid origins
111+
origins = "http://localhost:3000,invalid-url,ftp://wrong-scheme.com,http://example.com/path"
112+
monkeypatch.setenv("TANGLE_CORS_ALLOWED_ORIGINS", origins)
113+
114+
middleware.setup_cors_middleware(app)
115+
client = TestClient(app)
116+
117+
# Valid origin should work
118+
response = client.get("/test", headers={"Origin": "http://localhost:3000"})
119+
assert response.status_code == 200
120+
assert response.headers.get("access-control-allow-origin") == "http://localhost:3000"
121+
122+
# Check that invalid origins were logged
123+
assert "Invalid CORS origins found and ignored" in caplog.text
124+
assert "invalid-url" in caplog.text
125+
126+
127+
def test_cors_middleware_validates_scheme_http_https_only(app, clean_env, monkeypatch):
128+
"""Test that only http and https schemes are accepted."""
129+
origins = "http://localhost:3000,ftp://example.com,ws://example.com"
130+
monkeypatch.setenv("TANGLE_CORS_ALLOWED_ORIGINS", origins)
131+
middleware.setup_cors_middleware(app)
132+
client = TestClient(app)
133+
134+
# http should work
135+
response = client.get("/test", headers={"Origin": "http://localhost:3000"})
136+
assert response.status_code == 200
137+
assert response.headers.get("access-control-allow-origin") == "http://localhost:3000"
138+
139+
# ftp and ws should not be in allowed origins
140+
response = client.get("/test", headers={"Origin": "ftp://example.com"})
141+
assert response.headers.get("access-control-allow-origin") != "ftp://example.com"
142+
143+
144+
def test_cors_middleware_rejects_origins_with_paths(app, clean_env, monkeypatch):
145+
"""Test that origins with paths are rejected."""
146+
origins = "http://localhost:3000,http://example.com/api,http://example.com/path/to/resource"
147+
monkeypatch.setenv("TANGLE_CORS_ALLOWED_ORIGINS", origins)
148+
middleware.setup_cors_middleware(app)
149+
client = TestClient(app)
150+
151+
# Origin without path should work
152+
response = client.get("/test", headers={"Origin": "http://localhost:3000"})
153+
assert response.status_code == 200
154+
assert response.headers.get("access-control-allow-origin") == "http://localhost:3000"
155+
156+
# Origins with paths should not work
157+
response = client.get("/test", headers={"Origin": "http://example.com/api"})
158+
assert response.headers.get("access-control-allow-origin") != "http://example.com/api"
159+
160+
161+
def test_cors_middleware_empty_origins_string(app, clean_env, monkeypatch, caplog):
162+
"""Test behavior when TANGLE_CORS_ALLOWED_ORIGINS is set to empty string."""
163+
monkeypatch.setenv("TANGLE_CORS_ALLOWED_ORIGINS", "")
164+
middleware.setup_cors_middleware(app)
165+
166+
# Should log warning about no valid origins
167+
assert "No valid CORS origins found" in caplog.text
168+
169+
170+
def test_cors_middleware_allows_credentials(app, clean_env, monkeypatch):
171+
"""Test that CORS middleware is configured to allow credentials."""
172+
monkeypatch.setenv("TANGLE_CORS_ALLOWED_ORIGINS", "http://localhost:3000")
173+
middleware.setup_cors_middleware(app)
174+
client = TestClient(app)
175+
176+
response = client.get("/test", headers={"Origin": "http://localhost:3000"})
177+
assert response.status_code == 200
178+
# FastAPI's CORSMiddleware should set this header
179+
assert "access-control-allow-credentials" in response.headers
180+
181+
182+
def test_cors_middleware_allows_all_methods_and_headers(app, clean_env, monkeypatch):
183+
"""Test that CORS middleware allows all methods and headers."""
184+
monkeypatch.setenv("TANGLE_CORS_ALLOWED_ORIGINS", "http://localhost:3000")
185+
middleware.setup_cors_middleware(app)
186+
client = TestClient(app)
187+
188+
# Preflight request
189+
response = client.options(
190+
"/test",
191+
headers={
192+
"Origin": "http://localhost:3000",
193+
"Access-Control-Request-Method": "POST",
194+
"Access-Control-Request-Headers": "X-Custom-Header",
195+
},
196+
)
197+
198+
assert response.status_code == 200
199+
assert "access-control-allow-methods" in response.headers
200+
assert "access-control-allow-headers" in response.headers
201+
202+
203+
def test_is_valid_origin_helper():
204+
"""Test the _is_valid_origin helper function directly."""
205+
# Valid origins
206+
assert middleware._is_valid_origin("http://localhost:3000") == True
207+
assert middleware._is_valid_origin("https://example.com") == True
208+
assert middleware._is_valid_origin("http://127.0.0.1:8080") == True
209+
assert middleware._is_valid_origin("https://subdomain.example.com:443") == True
210+
211+
# Invalid origins - no scheme
212+
assert middleware._is_valid_origin("localhost:3000") == False
213+
assert middleware._is_valid_origin("example.com") == False
214+
215+
# Invalid origins - wrong scheme
216+
assert middleware._is_valid_origin("ftp://example.com") == False
217+
assert middleware._is_valid_origin("ws://example.com") == False
218+
assert middleware._is_valid_origin("file:///path/to/file") == False
219+
220+
# Invalid origins - has path
221+
assert middleware._is_valid_origin("http://example.com/api") == False
222+
assert middleware._is_valid_origin("https://example.com/path/to/resource") == False
223+
224+
# Invalid origins - malformed
225+
assert middleware._is_valid_origin("not-a-url") == False
226+
assert middleware._is_valid_origin("http://") == False
227+
assert middleware._is_valid_origin("") == False
228+
229+
230+
def test_setup_cors_middleware_integration(app, clean_env, monkeypatch):
231+
"""Test that setup_cors_middleware works end-to-end."""
232+
monkeypatch.setenv("TANGLE_CORS_ALLOWED_ORIGINS", "http://localhost:3000")
233+
234+
# Call setup_cors_middleware
235+
middleware.setup_cors_middleware(app)
236+
client = TestClient(app)
237+
238+
# Verify CORS is working
239+
response = client.get("/test", headers={"Origin": "http://localhost:3000"})
240+
assert response.status_code == 200
241+
assert response.headers.get("access-control-allow-origin") == "http://localhost:3000"
242+
243+
244+
if __name__ == "__main__":
245+
pytest.main([__file__, "-v"])
246+

0 commit comments

Comments
 (0)