Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions quantara/frontend/quantara.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions quantara/frontend/quantara_dev.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
3 changes: 2 additions & 1 deletion quantara/web_app/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
42 changes: 42 additions & 0 deletions quantara/web_app/api/middleware.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
42 changes: 42 additions & 0 deletions quantara/web_app/tests/test_security_headers.py
Original file line number Diff line number Diff line change
@@ -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
Loading