Skip to content

fix(seed): guard SeedDataManager.load_from_api against SSRF - #950

Closed
yunaremaia wants to merge 1 commit into
semantica-agi:mainfrom
yunaremaia:fix/ssrf-guard-seed-api-936
Closed

fix(seed): guard SeedDataManager.load_from_api against SSRF#950
yunaremaia wants to merge 1 commit into
semantica-agi:mainfrom
yunaremaia:fix/ssrf-guard-seed-api-936

Conversation

@yunaremaia

Copy link
Copy Markdown
Contributor

Closes #936

Summary

Guards SeedDataManager.load_from_api() against SSRF: callers passing untrusted API URLs could previously trigger outbound requests to loopback or link-local endpoints (e.g. cloud metadata services).

is_safe_api_url() (new, in semantica/seed/seed_manager.py):

  • Resolves the hostname via socket.getaddrinfo and checks every returned address against ipaddress flags: rejects loopback, private, link-local, reserved, multicast, and unspecified (IPv4 and IPv6, including [::1])
  • Rejects non-http(s) schemes (e.g. file://)
  • Returns False for unresolvable hostnames instead of raising

load_from_api() now raises ProcessingError with a clear message before any request is made when the target is unsafe.

Tests — 11 new cases in tests/seed/test_seed_manager.py:

  • Blocked: 127.0.0.1, localhost, 192.168.x, 169.254.169.254 (metadata), 10.x, [::1], file://
  • Allowed: public IPs (8.8.8.8)
  • Integration: load_from_api("http://127.0.0.1...") raises ProcessingError

Also updated the existing test_load_from_api (used the non-resolving api.example.com, which the guard now rejects) to a public IP to preserve the test's original intent.

Note

  • Pre-existing failure: TestSeedDataManager::test_load_from_csv fails on main (0 records parsed from the mocked CSV) — unrelated to this change, observed before this branch.
  • DNS rebinding (TOCTOU between validation and connect) is noted as a limitation in the guard's docstring; full mitigation requires connecting through a validating proxy or re-checking the resolved IP at connect time.

@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@yunaremaia
yunaremaia force-pushed the fix/ssrf-guard-seed-api-936 branch from 660f8a8 to 07b20de Compare August 12, 2026 18:10
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Guard SeedDataManager.load_from_api against SSRF

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Add SSRF URL validation to block loopback/private/link-local and non-HTTP(S) targets.
• Fail fast in load_from_api with a clear ProcessingError before any outbound request.
• Add/adjust tests to cover blocked/allowed URLs and guard behavior in load_from_api.
Diagram

graph TD
  A[Caller] --> B["SeedDataManager.load_from_api"] --> D["is_safe_api_url"] --> C{URL safe?}
  C -->|"Yes"| E["requests.get"] --> F["External API"]
  C -->|"No"| G["ProcessingError"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Allowlist-based validation (domains/IP ranges)
  • ➕ Stronger security posture for internal deployments with known API hosts
  • ➕ Avoids relying on IP blocklists that may miss future special-use ranges
  • ➖ Requires configuration management and operational coordination
  • ➖ Less flexible for users who legitimately need arbitrary public endpoints
2. Validate at connect-time via custom transport / proxy
  • ➕ Mitigates DNS rebinding/TOCTOU by checking the actual connected peer IP
  • ➕ Centralizes outbound policy enforcement across all HTTP calls
  • ➖ More implementation complexity (custom requests adapter or mandatory proxy)
  • ➖ Harder to keep lightweight for a library context
3. Reuse a hardened SSRF protection library/component
  • ➕ Potentially better coverage of edge cases (IDNA, redirects, IPv6 nuances)
  • ➕ Reduces maintenance burden of security logic
  • ➖ Adds a dependency and possible footprint/compatibility concerns
  • ➖ Still may require customization for project-specific policy

Recommendation: The PR’s approach (scheme restriction + DNS resolution + IP range rejection + fail-fast error) is an appropriate, low-dependency baseline for preventing obvious SSRF targets in a library context. If this code is used in higher-risk environments, consider a follow-up to mitigate DNS rebinding by validating the resolved/connected peer IP at connect-time (custom transport or outbound proxy), and/or adding an optional allowlist mode for deployments with known API hosts.

Files changed (3) +90 / -2

Bug fix (1) +49 / -0
seed_manager.pyAdd SSRF guard for API seed loading +49/-0

Add SSRF guard for API seed loading

• Introduces is_safe_api_url() that parses URLs, restricts schemes to http/https, resolves hostnames, and rejects non-public address classes (loopback/private/link-local/reserved/multicast/unspecified) for IPv4/IPv6. load_from_api() now checks the fully constructed URL and raises ProcessingError with a clear message before any requests.get() call when the target is unsafe.

semantica/seed/seed_manager.py

Tests (2) +41 / -2
test_seed_manager.pyAdd unit tests for SSRF URL validation and guard behavior +40/-1

Add unit tests for SSRF URL validation and guard behavior

• Adds a dedicated TestIsSafeApiUrl suite covering allowed public URLs and blocked loopback/private/link-local/IPv6 loopback and non-http(s) schemes. Includes an integration-style test asserting SeedDataManager.load_from_api rejects a loopback URL by raising ProcessingError.

tests/seed/test_seed_manager.py

test_seed_manager.pyUpdate load_from_api test to use a resolvable public IP +1/-1

Update load_from_api test to use a resolvable public IP

• Changes the test base api_url from a hostname to a public IP (8.8.8.8) so it passes the new SSRF guard while preserving the test’s intent to validate response parsing and request invocation.

tests/test_seed_manager.py

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (1) 📜 Skill insights (0)

Grey Divider


Action required

1. load_from_api redirects unguarded 📎 Requirement gap ⛨ Security
Description
SeedDataManager.load_from_api() only validates the initial full_url via is_safe_api_url() and
does not enforce redirect safety, allowing a public URL to redirect to
internal/loopback/private/link-local targets after passing the guard. This violates the requirement
that load_from_api() include redirect checks as part of SSRF protection.
Code

semantica/seed/seed_manager.py[R537-540]

+            if not is_safe_api_url(full_url):
+                raise ProcessingError(
+                    f"Unsafe API URL rejected: {full_url} "
+                    "(loopback, private, or link-local addresses are not allowed)"
Evidence
PR Compliance ID 1 explicitly requires SSRF protection in load_from_api() that includes redirect
checks. The change adds is_safe_api_url() to validate the parsed hostname/IPs for the initial URL,
and load_from_api() calls it once before issuing the HTTP request, but there is no shown logic
that validates redirect destinations in a redirect chain. Additionally, the repository’s use of
allow_redirects elsewhere indicates redirect-following is a supported behavior and therefore
requires explicit handling here to avoid redirect-based SSRF bypass.

SeedDataManager.load_from_api() must use SSRF-guarded HTTP requests (including redirect checks)
semantica/seed/seed_manager.py[51-86]
semantica/seed/seed_manager.py[536-542]
semantica/seed/seed_manager.py[522-545]
semantica/explorer/routes/ontology.py[1005-1016]

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

## Issue description
`SeedDataManager.load_from_api()` currently validates only the initial caller-provided URL via `is_safe_api_url(full_url)` and then performs the HTTP request without validating any redirect targets. Because redirects are commonly followed by HTTP clients, an attacker-controlled public URL could redirect to a private/loopback/link-local/internal destination and bypass the intended SSRF protection; the fix must ensure redirect checks are included to satisfy the compliance requirement.

## Issue Context
- PR Compliance ID 1 requires SSRF protection in `SeedDataManager.load_from_api()` **including redirect checks**.
- The current implementation applies the URL safety guard once (on the initial URL) and does not show any validation of redirect destinations.
- Redirect-following is a supported behavior in the repo (e.g., use of `allow_redirects` elsewhere), so this code path must explicitly handle redirects to prevent redirect-based SSRF.

## Fix Focus Areas
- semantica/seed/seed_manager.py[51-86]
- semantica/seed/seed_manager.py[480-587]
- semantica/seed/seed_manager.py[533-545]

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



Remediation recommended

2. URL guard fails open 🐞 Bug ⛨ Security
Description
is_safe_api_url() continues on ipaddress parse failures and still returns True, meaning a URL can be
marked safe even if none of the resolved addresses were successfully validated. SSRF guards should
fail closed when address parsing/validation is incomplete.
Code

semantica/seed/seed_manager.py[R72-75]

+            try:
+                ip = ipaddress.ip_address(str(addr).split("%")[0])
+            except ValueError:
+                continue
Evidence
The code explicitly continues on ValueError and then returns True unconditionally after the loop, so
parse failures do not cause rejection.

semantica/seed/seed_manager.py[51-87]

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

### Issue description
`is_safe_api_url()` skips addresses that fail `ipaddress.ip_address(...)` parsing and will still `return True` after the loop, which is a fail-open outcome if parsing fails for all entries.

### Issue Context
This is a security validation function; if it cannot validate resolved addresses, it should treat the URL as unsafe.

### Fix Focus Areas
- semantica/seed/seed_manager.py[51-88]

### Suggested fix
- Track whether at least one address was successfully parsed (`parsed_any = False`).
- If an address cannot be parsed, either:
 - immediately `return False` (strict fail-closed), or
 - continue but at end `return False` if `parsed_any` is still `False`.
- Also consider `return False` if `addresses` is empty.

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


3. Non-global IPs allowed 🐞 Bug ⛨ Security
Description
is_safe_api_url() blocks several special ranges but does not require that resolved IPs be globally
routable, so some non-public address spaces can still be treated as safe. This weakens the intended
“public-only” SSRF policy.
Code

semantica/seed/seed_manager.py[R76-83]

+            if (
+                ip.is_loopback
+                or ip.is_private
+                or ip.is_link_local
+                or ip.is_reserved
+                or ip.is_multicast
+                or ip.is_unspecified
+            ):
Evidence
The function returns True as long as none of the selected flags match, which is not equivalent to
requiring a public/global destination.

semantica/seed/seed_manager.py[51-88]

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 guard enumerates a set of `ipaddress` flags to block, but does not enforce that an IP is actually globally routable/public.

### Issue Context
The docstring states the goal is to reject non-public addresses, but a denylist of flags is easier to get wrong than an allowlist condition.

### Fix Focus Areas
- semantica/seed/seed_manager.py[51-88]

### Suggested fix
- Replace/augment the current predicate with a global-routability check, e.g. reject when `not ip.is_global`.
- Keep explicit checks as needed, but ensure the final condition implements “public-only” behavior.

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



Informational

4. Error exposes full URL 🐞 Bug ⛨ Security
Description
When rejecting a URL, load_from_api() raises ProcessingError containing the full URL string, which
can include credentials/userinfo or sensitive query parameters. This increases the risk of leaking
secrets through exception propagation or logs.
Code

semantica/seed/seed_manager.py[R538-541]

+                raise ProcessingError(
+                    f"Unsafe API URL rejected: {full_url} "
+                    "(loopback, private, or link-local addresses are not allowed)"
+                )
Evidence
The new exception message includes the raw full_url value directly in the error string.

semantica/seed/seed_manager.py[533-541]

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 rejection error message interpolates `full_url` directly, potentially disclosing secrets embedded in URLs.

### Issue Context
Even if callers *shouldn’t* embed secrets in URLs, it happens in practice (query tokens, basic auth userinfo). Exceptions often get logged.

### Fix Focus Areas
- semantica/seed/seed_manager.py[533-541]

### Suggested fix
- Log/raise a redacted form (e.g., scheme + hostname + port, without userinfo/query/fragment), or include only `parsed.hostname`.
- Keep the error actionable without echoing full sensitive inputs.

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


Grey Divider

Context

Grey Divider

Tip of the day
💡 Did you know, you can enable the Remediation agent and Qodo fixes findings in a dedicated fix PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +537 to +540
if not is_safe_api_url(full_url):
raise ProcessingError(
f"Unsafe API URL rejected: {full_url} "
"(loopback, private, or link-local addresses are not allowed)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. load_from_api redirects unguarded 📎 Requirement gap ⛨ Security

SeedDataManager.load_from_api() only validates the initial full_url via is_safe_api_url() and
does not enforce redirect safety, allowing a public URL to redirect to
internal/loopback/private/link-local targets after passing the guard. This violates the requirement
that load_from_api() include redirect checks as part of SSRF protection.
Agent Prompt
## Issue description
`SeedDataManager.load_from_api()` currently validates only the initial caller-provided URL via `is_safe_api_url(full_url)` and then performs the HTTP request without validating any redirect targets. Because redirects are commonly followed by HTTP clients, an attacker-controlled public URL could redirect to a private/loopback/link-local/internal destination and bypass the intended SSRF protection; the fix must ensure redirect checks are included to satisfy the compliance requirement.

## Issue Context
- PR Compliance ID 1 requires SSRF protection in `SeedDataManager.load_from_api()` **including redirect checks**.
- The current implementation applies the URL safety guard once (on the initial URL) and does not show any validation of redirect destinations.
- Redirect-following is a supported behavior in the repo (e.g., use of `allow_redirects` elsewhere), so this code path must explicitly handle redirects to prevent redirect-based SSRF.

## Fix Focus Areas
- semantica/seed/seed_manager.py[51-86]
- semantica/seed/seed_manager.py[480-587]
- semantica/seed/seed_manager.py[533-545]

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

Comment on lines +72 to +75
try:
ip = ipaddress.ip_address(str(addr).split("%")[0])
except ValueError:
continue

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

2. Url guard fails open 🐞 Bug ⛨ Security

is_safe_api_url() continues on ipaddress parse failures and still returns True, meaning a URL can be
marked safe even if none of the resolved addresses were successfully validated. SSRF guards should
fail closed when address parsing/validation is incomplete.
Agent Prompt
### Issue description
`is_safe_api_url()` skips addresses that fail `ipaddress.ip_address(...)` parsing and will still `return True` after the loop, which is a fail-open outcome if parsing fails for all entries.

### Issue Context
This is a security validation function; if it cannot validate resolved addresses, it should treat the URL as unsafe.

### Fix Focus Areas
- semantica/seed/seed_manager.py[51-88]

### Suggested fix
- Track whether at least one address was successfully parsed (`parsed_any = False`).
- If an address cannot be parsed, either:
  - immediately `return False` (strict fail-closed), or
  - continue but at end `return False` if `parsed_any` is still `False`.
- Also consider `return False` if `addresses` is empty.

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

Comment on lines +76 to +83
if (
ip.is_loopback
or ip.is_private
or ip.is_link_local
or ip.is_reserved
or ip.is_multicast
or ip.is_unspecified
):

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

3. Non-global ips allowed 🐞 Bug ⛨ Security

is_safe_api_url() blocks several special ranges but does not require that resolved IPs be globally
routable, so some non-public address spaces can still be treated as safe. This weakens the intended
“public-only” SSRF policy.
Agent Prompt
### Issue description
The guard enumerates a set of `ipaddress` flags to block, but does not enforce that an IP is actually globally routable/public.

### Issue Context
The docstring states the goal is to reject non-public addresses, but a denylist of flags is easier to get wrong than an allowlist condition.

### Fix Focus Areas
- semantica/seed/seed_manager.py[51-88]

### Suggested fix
- Replace/augment the current predicate with a global-routability check, e.g. reject when `not ip.is_global`.
- Keep explicit checks as needed, but ensure the final condition implements “public-only” behavior.

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

Comment on lines +538 to +541
raise ProcessingError(
f"Unsafe API URL rejected: {full_url} "
"(loopback, private, or link-local addresses are not allowed)"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Informational

4. Error exposes full url 🐞 Bug ⛨ Security

When rejecting a URL, load_from_api() raises ProcessingError containing the full URL string, which
can include credentials/userinfo or sensitive query parameters. This increases the risk of leaking
secrets through exception propagation or logs.
Agent Prompt
### Issue description
The rejection error message interpolates `full_url` directly, potentially disclosing secrets embedded in URLs.

### Issue Context
Even if callers *shouldn’t* embed secrets in URLs, it happens in practice (query tokens, basic auth userinfo). Exceptions often get logged.

### Fix Focus Areas
- semantica/seed/seed_manager.py[533-541]

### Suggested fix
- Log/raise a redacted form (e.g., scheme + hostname + port, without userinfo/query/fragment), or include only `parsed.hostname`.
- Keep the error actionable without echoing full sensitive inputs.

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

@ZohaibHassan16

Copy link
Copy Markdown
Collaborator

Thanks for the fix. I found a couple of security issues that need to be fixed before merging.

Redirects are still not protected

load_from_api() only checks the first URL, then it does:

requests.get(full_url, headers=request_headers, timeout=30)

requests follows redirects by default. This means a safe public URL can pass is_safe_api_url() first, then redirect to 127.0.0.1, a private IP, or a cloud metadata endpoint.

We already have request_with_ssrf_guard() in semantica.ingest.ssrf. It checks every redirect too. We should use that existing helper here instead of adding a separate guard.

Non-global IP ranges are still allowed

The new guard blocks some internal ranges but it does not require the IP to be globally routable.

For example, this currently returns True:

is_safe_api_url("http://100.64.0.1/")

100.64.0.0/10 is shared address space and should not be allowed for a public-only SSRF policy.

Using the existing shared SSRF helper should fix this too and keep the same SSRF rules across the project.

Please also add tests for a public URL that redirects to a loopback or metadata URL, and make sure the redirected request is never made.

Also, the error message includes the full rejected URL. It can contain tokens or query parameters, so it would be better not to include the full URL in the exception message.

)

Adds is_safe_api_url() which rejects URLs resolving to loopback, private,
link-local, reserved, multicast, or unspecified addresses (IPv4/IPv6),
and blocks non-http(s) schemes. load_from_api now raises ProcessingError
for unsafe targets before any request is made.

11 new tests (loopback/private/link-local/ipv6/scheme blocked; public
allowed; load_from_api rejects loopback) — all green. The existing
test_load_from_api used a non-resolving example.com hostname which the
guard now rejects; switched to a public IP to preserve its intent.

Signed-off-by: Yunare Maia <yunare@gmail.com>
@yunaremaia
yunaremaia force-pushed the fix/ssrf-guard-seed-api-936 branch from 07b20de to dc2f15a Compare August 12, 2026 20:01
@yunaremaia

Copy link
Copy Markdown
Contributor Author

Closing this PR in favor of #959, which resolves #936 with the repo's shared SSRF guard.

Since opening this, semantica/ingest/ssrf.py landed upstream (in #906, fixing #867) — a complete shared guard with redirect-chain validation and bounded DNS resolution. PR #959 plugs SeedDataManager.load_from_api into that shared guard (request_with_ssrf_guard), which blocks loopback/private/link-local by default exactly as this PR did, and adds the allow_private_ips opt-in requested in #943.

Consolidating on the shared guard avoids two parallel SSRF implementations in the codebase. The tests from this PR's intent are covered in #959 (block-by-default + opt-in). Thank you for the issue — it drove the right fix.

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.

Guard SeedDataManager.load_from_api() against SSRF

2 participants