fix: ensure security headers on all HTTP responses#437
Conversation
Litestar's response_headers only applies to successfully handled routes — exception-handler responses (400, 500) and router-level 404/405 responses were missing all security headers (CORP, HSTS, X-Content-Type-Options, etc.). Replace response_headers + CSPMiddleware with a single before_send hook (security_headers_hook). Litestar's before_send wraps the ASGI send callback at the outermost layer, so it fires for ALL responses. Closes #355
Address review agent findings: - Switch headers.add() to headers[name]=value to prevent duplicates - Add base-uri 'self' to both CSP policies (prevents base tag injection) - Add Cross-Origin-Opener-Policy: same-origin to static headers - Document unsafe-inline in docs CSP as accepted Scalar UI constraint - Add Args section to security_headers_hook docstring - Add CSP assertion to all response-type tests - Add test for non-HTTP scope passthrough Pre-reviewed by 5 agents, 7 findings addressed
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
📜 Recent review details⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
🧰 Additional context used📓 Path-based instructions (2)**/*.py📄 CodeRabbit inference engine (CLAUDE.md)
Files:
src/synthorg/**/*.py📄 CodeRabbit inference engine (CLAUDE.md)
Files:
🧠 Learnings (3)📚 Learning: 2026-03-15T01:53:24.084ZApplied to files:
📚 Learning: 2026-03-15T01:53:24.084ZApplied to files:
📚 Learning: 2026-03-15T01:53:24.084ZApplied to files:
🔇 Additional comments (3)
📝 WalkthroughSummary by CodeRabbit
WalkthroughReplaces class-based CSPMiddleware with a global before_send hook Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant App as "Litestar App"
participant Handler as "Route / Exception Handler"
participant Hook as "security_headers_hook"
participant Response
Client->>App: HTTP request
App->>Handler: dispatch request
Handler-->>App: response / exception
App->>Hook: before_send(message, scope)
Hook-->>App: write/overwrite security headers (choose CSP by path, add COOP for /docs)
App-->>Response: finalize ASGI response
Response-->>Client: HTTP response with security headers
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
✨ Simplify code
📝 Coding Plan
Comment |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly enhances the application's security posture by centralizing and standardizing the application of security headers. By migrating from disparate Litestar mechanisms to a unified Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request is a great improvement for security, centralizing the logic for security headers into a before_send hook to ensure they are applied to all HTTP responses, including errors. The implementation is clean and the new tests are comprehensive. I've found one area for improvement in the Content-Security-Policy definitions to make them even more robust against clickjacking.
src/synthorg/api/middleware.py
Outdated
|
|
||
| # Strict CSP for API routes — no inline scripts, self-origin only. | ||
| _API_CSP: Final[str] = "default-src 'self'; script-src 'self'" | ||
| _API_CSP: Final[str] = "default-src 'self'; script-src 'self'; base-uri 'self'" |
There was a problem hiding this comment.
The current Content-Security-Policy (CSP) for API routes is missing the frame-ancestors directive. While X-Frame-Options: DENY is set, modern browsers prefer the frame-ancestors CSP directive and will ignore X-Frame-Options if a CSP is present. Without frame-ancestors, the policy does not restrict framing, which contradicts the intent to prevent clickjacking. To ensure consistent protection, you should add frame-ancestors 'none' to the policy.
| _API_CSP: Final[str] = "default-src 'self'; script-src 'self'; base-uri 'self'" | |
| _API_CSP: Final[str] = "default-src 'self'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'" |
| _DOCS_CSP: Final[str] = ( | ||
| "default-src 'self'; " | ||
| "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " | ||
| "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " | ||
| "img-src 'self' data: https://cdn.jsdelivr.net; " | ||
| "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.scalar.com; " | ||
| "connect-src 'self' https://cdn.jsdelivr.net https://proxy.scalar.com" | ||
| "connect-src 'self' https://cdn.jsdelivr.net https://proxy.scalar.com; " | ||
| "base-uri 'self'" | ||
| ) |
There was a problem hiding this comment.
Similar to the API CSP, the CSP for the documentation pages is missing the frame-ancestors directive. This could allow the documentation site to be framed by malicious pages, potentially leading to clickjacking attacks. To prevent this, you should add frame-ancestors 'none' to this policy as well.
| _DOCS_CSP: Final[str] = ( | |
| "default-src 'self'; " | |
| "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " | |
| "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " | |
| "img-src 'self' data: https://cdn.jsdelivr.net; " | |
| "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.scalar.com; " | |
| "connect-src 'self' https://cdn.jsdelivr.net https://proxy.scalar.com" | |
| "connect-src 'self' https://cdn.jsdelivr.net https://proxy.scalar.com; " | |
| "base-uri 'self'" | |
| ) | |
| _DOCS_CSP: Final[str] = ( | |
| "default-src 'self'; " | |
| "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " | |
| "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " | |
| "img-src 'self' data: https://cdn.jsdelivr.net; " | |
| "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.scalar.com; " | |
| "connect-src 'self' https://cdn.jsdelivr.net https://proxy.scalar.com; " | |
| "base-uri 'self'; " | |
| "frame-ancestors 'none'" | |
| ) |
Greptile SummaryThis PR replaces Litestar's Key changes:
Confidence Score: 5/5
Important Files Changed
Last reviewed commit: 8438493 |
| async def test_non_http_scope_is_skipped(self) -> None: | ||
| """Non-HTTP scopes (WebSocket, lifespan) are not modified.""" | ||
| message: Any = { | ||
| "type": "websocket.connect", | ||
| "headers": [], | ||
| } | ||
| ) | ||
| await send({"type": "http.response.body", "body": b""}) | ||
| scope: Any = {"type": ScopeType.WEBSOCKET} | ||
|
|
||
| await security_headers_hook(message, scope) | ||
|
|
||
| def _make_scope(path: str) -> dict[str, Any]: | ||
| """Build a minimal ASGI HTTP scope for testing.""" | ||
| return { | ||
| "type": "http", | ||
| "path": path, | ||
| "method": "GET", | ||
| "headers": [], | ||
| "query_string": b"", | ||
| "root_path": "", | ||
| "scheme": "http", | ||
| "server": ("localhost", 8000), | ||
| } | ||
| # Headers list should remain empty — hook returned early. | ||
| assert message.get("headers") == [] |
There was a problem hiding this comment.
async def test without @pytest.mark.asyncio
test_non_http_scope_is_skipped is an async def method in TestSecurityHeadersHook, while all sibling tests are synchronous def methods. This works because asyncio_mode = "auto" is configured in pyproject.toml, but it's inconsistent with the class and makes the async nature implicit.
Since security_headers_hook is an async function (the await on line 123 is necessary), the test must remain async. Consider adding an explicit @pytest.mark.asyncio decorator for clarity, or document in a comment why this one method is async while the others are not.
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/api/test_middleware.py
Line: 115-126
Comment:
**`async def` test without `@pytest.mark.asyncio`**
`test_non_http_scope_is_skipped` is an `async def` method in `TestSecurityHeadersHook`, while all sibling tests are synchronous `def` methods. This works because `asyncio_mode = "auto"` is configured in `pyproject.toml`, but it's inconsistent with the class and makes the async nature implicit.
Since `security_headers_hook` is an `async` function (the `await` on line 123 is necessary), the test must remain `async`. Consider adding an explicit `@pytest.mark.asyncio` decorator for clarity, or document in a comment why this one method is async while the others are not.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Address external reviewer feedback (Gemini, Greptile): - Add frame-ancestors 'none' to both API and docs CSP policies (browsers ignore X-Frame-Options when CSP is present) - Make COOP path-aware: relax to unsafe-none on /docs paths to avoid breaking Scalar UI cross-origin popup features
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/synthorg/api/middleware.py`:
- Around line 16-17: The global _SECURITY_HEADERS dict is declared Final but
remains mutable; replace its mutable dict with an immutable mapping by
constructing it via a deepcopy of the original literal and wrapping with
MappingProxyType, update the annotation to Final[Mapping[str, str]] (or
types.MappingProxyType) and add required imports (Mapping/MappingProxyType and
deepcopy) so _SECURITY_HEADERS cannot be mutated at runtime; locate the
definition named _SECURITY_HEADERS in this module and apply the wrap and type
change accordingly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 7f4eaff8-bda3-4f00-aeb6-f3ec0a1411df
📒 Files selected for processing (2)
src/synthorg/api/middleware.pytests/unit/api/test_app.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Build Backend
- GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py
📄 CodeRabbit inference engine (CLAUDE.md)
Read the relevant
docs/design/page before implementing any feature or planning any issue. The design spec is the starting point for architecture, data models, and behavior.
Files:
tests/unit/api/test_app.pysrc/synthorg/api/middleware.py
tests/**/*.py
📄 CodeRabbit inference engine (CLAUDE.md)
tests/**/*.py: Mark all tests with pytest markers:@pytest.mark.unit,@pytest.mark.integration,@pytest.mark.e2e, or@pytest.mark.slow.
Maintain 80% minimum code coverage. Always run pytest with-n autofor parallel execution via pytest-xdist. Set test timeout to 30 seconds per test.
Use@pytest.mark.parametrizefor testing similar cases instead of multiple test functions.
Use Hypothesis for property-based testing with@given+@settings. Profiles:ci(200 examples, default) anddev(1000 examples), controlled viaHYPOTHESIS_PROFILEenv var. Run dev profile:HYPOTHESIS_PROFILE=dev uv run python -m pytest tests/ -m unit -n auto -k properties.
NEVER use real vendor names (Anthropic, OpenAI, Claude, GPT, etc.) in tests. Use generic names:test-provider,test-small-001,test-medium-001,test-large-001, or aliasessmall/medium/large.
Files:
tests/unit/api/test_app.py
src/synthorg/**/*.py
📄 CodeRabbit inference engine (CLAUDE.md)
src/synthorg/**/*.py: Nofrom __future__ import annotations— Python 3.14+ has PEP 649 native lazy annotations.
Useexcept A, B:syntax (no parentheses) — ruff enforces PEP 758 except syntax for Python 3.14.
Add type hints to all public functions. Use mypy strict mode.
Write Google-style docstrings on all public classes and functions. Enforced by ruff D rules.
Create new objects instead of mutating existing ones. For non-Pydantic internal collections (registries,BaseTool), usecopy.deepcopy()at construction +MappingProxyTypewrapping for read-only enforcement.
Usecopy.deepcopy()at system boundaries (tool execution, LLM provider serialization, inter-agent delegation, serializing for persistence) fordict/listfields in frozen Pydantic models.
Use frozen Pydantic models for config/identity. Use separate mutable-via-copy models (usingmodel_copy(update=...)) for runtime state that evolves. Never mix static config fields with mutable runtime fields.
Use@computed_fieldfor derived values instead of storing + validating redundant fields. UseNotBlankStrfor all identifier/name fields (including optional and tuple variants).
Preferasyncio.TaskGroupfor fan-out/fan-in parallel operations in new code. Prefer structured concurrency over barecreate_task.
Keep functions under 50 lines and files under 800 lines.
Handle errors explicitly, never silently swallow them.
Validate at system boundaries (user input, external APIs, config files).
Every module with business logic MUST have:from synthorg.observability import get_loggerthenlogger = get_logger(__name__). Use event name constants fromsynthorg.observability.events.<domain>.
Never useimport logging,logging.getLogger(), orprint()in application code.
Always use the variable namelogger(not_logger, notlog).
Use structured logging:logger.info(EVENT, key=value)— neverlogger.info("msg %s", val). Log at WARNING or ERROR with context before raising. Log all state t...
Files:
src/synthorg/api/middleware.py
🔇 Additional comments (2)
src/synthorg/api/middleware.py (1)
90-109: Hook coverage and overwrite semantics are correctly implemented.The Line 90/92 guards and Line 98-Line 109 overwrite strategy are aligned with the goal of consistent headers on all HTTP response starts, including error paths.
tests/unit/api/test_app.py (1)
36-48: Good test targeting for static-header assertions.Using a non-docs endpoint at Line 47 avoids the docs-specific COOP relaxation and keeps this parameterized check focused on baseline security headers.
Address round-2 reviewer feedback (CodeRabbit, Greptile): - Wrap _SECURITY_HEADERS with MappingProxyType for runtime immutability - Add tests for docs-path COOP relaxation (unsafe-none vs same-origin) - Document _assert_all_security_headers as non-docs-path only
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #437 +/- ##
==========================================
- Coverage 93.67% 93.67% -0.01%
==========================================
Files 469 469
Lines 22214 22213 -1
Branches 2142 2144 +2
==========================================
- Hits 20809 20808 -1
Misses 1094 1094
Partials 311 311 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Prevents plugin injection via <object>/<embed>/<applet> elements. Without explicit object-src, default-src 'self' allows same-origin plugin embeds — flagged by OWASP and Google CSP evaluator.
🤖 I have created a release *beep* *boop* --- ## [0.2.4](v0.2.3...v0.2.4) (2026-03-15) ### Bug Fixes * attach cosign signatures and provenance bundle to release assets ([#438](#438)) ([f191a4d](f191a4d)) * create git tag explicitly for draft releases ([#432](#432)) ([1f5120e](1f5120e)) * docker healthcheck, CI optimization, and container hardening ([#436](#436)) ([4d32bca](4d32bca)) * ensure security headers on all HTTP responses ([#437](#437)) ([837f2fc](837f2fc)) * make install scripts usable immediately without terminal restart ([#433](#433)) ([b45533c](b45533c)) * migrate pids_limit to deploy.resources.limits.pids ([#439](#439)) ([66b94fd](66b94fd)) ### Refactoring * redesign release notes layout ([#434](#434)) ([239aaf7](239aaf7)) ### Maintenance * **site:** replace hero CTA with license link and scroll arrow ([#440](#440)) ([56af41c](56af41c)) * **web:** adopt @vue/tsconfig preset ([#435](#435)) ([7d4b214](7d4b214)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
🤖 I have created a release *beep* *boop* --- ## [0.2.4](v0.2.3...v0.2.4) (2026-03-15) ### Bug Fixes * attach cosign signatures and provenance bundle to release assets ([#438](#438)) ([f191a4d](f191a4d)) * create git tag explicitly for draft releases ([#432](#432)) ([1f5120e](1f5120e)) * docker healthcheck, CI optimization, and container hardening ([#436](#436)) ([4d32bca](4d32bca)) * ensure security headers on all HTTP responses ([#437](#437)) ([837f2fc](837f2fc)) * make install scripts usable immediately without terminal restart ([#433](#433)) ([b45533c](b45533c)) * migrate pids_limit to deploy.resources.limits.pids ([#439](#439)) ([66b94fd](66b94fd)) * use cosign --bundle flag for checksums signing ([#443](#443)) ([19735b9](19735b9)) ### Refactoring * redesign release notes layout ([#434](#434)) ([239aaf7](239aaf7)) ### Maintenance * **main:** release 0.2.4 ([#431](#431)) ([63b03c4](63b03c4)) * remove stale v0.2.4 changelog section from failed release ([#446](#446)) ([769de10](769de10)) * reset version to 0.2.3 for re-release ([#444](#444)) ([8579993](8579993)) * **site:** replace hero CTA with license link and scroll arrow ([#440](#440)) ([56af41c](56af41c)) * **web:** adopt @vue/tsconfig preset ([#435](#435)) ([7d4b214](7d4b214)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
Summary
response_headers+CSPMiddlewarewith a singlebefore_sendhook (security_headers_hook) that fires for ALL HTTP responses — including exception-handler responses (400, 500) and router-level 404/405 that previously had no security headersCross-Origin-Opener-Policy: same-originto the static security headers setbase-uri 'self'to both CSP policies to prevent<base>tag injectionheaders[name] = value(overwrite) instead ofheaders.add()(append) to prevent duplicate headers if any middleware sets the same headerunsafe-inlinein docs CSP as an accepted Scalar UI constraintWhy
before_send?Litestar's
before_sendhook wraps the ASGIsendcallback at the outermost layer (Litestar.__call__), before the middleware stack. User-defined ASGI middleware only runs for matched routes — 404 and 405 responses from the router bypass it entirely.response_headersonly applies to successful route handler responses, not exception handler responses.Before/After
Closes #355
Test plan
TestSecurityHeadersHookclass verifies headers on 200, 400, 404, 405, 500 responsestest_non_http_scope_is_skippedverifies WebSocket/lifespan scopes are not modified🤖 Generated with Claude Code