Skip to content

fix(http_client): strip credentials on cross-origin redirect hops#2635

Merged
rmusser01 merged 3 commits into
devfrom
fix/http-client-redirect-header-hardening
Jul 4, 2026
Merged

fix(http_client): strip credentials on cross-origin redirect hops#2635
rmusser01 merged 3 commits into
devfrom
fix/http-client-redirect-header-hardening

Conversation

@rmusser01

@rmusser01 rmusser01 commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Summary

Central redirect-credential hardening — the tracked follow-up from PR #2604's review (qodo "Option B") and the Docs/superpowers/plans/2026-07-04-test-suite-improvement-implementation-plan.md Tracked Follow-Ups section.

Problem: the manual redirect loops in fetch/afetch (httpx sync/async and aiohttp backends) rebuilt each hop's request from the caller's original headers/cookies, so a cross-origin redirect resent Authorization, x-api-key, cookies, etc. to the redirect target.

Fix: every per-hop request (including the HEAD→GET Range fallbacks) now strips SENSITIVE_REDIRECT_HEADERS and drops cookies when the hop's origin (scheme/host/port, default ports normalized) differs from the original request's origin. Unparseable URLs fail closed. Same-origin hops are byte-for-byte unchanged. Stripped header names are logged at debug with the target host bound.

The per-caller mitigation from PR #2634 (tokenizer_resolver allow_redirects=False) stays — provider tokenizer APIs never legitimately redirect.

Verification (local — CI currently broken per maintainer)

  • New tests/http_client/test_redirect_header_hardening.py: 13 tests — origin-helper units + hypothesis self-consistency property + httpx.MockTransport end-to-end flows through the real sync and async redirect loops (cross-origin strips credentials and keeps non-sensitive headers; same-origin keeps credentials)
  • Full tests/http_client/: 96/96 pass
  • Redirect-consuming callers (test_research_adapters.py, test_tokenizer_resolver_unit.py): 117/117 pass
  • Shard-coverage guard green

🤖 Generated with Claude Code


Summary by cubic

Hardened redirect handling in the HTTP client to prevent credential leaks. On cross-origin hops we now strip credential headers, drop cookies, clear cookie jars, and do the same for SSE streaming redirects; same-origin is unchanged and invalid ports or malformed IPv6 targets fail closed.

  • Bug Fixes
    • Strip Authorization, Proxy-Authorization, Cookie, X-API-Key, API-Key, X-Auth-Token on cross-origin hops (case-insensitive).
    • Drop explicit cookies and clear client/session cookie jars at cross-origin boundaries; keep non-sensitive headers.
    • Apply to all redirect loops (including SSE and HEAD→GET Range fallbacks) across httpx (sync/async) and aiohttp.
    • Treat unparseable targets as cross-origin (fail closed); log stripped header names at debug with the target host.

Written for commit b4fc13d. Summary will update on new commits.

Review in cubic

Central fix for the PR #2604 review finding (qodo Option B): the manual
redirect loops in fetch/afetch (httpx sync/async + aiohttp backends)
reused the caller's headers and cookies across hops, so a cross-origin
redirect resent Authorization / x-api-key / cookies to the redirect
target.

Every per-hop request (including the HEAD->GET Range fallbacks) now:
- strips SENSITIVE_REDIRECT_HEADERS (authorization, proxy-authorization,
  cookie, x-api-key, api-key, x-auth-token; case-insensitive) when the
  hop's origin (scheme/host/port, default ports normalized) differs from
  the original request's origin — unparseable URLs fail closed
- drops cookies on cross-origin hops
- logs stripped header names (debug, bound target_host)

Same-origin hops are unchanged. Supersedes the per-caller mitigation
from PR #2634 (tokenizer_resolver allow_redirects=False stays — provider
APIs never legitimately redirect).

