Merge pull request 'fix: disable auto-redirects to prevent SSRF (#3)' (#15) from fix/issue-3 into main
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / docker-build (push) Waiting to run
CI / security (push) Waiting to run
CI / build-result (push) Blocked by required conditions

Reviewed-on: https://git.example.com/jarianc/NASAImageDownloader/pulls/15
This commit is contained in:
Jarian Cottingham 2026-07-05 02:50:51 -05:00
commit f843270aab

View File

@ -42,6 +42,7 @@ Dependencies
import argparse import argparse
import datetime import datetime
import ipaddress
import json import json
import os import os
import re import re
@ -49,6 +50,7 @@ import sys
import time import time
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Dict, List
from urllib.parse import urlparse
import requests import requests
@ -166,12 +168,42 @@ def fetch_items(start: datetime.datetime, end: datetime.datetime) -> List[Dict]:
return filtered return filtered
def _is_safe_url(url: str) -> bool:
"""Check that a URL points to a safe (non-internal) destination."""
parsed = urlparse(url)
host = parsed.hostname or ""
# Block private and reserved IP ranges
try:
addr = ipaddress.ip_address(host)
if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved:
return False
except ValueError:
pass
# Block internal hostnames
if host.endswith((".local", ".internal", ".home.arpa")) or host in ("localhost", "metadata.google.internal"):
return False
return True
def download_file(url: str, dest: Path) -> bool: def download_file(url: str, dest: Path) -> bool:
"""Streamdownload the file at *url* to *dest* if it does not already exist.""" """Streamdownload the file at *url* to *dest* if it does not already exist."""
if dest.exists(): if dest.exists():
return False return False
if not _is_safe_url(url):
raise RuntimeError(f"Unsafe download URL blocked (SSRF protection): {url}")
try: try:
with requests.get(url, stream=True, timeout=30) as r: with requests.get(url, stream=True, timeout=30, allow_redirects=False) as r:
if r.status_code in (301, 302, 303, 307, 308):
location = r.headers.get("Location", "")
if not _is_safe_url(location):
raise RuntimeError(f"Redirect to unsafe URL blocked (SSRF protection): {location}")
# Follow single validated redirect
with requests.get(location, stream=True, timeout=30, allow_redirects=False) as r2:
r2.raise_for_status()
with dest.open("wb") as f:
for chunk in r2.iter_content(chunk_size=8192):
f.write(chunk)
else:
r.raise_for_status() r.raise_for_status()
with dest.open("wb") as f: with dest.open("wb") as f:
for chunk in r.iter_content(chunk_size=8192): for chunk in r.iter_content(chunk_size=8192):