Skip to content

Commit f73c584

Browse files
Merge pull request #842 (Security Headers Hardening) resolved conflicts against clean app.py copy
2 parents 1f52675 + af8b6ba commit f73c584

2 files changed

Lines changed: 93 additions & 31 deletions

File tree

‎backend/app.py‎

Lines changed: 61 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,55 @@
152152
# Initialize JWT Manager
153153
jwt = JWTManager(app)
154154

155+
DEFAULT_CORS_ORIGINS = [
156+
"http://127.0.0.1:5500",
157+
"http://localhost:5500",
158+
"http://127.0.0.1:5501",
159+
"http://localhost:5501",
160+
"http://127.0.0.1:5000",
161+
"http://localhost:5000",
162+
]
163+
ALLOWED_CORS_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
164+
ALLOWED_CORS_HEADERS = [
165+
"Content-Type",
166+
"Authorization",
167+
"X-CSRF-TOKEN",
168+
"X-CSRF-Token",
169+
"X-Requested-With",
170+
"Accept",
171+
]
172+
173+
def _load_cors_origins() -> list[str]:
174+
"""Load an explicit CORS allowlist from the environment or defaults."""
175+
raw_origins = os.getenv("ALLOWED_ORIGINS", "")
176+
if raw_origins.strip():
177+
return [origin.strip() for origin in raw_origins.split(",") if origin.strip()]
178+
return DEFAULT_CORS_ORIGINS
179+
180+
def _apply_security_headers(response):
181+
"""Apply hardening headers to every API response."""
182+
csp_policy = (
183+
"default-src 'self'; "
184+
"script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; "
185+
"style-src 'self' https://fonts.googleapis.com https://cdnjs.cloudflare.com; "
186+
"img-src 'self' data: https:; "
187+
"font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; "
188+
"connect-src 'self' https: ws: wss:; "
189+
"object-src 'none'; "
190+
"base-uri 'self'; "
191+
"frame-ancestors 'none'; "
192+
"form-action 'self'; "
193+
"upgrade-insecure-requests"
194+
)
195+
196+
response.headers['Content-Security-Policy'] = csp_policy
197+
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
198+
response.headers['X-Content-Type-Options'] = 'nosniff'
199+
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
200+
response.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
201+
response.headers['X-Frame-Options'] = 'DENY'
202+
return response
203+
155204
# =====================================================================
156205
# SECURITY COMPLIANCE UPDATE: CORS CONFIGURATION
157206
# The previous CORS(app) was overly permissive and allowed all origins.
@@ -162,8 +211,17 @@
162211
# optionally load allowed origins from environment variables.
163212
# =====================================================================
164213
# ALLOWED_ORIGINS=http://127.0.0.1:5500,http://localhost:5500,http://127.0.0.1:5000,http://localhost:5000
165-
# For development, we'll allow all to be safe, then restrict in prod
166-
CORS(app, supports_credentials=True, origins=["http://127.0.0.1:5500", "http://localhost:5500", "http://127.0.0.1:5501", "http://localhost:5501", "http://127.0.0.1:5000", "http://localhost:5000"])
214+
# For development, we keep a strict explicit allowlist and never use '*'
215+
_cors_origins = _load_cors_origins()
216+
CORS(
217+
app,
218+
supports_credentials=True,
219+
origins=_cors_origins,
220+
methods=ALLOWED_CORS_METHODS,
221+
allow_headers=ALLOWED_CORS_HEADERS,
222+
expose_headers=['Retry-After'],
223+
resources={r"/api/*": {"origins": _cors_origins}},
224+
)
167225

168226
# Initialize cache service
169227
cache_service.init_app(app)
@@ -557,35 +615,7 @@ def add_security_headers(response):
557615
Returns:
558616
response: Response with added security headers
559617
"""
560-
csp_policy = (
561-
"default-src 'self'; "
562-
"script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; "
563-
"style-src 'self' https://fonts.googleapis.com https://cdnjs.cloudflare.com; "
564-
"img-src 'self' data: blob: https:; "
565-
"font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; "
566-
"connect-src 'self' ws: wss: https:; "
567-
"frame-ancestors 'none'; "
568-
"base-uri 'self'; "
569-
"form-action 'self'; "
570-
"upgrade-insecure-requests"
571-
)
572-
response.headers['Content-Security-Policy'] = csp_policy
573-
response.headers['X-Content-Type-Options'] = 'nosniff'
574-
response.headers['X-Frame-Options'] = 'DENY'
575-
response.headers['X-XSS-Protection'] = '1; mode=block'
576-
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
577-
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
578-
response.headers['Permissions-Policy'] = (
579-
'geolocation=(), '
580-
'microphone=(), '
581-
'camera=(), '
582-
'payment=(), '
583-
'usb=(), '
584-
'magnetometer=(), '
585-
'gyroscope=(), '
586-
'accelerometer=()'
587-
)
588-
return response
618+
return _apply_security_headers(response)
589619

590620
# Rate limiting configuration
591621
RATE_LIMIT_WINDOW = int(os.getenv('RATE_LIMIT_WINDOW', '60'))

‎tests/test_security_headers.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import os
2+
import sys
3+
4+
import pytest
5+
6+
sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'backend'))
7+
from app import app
8+
9+
10+
@pytest.fixture
11+
def client():
12+
app.config['TESTING'] = True
13+
with app.test_client() as test_client:
14+
yield test_client
15+
16+
17+
def test_security_headers_are_present_on_api_responses(client):
18+
response = client.get(
19+
'/api/v1/health',
20+
headers={'Origin': 'http://localhost:5500'},
21+
)
22+
23+
assert response.status_code == 200
24+
assert response.headers['Content-Security-Policy'].startswith("default-src 'self'")
25+
assert "frame-ancestors 'none'" in response.headers['Content-Security-Policy']
26+
assert response.headers['Strict-Transport-Security'] == 'max-age=31536000; includeSubDomains'
27+
assert response.headers['X-Content-Type-Options'] == 'nosniff'
28+
assert response.headers['Referrer-Policy'] == 'strict-origin-when-cross-origin'
29+
assert response.headers['Permissions-Policy'] == 'geolocation=(), microphone=(), camera=()'
30+
assert response.headers['X-Frame-Options'] == 'DENY'
31+
assert response.headers['Access-Control-Allow-Origin'] == 'http://localhost:5500'
32+
assert response.headers['Access-Control-Allow-Credentials'] == 'true'

0 commit comments

Comments
 (0)