Tests: origin helpers incl. hypothesis self-consistency property, header
stripping unit tests, and MockTransport end-to-end flows through the
real sync+async redirect loops (cross-origin strips, same-origin keeps).
96/96 tests/http_client pass; 117/117 adapter/tokenizer consumers pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a7113c87-6b50-4a52-b988-8c8a3d59b168

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/http-client-redirect-header-hardening

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

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Harden redirects: strip credentials on cross-origin redirect hops

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Prevent credential/header leakage on cross-origin redirects in fetch/afetch redirect loops.
• Strip sensitive headers and drop cookies when a redirect hop changes origin.
• Add unit + MockTransport end-to-end tests covering same- vs cross-origin behavior.
Diagram

graph TD
  A["Caller"] --> B["fetch/afetch"] --> C["Manual redirect loop"] --> D{"Cross-origin hop?"}
  D -->|"No"| E["Per-hop request"] --> G["Backend client"] --> H["Remote host"]
  D -->|"Yes"| F["Strip creds & cookies"] --> E
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use backend-native redirect handling (follow_redirects)
  • ➕ Delegates redirect policy and header handling to a well-tested client implementation
  • ➕ Reduces custom redirect loop complexity
  • ➖ May not preserve current custom behaviors (e.g., HEAD→GET Range fallback, parity logic across backends)
  • ➖ Harder to enforce identical semantics across httpx/aiohttp and sync/async variants
2. Always drop Authorization/cookies on any redirect (even same-origin)
  • ➕ Simplest rule; minimizes risk of credential forwarding in all redirect scenarios
  • ➖ Behavior-breaking for legitimate same-origin redirects that rely on auth/session continuity
  • ➖ Would likely require downstream caller changes and could increase support burden

Recommendation: The chosen approach—centralizing cross-origin detection and applying the same strip/drop policy in every redirect hop—is the best fit given the existing manual redirect loops. It fixes the credential-leak class without changing same-origin behavior, keeps backend parity, and is test-backed with both helper-level unit tests and end-to-end flows through the real redirect loops.

Files changed (2) +339 / -6

Bug fix (1) +106 / -6
http_client.pyStrip sensitive headers/cookies on cross-origin redirect hops +106/-6

Strip sensitive headers/cookies on cross-origin redirect hops

• Introduces origin parsing/comparison helpers and a centralized policy for removing credential-bearing headers on cross-origin redirect hops (fail-closed on unparseable URLs). Applies the policy consistently across httpx sync/async and aiohttp async redirect loops, including HEAD→GET Range fallback requests, and emits debug logs listing stripped header names.

tldw_Server_API/app/core/http_client.py

Tests (1) +233 / -0
test_redirect_header_hardening.pyAdd regression tests for redirect credential hardening +233/-0

Add regression tests for redirect credential hardening

• Adds unit tests for origin normalization and sensitive-header stripping (including a Hypothesis self-consistency property). Adds end-to-end redirect-flow tests using httpx.MockTransport to verify that cross-origin redirects drop credentials while same-origin redirects preserve them.

tldw_Server_API/tests/http_client/test_redirect_header_hardening.py

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request implements credential hardening for cross-origin redirects in the HTTP client. It introduces helper functions to identify cross-origin hops and strip sensitive headers (such as Authorization, API keys, and cookies) or drop cookies entirely when a redirect leaves the original origin. These checks are integrated into both the synchronous and asynchronous request execution paths, and a new test suite is added to verify the behavior. The review feedback suggests catching ValueError in _url_origin to handle malformed ports gracefully, optimizing the header stripping logic to use a single pass, and adding a test case to verify that invalid ports fail closed.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +1142 to +1143
except _HTTPCLIENT_NONCRITICAL_EXCEPTIONS:
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Accessing parsed.port or parsing malformed URLs (e.g., with invalid port formats like :invalid-port or malformed IPv6 brackets) can raise a ValueError. Catching ValueError explicitly ensures that these cases are handled gracefully and fail closed instead of raising an unhandled exception.

