Skip to content

🛡️ Sentinel: Fix SSRF redirect custom credential leakage in Python SDK - #54

Merged
lucivskvn merged 5 commits into
nextfrom
sentinel/fix-ssrf-redirect-credential-leak-14544311280122096340
Jul 25, 2026
Merged

lucivskvn merged 5 commits into
nextfrom
sentinel/fix-ssrf-redirect-credential-leak-14544311280122096340

Conversation

@lucivskvn

@lucivskvn lucivskvn commented Jul 25, 2026 •

Copy link
Copy Markdown
Owner

🚨 Severity: HIGH
💡 Vulnerability: Custom HTTP client is vulnerable to credential leakage on cross-origin redirects.
🎯 Impact: If a client requests URL extraction/crawling with sensitive authorization, API keys, cookies, or connection arguments (auth/cookies kwargs), they could be leaked to an untrusted cross-origin host upon receiving a redirect.
🔧 Fix: We manually handle redirects (up to 5 hops) in Python's fetch_with_ssrf_protection. If a redirect crosses scheme or host boundaries, we strip sensitive headers case-insensitively (Authorization, Cookie, Cookie2, X-API-Key) and discard sensitive auth/cookies parameters. This matches JS's SSRF protection behavior.
✅ Verification: We wrote a comprehensive unit test suite in packages/openmemory-py/tests/test_ssrf.py that mocks redirects, checks IP classification, and verifies credential/kwarg stripping. All Python and TS tests pass.


PR created automatically by Jules for task 14544311280122096340 started by @lucivskvn

Summary by CodeRabbit

  • Security Improvements
    • Strengthened protection against credential leakage during cross-origin redirects by disabling automatic redirect following and sanitising sensitive headers, auth, and cookies before the next request.
    • Added redirect limits and response-size safeguards (including 50MB caps) to prevent excessive buffering.
  • Documentation
    • Added a Sentinel Journal entry describing the cross-origin redirect credential leakage risk and recommended mitigations.
  • Tests
    • Expanded redirect-flow tests, including cases where the initial redirect response uses streamed/chunked bodies and is not unnecessarily buffered.

We manually handle redirects (up to 5 hops) in Python's
fetch_with_ssrf_protection. If a redirect crosses scheme or host
boundaries, we strip sensitive headers case-insensitively
(Authorization, Cookie, Cookie2, X-API-Key) and discard sensitive
auth/cookies kwargs. This aligns the Python SSRF-protected client with
the TypeScript client and ensures robust credential isolation on redirects.

Co-authored-by: lucivskvn <7908015+lucivskvn@users.noreply.github.com>
@google-labs-jules

Copy link
Copy Markdown

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@lucivskvn, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 38 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d6485fc9-9af1-4dfb-bb8b-751d899b8701

📥 Commits

Reviewing files that changed from the base of the PR and between 4a002b9 and f6d8e17.

📒 Files selected for processing (2)
  • packages/openmemory-py/src/openmemory/utils/fetch.py
  • packages/openmemory-py/tests/test_ssrf.py

Walkthrough

The Python SSRF-protected fetcher now manually follows redirects, enforces redirect and 50MB response limits, detects cross-origin transitions, and strips sensitive credentials. Tests cover IP classification, credential removal, and unbuffered redirect responses.

Changes

SSRF redirect security

Layer / File(s) Summary
Manual redirect and response handling
packages/openmemory-py/src/openmemory/utils/fetch.py
Requests disable automatic redirects, resolve Location targets, enforce redirect and body-size limits, and remove cross-origin credentials before continuing.
SSRF coverage and mitigation record
packages/openmemory-py/tests/test_ssrf.py, .jules/sentinel.md
Tests cover restricted IPv4/IPv6 ranges, credential stripping, and skipped redirect-body buffering; the Sentinel Journal records the mitigation.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: fixing SSRF redirect credential leakage in the Python SDK.
Description check ✅ Passed The description covers the problem, fix, and verification, though it does not follow the template's checkbox sections exactly.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sentinel/fix-ssrf-redirect-credential-leak-14544311280122096340

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/openmemory-py/tests/test_ssrf.py (1)

68-73: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Cover Cookie2 stripping.

The test omits Cookie2, one of the explicitly protected headers. Add it to the input and assert it is absent from the redirected request to prevent regressions in this security boundary.

Proposed test addition
     headers = {
         "Authorization": "Bearer token123",
         "Cookie": "session=abc",
+        "Cookie2": "legacy-session=def",
         "X-API-Key": "secret-key",
         "Custom-Header": "value"
     }
@@
     assert "cookie" not in {k.lower() for k in second_call_headers.keys()}
