Skip to content

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

@prince-shakyaa

Description

@prince-shakyaa

[Vulnerability] Security Bypass in Profile Update leads to Server-Side Request Forgery (SSRF) via Insecure Avatar URLs

Summary

A security validation bypass exists in the user profile update router (finbot/apps/ctf/routes/profile.py). A user can bypass the HTTPS URL validation check and inject arbitrary URL schemes (such as http://, internal/loopback IP addresses, or local services) into their avatar_url database field.

When the backend generates user share cards at the /share/profile/{username}/card.png endpoint, it executes a server-side httpx.get request to the user's avatar URL. This allows an attacker to perform a blind Server-Side Request Forgery (SSRF) attack, potentially mapping local services, scanning ports, or querying cloud metadata endpoints (e.g. 169.254.169.254).


Affected Code & Files

  1. Validation Bypass: finbot/apps/ctf/routes/profile.py
  2. SSRF Execution: finbot/apps/ctf/routes/share.py

Vulnerability Details & Root Cause

1. The Validation Bypass

In the ProfileUpdateRequest Pydantic schema, the avatar_type and avatar_url fields are optional:

class ProfileUpdateRequest(BaseModel):
    username: str | None = Field(None, min_length=3, max_length=20)
    avatar_type: str | None = Field(None, pattern="^(emoji|gravatar|url)$")
    avatar_url: str | None = Field(None, max_length=500)
    ...

When handling updates, the route enforces that avatar URLs must use https://:

    # Validate avatar_url if switching to url type
    if request.avatar_type == "url" and request.avatar_url:
        if not request.avatar_url.startswith("https://"):
            raise HTTPException(status_code=400, detail="Avatar URL must use HTTPS")

The Issue: This check only triggers if request.avatar_type is explicitly sent as "url" in the incoming request payload.

If a user already has an active profile where the avatar_type is saved as "url" in the database, they can send a subsequent update request containing only avatar_url (omitting avatar_type entirely):

  • The if request.avatar_type == "url" and request.avatar_url: statement evaluates to False (because request.avatar_type is None / omitted).
  • The https:// validation check is completely skipped.
  • The repository proceeds to save the insecure avatar_url (e.g., http://127.0.0.1:8500/status or http://169.254.169.254/latest/meta-data/) to the database.

2. The SSRF Attack Vector

When anyone requests the user's profile card at /share/profile/{username}/card.png, the backend attempts to fetch and base64-encode the avatar image:

# finbot/apps/ctf/routes/share.py
async def _fetch_avatar_b64(url: str) -> str:
    try:
        async with httpx.AsyncClient(timeout=5.0, follow_redirects=True) as client:
            resp = await client.get(url)
            ...

The server issues an out-of-band httpx.get request directly to the insecure URL, enabling SSRF.


Proof of Concept (PoC)

  1. Set up a user profile with the avatar type initially set to url:
    PUT /api/v1/profile HTTP/1.1
    Content-Type: application/json
    
    {
      "avatar_type": "url",
      "avatar_url": "https://secure-avatar.com/image.png"
    }
  2. Trigger the validation bypass by updating only the avatar_url field with an insecure HTTP loopback address (omitting avatar_type):
    PUT /api/v1/profile HTTP/1.1
    Content-Type: application/json
    
    {
      "avatar_url": "http://127.0.0.1:8500/internal-status"
    }
    The server responds with 200 OK, successfully writing the insecure URL to the database.
  3. Retrieve the profile share card to trigger the SSRF:
    GET /share/profile/{your_username}/card.png HTTP/1.1
    The server makes a backend HTTP request to the internal loopback address http://127.0.0.1:8500/internal-status.

Proposed Remediation

Enforce the https:// validation in the route logic based on the effective avatar type (the incoming value if provided, falling back to the database value if omitted). Furthermore, https:// alone is insufficient to prevent SSRF against loopback IPs or cloud metadata endpoints. A robust two-layer defense is required:

Layer 1: DNS/IP Blocklist in profile.py

Implement a blocklist using ipaddress and socket to resolve the hostname and ensure it does not map to private, link-local, or loopback IPs (e.g., 127.0.0.0/8, 169.254.0.0/16, 10.0.0.0/8).

def is_ssrf_safe(url: str) -> bool:
    parsed = urlparse(url)
    if parsed.scheme != "https":
        return False
    try:
        host = parsed.hostname
        if not host:
            return False
        ip = ipaddress.ip_address(socket.gethostbyname(host))
        return not any(ip in net for net in BLOCKED_NETWORKS)
    except Exception:
        return False

Validate the URL in the route logic using this function:

    effective_avatar_type = request.avatar_type or profile.avatar_type
    effective_avatar_url = request.avatar_url or profile.avatar_url
    if effective_avatar_type == "url" and effective_avatar_url:
        if not is_ssrf_safe(effective_avatar_url):
            raise HTTPException(status_code=400, detail="Invalid Avatar URL: Only public HTTPS URLs are allowed")

Layer 2: Disable Redirects in share.py

To prevent redirect-based SSRF (where an attacker provides a public URL that issues a 302 Found to 127.0.0.1), configure the httpx.AsyncClient to disable automatic redirects:

    try:
        async with httpx.AsyncClient(timeout=5.0, follow_redirects=False) as client:
            resp = await client.get(url)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions