Skip to content

Extract HTTP response safety helpers#371

Open
TUPM96 wants to merge 3 commits into
ramimbo:mainfrom
TUPM96:codex/bounty-320-http-response-safety
Open

Extract HTTP response safety helpers#371
TUPM96 wants to merge 3 commits into
ramimbo:mainfrom
TUPM96:codex/bounty-320-http-response-safety

Conversation

@TUPM96
Copy link
Copy Markdown
Contributor

@TUPM96 TUPM96 commented May 26, 2026

Refs #320

What changed:

  • Extracted HTTP response safety behavior from app.main into app/http_middleware.py, covering security headers, docs CSP selection, HEAD-as-GET routing, and forwarded-HTTPS redirect preservation.
  • Kept app.main focused on app wiring by delegating the middleware work through run_with_head_as_get() and apply_http_response_safety().
  • Added focused helper coverage for docs-vs-regular CSP, same-host forwarded HTTPS redirects, HEAD response normalization, and combined response safety application.
  • Preserved the existing OAuth redirect safety expectation for encoded backslash next values, and follow-up ef99f84 keeps explicit SQLAlchemy rowcount typing compatible with strict mypy.

Validation:

  • python -m pytest -q -> 339 passed.
  • python -m pytest tests/test_http_middleware.py tests/test_security.py::test_browser_responses_set_security_headers tests/test_security.py::test_api_docs_allow_external_assets_under_csp tests/test_security.py::test_regular_pages_keep_strict_csp tests/test_api_mcp.py::test_head_requests_match_get_routes_without_body tests/test_api_mcp.py::test_trailing_slash_redirects_keep_forwarded_https_scheme -q -> 10 passed.
  • python -m ruff format --check . -> 50 files already formatted.
  • python -m ruff check . -> All checks passed.
  • uv run --python 3.12 mypy app -> Success.
  • python scripts/docs_smoke.py -> docs smoke ok.
  • Hosted Quality, readiness, docs, and image checks -> passed.
  • CodeRabbit -> passed.
  • GitHub mergeState CLEAN.

No private keys, seed material, secrets, deployment credentials, private vulnerability details, payout credentials, or MRWK price claims are included.

Copilot AI review requested due to automatic review settings May 26, 2026 02:24
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 26, 2026

📝 Walkthrough

Walkthrough

This PR refactors HTTP middleware logic into dedicated helpers, strengthens redirect validation, and simplifies type casting. New app/http_middleware.py provides security headers configuration, HEAD request handling, and forwarded-HTTPS redirect preservation. app/main.py is simplified by delegating to these helpers and enhancing _safe_next_path validation. app/ledger/service.py removes unnecessary type casts. Comprehensive tests validate all middleware behavior.

Changes

HTTP Middleware Refactoring and Security Hardening

Layer / File(s) Summary
HTTP middleware security headers and HEAD handling
app/http_middleware.py
New module defines SECURITY_HEADERS, docs-specific CSP overrides, and helpers for routing HEAD as GET, normalizing HEAD responses to zero content-length, detecting forwarded-HTTPS, preserving same-host HTTPS redirects, and applying security headers with path-specific CSP.
Main.py middleware integration and refactoring
app/main.py
Imports middleware helpers, removes SECURITY_HEADERS/API_DOCS_CSP/API_DOCS_PATHS constants, and refactors add_security_headers middleware to use run_with_head_as_get and apply_http_response_safety helpers instead of inline logic.
Enhanced redirect validation for open-redirect prevention
app/main.py
_safe_next_path now decodes the redirect target and validates both raw and decoded versions against encoded/raw malicious patterns including encoded slashes, backslashes, and disallowed control/Unicode characters.
Middleware integration tests
tests/test_http_middleware.py
Adds _request helper and tests covering security header path-specific CSP, forwarded-HTTPS same-host redirect preservation, HEAD request routing and empty-body normalization, and end-to-end safety header and redirect scheme application.

Type Simplification in Ledger Service

Layer / File(s) Summary
Bounty operation type casting cleanup
app/ledger/service.py
Imports Any directly and replaces cast(CursorResult[Any], ...) with direct Any assignment in pay_bounty and close_bounty execute results, preserving conditional update logic and error handling.

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly summarizes the main change: extracting HTTP response safety helpers into a dedicated module.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed PR description provides clear summary, evidence of changes, comprehensive validation results, and issue reference, matching the repository template structure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR extracts HTTP security/redirect + HEAD-handling behavior into a dedicated middleware module, updates the FastAPI app to use it, and adds test coverage for the new middleware utilities.

Changes:

  • Added app/http_middleware.py with response hardening (security headers, forwarded-HTTPS redirect preservation) and HEAD-as-GET routing utilities.
  • Updated app/main.py to call the new middleware helpers and tightened _safe_next_path validation by checking the URL-decoded value.
  • Added tests/test_http_middleware.py to cover CSP differences for docs, forwarded proto redirects, and HEAD response behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
