diff --git a/quantara/frontend/quantara.conf b/quantara/frontend/quantara.conf index 71cc2c08..afc1134a 100644 --- a/quantara/frontend/quantara.conf +++ b/quantara/frontend/quantara.conf @@ -4,6 +4,11 @@ server { server_name localhost; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://*.stellar.org; frame-ancestors 'none';" always; + location / { root /usr/share/nginx/html; index index.html; diff --git a/quantara/frontend/quantara_dev.conf b/quantara/frontend/quantara_dev.conf index 13a45951..5c19246e 100644 --- a/quantara/frontend/quantara_dev.conf +++ b/quantara/frontend/quantara_dev.conf @@ -18,6 +18,12 @@ server { ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=86400; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://*.stellar.org; frame-ancestors 'none';" always; + access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log; diff --git a/quantara/web_app/api/main.py b/quantara/web_app/api/main.py index 779b6646..3eb13441 100644 --- a/quantara/web_app/api/main.py +++ b/quantara/web_app/api/main.py @@ -33,7 +33,7 @@ from web_app.api.wallet_auth import router as auth_router from web_app.api.metrics import router as metrics_router, PrometheusMiddleware from web_app.config_validator import assert_valid_config -from web_app.api.middleware import MaxBodySizeMiddleware +from web_app.api.middleware import MaxBodySizeMiddleware, SecurityHeadersMiddleware from web_app.db.database import init_db from web_app.db.database import init_db, get_database from web_app.utils.logger import configure_logging, get_logger @@ -150,6 +150,7 @@ async def global_exception_handler(request: Request, exc: Exception): app.add_middleware(SlowAPIMiddleware) app.add_middleware(MaxBodySizeMiddleware, max_body_size=1024*1024) app.add_middleware(PrometheusMiddleware) +app.add_middleware(SecurityHeadersMiddleware) @app.middleware("http") diff --git a/quantara/web_app/api/middleware.py b/quantara/web_app/api/middleware.py index 3bdde2ab..f7ce19af 100644 --- a/quantara/web_app/api/middleware.py +++ b/quantara/web_app/api/middleware.py @@ -1,4 +1,46 @@ import json +import os + + +class SecurityHeadersMiddleware: + """ASGI middleware that injects security headers into every HTTP response. + + Adds Content-Security-Policy, Strict-Transport-Security (production only), + X-Frame-Options, X-Content-Type-Options, and Referrer-Policy headers as a + defense-in-depth measure against XSS, clickjacking, MIME-sniffing, and + protocol-downgrade attacks. + """ + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + is_production = os.getenv("ENV_VERSION") == "PROD" + + async def send_with_headers(message): + if message["type"] == "http.response.start": + headers = [ + (b"content-security-policy", b"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://*.stellar.org; frame-ancestors 'none';"), + (b"x-frame-options", b"DENY"), + (b"x-content-type-options", b"nosniff"), + (b"referrer-policy", b"strict-origin-when-cross-origin"), + ] + if is_production: + headers.append((b"strict-transport-security", b"max-age=31536000; includeSubDomains")) + + existing_headers = list(message.get("headers", [])) + existing_names = {name.lower() for name, _ in existing_headers} + filtered = [h for h in existing_headers if h[0].lower() not in {name.lower() for name, _ in headers}] + message["headers"] = filtered + headers + + await send(message) + + await self.app(scope, receive, send_with_headers) + class MaxBodySizeMiddleware: """ASGI middleware to enforce a maximum request body size. diff --git a/quantara/web_app/tests/test_security_headers.py b/quantara/web_app/tests/test_security_headers.py new file mode 100644 index 00000000..d47aa815 --- /dev/null +++ b/quantara/web_app/tests/test_security_headers.py @@ -0,0 +1,42 @@ +import pytest +from httpx import AsyncClient +from web_app.api.main import app +from web_app.api.middleware import SecurityHeadersMiddleware + + +def test_security_headers_registered(): + middleware_classes = [m.cls for m in app.user_middleware] + assert SecurityHeadersMiddleware in middleware_classes + + +@pytest.mark.asyncio +async def test_security_headers_present(): + async with AsyncClient(app=app, base_url="http://testserver") as client: + response = await client.get("/health") + + assert response.headers.get("x-frame-options") == "DENY" + assert response.headers.get("x-content-type-options") == "nosniff" + assert response.headers.get("referrer-policy") == "strict-origin-when-cross-origin" + assert "content-security-policy" in response.headers + csp = response.headers["content-security-policy"] + assert "default-src 'self'" in csp + assert "frame-ancestors 'none'" in csp + + +@pytest.mark.asyncio +async def test_hsts_not_set_when_not_production(monkeypatch): + monkeypatch.setenv("ENV_VERSION", "DEV") + async with AsyncClient(app=app, base_url="http://testserver") as client: + response = await client.get("/health") + assert "strict-transport-security" not in response.headers + + +@pytest.mark.asyncio +async def test_hsts_set_in_production(monkeypatch): + monkeypatch.setenv("ENV_VERSION", "PROD") + async with AsyncClient(app=app, base_url="http://testserver") as client: + response = await client.get("/health") + assert "strict-transport-security" in response.headers + hsts = response.headers["strict-transport-security"] + assert "max-age=31536000" in hsts + assert "includeSubDomains" in hsts