Suggested change
except _HTTPCLIENT_NONCRITICAL_EXCEPTIONS:
return None
except ValueError:
return None
except _HTTPCLIENT_NONCRITICAL_EXCEPTIONS:
return None

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Verified rather than changed: ValueError is already in _HTTPCLIENT_NONCRITICAL_EXCEPTIONS (http_client.py:76-92), so _url_origin returns None on malformed ports and _is_cross_origin fails closed. Added the explicit regression test (test_invalid_ports_and_malformed_hosts_fail_closed) proving it.

Comment thread tldw_Server_API/app/core/http_client.py Outdated
Comment on lines +1177 to +1183
kept = {k: v for k, v in headers.items() if k.lower() not in SENSITIVE_REDIRECT_HEADERS}
dropped = sorted(k for k in headers if k.lower() in SENSITIVE_REDIRECT_HEADERS)
if dropped:
logger.bind(target_host=_parse_host_from_url(target_url)).debug(
"Stripped sensitive headers on cross-origin redirect: {}", ", ".join(dropped)
)
return kept

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The headers dictionary is currently iterated over twice to separate kept and dropped headers. We can optimize this to a single pass for better efficiency and readability.

    kept = {}
    dropped = []
    for k, v in headers.items():
        if k.lower() in SENSITIVE_REDIRECT_HEADERS:
            dropped.append(k)
        else:
            kept[k] = v
    if dropped:
        dropped.sort()
        logger.bind(target_host=_parse_host_from_url(target_url)).debug(
            \"Stripped sensitive headers on cross-origin redirect: {}\", \", \".join(dropped)
        )
    return kept

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in 3fb470c: kept/dropped are now split in a single pass over the headers.

Comment on lines +65 to +67
def test_unparseable_urls_fail_closed(self) -> None:
assert _is_cross_origin("https://a.example", "not a url")
assert _is_cross_origin("", "https://a.example")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Add a test case to verify that URLs with invalid ports are handled gracefully and treated as cross-origin (fail closed).

    def test_unparseable_urls_fail_closed(self) -> None:
        assert _is_cross_origin(\"https://a.example\", \"not a url\")
        assert _is_cross_origin(\"\", \"https://a.example\")
        assert _is_cross_origin(\"https://a.example\", \"https://a.example:invalid-port\")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in 3fb470c: test_invalid_ports_and_malformed_hosts_fail_closed covers invalid ports, out-of-range ports, and malformed IPv6 brackets — all fail closed and strip.

@qodo-code-review

qodo-code-review Bot commented Jul 4, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 74 rules

Grey Divider


Action required

1. pytest.mark.asyncio violates marker rule ✓ Resolved 📘 Rule violation ▣ Testability
Description
The new async tests add @pytest.mark.asyncio on top of the module-level `pytestmark =
pytest.mark.unit`, resulting in more than one marker per test and using a marker not listed as an
accepted test marker. This violates the rule requiring exactly one approved pytest marker per test.
Code

tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[R148-150]

+    @pytest.mark.asyncio
+    async def test_afetch_strips_credentials_on_cross_origin_redirect(self) -> None:
+        import httpx
Evidence
The checklist requires exactly one accepted pytest marker per test. The module sets a unit marker
for all tests, but the async tests add an additional asyncio marker, creating multiple markers and
introducing a non-approved marker.

Rule 380651: Apply appropriate pytest markers to all tests
tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[21-22]
tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[148-179]
pyproject.toml[606-611]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Async tests are decorated with `@pytest.mark.asyncio` while the module already applies `pytest.mark.unit` via `pytestmark`, violating the "exactly one accepted marker" requirement.

## Issue Context
Project pytest config uses `asyncio_mode = "auto"`, so async tests do not require the `asyncio` marker to run.

## Fix Focus Areas
- tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[21-22]
- tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[148-179]
- pyproject.toml[606-611]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. _handler missing return type ✓ Resolved 📘 Rule violation ✧ Quality
Description
The new TestRedirectFlows._handler method lacks an explicit return type annotation. This violates
the requirement for type hints on all function parameters and return values and reduces static
analyzability.
Code

tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[129]

+    def _handler(seen: dict[str, dict[str, str]]):
Evidence
The checklist requires return type annotations on all functions. The newly added _handler
definition has no -> ... return annotation.

Rule 224215: Require type hints on all function parameters and return values
tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[128-147]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`TestRedirectFlows._handler` is missing a return type annotation.

## Issue Context
Compliance requires explicit type hints for all function parameters and return values.

## Fix Focus Areas
- tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[128-147]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. SSE redirect credential leak ✓ Resolved 🐞 Bug ⛨ Security
Description
The SSE streaming redirect loops (_astream_sse_httpx/_astream_sse_aiohttp) follow 3xx hops while
reusing caller-supplied headers via _inject_trace_headers(hdrs) without applying the new
cross-origin stripping helpers, so a cross-origin redirect can resend
Authorization/x-api-key/Cookie headers to the redirect target.
Code

tldw_Server_API/app/core/http_client.py[R1158-1192]

+def _strip_sensitive_headers_for_cross_origin(
+    headers: dict[str, str] | None,
+    *,
+    original_url: str,
+    target_url: str,
+) -> dict[str, str] | None:
+    """Drop credential-bearing headers when a request hop leaves the original origin.
+
+    Args:
+        headers: Prepared per-hop request headers (may be ``None``).
+        original_url: URL of the caller's original request.
+        target_url: URL of the hop about to be issued (redirect target).
+
+    Returns:
+        *headers* unchanged for same-origin hops; otherwise a copy with the
+        :data:`SENSITIVE_REDIRECT_HEADERS` removed (case-insensitive).
+    """
+    if not headers or not _is_cross_origin(original_url, target_url):
+        return headers
+    kept = {k: v for k, v in headers.items() if k.lower() not in SENSITIVE_REDIRECT_HEADERS}
+    dropped = sorted(k for k in headers if k.lower() in SENSITIVE_REDIRECT_HEADERS)
+    if dropped:
+        logger.bind(target_host=_parse_host_from_url(target_url)).debug(
+            "Stripped sensitive headers on cross-origin redirect: {}", ", ".join(dropped)
+        )
+    return kept
+
+
+def _cookies_for_hop(
+    cookies: dict[str, str] | None, *, original_url: str, target_url: str
+) -> dict[str, str] | None:
+    """Return *cookies* for same-origin hops, ``None`` for cross-origin hops."""
+    if cookies and _is_cross_origin(original_url, target_url):
+        return None
+    return cookies
Evidence
The PR introduces helpers to strip sensitive headers/cookies on cross-origin redirects and applies
them to fetch/afetch, but the SSE streaming redirect loops still follow redirects while always
using _inject_trace_headers(hdrs) for hop headers, with no call to the stripping helper, so
sensitive headers can persist across cross-origin hops. astream_sse is used by the LLM streaming
layer, which passes through caller headers into the SSE client.

tldw_Server_API/app/core/http_client.py[1116-1192]
tldw_Server_API/app/core/http_client.py[2218-2251]
tldw_Server_API/app/core/http_client.py[2550-2581]
tldw_Server_API/app/core/http_client.py[3967-4046]
tldw_Server_API/app/core/http_client.py[4101-4180]
tldw_Server_API/app/core/LLM_Calls/streaming.py[200-224]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new redirect hardening helpers (`_strip_sensitive_headers_for_cross_origin`, `SENSITIVE_REDIRECT_HEADERS`) are applied to the main `fetch/afetch` redirect loops, but the SSE streaming redirect loops still reissue requests on redirects using the caller’s headers unchanged (except for trace injection). This leaves an active cross-origin credential disclosure path for streaming providers.

## Issue Context
`astream_sse()` is used by the LLM streaming layer and accepts arbitrary caller headers; those headers can include bearer tokens/API keys. The SSE helpers manually follow redirects, so they must apply the same per-hop stripping as `fetch/afetch`.

## Fix Focus Areas
- tldw_Server_API/app/core/http_client.py[3967-4046]
- tldw_Server_API/app/core/http_client.py[4101-4180]
- tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[1-233]

### Implementation notes
- Track `original_url` (the initial `url` argument) and before each hop call:
 - `hop_headers = _inject_trace_headers(hdrs)`
 - `hop_headers = _strip_sensitive_headers_for_cross_origin(hop_headers, original_url=original_url, target_url=cur_url)`
- Apply the same logic to both httpx and aiohttp SSE streaming paths.
- Add an end-to-end `httpx.MockTransport` SSE redirect test similar to the new redirect-hardening tests, asserting the redirect target does not receive sensitive headers while non-sensitive headers still do.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. requires_httpx skips redirect tests ✓ Resolved 📘 Rule violation ▣ Testability
Description
A new skip condition (requires_httpx) was added to skip redirect-flow tests when httpx is
unavailable, which can mask failures rather than fixing them. Since httpx is a declared project
dependency, this skip is unnecessary and violates the rule against skipping/disabling tests to
bypass failures.
Code

tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[R24-34]

+def _has_httpx() -> bool:
+    try:
+        import httpx  # noqa: F401
+
+        return True
+    except Exception:
+        return False
+
+
+requires_httpx = pytest.mark.skipif(not _has_httpx(), reason="httpx not installed")
+
Evidence
The checklist forbids adding skip/disable mechanisms. The PR adds a skipif marker
(requires_httpx) and applies it to the redirect-flow test class, while the project dependency list
already includes httpx, making the skip path an avoidable test suppression mechanism.

Rule 224226: Do not skip or disable tests to hide failures
tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[24-34]
tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[124-125]
pyproject.toml[39-60]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The test module introduces `requires_httpx = pytest.mark.skipif(...)` and a broad `try/except Exception` import guard to skip tests if `httpx` is not installed.

## Issue Context
`httpx` is already a project dependency, so skipping these tests can hide real regressions.

## Fix Focus Areas
- tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[24-34]
- tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[124-125]
- pyproject.toml[39-60]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. TestOriginHelpers missing docstrings ✓ Resolved 📘 Rule violation ✧ Quality
Description
New classes and test functions were added without docstrings (e.g., TestOriginHelpers and its test
methods). This violates the requirement that all modules, classes, and functions/methods include
docstrings.
Code

tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[R48-55]

+class TestOriginHelpers:
+    def test_default_ports_normalized(self) -> None:
+        assert _url_origin("http://a.example") == ("http", "a.example", 80)
+        assert _url_origin("http://a.example:80/x") == ("http", "a.example", 80)
+        assert _url_origin("https://a.example") == ("https", "a.example", 443)
+        assert not _is_cross_origin("http://a.example", "http://a.example:80/path")
+        assert not _is_cross_origin("https://a.example/x", "https://a.example:443/y")
+
Evidence
The checklist requires docstrings for every class and function/method. In the new test module,
multiple class and function definitions begin immediately with code (assertions) rather than a
docstring string literal.

Rule 224214: Require docstrings for all modules, classes, and functions
tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[48-123]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Several newly added classes and test functions have no docstrings.

## Issue Context
Compliance requires docstrings for all modules, classes, and functions/methods in new/modified Python files.

## Fix Focus Areas
- tldw_Server_API/tests/http_client/test_redirect_header_hardening.py[48-123]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Ambient redirect cookies unhandled 🐞 Bug ⛨ Security
Description
Cross-origin redirect handling in fetch/afetch only drops the explicit per-call cookies argument
via _cookies_for_hop; it does not clear or bypass any cookies already stored on the underlying
httpx/aiohttp client/session, leaving a gap where ambient cookie-jar state is not prevented from
being sent on cross-origin hops.
Code

tldw_Server_API/app/core/http_client.py[R2217-2226]

    async def _do_once(ac: httpx.AsyncClient, target_url: str) -> tuple[httpx.Response | None, str]:
        req_headers = _inject_trace_headers(headers)
+        req_headers = _strip_sensitive_headers_for_cross_origin(
+            req_headers, original_url=url, target_url=target_url
+        )
+        req_cookies = _cookies_for_hop(cookies, original_url=url, target_url=target_url)
        # Parity with other paths: drop 'zstd' from Accept-Encoding for httpx
        try:
            with suppress(_HTTPCLIENT_NONCRITICAL_EXCEPTIONS):
Evidence
The new _cookies_for_hop logic only returns None for the per-call cookies argument and
fetch/afetch now pass that value into the request, but they do not clear or reset the underlying
client/session cookie state. In contrast, another redirect implementation in the same module
explicitly clears session cookie state at redirect boundaries, and the corresponding tests model
cookie-jar merging behavior and assert it is cleared on boundary redirects.

tldw_Server_API/app/core/http_client.py[1186-1192]
tldw_Server_API/app/core/http_client.py[2218-2251]
tldw_Server_API/app/core/http_client.py[2550-2581]
tldw_Server_API/app/core/http_client.py[2907-2940]
tldw_Server_API/app/core/http_client.py[3302-3314]
tldw_Server_API/app/core/http_client.py[3351-3390]
tldw_Server_API/tests/Web_Scraping/test_http_client_fetch.py[444-505]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The redirect hardening now drops the per-call `cookies` parameter on cross-origin hops, but it does not address cookies stored on the underlying client/session cookie jar. Elsewhere in this module (the “simple fetch” redirect loop), redirect-boundary logic explicitly clears session cookie state, suggesting ambient cookie state is considered part of redirect-borne credentials.

## Issue Context
- `fetch/afetch` accept a caller-supplied `client` / `session`, which can accumulate cookie state.
- The new change only influences the explicit `cookies=` argument.
- The module already contains redirect-boundary code that clears cookie jars (`_clear_session_cookie_state`) for another redirect implementation.

## Fix Focus Areas
- tldw_Server_API/app/core/http_client.py[1186-1192]
- tldw_Server_API/app/core/http_client.py[2218-2353]
- tldw_Server_API/app/core/http_client.py[2550-2641]
- tldw_Server_API/app/core/http_client.py[3267-3440]
- tldw_Server_API/tests/Web_Scraping/test_http_client_fetch.py[444-505]

### Implementation options (pick one)
1) **Bypass the client cookie jar on cross-origin hops** (preferred if feasible): construct/send the hop request in a way that does not merge in client/session cookie-jar state.
2) **Clear cookie jar only for internally-managed clients/sessions** on cross-origin hops (avoid mutating caller-supplied shared clients), mirroring the existing `_clear_session_cookie_state(...)` behavior used in the other redirect loop.
3) **Add a guardrail**: if `allow_redirects=True` and a caller-supplied client/session has non-empty cookie state, either refuse redirects or require the caller to opt in explicitly.

