- Location
- scripts/check_link_targets.py:134
- Finding
- Server-Side Request Forgery in Outbound Link Validation## Vulnerability Details
**File Location**: `scripts/check_link_targets.py:134-157`
**Vulnerability Type**: Server-Side Request Forgery through unrestricted URL validation
**Risk Level**: Medium
### Vulnerable Code
```python
def extract_links(html: str) -> list[tuple[str, str]]:
"""Return (kind, url) for every outbound button, de-duplicated, in document order."""
found: dict[str, str] = {}
for match in re.finditer(r'<a\s+class="([^"]*)"([^>]*?)href="([^"]+)"', html):
class_attr, attrs, href = match.group(1), match.group(2), unescape(match.group(3))
if not any(name in class_attr for name in LINK_CLASSES):
continue
if not href.lower().startswith("https://"):
continue
booking_type = re.search(r'data-booking-type="([^"]*)"', attrs)
if booking_type:
kind = booking_type.group(1)
elif "dining-link" in class_attr:
kind = "dining"
else:
kind = "map"
found.setdefault(href, kind)
return [(kind, url) for url, kind in found.items()]
def probe(url: str, timeout: float) -> dict:
"""Ask once, then up to twice more with backoff if the answer says 'too fast'."""
host = urlparse(url).hostname or ""
request = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "text/html,*/*"})
result: dict = {}
for attempt in range(len(RETRY_BACKOFF) + 1):
if attempt:
time.sleep(RETRY_BACKOFF[attempt - 1])
_host_gate(host)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return {"status": response.status, "final_url": response.geturl(),
"attempts": attempt + 1}
```
### Technical Analysis
The link checker extracts any URL beginning with `https://` from qualifying HTML anchors and passes it directly to `urllib.request.urlopen`. It d
...[truncated 2495 chars]
- Remediation
- ## Remediation Suggestions
1. Parse URLs with `urllib.parse.urlsplit` and permit only the `https` scheme.
2. Reject embedded credentials, malformed hostnames, fragments where unnecessary, and ports other than an explicitly approved set.
3. Resolve every hostname with `socket.getaddrinfo`.
4. Use `ipaddress.ip_address` to reject every resolved address for which any of the following applies:
- `is_private`
- `is_loopback`
- `is_link_local`
- `is_multicast`
- `is_reserved`
- `is_unspecified`
5. Reject a hostname if any returned address is unsafe, rather than selecting one apparently safe result.
6. Disable automatic redirects and process them manually. Apply the same scheme, hostname, port, and resolved-address validation before every redirect hop.
7. Set a low redirect limit and reject HTTPS-to-HTTP downgrades.
8. Where practical, allowlist verified travel-provider domains instead of accepting arbitrary Internet hosts.
9. Consider executing network validation in a sandbox with no access to loopback, private networks, cloud metadata endpoints, or corporate intranet ranges.
10. Add tests covering IPv4, IPv6, integer/encoded IP forms, private DNS resolution, redirects to private hosts, mixed DNS answers, and DNS rebinding scenarios.