Skip to content

Commit cb628ba

Browse files
committed
cleanup http if error not httpx
1 parent a756b18 commit cb628ba

3 files changed

Lines changed: 170 additions & 1 deletion

File tree

gdrive/drive_tools.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -936,8 +936,8 @@ async def _ssrf_safe_stream(url: str) -> AsyncIterator[httpx.Response]:
936936
resp: Optional[httpx.Response] = None
937937
for resolved_ip in resolved_ips:
938938
pinned_url = _build_pinned_url(parsed, resolved_ip)
939+
client = httpx.AsyncClient(follow_redirects=False, trust_env=False)
939940
try:
940-
client = httpx.AsyncClient(follow_redirects=False, trust_env=False)
941941
request = client.build_request(
942942
"GET",
943943
pinned_url,
@@ -953,6 +953,9 @@ async def _ssrf_safe_stream(url: str) -> AsyncIterator[httpx.Response]:
953953
f"[ssrf_safe_stream] Failed via IP {resolved_ip} for "
954954
f"{parsed.hostname}: {exc}"
955955
)
956+
except Exception:
957+
await client.aclose()
958+
raise
956959

957960
if resp is None:
958961
raise Exception(

tests/gdrive/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""
2+
Unit tests for Drive SSRF protections and DNS pinning helpers.
3+
"""
4+
5+
import os
6+
import socket
7+
import sys
8+
9+
import httpx
10+
import pytest
11+
12+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
13+
14+
from gdrive import drive_tools
15+
16+
17+
def test_resolve_and_validate_host_fails_closed_on_dns_error(monkeypatch):
18+
"""DNS resolution failures must fail closed."""
19+
20+
def fake_getaddrinfo(hostname, port):
21+
raise socket.gaierror("mocked resolution failure")
22+
23+
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
24+
25+
with pytest.raises(ValueError, match="Refusing request \\(fail-closed\\)"):
26+
drive_tools._resolve_and_validate_host("example.com")
27+
28+
29+
def test_resolve_and_validate_host_rejects_ipv6_private(monkeypatch):
30+
"""IPv6 internal addresses must be rejected."""
31+
32+
def fake_getaddrinfo(hostname, port):
33+
return [
34+
(
35+
socket.AF_INET6,
36+
socket.SOCK_STREAM,
37+
6,
38+
"",
39+
("fd00::1", 0, 0, 0),
40+
)
41+
]
42+
43+
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
44+
45+
with pytest.raises(ValueError, match="private/internal networks"):
46+
drive_tools._resolve_and_validate_host("ipv6-internal.example")
47+
48+
49+
def test_resolve_and_validate_host_deduplicates_addresses(monkeypatch):
50+
"""Duplicate DNS answers should be de-duplicated while preserving order."""
51+
52+
def fake_getaddrinfo(hostname, port):
53+
return [
54+
(
55+
socket.AF_INET,
56+
socket.SOCK_STREAM,
57+
6,
58+
"",
59+
("93.184.216.34", 0),
60+
),
61+
(
62+
socket.AF_INET,
63+
socket.SOCK_STREAM,
64+
6,
65+
"",
66+
("93.184.216.34", 0),
67+
),
68+
(
69+
socket.AF_INET6,
70+
socket.SOCK_STREAM,
71+
6,
72+
"",
73+
("2606:2800:220:1:248:1893:25c8:1946", 0, 0, 0),
74+
),
75+
]
76+
77+
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
78+
79+
assert drive_tools._resolve_and_validate_host("example.com") == [
80+
"93.184.216.34",
81+
"2606:2800:220:1:248:1893:25c8:1946",
82+
]
83+
84+
85+
@pytest.mark.asyncio
86+
async def test_fetch_url_with_pinned_ip_uses_pinned_target_and_host_header(monkeypatch):
87+
"""Requests should target a validated IP while preserving Host + SNI hostname."""
88+
captured = {}
89+
90+
class FakeAsyncClient:
91+
def __init__(self, *args, **kwargs):
92+
captured["client_kwargs"] = kwargs
93+
94+
async def __aenter__(self):
95+
return self
96+
97+
async def __aexit__(self, exc_type, exc, tb):
98+
return False
99+
100+
def build_request(self, method, url, headers=None, extensions=None):
101+
captured["method"] = method
102+
captured["url"] = url
103+
captured["headers"] = headers or {}
104+
captured["extensions"] = extensions or {}
105+
return {"url": url}
106+
107+
async def send(self, request):
108+
return httpx.Response(200, request=httpx.Request("GET", request["url"]))
109+
110+
monkeypatch.setattr(
111+
drive_tools, "_validate_url_not_internal", lambda url: ["93.184.216.34"]
112+
)
113+
monkeypatch.setattr(drive_tools.httpx, "AsyncClient", FakeAsyncClient)
114+
115+
response = await drive_tools._fetch_url_with_pinned_ip(
116+
"https://example.com/path/to/file.txt?x=1"
117+
)
118+
119+
assert response.status_code == 200
120+
assert captured["method"] == "GET"
121+
assert captured["url"] == "https://93.184.216.34/path/to/file.txt?x=1"
122+
assert captured["headers"]["Host"] == "example.com"
123+
assert captured["extensions"]["sni_hostname"] == "example.com"
124+
assert captured["client_kwargs"]["trust_env"] is False
125+
assert captured["client_kwargs"]["follow_redirects"] is False
126+
127+
128+
@pytest.mark.asyncio
129+
async def test_ssrf_safe_fetch_follows_relative_redirects(monkeypatch):
130+
"""Relative redirects should be resolved and re-checked."""
131+
calls = []
132+
133+
async def fake_fetch(url):
134+
calls.append(url)
135+
if len(calls) == 1:
136+
return httpx.Response(
137+
302,
138+
headers={"location": "/next"},
139+
request=httpx.Request("GET", url),
140+
)
141+
return httpx.Response(200, request=httpx.Request("GET", url), content=b"ok")
142+
143+
monkeypatch.setattr(drive_tools, "_fetch_url_with_pinned_ip", fake_fetch)
144+
145+
response = await drive_tools._ssrf_safe_fetch("https://example.com/start")
146+
147+
assert response.status_code == 200
148+
assert calls == ["https://example.com/start", "https://example.com/next"]
149+
150+
151+
@pytest.mark.asyncio
152+
async def test_ssrf_safe_fetch_rejects_disallowed_redirect_scheme(monkeypatch):
153+
"""Redirects to non-http(s) schemes should be blocked."""
154+
155+
async def fake_fetch(url):
156+
return httpx.Response(
157+
302,
158+
headers={"location": "file:///etc/passwd"},
159+
request=httpx.Request("GET", url),
160+
)
161+
162+
monkeypatch.setattr(drive_tools, "_fetch_url_with_pinned_ip", fake_fetch)
163+
164+
with pytest.raises(ValueError, match="Redirect to disallowed scheme"):
165+
await drive_tools._ssrf_safe_fetch("https://example.com/start")

0 commit comments

Comments
 (0)