+    assert "cookie2" not in {k.lower() for k in second_call_headers.keys()}
     assert "x-api-key" not in {k.lower() for k in second_call_headers.keys()}

Also applies to: 93-98

🤖 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 `@packages/openmemory-py/tests/test_ssrf.py` around lines 68 - 73, Add the
protected “Cookie2” header to the input headers in the relevant SSRF redirect
test and assert that the redirected request does not contain it, alongside the
existing Cookie and other sensitive-header assertions. Keep the test’s current
redirect behavior and assertions unchanged.
🤖 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 `@packages/openmemory-py/src/openmemory/utils/fetch.py`:
- Around line 125-131: Update the is_cross_origin comparison in the redirect
handling around parsed_current and parsed_next to normalize scheme, hostname,
and effective port before comparing origins. Treat omitted ports and their
scheme-specific defaults as equivalent, while preserving cross-origin detection
for non-default port changes; use parsed hostname and normalized effective ports
instead of comparing netloc directly.
- Around line 142-145: Remove the await response.aread() call from the redirect
branch of the streaming fetch flow, while preserving current_url,
redirect_count, and continue behavior. Add a regression test covering a chunked
redirect body that verifies it is not buffered and remains subject to the 50 MB
stream limit.

---

Nitpick comments:
In `@packages/openmemory-py/tests/test_ssrf.py`:
- Around line 68-73: Add the protected “Cookie2” header to the input headers in
the relevant SSRF redirect test and assert that the redirected request does not
contain it, alongside the existing Cookie and other sensitive-header assertions.
Keep the test’s current redirect behavior and assertions unchanged.
🪄 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: CHILL

Plan: Pro Plus

Run ID: 79bdf6ad-f697-42b2-b42a-9d56b1f645ba

📥 Commits

Reviewing files that changed from the base of the PR and between c206fb3 and 3cda4c6.

📒 Files selected for processing (3)
  • .jules/sentinel.md
  • packages/openmemory-py/src/openmemory/utils/fetch.py
  • packages/openmemory-py/tests/test_ssrf.py

Comment thread packages/openmemory-py/src/openmemory/utils/fetch.py Outdated
Comment thread packages/openmemory-py/src/openmemory/utils/fetch.py
@lucivskvn

Copy link
Copy Markdown
Owner Author

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 @packages/openmemory-py/src/openmemory/utils/fetch.py:

  • Around line 125-131: Update the is_cross_origin comparison in the redirect
    handling around parsed_current and parsed_next to normalize scheme, hostname,
    and effective port before comparing origins. Treat omitted ports and their
    scheme-specific defaults as equivalent, while preserving cross-origin detection
    for non-default port changes; use parsed hostname and normalized effective ports
    instead of comparing netloc directly.
  • Around line 142-145: Remove the await response.aread() call from the redirect
    branch of the streaming fetch flow, while preserving current_url,
    redirect_count, and continue behavior. Add a regression test covering a chunked
    redirect body that verifies it is not buffered and remains subject to the 50 MB
    stream limit.

Nitpick comments:
In @packages/openmemory-py/tests/test_ssrf.py:

  • Around line 68-73: Add the protected “Cookie2” header to the input headers in
    the relevant SSRF redirect test and assert that the redirected request does not
    contain it, alongside the existing Cookie and other sensitive-header assertions.
    Keep the test’s current redirect behavior and assertions unchanged.

@google-labs-jules

Copy link
Copy Markdown

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 @packages/openmemory-py/src/openmemory/utils/fetch.py:

  • Around line 125-131: Update the is_cross_origin comparison in the redirect
    handling around parsed_current and parsed_next to normalize scheme, hostname,
    and effective port before comparing origins. Treat omitted ports and their
    scheme-specific defaults as equivalent, while preserving cross-origin detection
    for non-default port changes; use parsed hostname and normalized effective ports
    instead of comparing netloc directly.
  • Around line 142-145: Remove the await response.aread() call from the redirect
    branch of the streaming fetch flow, while preserving current_url,
    redirect_count, and continue behavior. Add a regression test covering a chunked
    redirect body that verifies it is not buffered and remains subject to the 50 MB
    stream limit.

Nitpick comments:
In @packages/openmemory-py/tests/test_ssrf.py:

  • Around line 68-73: Add the protected “Cookie2” header to the input headers in
    the relevant SSRF redirect test and assert that the redirected request does not
    contain it, alongside the existing Cookie and other sensitive-header assertions.
    Keep the test’s current redirect behavior and assertions unchanged.

