Introduction
In a FastAPI application serving as a parser API for Doubao and Qianwen conversation links, we discovered a critical Server-Side Request Forgery (SSRF) vulnerability in app.py at line 70. The /parse and /parse-video endpoints accepted user-supplied URLs and made HTTP requests to fetch content, but the validation logic used a dangerously weak approach: checking if the string 'doubao.com' appeared anywhere in the URL. This seemingly simple oversight created a severe security risk that could allow attackers to access internal AWS metadata endpoints, scan the internal network, or interact with backend services never intended to be publicly accessible.
The vulnerable code in the parse_doubao() function looked innocuous at first glance, but the substring check if "doubao.com" in str(request.url) was fundamentally flawed and easily bypassed.
The Vulnerability Explained
Let's examine the vulnerable code from app.py at line 83:
@app.post("/parse", summary="解析豆包|千问对话图片")
async def parse_doubao(request: DouBaoRequest):
try:
if "doubao.com" in str(request.url):
result = await doubao_image_parse(str(request.url), return_raw=request.return_raw)
else:
result = await qianwen_image_parse(str(request.url), return_raw=request.return_raw)
The problem lies in line 83: if "doubao.com" in str(request.url). This validation only checks if the string "doubao.com" appears anywhere in the URL—not whether it's actually the hostname being accessed. This is a classic SSRF vulnerability pattern.
How Could This Be Exploited?
An attacker could craft malicious URLs that pass this validation check but actually target internal resources:
Attack Scenario 1: AWS Metadata Endpoint Access
# Attacker sends this URL:
"http://169.254.169.254/latest/meta-data/iam/security-credentials/doubao.com"
# The check passes because "doubao.com" is in the string
# But the actual HTTP request goes to AWS metadata endpoint
# Attacker retrieves IAM credentials, API keys, and cloud secrets
Attack Scenario 2: Internal Service Scanning
# Attacker sends:
"http://internal-database:5432/admin?ref=doubao.com"
# Again, the check passes
# The doubao_image_parse() function makes a request to the internal database
# Attacker can probe internal network topology and services
Attack Scenario 3: Localhost Exploitation
# Attacker sends:
"http://localhost:8080/admin/delete-all?doubao.com"
# The substring check passes
# Request hits local administrative endpoints
# Attacker can trigger dangerous operations on the server itself
Real-World Impact
In this specific FastAPI application, the SSRF vulnerability could have allowed attackers to:
- Extract cloud credentials: Access AWS EC2 metadata at
http://169.254.169.254/to retrieve IAM role credentials, SSH keys, and environment variables - Map internal infrastructure: Probe internal IP ranges to discover databases, cache servers, and microservices
- Bypass authentication: Access internal-only admin panels or APIs that don't require authentication when accessed from localhost
- Chain attacks: Use the SSRF as a pivot point to exploit other vulnerabilities in internal services
The doubao_image_parse() and qianwen_image_parse() functions would dutifully make HTTP requests to whatever URL was provided, effectively turning the server into an attack proxy.
The Fix
The security fix implemented proper hostname validation using Python's urlparse() module. Here's the before and after comparison:
Before (Vulnerable Code):
if "doubao.com" in str(request.url):
result = await doubao_image_parse(str(request.url), return_raw=request.return_raw)
else:
result = await qianwen_image_parse(str(request.url), return_raw=request.return_raw)
After (Secure Code):
from urllib.parse import urlparse
ALLOWED_DOUBAO_HOSTS = {"doubao.com", "www.doubao.com"}
ALLOWED_QIANWEN_HOSTS = {"qianwen.com", "www.qianwen.com"}
def _host_matches(url: str, allowed_hosts: set[str]) -> bool:
hostname = (urlparse(url).hostname or "").lower()
return hostname in allowed_hosts or any(hostname.endswith(f".{host}") for host in allowed_hosts)
# In the endpoint:
url_str = str(request.url)
if _host_matches(url_str, ALLOWED_DOUBAO_HOSTS):
result = await doubao_image_parse(url_str, return_raw=request.return_raw)
elif _host_matches(url_str, ALLOWED_QIANWEN_HOSTS):
result = await qianwen_image_parse(url_str, return_raw=request.return_raw)
else:
raise HTTPException(status_code=400, detail="不支持的链接域名")
How This Fix Solves the Problem
The new _host_matches() helper function implements several critical security improvements:
- Proper hostname extraction:
urlparse(url).hostnameextracts only the actual hostname from the URL, ignoring paths, query parameters, and fragments - Explicit allowlist: The
ALLOWED_DOUBAO_HOSTSandALLOWED_QIANWEN_HOSTSsets define exactly which domains are permitted - Subdomain support: The check
hostname.endswith(f".{host}")allows legitimate subdomains likeapi.doubao.comwhile rejectingmalicious-site.com/doubao.com - Case normalization:
.lower()prevents bypass attempts using mixed-case hostnames - Fail-secure default: If the hostname doesn't match either allowlist, the endpoint raises an HTTP 400 error with a clear message
Now, the malicious URLs from our attack scenarios are properly rejected:
# These URLs are now blocked:
_host_matches("http://169.254.169.254/doubao.com", ALLOWED_DOUBAO_HOSTS) # False
_host_matches("http://internal-db:5432?ref=doubao.com", ALLOWED_DOUBAO_HOSTS) # False
_host_matches("http://localhost/admin?doubao.com", ALLOWED_DOUBAO_HOSTS) # False
# Only legitimate URLs pass:
_host_matches("https://doubao.com/chat/123", ALLOWED_DOUBAO_HOSTS) # True
_host_matches("https://api.doubao.com/v1/parse", ALLOWED_DOUBAO_HOSTS) # True
The same validation pattern was applied to the GET endpoint at line 104, ensuring consistent security across all entry points.
Prevention & Best Practices
To prevent SSRF vulnerabilities in your FastAPI (and other Python web) applications:
1. Always Use Proper URL Parsing
Never validate URLs with string operations like in, .contains(), or regex patterns. Use urllib.parse.urlparse():
from urllib.parse import urlparse
def validate_url(url: str, allowed_hosts: set[str]) -> bool:
parsed = urlparse(url)
hostname = (parsed.hostname or "").lower()
return hostname in allowed_hosts
2. Implement Explicit Allowlists
Define exactly which hosts your application should connect to:
# Good: Explicit allowlist
ALLOWED_HOSTS = {"api.example.com", "cdn.example.com"}
# Bad: Blocklist approach (easy to bypass)
BLOCKED_HOSTS = {"localhost", "127.0.0.1"} # Incomplete!
3. Validate Protocol and Port
Restrict which protocols and ports are allowed:
def validate_url_strict(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in {"https", "http"}:
return False
if parsed.port and parsed.port not in {80, 443}:
return False
return True
4. Block Private IP Ranges
Even with hostname validation, resolve the hostname and check if it points to private IP space:
import ipaddress
import socket
def is_private_ip(hostname: str) -> bool:
try:
ip = ipaddress.ip_address(socket.gethostbyname(hostname))
return ip.is_private or ip.is_loopback or ip.is_link_local
except (socket.gaierror, ValueError):
return True # Fail secure if resolution fails
5. Use HTTP Client Libraries with SSRF Protection
Consider using libraries that provide built-in SSRF protection:
import httpx
# Configure client with timeout and redirect limits
client = httpx.AsyncClient(
timeout=10.0,
max_redirects=3,
follow_redirects=True
)
6. Implement Defense in Depth
Layer multiple validation checks:
async def safe_fetch(url: str, allowed_hosts: set[str]) -> str:
# 1. Validate hostname against allowlist
if not _host_matches(url, allowed_hosts):
raise ValueError("Host not allowed")
# 2. Check protocol
parsed = urlparse(url)
if parsed.scheme not in {"https", "http"}:
raise ValueError("Invalid protocol")
# 3. Resolve and check IP
if is_private_ip(parsed.hostname):
raise ValueError("Private IP not allowed")
# 4. Make request with timeout
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
return response.text
Security Standards References
This vulnerability maps to:
- CWE-918: Server-Side Request Forgery (SSRF)
- OWASP Top 10 2021: A10:2021 – Server-Side Request Forgery
- OWASP ASVS 4.0: V12.6 SSRF Protection Requirements
Key Takeaways
- Never use substring matching for URL validation: The pattern
if "doubao.com" in urlis fundamentally insecure and can be bypassed by placing the trusted string in paths, query parameters, or fragments - The
_host_matches()function is the critical fix: It usesurlparse(url).hostnameto extract only the actual hostname, then validates it against an explicit allowlist of permitted domains - Fail-secure with explicit rejection: The new code raises
HTTPException(status_code=400, detail="不支持的链接域名")when URLs don't match either allowlist, making the security boundary clear - Apply validation consistently: Both the POST endpoint at line 80 and GET endpoint at line 104 now use the same
_host_matches()validation, preventing inconsistencies - Subdomain handling matters: The fix correctly allows subdomains like
api.doubao.comthroughhostname.endswith(f".{host}")while still blocking malicious domains
How Orbis AppSec Detected This
- Source: User-supplied URL parameter in the
DouBaoRequestmodel accepted by the/parsePOST endpoint - Sink: HTTP request made by
doubao_image_parse()andqianwen_image_parse()functions inapp.py:85andapp.py:87 - Missing control: No proper hostname validation—only substring matching that could be bypassed
- CWE: CWE-918 (Server-Side Request Forgery)
- Fix: Implemented
_host_matches()helper usingurlparse()to extract hostname and validate against explicit allowlistsALLOWED_DOUBAO_HOSTSandALLOWED_QIANWEN_HOSTS
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
The SSRF vulnerability in app.py demonstrates how seemingly simple validation logic can create critical security holes. The substring check if "doubao.com" in str(request.url) looked reasonable at first glance but failed to account for how URLs actually work. By implementing proper hostname parsing with urlparse() and validating against explicit allowlists, the fix ensures that only legitimate Doubao and Qianwen domains can be accessed through the /parse and /parse-video endpoints.
This vulnerability serves as a reminder that security validation must be precise and comprehensive. When your application makes HTTP requests based on user input, always extract and validate the actual hostname, use explicit allowlists, and consider blocking private IP ranges. The defense-in-depth approach—combining multiple layers of validation—provides the most robust protection against SSRF attacks.