### Add regression test
Add a unit/integration test that simulates a cookie being present in the client/session state and asserts it is not sent on a cross-origin redirect hop in `fetch/afetch`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread tldw_Server_API/tests/http_client/test_redirect_header_hardening.py Outdated
Comment thread tldw_Server_API/tests/http_client/test_redirect_header_hardening.py Outdated
Comment thread tldw_Server_API/tests/http_client/test_redirect_header_hardening.py Outdated
Comment thread tldw_Server_API/tests/http_client/test_redirect_header_hardening.py
Comment thread tldw_Server_API/app/core/http_client.py
Comment on lines 2217 to 2226

async def _do_once(ac: httpx.AsyncClient, target_url: str) -> tuple[httpx.Response | None, str]:
req_headers = _inject_trace_headers(headers)
req_headers = _strip_sensitive_headers_for_cross_origin(
req_headers, original_url=url, target_url=target_url
)
req_cookies = _cookies_for_hop(cookies, original_url=url, target_url=target_url)
# Parity with other paths: drop 'zstd' from Accept-Encoding for httpx
try:
with suppress(_HTTPCLIENT_NONCRITICAL_EXCEPTIONS):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

6. Ambient redirect cookies unhandled 🐞 Bug ⛨ Security

Cross-origin redirect handling in fetch/afetch only drops the explicit per-call cookies argument
via _cookies_for_hop; it does not clear or bypass any cookies already stored on the underlying
httpx/aiohttp client/session, leaving a gap where ambient cookie-jar state is not prevented from
being sent on cross-origin hops.
Agent Prompt
## Issue description
The redirect hardening now drops the per-call `cookies` parameter on cross-origin hops, but it does not address cookies stored on the underlying client/session cookie jar. Elsewhere in this module (the “simple fetch” redirect loop), redirect-boundary logic explicitly clears session cookie state, suggesting ambient cookie state is considered part of redirect-borne credentials.