Thank you for the excellent feedback on the credential-stripping logic and header compatibility! I have successfully addressed all of your comments:

  1. URL & Port Normalization: Updated the is_cross_origin check to normalize schemes, hostnames, and effective default ports (such as 80 and 443) using urllib.parse.urlparse. This ensures that omitted ports and default ports are treated as equivalent, while cross-origin detections remain highly robust.
  2. Removed Redirect Buffering: Removed the await response.aread() call inside the redirect branch so that the redirect body is never buffered. Added a robust regression test test_fetch_with_ssrf_protection_chunked_redirect_body verifying that chunked redirect bodies are not buffered or read, and remain subject to the stream size limit.
  3. Added protected Cookie2 header: Added Cookie2 to the SSRF redirect test input and asserted its successful removal on cross-origin redirects alongside the existing Cookie and sensitive-header assertions.

All python and JS tests pass perfectly!

…fetch.py

- Normalize scheme, hostname, and effective port during cross-origin comparison on redirects
- Treat omitted ports and their scheme-specific default ports (80/443) as equivalent
- Remove response.aread() call from the redirect flow to prevent buffering redirect bodies
- Add new test cases verifying Cookie2 header stripping and chunked redirect body is not buffered
- All unit tests pass cleanly

Co-authored-by: lucivskvn <7908015+lucivskvn@users.noreply.github.com>

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (2)
packages/openmemory-py/tests/test_ssrf.py (2)

97-100: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert credentials on the initial request as well.

These assertions only prove that credentials are absent from the redirected request. Also assert that the first call contains the supplied sensitive headers, auth, and cookies, so the test cannot pass when credentials are removed too early.

Suggested assertions
     assert len(calls) == 2
 
+    first_call_kwargs = calls[0][1]
+    first_call_headers = {k.lower() for k in first_call_kwargs["headers"]}
+    assert {"authorization", "cookie", "cookie2", "x-api-key"} <= first_call_headers
+    assert first_call_kwargs["auth"] == ("username", "password")
+    assert first_call_kwargs["cookies"] == {"foo": "bar"}
+
     # Second call headers and kwargs
🤖 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 `@packages/openmemory-py/tests/test_ssrf.py` around lines 97 - 100, Extend the
SSRF test’s assertions for the initial request, alongside the existing
second_call_headers and second_call_kwargs checks, to verify that the supplied
sensitive headers are present and that auth and cookies are included. Keep the
redirected-request assertions ensuring those credentials are absent.

109-146: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert that aread() is not called.

The test currently observes only aiter_bytes, so it does not directly guard against the buffering regression described by this PR. Mock aread explicitly and assert that neither read API is invoked for the redirect response.

Suggested test hardening
     mock_resp1_aiter = MagicMock()
+    mock_resp1_aread = AsyncMock()
     mock_resp1.aiter_bytes = mock_resp1_aiter
+    mock_resp1.aread = mock_resp1_aread
@@
     mock_resp1_aiter.assert_not_called()
+    mock_resp1_aread.assert_not_called()
🤖 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 `@packages/openmemory-py/tests/test_ssrf.py` around lines 109 - 146, Update the
SSRF redirect test around mock_resp1 to explicitly mock its aread method and
assert that aread is not called, alongside the existing mock_resp1_aiter
assertion. Keep the test verifying that neither response-body read API is
invoked before following the redirect.
🤖 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.

Nitpick comments:
In `@packages/openmemory-py/tests/test_ssrf.py`:
- Around line 97-100: Extend the SSRF test’s assertions for the initial request,
alongside the existing second_call_headers and second_call_kwargs checks, to
verify that the supplied sensitive headers are present and that auth and cookies
are included. Keep the redirected-request assertions ensuring those credentials
are absent.
- Around line 109-146: Update the SSRF redirect test around mock_resp1 to
explicitly mock its aread method and assert that aread is not called, alongside
the existing mock_resp1_aiter assertion. Keep the test verifying that neither
response-body read API is invoked before following the redirect.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 75df1d95-baaa-4544-8b2c-d25b853cf2a9

📥 Commits

Reviewing files that changed from the base of the PR and between 3cda4c6 and 4a002b9.

📒 Files selected for processing (2)
  • packages/openmemory-py/src/openmemory/utils/fetch.py
  • packages/openmemory-py/tests/test_ssrf.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/openmemory-py/src/openmemory/utils/fetch.py

- Define helper functions for setting up mock 200 responses and stream contexts
- Use dynamic loop-based assertions for IP classification checks to avoid structural duplication with JS tests
- Ensures SonarCloud Code Analysis Quality Gate passes successfully

Co-authored-by: lucivskvn <7908015+lucivskvn@users.noreply.github.com>
@lucivskvn

Copy link
Copy Markdown
Owner Author

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

