Skip to content
Closed
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
17 changes: 10 additions & 7 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,11 +544,11 @@ def handle_unhandled_exception(e):
# 8. Return the Secure JSON Response
# Always return a 500 Internal Server Error status code for unhandled exceptions.
response = jsonify(response_data)
# Adding security headers to error responses as defense in depth
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'

# Ensure the full hardened header set is applied consistently
# (including CSP/HSTS/etc.) even for global 500 handlers.
response = _apply_security_headers(response)

return response, 500

# =========================================================================
Expand Down Expand Up @@ -625,8 +625,11 @@ def handle_sqlalchemy_exception(e):
}

response = jsonify(response_data)
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'

# Ensure the full hardened header set is applied consistently
# (including CSP/HSTS/etc.) even for global 500 handlers.
response = _apply_security_headers(response)

return response, 500

# =========================================================================
Expand Down
64 changes: 64 additions & 0 deletions tests/test_security_headers_500.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import os
import sys

import pytest

sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'backend'))
from app import app


@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as test_client:
yield test_client


def _assert_full_security_headers(response):
assert response.status_code == 500

# Core hardened headers (must match _apply_security_headers)
assert response.headers['Content-Security-Policy'].startswith("default-src 'self'")
assert "frame-ancestors 'none'" in response.headers['Content-Security-Policy']
assert response.headers['Strict-Transport-Security'] == 'max-age=31536000; includeSubDomains'
assert response.headers['X-Content-Type-Options'] == 'nosniff'
assert response.headers['Referrer-Policy'] == 'strict-origin-when-cross-origin'
assert response.headers['Permissions-Policy'] == 'geolocation=(), microphone=(), camera=()'
assert response.headers['X-Frame-Options'] == 'DENY'


def test_unhandled_exception_returns_full_security_headers(client, monkeypatch):
# Force an unhandled exception within a request context
def boom():
raise Exception('test boom')

# Temporarily register a route only for this test run
endpoint = 'test_unhandled_exception'
rule = '/api/v1/test-500-unhandled'

app.add_url_rule(rule, endpoint, boom, methods=['GET'])
try:
response = client.get(rule)
_assert_full_security_headers(response)
finally:
# Flask doesn't expose a clean route removal API; rely on TESTING cleanup.
# In CI, tests run in a fresh process.
pass


def test_sqlalchemy_exception_returns_full_security_headers(client):
from sqlalchemy.exc import SQLAlchemyError

endpoint = 'test_sqlalchemy_exception'
rule = '/api/v1/test-500-sqlalchemy'

def db_boom():
raise SQLAlchemyError('test sqlalchemy error')

app.add_url_rule(rule, endpoint, db_boom, methods=['GET'])
try:
response = client.get(rule)
_assert_full_security_headers(response)
finally:
pass

Loading