## Issue Context
- `fetch/afetch` accept a caller-supplied `client` / `session`, which can accumulate cookie state.
- The new change only influences the explicit `cookies=` argument.
- The module already contains redirect-boundary code that clears cookie jars (`_clear_session_cookie_state`) for another redirect implementation.

## Fix Focus Areas
- tldw_Server_API/app/core/http_client.py[1186-1192]
- tldw_Server_API/app/core/http_client.py[2218-2353]
- tldw_Server_API/app/core/http_client.py[2550-2641]
- tldw_Server_API/app/core/http_client.py[3267-3440]
- tldw_Server_API/tests/Web_Scraping/test_http_client_fetch.py[444-505]

### Implementation options (pick one)
1) **Bypass the client cookie jar on cross-origin hops** (preferred if feasible): construct/send the hop request in a way that does not merge in client/session cookie-jar state.
2) **Clear cookie jar only for internally-managed clients/sessions** on cross-origin hops (avoid mutating caller-supplied shared clients), mirroring the existing `_clear_session_cookie_state(...)` behavior used in the other redirect loop.
3) **Add a guardrail**: if `allow_redirects=True` and a caller-supplied client/session has non-empty cookie state, either refuse redirects or require the caller to opt in explicitly.

### Add regression test
Add a unit/integration test that simulates a cookie being present in the client/session state and asserts it is not sent on a cross-origin redirect hop in `fetch/afetch`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in b4fc13d (option 2, mirroring the simple-fetch precedent): new _clear_client_cookie_jar clears client/session jar state at cross-origin redirect boundaries in all five redirect loops; regression test asserts the jar is emptied and no cookie reaches the redirect target.