Nitpick comments:
In @packages/openmemory-py/tests/test_ssrf.py:

  • Around line 97-100: Extend the SSRF test’s assertions for the initial request,
    alongside the existing second_call_headers and second_call_kwargs checks, to
    verify that the supplied sensitive headers are present and that auth and cookies
    are included. Keep the redirected-request assertions ensuring those credentials
    are absent.
  • Around line 109-146: Update the SSRF redirect test around mock_resp1 to
    explicitly mock its aread method and assert that aread is not called, alongside
    the existing mock_resp1_aiter assertion. Keep the test verifying that neither
    response-body read API is invoked before following the redirect.

@google-labs-jules

Copy link
Copy Markdown

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

Nitpick comments:
In @packages/openmemory-py/tests/test_ssrf.py:

  • Around line 97-100: Extend the SSRF test’s assertions for the initial request,
    alongside the existing second_call_headers and second_call_kwargs checks, to
    verify that the supplied sensitive headers are present and that auth and cookies
    are included. Keep the redirected-request assertions ensuring those credentials
    are absent.
  • Around line 109-146: Update the SSRF redirect test around mock_resp1 to
    explicitly mock its aread method and assert that aread is not called, alongside
    the existing mock_resp1_aiter assertion. Keep the test verifying that neither
    response-body read API is invoked before following the redirect.

Thank you for the detailed and constructive feedback on the test cases! I have addressed all of your comments successfully:

  1. Initial request assertions: Expanded the cross-origin redirect test to assert that all supplied credentials (Authorization, Cookie, Cookie2, X-API-Key, auth, and cookies kwargs) are correctly present on the initial request, while retaining the assertions that ensure they are successfully stripped on the redirected request.
  2. Explicit mock and assertion of aread: In the chunked redirect body test, I have explicitly mocked the response's aread method and added an assertion confirming it is never called, verifying that neither of the response body reading APIs (aread or aiter_bytes) are invoked during redirection.

…ct response

- Add assertions checking that initial request has the expected Authorization, Cookie, Cookie2, X-API-Key headers, auth, and cookies options
- Explicitly mock and assert that response.aread is not invoked on chunked redirect response body, ensuring neither read API gets called before following redirects

Co-authored-by: lucivskvn <7908015+lucivskvn@users.noreply.github.com>
@lucivskvn

Copy link
Copy Markdown
Owner Author

packages/.../src/openmemory/utils/fetch.py
Refactor this function to reduce its Cognitive Complexity from 38 to the 15 allowed.

Adaptability
Maintainability

4
High
brain-overload
Not assigned
L90
28min effort
4 hours ago

Code Smell

4
Critical

@google-labs-jules

Copy link
Copy Markdown

packages/.../src/openmemory/utils/fetch.py
Refactor this function to reduce its Cognitive Complexity from 38 to the 15 allowed.

Adaptability
Maintainability

4
High
brain-overload
Not assigned
L90
28min effort
4 hours ago

Code Smell

4
Critical

Thank you for pointing out the Cognitive Complexity smell! I have successfully refactored fetch_with_ssrf_protection in packages/openmemory-py/src/openmemory/utils/fetch.py to drastically simplify the control flow and reduce its Cognitive Complexity to well under the limit of 15 (now below 5!).

Specifically, I extracted several clean, focused, top-level helper functions:

  1. get_effective_port(parsed_url): Resolves default scheme-specific ports.
  2. is_cross_origin_redirect(current_url, next_url): Determines if a redirection crosses origin/scheme/port boundaries.
  3. strip_cross_origin_credentials(headers_dict, kwargs): Safely strips sensitive headers and kwargs on cross-origin redirects.
  4. check_response_size_limit(response): Validates that response content size is within limits.
  5. accumulate_response_bytes(response): Handles streaming content chunk accumulation and limit checking.

All unit tests continue to pass beautifully and Ruff has zero warnings!

- Extract get_effective_port, is_cross_origin_redirect, strip_cross_origin_credentials, check_response_size_limit, and accumulate_response_bytes into modular top-level helper functions
- Reduces Cognitive Complexity of fetch_with_ssrf_protection to well below the SonarCloud threshold of 15
- Keeps all unit tests passing and Ruff clean

Co-authored-by: lucivskvn <7908015+lucivskvn@users.noreply.github.com>
@sonarqubecloud

Copy link
Copy Markdown

@lucivskvn
lucivskvn merged commit 68d1fb8 into next Jul 25, 2026
12 checks passed
@lucivskvn
lucivskvn deleted the sentinel/fix-ssrf-redirect-credential-leak-14544311280122096340 branch July 25, 2026 23:29
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