Skip to content

Commit ebc6e5b

Browse files
authored
Merge branch 'main' into feature/eja-eli-434-adding-owasp-dependency-scan
2 parents 0a262b0 + 90b7250 commit ebc6e5b

File tree

10 files changed

+428
-0
lines changed

10 files changed

+428
-0
lines changed

docs/security-headers.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Security Headers Implementation
2+
3+
This document describes the implementation of security headers for the Eligibility Signposting API.
4+
5+
## Overview
6+
7+
Security headers have been implemented using a two-layer approach:
8+
9+
1. **API Gateway Responses** (Terraform) - For error responses (4xx, 5xx) generated by API Gateway
10+
2. **Flask middleware** (Python) - For all successful responses (200, 201, etc.) from the Lambda function
11+
12+
This ensures security headers are present on **all** responses, regardless of where they originate.
13+
14+
## Security Headers
15+
16+
The following security headers are applied to all responses:
17+
18+
| Header | Value | Purpose |
19+
|--------|-------|---------|
20+
| `Cache-Control` | `no-store, private` | Prevents caching of sensitive data |
21+
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | Enforces HTTPS for 1 year on all subdomains |
22+
| `X-Content-Type-Options` | `nosniff` | Prevents MIME type sniffing |
23+
24+
## Implementation Details
25+
26+
### 1. API Gateway Responses (Terraform)
27+
28+
File: `infrastructure/stacks/api-layer/gateway_responses.tf`
29+
30+
Gateway responses handle error responses generated by API Gateway itself (e.g., validation failures, authorization errors, throttling).
31+
32+
The following response types have been configured:
33+
34+
- `DEFAULT_4XX` - All 4xx errors
35+
- `DEFAULT_5XX` - All 5xx errors
36+
- `UNAUTHORIZED` (401)
37+
- `ACCESS_DENIED` (403)
38+
- `THROTTLED` (429)
39+
40+
### 2. Flask middleware
41+
42+
Files:
43+
44+
- `src/eligibility_signposting_api/middleware/security_headers.py` - middleware implementation
45+
- `src/eligibility_signposting_api/middleware/__init__.py` - Package exports
46+
- `src/eligibility_signposting_api/app.py` - middleware registration
47+
48+
The middleware uses Flask's `after_request` hook to add security headers to all responses from the Lambda function. It's non-overriding: Allows specific endpoints to override headers if needed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Gateway Responses with Security Headers
2+
# These responses are used when API Gateway itself returns an error (e.g., validation failures, auth errors)
3+
# They ensure security headers are present even on API Gateway-generated error responses
4+
5+
resource "aws_api_gateway_gateway_response" "response_4xx" {
6+
rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id
7+
response_type = "DEFAULT_4XX"
8+
9+
response_parameters = {
10+
"gatewayresponse.header.Cache-Control" = "'no-store, private'"
11+
"gatewayresponse.header.Strict-Transport-Security" = "'max-age=31536000; includeSubDomains'"
12+
"gatewayresponse.header.X-Content-Type-Options" = "'nosniff'"
13+
}
14+
}
15+
16+
resource "aws_api_gateway_gateway_response" "response_5xx" {
17+
rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id
18+
response_type = "DEFAULT_5XX"
19+
20+
response_parameters = {
21+
"gatewayresponse.header.Cache-Control" = "'no-store, private'"
22+
"gatewayresponse.header.Strict-Transport-Security" = "'max-age=31536000; includeSubDomains'"
23+
"gatewayresponse.header.X-Content-Type-Options" = "'nosniff'"
24+
}
25+
}
26+
27+
resource "aws_api_gateway_gateway_response" "unauthorized" {
28+
rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id
29+
response_type = "UNAUTHORIZED"
30+
status_code = "401"
31+
32+
response_parameters = {
33+
"gatewayresponse.header.Cache-Control" = "'no-store, private'"
34+
"gatewayresponse.header.Strict-Transport-Security" = "'max-age=31536000; includeSubDomains'"
35+
"gatewayresponse.header.X-Content-Type-Options" = "'nosniff'"
36+
}
37+
}
38+
39+
resource "aws_api_gateway_gateway_response" "access_denied" {
40+
rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id
41+
response_type = "ACCESS_DENIED"
42+
status_code = "403"
43+
44+
response_parameters = {
45+
"gatewayresponse.header.Cache-Control" = "'no-store, private'"
46+
"gatewayresponse.header.Strict-Transport-Security" = "'max-age=31536000; includeSubDomains'"
47+
"gatewayresponse.header.X-Content-Type-Options" = "'nosniff'"
48+
}
49+
}
50+
51+
resource "aws_api_gateway_gateway_response" "throttled" {
52+
rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id
53+
response_type = "THROTTLED"
54+
status_code = "429"
55+
56+
response_parameters = {
57+
"gatewayresponse.header.Cache-Control" = "'no-store, private'"
58+
"gatewayresponse.header.Strict-Transport-Security" = "'max-age=31536000; includeSubDomains'"
59+
"gatewayresponse.header.X-Content-Type-Options" = "'nosniff'"
60+
}
61+
}

infrastructure/stacks/api-layer/patient_check.tf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,8 @@ resource "aws_api_gateway_gateway_response" "bad_request_parameters" {
112112
response_parameters = {
113113
"gatewayresponse.header.Access-Control-Allow-Origin" = "'*'"
114114
"gatewayresponse.header.Content-Type" = "'application/fhir+json'"
115+
"gatewayresponse.header.Cache-Control" = "'no-store, private'"
116+
"gatewayresponse.header.Strict-Transport-Security" = "'max-age=31536000; includeSubDomains'"
117+
"gatewayresponse.header.X-Content-Type-Options" = "'nosniff'"
115118
}
116119
}

scripts/config/vale/styles/config/vocabularies/words/accept.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Gitleaks
88
Grype
99
idempotence
1010
Makefile
11+
Middleware
12+
middleware
1113
OAuth
1214
Octokit
1315
onboarding

src/eligibility_signposting_api/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from eligibility_signposting_api.logging.logs_helper import log_request_ids_from_headers
1818
from eligibility_signposting_api.logging.logs_manager import add_lambda_request_id_to_logger, init_logging
1919
from eligibility_signposting_api.logging.tracing_helper import tracing_setup
20+
from eligibility_signposting_api.middleware import SecurityHeadersMiddleware
2021
from eligibility_signposting_api.views import eligibility_blueprint
2122

2223
if os.getenv("ENABLE_XRAY_PATCHING"):
@@ -62,6 +63,9 @@ def create_app() -> Flask:
6263
app = Flask(__name__)
6364
logger.info("app created")
6465

66+
# Register security headers middleware
67+
SecurityHeadersMiddleware(app)
68+
6569
# Register views & error handler
6670
app.register_blueprint(eligibility_blueprint, url_prefix=f"/{URL_PREFIX}")
6771
app.register_error_handler(Exception, handle_exception)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Middleware package for the Eligibility Signposting API."""
2+
3+
from eligibility_signposting_api.middleware.security_headers import SecurityHeadersMiddleware
4+
5+
__all__ = ["SecurityHeadersMiddleware"]
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Security headers middleware for Flask application.
2+
3+
This middleware adds security headers to all responses from the Lambda function.
4+
These headers are applied to successful responses (200, 201, etc.) from the application.
5+
6+
For API Gateway error responses (4xx, 5xx generated by API Gateway itself),
7+
see gateway_responses.tf in the infrastructure configuration.
8+
"""
9+
10+
from typing import ClassVar
11+
12+
from flask import Flask, Response
13+
14+
15+
class SecurityHeadersMiddleware:
16+
"""Middleware to add security headers to all Flask responses."""
17+
18+
# Security headers recommended for NHS APIs
19+
SECURITY_HEADERS: ClassVar[dict[str, str]] = {
20+
"Cache-Control": "no-store, private",
21+
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
22+
"X-Content-Type-Options": "nosniff",
23+
}
24+
25+
def __init__(self, app: Flask | None = None) -> None:
26+
"""Initialize the middleware.
27+
28+
Args:
29+
app: Flask application instance. Can be provided later via init_app()
30+
"""
31+
if app is not None:
32+
self.init_app(app)
33+
34+
def init_app(self, app: Flask) -> None:
35+
"""Initialize the middleware with a Flask application.
36+
37+
Args:
38+
app: Flask application instance to apply middleware to
39+
"""
40+
app.after_request(self.add_security_headers)
41+
42+
@classmethod
43+
def add_security_headers(cls, response: Response) -> Response:
44+
"""Add security headers to the Flask response.
45+
46+
This is called automatically by Flask after each request.
47+
48+
Args:
49+
response: Flask response object
50+
51+
Returns:
52+
Modified response object with security headers added
53+
"""
54+
for header, value in cls.SECURITY_HEADERS.items():
55+
# Only add header if it doesn't already exist
56+
# This allows specific endpoints to override if needed
57+
if header not in response.headers:
58+
response.headers[header] = value
59+
60+
return response