rmusser01 and others added 2 commits July 4, 2026 16:29
…losed

Address PR #2635 review: split kept/dropped headers in one iteration;
add tests proving malformed ports and IPv6 brackets fail closed
(ValueError from urlparse .port is in _HTTPCLIENT_NONCRITICAL_EXCEPTIONS,
so _url_origin returns None -> treated as cross-origin -> stripped).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address remaining PR #2635 review findings (qodo):
- SSE streaming redirect loops (_astream_sse_httpx/_astream_sse_aiohttp)
  now strip sensitive headers per hop like fetch/afetch — previously an
  active cross-origin credential path for streaming providers
- ambient client/session cookie-jar state is cleared at cross-origin
  redirect boundaries in all five redirect loops via new
  _clear_client_cookie_jar (mirrors the simple-fetch loop's
  _clear_session_cookie_state precedent)
- tests: drop unnecessary requires_httpx skip guard (httpx is a hard
  dep), remove redundant @pytest.mark.asyncio markers, annotate
  _handler return type, add docstrings throughout, plus regression
  tests for SSE redirect stripping and jar clearing

100/100 tests/http_client; 131/131 Web_Scraping fetch + adapter +
tokenizer consumers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@rmusser01 rmusser01 merged commit 09d9ec9 into dev Jul 4, 2026
17 of 18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant