-
Notifications
You must be signed in to change notification settings - Fork 8.1k
Expand file tree
/
Copy pathsafe_requests.py
More file actions
173 lines (143 loc) · 5.65 KB
/
Copy pathsafe_requests.py
File metadata and controls
173 lines (143 loc) · 5.65 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
"""HTTP helpers that preserve crewai-tools URL safety checks."""
from __future__ import annotations
from typing import Any
from urllib.parse import urljoin, urlparse
import requests
from crewai_tools.security.safe_path import validate_url
_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
_STREAM_CHUNK_SIZE = 65536
_SENSITIVE_HEADER_NAMES = {
"authorization",
"cookie",
"proxy-authorization",
"x-api-key",
}
_SENSITIVE_HEADER_FRAGMENTS = ("api-key", "apikey", "secret", "token")
def _same_origin(previous_url: str, next_url: str) -> bool:
previous = urlparse(previous_url)
next_ = urlparse(next_url)
return (previous.scheme, previous.netloc) == (next_.scheme, next_.netloc)
def _is_sensitive_header(header: str) -> bool:
normalized = header.lower()
return (
normalized in _SENSITIVE_HEADER_NAMES
or normalized.startswith("authorization-")
or any(fragment in normalized for fragment in _SENSITIVE_HEADER_FRAGMENTS)
)
def _strip_cross_origin_credentials(request_kwargs: dict[str, Any]) -> dict[str, Any]:
sanitized = {**request_kwargs}
headers = sanitized.get("headers")
if headers:
sanitized["headers"] = {
key: value
for key, value in headers.items()
if not _is_sensitive_header(str(key))
}
sanitized.pop("cookies", None)
return sanitized
def safe_get(url: str, *, max_redirects: int = 10, **kwargs: Any) -> requests.Response:
"""GET a URL while validating each redirect target before following it.
On success the hops are attached to the returned response's ``history`` and
are the caller's to close. On failure they are closed here: a caller given
an exception has no handle on them, and a streamed hop holds its connection
until its body is read or closed.
"""
current_url = validate_url(url)
request_kwargs = {**kwargs, "allow_redirects": False}
timeout = request_kwargs.pop("timeout", 30)
history: list[requests.Response] = []
redirects_followed = 0
try:
while True:
response = requests.get(current_url, timeout=timeout, **request_kwargs)
if (
response.status_code not in _REDIRECT_STATUS_CODES
or "Location" not in response.headers
):
response.history = history
return response
if redirects_followed >= max_redirects:
response.close()
raise ValueError(f"Too many redirects while fetching URL: {url}")
location = response.headers.get("Location")
if not location:
response.history = history
return response
try:
redirect_url = validate_url(urljoin(response.url, location))
except ValueError:
response.close()
raise
if not _same_origin(current_url, redirect_url):
request_kwargs = _strip_cross_origin_credentials(request_kwargs)
history.append(response)
current_url = redirect_url
redirects_followed += 1
except BaseException:
for hop in history:
hop.close()
raise
def safe_get_bounded(
url: str,
*,
max_bytes: int,
timeout: float | tuple[float, float] = 30,
headers: dict[str, str] | None = None,
max_redirects: int = 10,
) -> tuple[bytes, str, str]:
"""GET a URL through :func:`safe_get`, refusing bodies over *max_bytes*.
The body is streamed and abandoned as soon as it crosses the limit, so an
oversized response costs one chunk of memory instead of all of it. The cap
counts decoded bytes, which is what a compressed response expands into --
``Content-Length`` describes the wire size and cannot bound that.
Args:
url: The URL to fetch.
max_bytes: Largest body to accept, in decoded bytes.
timeout: Request timeout, passed through to requests.
headers: Request headers.
max_redirects: Hops to follow before giving up.
Returns:
A ``(body, content_type, final_url)`` tuple, where *final_url* is the
last validated URL in the redirect chain.
Raises:
ValueError: If *max_bytes* is not positive, URL validation fails, the
redirect chain is too long, or the body exceeds *max_bytes*.
requests.RequestException: If the request fails or returns an error
status.
"""
if max_bytes <= 0:
raise ValueError(f"max_bytes must be positive, got {max_bytes}.")
response = safe_get(
url,
max_redirects=max_redirects,
headers=headers,
timeout=timeout,
stream=True,
)
try:
response.raise_for_status()
chunks: list[bytes] = []
total = 0
for chunk in response.iter_content(chunk_size=_STREAM_CHUNK_SIZE):
if not chunk:
continue
total += len(chunk)
if total > max_bytes:
# Names the URL that served the body, which after a redirect is
# not the one that was requested.
raise ValueError(
f"Response body from '{response.url}' exceeds the "
f"{max_bytes} byte limit."
)
chunks.append(chunk)
return (
b"".join(chunks),
response.headers.get("Content-Type", ""),
response.url,
)
finally:
# Under stream=True each hop holds its connection until the body is read,
# so the redirects need closing too, not just the response we return.
for hop in response.history:
hop.close()
response.close()