tests/unit/middleware/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Tests for middleware components."""
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""Tests for security headers middleware."""
2+
3+
from http import HTTPStatus
4+
5+
import pytest
6+
from flask import Flask
7+
from flask.testing import FlaskClient
8+
from hamcrest import assert_that, contains_string, equal_to, has_entries, is_
9+
10+
from eligibility_signposting_api.middleware import SecurityHeadersMiddleware
11+
12+
13+
class MiddlewareTestError(Exception):
14+
"""Custom exception for middleware error handling tests."""
15+
16+
17+
@pytest.fixture
18+
def test_app() -> Flask:
19+
"""Create a test Flask app with security headers middleware."""
20+
app = Flask(__name__)
21+
SecurityHeadersMiddleware(app)
22+
23+
@app.route("/test")
24+
def test_route():
25+
return {"status": "ok"}, HTTPStatus.OK
26+
27+
@app.route("/error")
28+
def error_route():
29+
msg = "Test error"
30+
raise MiddlewareTestError(msg)
31+
32+
@app.errorhandler(MiddlewareTestError)
33+
def handle_value_error(e):
34+
return {"error": str(e)}, HTTPStatus.INTERNAL_SERVER_ERROR
35+
36+
return app
37+
38+
39+
@pytest.fixture
40+
def client(test_app: Flask) -> FlaskClient:
41+
"""Create a test client."""
42+
return test_app.test_client()
43+
44+
45+
class TestSecurityHeadersMiddleware:
46+
"""Test suite for SecurityHeadersMiddleware."""
47+
48+
def test_security_headers_present_on_successful_response(self, client: FlaskClient) -> None:
49+
"""Test that security headers are added to successful responses."""
50+
response = client.get("/test")
51+
52+
assert_that(response.status_code, is_(equal_to(HTTPStatus.OK)))
53+
assert_that(
54+
dict(response.headers),
55+
has_entries(
56+
{
57+
"Cache-Control": "no-store, private",
58+
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
59+
"X-Content-Type-Options": "nosniff",
60+
}
61+
),
62+
)
63+
64+
def test_security_headers_present_on_error_response(self, client: FlaskClient) -> None:
65+
"""Test that security headers are added to error responses."""
66+
response = client.get("/error")
67+
68+
assert_that(response.status_code, is_(equal_to(HTTPStatus.INTERNAL_SERVER_ERROR)))
69+
assert_that(
70+
dict(response.headers),
71+
has_entries(
72+
{
73+
"Cache-Control": "no-store, private",
74+
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
75+
"X-Content-Type-Options": "nosniff",
76+
}
77+
),
78+
)
79+
80+
def test_security_headers_present_on_404(self, client: FlaskClient) -> None:
81+
"""Test that security headers are added to 404 responses."""
82+
response = client.get("/nonexistent")
83+
84+
assert_that(response.status_code, is_(equal_to(HTTPStatus.NOT_FOUND)))
85+
assert_that(
86+
dict(response.headers),
87+
has_entries(
88+
{
89+
"Cache-Control": "no-store, private",
90+
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
91+
"X-Content-Type-Options": "nosniff",
92+
}
93+
),
94+
)
95+
96+
def test_all_expected_headers_are_present(self, client: FlaskClient) -> None:
97+
"""Test that all expected security headers are present."""
98+
response = client.get("/test")
99+
100+
expected_headers = {
101+
"Cache-Control",
102+
"Strict-Transport-Security",
103+
"X-Content-Type-Options",
104+
}
105+
106+
response_headers = set(response.headers.keys())
107+
assert expected_headers.issubset(response_headers), (
108+
f"Missing security headers: {expected_headers - response_headers}"
109+
)
110+
111+
def test_cache_control_prevents_caching(self, client: FlaskClient) -> None:
112+
"""Test that Cache-Control header prevents caching of sensitive data."""
113+
response = client.get("/test")
114+
115+
cache_control = response.headers.get("Cache-Control")
116+
assert_that(cache_control, contains_string("no-store"))
117+
assert_that(cache_control, contains_string("private"))
118+
119+
def test_hsts_header_enforces_https(self, client: FlaskClient) -> None:
120+
"""Test that HSTS header is properly configured."""
121+
response = client.get("/test")
122+
123+
hsts = response.headers.get("Strict-Transport-Security")
124+
assert_that(hsts, contains_string("max-age=31536000"))
125+
assert_that(hsts, contains_string("includeSubDomains"))
126+
127+
def test_content_type_options_prevents_sniffing(self, client: FlaskClient) -> None:
128+
"""Test that X-Content-Type-Options prevents MIME sniffing."""
129+
response = client.get("/test")
130+
131+
content_type_options = response.headers.get("X-Content-Type-Options")
132+
assert_that(content_type_options, is_(equal_to("nosniff")))
133+
134+
def test_middleware_init_app_method(self) -> None:
135+
"""Test that middleware can be initialized separately using init_app."""
136+
app = Flask(__name__)
137+
middleware = SecurityHeadersMiddleware()
138+
middleware.init_app(app)
139+
140+
@app.route("/test")
141+
def test_route():
142+
return {"status": "ok"}, HTTPStatus.OK
143+
144+
with app.test_client() as client:
145+
response = client.get("/test")
146+
assert_that(response.headers.get("Cache-Control"), is_(equal_to("no-store, private")))
147+
148+
def test_existing_headers_are_not_overridden(self) -> None:
149+
"""Test that existing headers are not overridden by middleware."""
150+
app = Flask(__name__)
151+
SecurityHeadersMiddleware(app)
152+
153+
@app.route("/test")
154+
def test_route():
155+
from flask import make_response
156+
157+
resp = make_response({"status": "ok"}, HTTPStatus.OK)
158+
resp.headers["Cache-Control"] = "public, max-age=3600"
159+
return resp
160+
161+
with app.test_client() as client:
162+
response = client.get("/test")
163+
# Should keep the custom Cache-Control value
164+
assert_that(response.headers.get("Cache-Control"), is_(equal_to("public, max-age=3600")))
165+
# But other headers should still be added
166+
assert_that(response.headers.get("X-Content-Type-Options"), is_(equal_to("nosniff")))

0 commit comments

Comments
 (0)