Skip to content

fix(security): resolve profile update validation bypass and SSRF - #507

Open
prince-shakyaa wants to merge 6 commits into
GenAI-Security-Project:mainfrom
prince-shakyaa:fix/profile-avatar-ssrf
Open

prince-shakyaa wants to merge 6 commits into
GenAI-Security-Project:mainfrom
prince-shakyaa:fix/profile-avatar-ssrf

Conversation

@prince-shakyaa

@prince-shakyaa prince-shakyaa commented May 19, 2026 •

Copy link
Copy Markdown

Fixes #506

Problem

PUT /api/v1/profile only checked that avatar_url starts with https:// when avatar_type: "url" was in the same request. If your profile already had avatar_type = "url", you could send just avatar_url and skip the check completely. Then /share/profile/{username}/card.png fetches that URL from the server, so you get SSRF to loopback, internal services or cloud metadata (169.254.169.254). It followed redirects too.

Fix

profile.py

  • Validate the effective avatar type and URL: the request value if sent, otherwise what's already saved.
  • get_safe_ssrf_ip() resolves the host with getaddrinfo (IPv4 and IPv6) and rejects the URL if any answer is non-public. That covers loopback, RFC-1918, link-local/metadata, CGNAT 100.64.0.0/10, 0.0.0.0/8, IPv6 loopback/ULA/link-local/unspecified, IPv4-mapped IPv6 like ::ffff:127.0.0.1, plus anything Python marks private/reserved/multicast.
  • Only plain https:// is allowed. Credentials in the URL and bad ports are rejected with a 400 (a bad port used to cause a 500).
  • DNS lookups run in a thread, so they don't block the event loop.

share.py

  • Redirects are off.
  • The fetch connects to the IP that was just validated, so a second DNS lookup can't be rebound to an internal address.
  • TLS verification stays on. The real hostname goes in the Host header and in SNI (httpx's public sni_hostname extension), so the cert is checked against the real domain. The earlier version of this PR used verify=False. That's gone now.
  • The avatar is downloaded in chunks and dropped as soon as it goes over 2 MiB (or if Content-Length already says it's too big), so a huge response never gets fully loaded into memory.

Testing

  • tests/unit/apps/ctf/test_profile_security.py: 26 tests covering the bypass, each blocked range, mixed public/private DNS answers, bad ports, credentials, and that the fetch pins the IP with redirects off, TLS verification on and the right Host/SNI, plus the 2 MiB cap. DNS is faked, so there are no network calls.
  • tests/unit overall: everything passes except 3 tests in tests/unit/agents/test_specialized_agents.py, which fail the same way on main.
  • Ran it locally end to end: logged in, set a real GitHub avatar (200), then tried the bypass and the payloads above (all 400, saved avatar unchanged). The share card rendered with the avatar. I also checked that a pinned request with the wrong SNI fails certificate verification.

@prince-shakyaa
prince-shakyaa force-pushed the fix/profile-avatar-ssrf branch from 17c86b3 to 40740ff Compare May 19, 2026 19:47
@prince-shakyaa

Copy link
Copy Markdown
Author

Hi @saikishu @e2hln ,
Fixed a validation bypass in profile.py where omitting avatar_type in a profile update request skipped the HTTPS check on avatar_url, allowing insecure HTTP URLs to be saved and triggering blind SSRF via the profile share card endpoint. The fix resolves this by checking the effective avatar type from both the request and the existing database profile before validating the URL. Added 4 unit tests covering the bypass and edge cases.
Thank You.

…en SSRF checks

- send the original hostname as SNI so the pinned-IP request verifies the real cert (drop verify=False)
- block CGNAT, 0.0.0.0/8, IPv6 unspecified, IPv4-mapped IPv6 and other non-public ranges
- return 400 instead of 500 for malformed ports, reject credentials in the URL
- resolve DNS off the event loop
- tests no longer hit real DNS
@prince-shakyaa

prince-shakyaa commented Oct 25, 2026 •

Copy link
Copy Markdown
Author

Hi @saikishu @e2hln, I've updated this PR and it's ready for review.

  • TLS verification is back on for the avatar fetch. It still connects to the IP that was already checked (so DNS rebinding can't redirect it), but the real hostname is sent in the TLS handshake, so the certificate is checked against the actual domain. No more verify=False.
  • Also blocks CGNAT, 0.0.0.0/8 and IPv4-in-IPv6 addresses. Bad ports now return 400 instead of 500. DNS lookups no longer block the event loop.
  • 24 unit tests (no real DNS calls), and I tested it end to end on a local run.

I've also seen #610 for the same issue.
Happy to pull in anything from there that you'd like to see here.
Thanks!

Read the avatar body in chunks and drop it as soon as it goes over the
limit, and reject a too-large Content-Length up front, so a huge response
is never fully loaded into memory.
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.

[Security] HTTPS Validation Bypass in User Profile Updates Leads to SSRF via Insecure Avatar URLs

1 participant