From 808826369586c49f7b922e21ad5cbc0d57856564 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 04:22:51 +0000 Subject: [PATCH 1/4] feat(api): add security headers and structured logging - Add X-Content-Type-Options, X-Frame-Options, and HSTS headers to all API responses. - Implement structured logging to prevent log injection and improve observability. - Add unit tests to verify security headers in response. --- templates/api/handler.py | 42 +++++++++++++++++++++++++++++---------- tests/api/test_handler.py | 16 +++++++++++++++ 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/templates/api/handler.py b/templates/api/handler.py index 264793c..c1c7402 100644 --- a/templates/api/handler.py +++ b/templates/api/handler.py @@ -19,6 +19,12 @@ repository = Repository(settings.table_name) app = APIGatewayRestResolver() +SECURITY_HEADERS = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", +} + @app.get("/items/") def get_item(id: str) -> Response: @@ -32,17 +38,23 @@ def get_item(id: str) -> Response: """ try: if (item := repository.get_item(id)) is None: - return Response(status_code=404, content_type="application/json", body=dumps({"message": "Not found"})) + return Response( + status_code=404, + content_type="application/json", + body=dumps({"message": "Not found"}), + headers=SECURITY_HEADERS, + ) item = Item.model_validate(item) # Validate model after retrieval to ensure data integrity except Exception as exc: - message = ( - "Item validation failed" if isinstance(exc, ValidationError) else f"Error retrieving item with id {id}" - ) - logger.error(message, exc_info=exc) + message = "Item validation failed" if isinstance(exc, ValidationError) else "Error retrieving item" + logger.error(message, exc_info=exc, extra={"item_id": id}) return Response( - status_code=500, content_type="application/json", body=dumps({"message": "Internal server error"}) + status_code=500, + content_type="application/json", + body=dumps({"message": "Internal server error"}), + headers=SECURITY_HEADERS, ) - return Response(status_code=200, content_type="application/json", body=item.dump()) + return Response(status_code=200, content_type="application/json", body=item.dump(), headers=SECURITY_HEADERS) @app.post("/items") @@ -55,17 +67,25 @@ def create_item() -> Response: try: item = Item.model_validate_json(app.current_event.body) except ValidationError as exc: - return Response(status_code=422, content_type="application/json", body=dumps({"errors": exc.errors()})) + return Response( + status_code=422, + content_type="application/json", + body=dumps({"errors": exc.errors()}), + headers=SECURITY_HEADERS, + ) try: repository.put_item(item.model_dump()) except Exception as exc: - logger.error("DynamoDB put_item failed", exc_info=exc) + logger.error("DynamoDB put_item failed", exc_info=exc, extra={"item_id": item.id}) return Response( - status_code=500, content_type="application/json", body=dumps({"message": "Internal server error"}) + status_code=500, + content_type="application/json", + body=dumps({"message": "Internal server error"}), + headers=SECURITY_HEADERS, ) - return Response(status_code=201, content_type="application/json", body=item.dump()) + return Response(status_code=201, content_type="application/json", body=item.dump(), headers=SECURITY_HEADERS) @logger.inject_lambda_context diff --git a/tests/api/test_handler.py b/tests/api/test_handler.py index 225fe41..19c3a78 100644 --- a/tests/api/test_handler.py +++ b/tests/api/test_handler.py @@ -192,5 +192,21 @@ def test_get_item_validation_error_returns_500(mock_repo, lambda_context): assert body["message"] == "Internal server error" +def test_response_headers_contain_security_headers(mock_repo, lambda_context): + """API responses should contain standard security headers.""" + from templates.api.handler import main + + mock_repo.get_item.return_value = {"id": "abc", "name": "Widget"} + + event = _apigw_event("GET", "/items/abc", path_params={"id": "abc"}) + response = main(event, lambda_context) + + assert response["statusCode"] == 200 + headers = response["multiValueHeaders"] + assert headers["X-Content-Type-Options"] == ["nosniff"] + assert headers["X-Frame-Options"] == ["DENY"] + assert "Strict-Transport-Security" in headers + + if __name__ == "__main__": main() From 749cdfb05c750c356467bb9b703023daafbf6815 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 02:27:44 +0000 Subject: [PATCH 2/4] refactor(api): use JsonResponse helper for security headers and JSON serialization - Implement JsonResponse class to encapsulate security headers and JSON serialization. - Refactor get_item and create_item to use JsonResponse for cleaner code. - Maintain structured logging and security header verification in tests. --- templates/api/handler.py | 51 ++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/templates/api/handler.py b/templates/api/handler.py index c1c7402..19b0c65 100644 --- a/templates/api/handler.py +++ b/templates/api/handler.py @@ -26,6 +26,24 @@ } +class JsonResponse(Response): + """An HTTP response with JSON body and security headers.""" + + def __init__(self, body: dict | str, status_code: int = 200) -> None: + """Initialize the JSON response. + + Args: + body: The response body as a dictionary or JSON string. + status_code: The HTTP status code. + """ + super().__init__( + status_code=status_code, + body=body if isinstance(body, str) else dumps(body), + content_type="application/json", + headers=SECURITY_HEADERS, + ) + + @app.get("/items/") def get_item(id: str) -> Response: """Retrieve an item by ID. @@ -38,23 +56,14 @@ def get_item(id: str) -> Response: """ try: if (item := repository.get_item(id)) is None: - return Response( - status_code=404, - content_type="application/json", - body=dumps({"message": "Not found"}), - headers=SECURITY_HEADERS, - ) + return JsonResponse({"message": "Not found"}, status_code=404) item = Item.model_validate(item) # Validate model after retrieval to ensure data integrity except Exception as exc: message = "Item validation failed" if isinstance(exc, ValidationError) else "Error retrieving item" logger.error(message, exc_info=exc, extra={"item_id": id}) - return Response( - status_code=500, - content_type="application/json", - body=dumps({"message": "Internal server error"}), - headers=SECURITY_HEADERS, - ) - return Response(status_code=200, content_type="application/json", body=item.dump(), headers=SECURITY_HEADERS) + return JsonResponse({"message": "Internal server error"}, status_code=500) + + return JsonResponse(item.dump()) @app.post("/items") @@ -67,25 +76,15 @@ def create_item() -> Response: try: item = Item.model_validate_json(app.current_event.body) except ValidationError as exc: - return Response( - status_code=422, - content_type="application/json", - body=dumps({"errors": exc.errors()}), - headers=SECURITY_HEADERS, - ) + return JsonResponse({"errors": exc.errors()}, status_code=422) try: repository.put_item(item.model_dump()) except Exception as exc: logger.error("DynamoDB put_item failed", exc_info=exc, extra={"item_id": item.id}) - return Response( - status_code=500, - content_type="application/json", - body=dumps({"message": "Internal server error"}), - headers=SECURITY_HEADERS, - ) + return JsonResponse({"message": "Internal server error"}, status_code=500) - return Response(status_code=201, content_type="application/json", body=item.dump(), headers=SECURITY_HEADERS) + return JsonResponse(item.dump(), status_code=201) @logger.inject_lambda_context From 6c986080b242812e458d4ddc92dbb70bca376004 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 02:33:15 +0000 Subject: [PATCH 3/4] refactor(api): move response logic to separate module - Create templates/api/response.py for SECURITY_HEADERS and JsonResponse. - Update templates/api/handler.py to import JsonResponse from the new module. - Further declutter the handler by removing redundant imports and definitions. --- templates/api/handler.py | 27 +-------------------------- templates/api/response.py | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 26 deletions(-) create mode 100644 templates/api/response.py diff --git a/templates/api/handler.py b/templates/api/handler.py index 19b0c65..44e999e 100644 --- a/templates/api/handler.py +++ b/templates/api/handler.py @@ -1,5 +1,3 @@ -from json import dumps - from aws_lambda_powertools import Logger, Metrics, Tracer from aws_lambda_powertools.event_handler import APIGatewayRestResolver from aws_lambda_powertools.event_handler.api_gateway import Response @@ -7,6 +5,7 @@ from pydantic import ValidationError from templates.api.models import Item +from templates.api.response import JsonResponse from templates.api.settings import Settings from templates.repository import Repository @@ -19,30 +18,6 @@ repository = Repository(settings.table_name) app = APIGatewayRestResolver() -SECURITY_HEADERS = { - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - "Strict-Transport-Security": "max-age=31536000; includeSubDomains", -} - - -class JsonResponse(Response): - """An HTTP response with JSON body and security headers.""" - - def __init__(self, body: dict | str, status_code: int = 200) -> None: - """Initialize the JSON response. - - Args: - body: The response body as a dictionary or JSON string. - status_code: The HTTP status code. - """ - super().__init__( - status_code=status_code, - body=body if isinstance(body, str) else dumps(body), - content_type="application/json", - headers=SECURITY_HEADERS, - ) - @app.get("/items/") def get_item(id: str) -> Response: diff --git a/templates/api/response.py b/templates/api/response.py new file mode 100644 index 0000000..161ee56 --- /dev/null +++ b/templates/api/response.py @@ -0,0 +1,27 @@ +from json import dumps + +from aws_lambda_powertools.event_handler.api_gateway import Response + +SECURITY_HEADERS = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", +} + + +class JsonResponse(Response): + """An HTTP response with JSON body and security headers.""" + + def __init__(self, body: dict | str, status_code: int = 200) -> None: + """Initialize the JSON response. + + Args: + body: The response body as a dictionary or JSON string. + status_code: The HTTP status code. + """ + super().__init__( + status_code=status_code, + body=body if isinstance(body, str) else dumps(body), + content_type="application/json", + headers=SECURITY_HEADERS, + ) From eb3d297b85a2eace4ec277fc5e0950b3f869340d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 02:41:00 +0000 Subject: [PATCH 4/4] refactor(api): use camelCase for logging extra keys - Update logging extra keys to use itemId instead of item_id for consistency. --- templates/api/handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/api/handler.py b/templates/api/handler.py index 44e999e..cbe4ee6 100644 --- a/templates/api/handler.py +++ b/templates/api/handler.py @@ -35,7 +35,7 @@ def get_item(id: str) -> Response: item = Item.model_validate(item) # Validate model after retrieval to ensure data integrity except Exception as exc: message = "Item validation failed" if isinstance(exc, ValidationError) else "Error retrieving item" - logger.error(message, exc_info=exc, extra={"item_id": id}) + logger.error(message, exc_info=exc, extra={"itemId": id}) return JsonResponse({"message": "Internal server error"}, status_code=500) return JsonResponse(item.dump()) @@ -56,7 +56,7 @@ def create_item() -> Response: try: repository.put_item(item.model_dump()) except Exception as exc: - logger.error("DynamoDB put_item failed", exc_info=exc, extra={"item_id": item.id}) + logger.error("DynamoDB put_item failed", exc_info=exc, extra={"itemId": item.id}) return JsonResponse({"message": "Internal server error"}, status_code=500) return JsonResponse(item.dump(), status_code=201)