tests/test_http_middleware.py Adds tests validating CSP selection, forwarded-HTTPS redirect rewriting, and HEAD handling.
app/main.py Removes inline middleware logic in favor of new helpers; strengthens next path sanitization.
app/ledger/service.py Simplifies SQLAlchemy execute(update(...)) typing around rowcount checks.
app/http_middleware.py Introduces centralized HTTP middleware utilities for security headers, redirects, and HEAD handling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/http_middleware.py
Comment on lines +93 to +97
def apply_http_response_safety(request: Request, response: Response) -> Response:
preserve_forwarded_https_redirect(request, response)
for name, value in security_headers_for_path(request.url.path).items():
response.headers.setdefault(name, value)
return response
Comment thread app/ledger/service.py
Comment on lines +659 to 670
claimed: Any = session.execute(
update(Bounty)
.where(Bounty.id == bounty.id, Bounty.awards_paid < Bounty.max_awards)
.values(
awards_paid=Bounty.awards_paid + 1,
status=case(
(Bounty.awards_paid + 1 >= Bounty.max_awards, "paid"),
else_="open",
),
)
)
if claimed.rowcount != 1:
Comment on lines +21 to +24
host: str = "mrwk.ltclab.site",
headers: dict[str, str] | None = None,
) -> Request:
request_headers = {"host": host}
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/http_middleware.py`:
- Around line 53-62: The function response_for_original_method is collapsing
multi-value headers by doing headers = dict(response.headers), which loses
repeated keys like Set-Cookie; change it to construct the HEAD Response using
response.raw_headers (or otherwise preserve all header tuples) instead of
dict(response.headers), and ensure you copy response.status_code, media_type,
and any other metadata (e.g., content-type) from the original Response; keep
setting content-length to "0" but add/replace that single header without
de-duplicating existing headers so multiple Set-Cookie headers are preserved.
🪄 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: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 581b075f-aac5-400f-ae0e-91d38ac19f94

📥 Commits

Reviewing files that changed from the base of the PR and between ac99d79 and 262c1b6.

📒 Files selected for processing (4)
  • app/http_middleware.py
  • app/ledger/service.py
  • app/main.py
  • tests/test_http_middleware.py

Comment thread app/http_middleware.py
Comment on lines +53 to +62
def response_for_original_method(response: Response, original_method: str) -> Response:
if original_method != "HEAD":
return response
headers = dict(response.headers)
headers["content-length"] = "0"
return Response(
status_code=response.status_code,
headers=headers,
media_type=response.media_type,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 1) Show the HEAD normalization implementation that rebuilds headers via dict()
rg -n -C3 "def response_for_original_method|headers = dict\\(response\\.headers\\)" app/http_middleware.py

# 2) Show routes creating multiple cookie headers that should be preserved
rg -n -C3 "set_cookie\\(|delete_cookie\\(" app/main.py

Repository: ramimbo/mergework

Length of output: 2280


Preserve multi-value headers when normalizing HEAD responses.

In app/http_middleware.py (response_for_original_method, lines 53-62), headers = dict(response.headers) can collapse repeated header keys; since app/main.py sets/deletes multiple cookies via set_cookie(...) / delete_cookie(...), the resulting Set-Cookie headers can be dropped or altered for HEAD requests. Build the HEAD response without de-duplicating headers (e.g., preserve from response.raw_headers) and copy necessary response metadata.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/http_middleware.py` around lines 53 - 62, The function
response_for_original_method is collapsing multi-value headers by doing headers
= dict(response.headers), which loses repeated keys like Set-Cookie; change it
to construct the HEAD Response using response.raw_headers (or otherwise preserve
all header tuples) instead of dict(response.headers), and ensure you copy
response.status_code, media_type, and any other metadata (e.g., content-type)
from the original Response; keep setting content-length to "0" but add/replace
that single header without de-duplicating existing headers so multiple
Set-Cookie headers are preserved.

Copy link
Copy Markdown
Contributor

@tolga-tom-nook tolga-tom-nook left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers from my review. I inspected app/http_middleware.py, app/main.py, app/ledger/service.py, and tests/test_http_middleware.py on head ef99f84.

What I checked:

  • The middleware extraction keeps the HEAD-as-GET routing behavior and restores the original request method before returning a zero-length HEAD response.
  • Same-host forwarded-HTTPS 307/308 redirects still get rewritten from http to https, while external locations are left alone.
  • API docs/redoc paths still receive the relaxed docs CSP, and normal pages keep the stricter default security headers.
  • The pay_bounty() / close_bounty() typing cleanup does not change the guarded SQL update predicates or post-update rowcount checks.

Validation run locally on this PR head:

  • ./.venv/bin/python -m pytest tests/test_http_middleware.py tests/test_security.py tests/test_docs_public_urls.py -q -> 74 passed
  • ./.venv/bin/python -m pytest -q -> 339 passed
  • ./.venv/bin/python -m ruff check app/http_middleware.py app/main.py app/ledger/service.py tests/test_http_middleware.py -> passed
  • ./.venv/bin/python -m ruff format --check app/http_middleware.py app/main.py app/ledger/service.py tests/test_http_middleware.py -> passed
  • ./.venv/bin/python -m mypy app -> passed
  • git diff --check origin/main...HEAD -> passed

Copy link
Copy Markdown
Contributor

@ayskobtw-lil ayskobtw-lil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the extraction. I found one blocker in the HEAD response wrapper.

response_for_original_method() rebuilds HEAD responses with headers = dict(response.headers). Starlette keeps repeated headers such as Set-Cookie in raw_headers, but converting to a dict collapses them, so a HEAD request for any route that sets multiple cookies will silently drop all but one cookie.

Repro on current head ef99f84:

original raw set-cookie count 2
head raw headers [(b'content-length', b'0'), (b'set-cookie', b'first=1; Path=/; SameSite=lax')]
head set-cookie count 1

The fix should preserve response.raw_headers when constructing the empty HEAD response, replacing/adding only the single content-length: 0 header rather than round-tripping through dict(response.headers).

@ramimbo ramimbo added the mrwk:needs-info More information needed label May 26, 2026
@ramimbo
Copy link
Copy Markdown
Owner

ramimbo commented May 26, 2026

Maintainer note after #320 filled: this PR still references closed bounty #320. If you want it considered for the current code-health round, rebase against main, update the bounty reference to #377, and keep the scope distinct from already accepted #328, #337, #364, #375, and #376. This is not accepted or paid as-is.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mrwk:needs-info More information needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants