fluidattacks_core_http 12.0.0__tar.gz → 12.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/.gitignore +4 -0
  2. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/PKG-INFO +1 -1
  3. fluidattacks_core_http-12.2.0/fluidattacks_core/http/__init__.py +7 -0
  4. fluidattacks_core_http-12.2.0/fluidattacks_core/http/client.py +211 -0
  5. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/fluidattacks_core/http/validations.py +1 -1
  6. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/import-linter.cfg +1 -10
  7. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/pyproject.toml +3 -1
  8. fluidattacks_core_http-12.2.0/tests/__init__.py +1 -0
  9. fluidattacks_core_http-12.2.0/tests/test_client.py +459 -0
  10. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/uv.lock +52 -1
  11. fluidattacks_core_http-12.0.0/build/default.nix +0 -20
  12. fluidattacks_core_http-12.0.0/build/filter.nix +0 -12
  13. fluidattacks_core_http-12.0.0/flake.lock +0 -303
  14. fluidattacks_core_http-12.0.0/flake.nix +0 -29
  15. fluidattacks_core_http-12.0.0/fluidattacks_core/http/__init__.py +0 -5
  16. fluidattacks_core_http-12.0.0/fluidattacks_core/http/client.py +0 -90
  17. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/.envrc +0 -0
  18. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/fluidattacks_core/http/py.typed +0 -0
  19. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/mypy.ini +0 -0
  20. {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/ruff.toml +0 -0
@@ -1,5 +1,9 @@
1
+ dist/
2
+ dist-*/
3
+ /.cargo-cache
1
4
  target/
2
5
  .direnv/
3
6
  .venv/
4
7
  .vscode/settings.json
5
8
  .zed/settings.json
9
+ *.so
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fluidattacks_core_http
3
- Version: 12.0.0
3
+ Version: 12.2.0
4
4
  Summary: Fluid Attacks Core HTTP Library
5
5
  Author-email: Development <development@fluidattacks.com>
6
6
  License: MPL-2.0
@@ -0,0 +1,7 @@
1
+ from .client import (
2
+ MAX_RESPONSE_BYTES,
3
+ ResponseTooLargeError,
4
+ request,
5
+ )
6
+
7
+ __all__ = ["MAX_RESPONSE_BYTES", "ResponseTooLargeError", "request"]
@@ -0,0 +1,211 @@
1
+ import ipaddress
2
+ import ssl
3
+ from collections.abc import Sequence
4
+ from contextvars import ContextVar
5
+ from typing import (
6
+ Any,
7
+ Literal,
8
+ )
9
+ from urllib.parse import urljoin
10
+
11
+ import aiohttp
12
+ import certifi
13
+
14
+ from .validations import (
15
+ validate_local_request,
16
+ validate_url,
17
+ )
18
+
19
+ _MAX_REDIRECTS = 10
20
+ _METHODS_SWITCH_TO_GET = frozenset({301, 302, 303})
21
+ _READ_CHUNK_BYTES = 64 * 1024
22
+
23
+ # Bounds each response so that no single request can exhaust the pod.
24
+ MAX_RESPONSE_BYTES = 64 * 1024 * 1024
25
+
26
+ # aiohttp builds the response object, so the caller's cap cannot be passed to
27
+ # it directly.
28
+ _CAP: ContextVar[int] = ContextVar("max_response_bytes", default=MAX_RESPONSE_BYTES)
29
+
30
+
31
+ class ResponseTooLargeError(aiohttp.ClientPayloadError):
32
+ pass
33
+
34
+
35
+ class BoundedResponse(aiohttp.ClientResponse):
36
+ async def read(self) -> bytes:
37
+ if self._body is None:
38
+ max_response_bytes = _CAP.get()
39
+ try:
40
+ self._reject_oversized_content_length(max_response_bytes)
41
+ self._body = await self._accumulate_capped(max_response_bytes)
42
+ except BaseException:
43
+ self.close()
44
+ raise
45
+ # Deferring to the parent awaits the request writer through internals
46
+ # a subclass cannot name.
47
+ return await super().read()
48
+
49
+ def _reject_oversized_content_length(self, max_response_bytes: int) -> None:
50
+ declared = self.content_length
51
+ if declared is not None and declared > max_response_bytes:
52
+ msg = f"Response declares {declared} bytes, over the {max_response_bytes} limit"
53
+ raise ResponseTooLargeError(msg)
54
+
55
+ async def _accumulate_capped(self, max_response_bytes: int) -> bytes:
56
+ # Counting decoded bytes as they arrive is the only bound that
57
+ # survives a compressed or chunked body, where the declared length
58
+ # says nothing about what decoding produces.
59
+ body = bytearray()
60
+ async for chunk in self.content.iter_chunked(_READ_CHUNK_BYTES):
61
+ body.extend(chunk)
62
+ if len(body) > max_response_bytes:
63
+ msg = f"Response exceeds the {max_response_bytes} byte limit"
64
+ raise ResponseTooLargeError(msg)
65
+ return bytes(body)
66
+
67
+
68
+ def get_secure_connector(
69
+ *,
70
+ allow_local_network: bool,
71
+ allow_localhost: bool,
72
+ ) -> type[aiohttp.TCPConnector]:
73
+ class SecureTCPConnector(aiohttp.TCPConnector):
74
+ async def _resolve_host(
75
+ self,
76
+ host: str,
77
+ port: int,
78
+ traces: Sequence[aiohttp.tracing.Trace] | None = None,
79
+ ) -> list[aiohttp.abc.ResolveResult]:
80
+ hosts = await super()._resolve_host(host, port, traces)
81
+ resolved_ips = [ipaddress.ip_address(host["host"]) for host in hosts]
82
+ validate_local_request(
83
+ resolved_ips,
84
+ allow_local_network=allow_local_network,
85
+ allow_localhost=allow_localhost,
86
+ )
87
+ return hosts
88
+
89
+ return SecureTCPConnector
90
+
91
+
92
+ async def request( # noqa: PLR0913
93
+ url: str,
94
+ *,
95
+ method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"],
96
+ allow_local_network: bool = False,
97
+ allow_localhost: bool = False,
98
+ allow_redirects: bool | None = None,
99
+ ascii_only: bool = False,
100
+ dns_rebind_protection: bool = True,
101
+ enforce_sanitization: bool = False,
102
+ headers: dict[str, str] | None = None,
103
+ json: Any | None = None, # noqa: ANN401
104
+ max_response_bytes: int = MAX_RESPONSE_BYTES,
105
+ ports: list[int] | None = None,
106
+ schemes: list[str] | None = None,
107
+ timeout: int = 10, # noqa: ASYNC109
108
+ ) -> aiohttp.ClientResponse:
109
+ validate_url(
110
+ url,
111
+ ascii_only=ascii_only,
112
+ enforce_sanitization=enforce_sanitization,
113
+ ports=ports or [],
114
+ schemes=schemes or [],
115
+ )
116
+ connector = (
117
+ get_secure_connector(
118
+ allow_local_network=allow_local_network,
119
+ allow_localhost=allow_localhost,
120
+ )
121
+ if dns_rebind_protection
122
+ else aiohttp.TCPConnector
123
+ )
124
+ connection = connector(
125
+ ssl=ssl.create_default_context(cafile=certifi.where()),
126
+ )
127
+ _CAP.set(max_response_bytes)
128
+ if allow_redirects is True:
129
+ return await _request_with_validated_redirects(
130
+ connection=connection,
131
+ headers=headers,
132
+ initial_url=url,
133
+ initial_method=method,
134
+ json=json,
135
+ timeout=timeout,
136
+ validation_options={
137
+ "ascii_only": ascii_only,
138
+ "enforce_sanitization": enforce_sanitization,
139
+ "ports": ports or [],
140
+ "schemes": schemes or [],
141
+ },
142
+ )
143
+ aiohttp_follow = allow_redirects if allow_redirects is not None else not dns_rebind_protection
144
+
145
+ async with (
146
+ aiohttp.ClientSession(
147
+ connector=connection,
148
+ headers=headers,
149
+ response_class=BoundedResponse,
150
+ ) as session,
151
+ session.request(
152
+ method,
153
+ url,
154
+ allow_redirects=aiohttp_follow,
155
+ json=json,
156
+ timeout=aiohttp.ClientTimeout(total=timeout),
157
+ ) as response,
158
+ ):
159
+ await response.read()
160
+ return response
161
+
162
+
163
+ async def _request_with_validated_redirects( # noqa: PLR0913
164
+ *,
165
+ connection: aiohttp.BaseConnector,
166
+ headers: dict[str, str] | None,
167
+ initial_url: str,
168
+ initial_method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"],
169
+ json: Any, # noqa: ANN401
170
+ timeout: int, # noqa: ASYNC109
171
+ validation_options: dict[str, Any],
172
+ ) -> aiohttp.ClientResponse:
173
+ """Follow redirects manually so validate_url runs on every hop.
174
+
175
+ aiohttp's ``allow_redirects=True`` short-circuits our
176
+ scheme/port/ascii/sanitization checks after the first hop, letting a
177
+ compromised server redirect to a target the caller had restricted.
178
+ """
179
+ current_url = initial_url
180
+ current_method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] = initial_method
181
+ current_json: Any = json
182
+ async with aiohttp.ClientSession(
183
+ connector=connection,
184
+ headers=headers,
185
+ response_class=BoundedResponse,
186
+ ) as session:
187
+ for _ in range(_MAX_REDIRECTS + 1):
188
+ async with session.request(
189
+ current_method,
190
+ current_url,
191
+ allow_redirects=False,
192
+ json=current_json,
193
+ timeout=aiohttp.ClientTimeout(total=timeout),
194
+ ) as response:
195
+ await response.read()
196
+ status = response.status
197
+ location = response.headers.get("Location")
198
+ if not (300 <= status < 400) or location is None:
199
+ return response
200
+ current_url = urljoin(current_url, location)
201
+ validate_url(current_url, **validation_options)
202
+ if status in _METHODS_SWITCH_TO_GET:
203
+ current_method = "GET"
204
+ current_json = None
205
+ raise aiohttp.TooManyRedirects(
206
+ response.request_info,
207
+ (),
208
+ message=(
209
+ f"exceeded {_MAX_REDIRECTS} redirects, aborted before requesting {current_url}"
210
+ ),
211
+ )
@@ -10,7 +10,7 @@ class HTTPValidationError(Exception):
10
10
 
11
11
 
12
12
  def validate_scheme(scheme: str | None, schemes: list[str]) -> None:
13
- if scheme and scheme not in schemes:
13
+ if scheme not in schemes:
14
14
  msg = f"Only allowed schemes are {', '.join(schemes)}"
15
15
  raise HTTPValidationError(msg)
16
16
 
@@ -1,16 +1,7 @@
1
1
  [importlinter]
2
- root_package = fluidattacks_core
2
+ root_package = fluidattacks_core.http
3
3
  include_external_packages = True
4
4
 
5
- [importlinter:contract:portion]
6
- name = Single Portion
7
- type = layers
8
- containers =
9
- fluidattacks_core
10
- exhaustive = true
11
- layers =
12
- http
13
-
14
5
  [importlinter:contract:dag]
15
6
  name = Direct Acyclic Graph
16
7
  type = layers
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fluidattacks_core_http"
3
- version = "12.0.0"
3
+ version = "12.2.0"
4
4
  description = "Fluid Attacks Core HTTP Library"
5
5
  authors = [{ name = "Development", email = "development@fluidattacks.com" }]
6
6
  license = { text = "MPL-2.0" }
@@ -24,6 +24,8 @@ dev = [
24
24
  "deptry>=0.23.1",
25
25
  "import-linter==2.11",
26
26
  "mypy==1.19.1",
27
+ "pytest==9.0.3",
28
+ "pytest-asyncio>=1.1.0",
27
29
  "ruff==0.15.0",
28
30
  ]
29
31
 
@@ -0,0 +1 @@
1
+ """Tests for fluidattacks_core.http."""
@@ -0,0 +1,459 @@
1
+ """Tests for fluidattacks_core.http.client.request."""
2
+
3
+ import asyncio
4
+ import gzip
5
+ from collections.abc import Awaitable, Callable
6
+
7
+ import aiohttp
8
+ import pytest
9
+ from aiohttp import web
10
+ from aiohttp.test_utils import TestServer
11
+
12
+ from fluidattacks_core.http import ResponseTooLargeError, request
13
+ from fluidattacks_core.http.validations import HTTPValidationError, validate_url
14
+
15
+ RouteHandler = Callable[[web.Request], Awaitable[web.StreamResponse]]
16
+ _CAP = 64 * 1024
17
+ _MAX_HOPS = 11
18
+
19
+
20
+ async def _build_server(routes: dict[str, RouteHandler]) -> TestServer:
21
+ app = web.Application()
22
+ for path, handler in routes.items():
23
+ app.router.add_get(path, handler)
24
+ app.router.add_post(path, handler)
25
+ server = TestServer(app)
26
+ await server.start_server()
27
+ return server
28
+
29
+
30
+ async def _permissive_request(
31
+ url: str,
32
+ *,
33
+ method: str = "GET",
34
+ allow_redirects: bool | None = None,
35
+ schemes: list[str] | None = None,
36
+ ports: list[int] | None = None,
37
+ json: object | None = None,
38
+ ) -> aiohttp.ClientResponse:
39
+ """Wrapper that turns off protections irrelevant to redirect-logic tests."""
40
+ return await request(
41
+ url,
42
+ method=method, # type: ignore[arg-type]
43
+ allow_redirects=allow_redirects,
44
+ allow_local_network=True,
45
+ allow_localhost=True,
46
+ dns_rebind_protection=False,
47
+ schemes=schemes,
48
+ ports=ports,
49
+ json=json,
50
+ )
51
+
52
+
53
+ def _make_server_url(server: TestServer, path: str) -> str:
54
+ return f"http://{server.host}:{server.port}{path}"
55
+
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_allow_redirects_default_does_not_follow_when_rebind_protected() -> None:
59
+ hits: list[str] = []
60
+
61
+ async def handle_start(_req: web.Request) -> web.Response:
62
+ hits.append("start")
63
+ return web.Response(status=301, headers={"Location": "/final"})
64
+
65
+ async def handle_final(_req: web.Request) -> web.Response:
66
+ hits.append("final")
67
+ return web.Response(status=200)
68
+
69
+ server = await _build_server({"/start": handle_start, "/final": handle_final})
70
+ try:
71
+ response = await request(
72
+ _make_server_url(server, "/start"),
73
+ method="GET",
74
+ allow_local_network=True,
75
+ allow_localhost=True,
76
+ )
77
+ assert response.status == 301
78
+ assert hits == ["start"]
79
+ finally:
80
+ await server.close()
81
+
82
+
83
+ @pytest.mark.asyncio
84
+ async def test_allow_redirects_default_follows_when_rebind_disabled() -> None:
85
+ async def handle_start(_req: web.Request) -> web.Response:
86
+ return web.Response(status=301, headers={"Location": "/final"})
87
+
88
+ async def handle_final(_req: web.Request) -> web.Response:
89
+ return web.Response(status=200)
90
+
91
+ server = await _build_server({"/start": handle_start, "/final": handle_final})
92
+ try:
93
+ response = await _permissive_request(_make_server_url(server, "/start"))
94
+ assert response.status == 200
95
+ finally:
96
+ await server.close()
97
+
98
+
99
+ @pytest.mark.asyncio
100
+ async def test_allow_redirects_false_never_follows() -> None:
101
+ async def handle_start(_req: web.Request) -> web.Response:
102
+ return web.Response(status=301, headers={"Location": "/final"})
103
+
104
+ async def handle_final(_req: web.Request) -> web.Response:
105
+ return web.Response(status=200)
106
+
107
+ server = await _build_server({"/start": handle_start, "/final": handle_final})
108
+ try:
109
+ response = await _permissive_request(
110
+ _make_server_url(server, "/start"), allow_redirects=False
111
+ )
112
+ assert response.status == 301
113
+ finally:
114
+ await server.close()
115
+
116
+
117
+ @pytest.mark.asyncio
118
+ async def test_allow_redirects_true_follows_canonical_chain_to_2xx() -> None:
119
+ async def handle_start(_req: web.Request) -> web.Response:
120
+ return web.Response(status=301, headers={"Location": "/final"})
121
+
122
+ async def handle_final(_req: web.Request) -> web.Response:
123
+ return web.Response(status=200, text="ok")
124
+
125
+ server = await _build_server({"/start": handle_start, "/final": handle_final})
126
+ try:
127
+ response = await _permissive_request(
128
+ _make_server_url(server, "/start"), allow_redirects=True
129
+ )
130
+ assert response.status == 200
131
+ assert await response.text() == "ok"
132
+ finally:
133
+ await server.close()
134
+
135
+
136
+ @pytest.mark.asyncio
137
+ async def test_allow_redirects_true_validates_scheme_on_redirect_target() -> None:
138
+ async def handle_start(_req: web.Request) -> web.Response:
139
+ return web.Response(
140
+ status=302, headers={"Location": "ftp://outside.example.com/attack"}
141
+ )
142
+
143
+ server = await _build_server({"/start": handle_start})
144
+ try:
145
+ with pytest.raises(HTTPValidationError):
146
+ await _permissive_request(
147
+ _make_server_url(server, "/start"),
148
+ allow_redirects=True,
149
+ schemes=["http"],
150
+ )
151
+ finally:
152
+ await server.close()
153
+
154
+
155
+ @pytest.mark.asyncio
156
+ async def test_allow_redirects_true_validates_port_on_redirect_target() -> None:
157
+ async def handle_start(_req: web.Request) -> web.Response:
158
+ return web.Response(
159
+ status=302,
160
+ headers={"Location": "http://outside.example.com:22/attack"},
161
+ )
162
+
163
+ server = await _build_server({"/start": handle_start})
164
+ try:
165
+ with pytest.raises(HTTPValidationError):
166
+ await _permissive_request(
167
+ _make_server_url(server, "/start"),
168
+ allow_redirects=True,
169
+ ports=[80, 443],
170
+ )
171
+ finally:
172
+ await server.close()
173
+
174
+
175
+ @pytest.mark.asyncio
176
+ @pytest.mark.parametrize("status", [301, 302, 303])
177
+ async def test_status_rewrites_follow_up_method_to_get(status: int) -> None:
178
+ methods: list[str] = []
179
+
180
+ async def handle_start(req: web.Request) -> web.Response:
181
+ methods.append(req.method)
182
+ return web.Response(status=status, headers={"Location": "/final"})
183
+
184
+ async def handle_final(req: web.Request) -> web.Response:
185
+ methods.append(req.method)
186
+ return web.Response(status=200)
187
+
188
+ server = await _build_server({"/start": handle_start, "/final": handle_final})
189
+ try:
190
+ response = await _permissive_request(
191
+ _make_server_url(server, "/start"),
192
+ method="POST",
193
+ allow_redirects=True,
194
+ json={"payload": "value"},
195
+ )
196
+ assert response.status == 200
197
+ assert methods == ["POST", "GET"]
198
+ finally:
199
+ await server.close()
200
+
201
+
202
+ @pytest.mark.asyncio
203
+ @pytest.mark.parametrize("status", [307, 308])
204
+ async def test_status_preserves_follow_up_method(status: int) -> None:
205
+ methods: list[str] = []
206
+
207
+ async def handle_start(req: web.Request) -> web.Response:
208
+ methods.append(req.method)
209
+ return web.Response(status=status, headers={"Location": "/final"})
210
+
211
+ async def handle_final(req: web.Request) -> web.Response:
212
+ methods.append(req.method)
213
+ return web.Response(status=200)
214
+
215
+ server = await _build_server({"/start": handle_start, "/final": handle_final})
216
+ try:
217
+ response = await _permissive_request(
218
+ _make_server_url(server, "/start"),
219
+ method="POST",
220
+ allow_redirects=True,
221
+ json={"payload": "value"},
222
+ )
223
+ assert response.status == 200
224
+ assert methods == ["POST", "POST"]
225
+ finally:
226
+ await server.close()
227
+
228
+
229
+ @pytest.mark.asyncio
230
+ async def test_exceeding_max_redirects_raises_too_many_redirects() -> None:
231
+ async def handle_hop(req: web.Request) -> web.Response:
232
+ return web.Response(
233
+ status=301, headers={"Location": f"/hop?n={req.query.get('n', '0')}"}
234
+ )
235
+
236
+ server = await _build_server({"/hop": handle_hop})
237
+ try:
238
+ with pytest.raises(aiohttp.TooManyRedirects):
239
+ await _permissive_request(
240
+ _make_server_url(server, "/hop?n=0"), allow_redirects=True
241
+ )
242
+ finally:
243
+ await server.close()
244
+
245
+
246
+ def _gzip_bomb(decompressed_bytes: int) -> bytes:
247
+ return gzip.compress(b"\x00" * decompressed_bytes, compresslevel=9)
248
+
249
+
250
+ async def _build_body_server(body: bytes, headers: dict[str, str]) -> TestServer:
251
+ async def handler(_req: web.Request) -> web.Response:
252
+ return web.Response(body=body, headers=headers)
253
+
254
+ return await _build_server({"/body": handler})
255
+
256
+
257
+ async def _capped_request(
258
+ url: str,
259
+ *,
260
+ max_response_bytes: int,
261
+ allow_redirects: bool | None = None,
262
+ ) -> aiohttp.ClientResponse:
263
+ return await request(
264
+ url,
265
+ method="GET",
266
+ allow_local_network=True,
267
+ allow_localhost=True,
268
+ allow_redirects=allow_redirects,
269
+ dns_rebind_protection=False,
270
+ max_response_bytes=max_response_bytes,
271
+ )
272
+
273
+
274
+ @pytest.mark.asyncio
275
+ async def test_compressed_body_over_cap_is_rejected() -> None:
276
+ bomb = _gzip_bomb(8 * 1024 * 1024)
277
+ server = await _build_body_server(
278
+ bomb,
279
+ {"Content-Encoding": "gzip", "Content-Type": "application/json"},
280
+ )
281
+ try:
282
+ assert len(bomb) < 1024 * 1024
283
+ with pytest.raises(ResponseTooLargeError):
284
+ await _capped_request(
285
+ _make_server_url(server, "/body"),
286
+ max_response_bytes=1024 * 1024,
287
+ )
288
+ finally:
289
+ await server.close()
290
+
291
+
292
+ @pytest.mark.asyncio
293
+ async def test_body_within_cap_is_still_returned() -> None:
294
+ server = await _build_body_server(b"payload", {})
295
+ try:
296
+ response = await _capped_request(
297
+ _make_server_url(server, "/body"),
298
+ max_response_bytes=1024 * 1024,
299
+ )
300
+ assert await response.text() == "payload"
301
+ finally:
302
+ await server.close()
303
+
304
+
305
+ @pytest.mark.asyncio
306
+ async def test_cap_rejects_an_oversized_body_after_a_redirect() -> None:
307
+ bomb = _gzip_bomb(8 * 1024 * 1024)
308
+
309
+ async def handle_start(_req: web.Request) -> web.Response:
310
+ return web.Response(status=302, headers={"Location": "/final"})
311
+
312
+ async def handle_final(_req: web.Request) -> web.Response:
313
+ return web.Response(
314
+ body=bomb,
315
+ headers={"Content-Encoding": "gzip", "Content-Type": "application/json"},
316
+ )
317
+
318
+ server = await _build_server({"/start": handle_start, "/final": handle_final})
319
+ try:
320
+ with pytest.raises(ResponseTooLargeError):
321
+ await _capped_request(
322
+ _make_server_url(server, "/start"),
323
+ max_response_bytes=1024 * 1024,
324
+ allow_redirects=True,
325
+ )
326
+ finally:
327
+ await server.close()
328
+
329
+
330
+ @pytest.mark.asyncio
331
+ async def test_declared_length_over_cap_is_rejected() -> None:
332
+ server = await _build_body_server(b"a" * (2 * 1024 * 1024), {})
333
+ try:
334
+ with pytest.raises(ResponseTooLargeError, match="declares"):
335
+ await _capped_request(
336
+ _make_server_url(server, "/body"),
337
+ max_response_bytes=1024 * 1024,
338
+ )
339
+ finally:
340
+ await server.close()
341
+
342
+
343
+ @pytest.mark.parametrize(
344
+ "url",
345
+ [
346
+ "//evil.example/jwks",
347
+ "//evil.example:8080/jwks",
348
+ "evil.example/jwks",
349
+ "evil.example:8080/jwks",
350
+ ],
351
+ )
352
+ def test_schemeless_url_is_rejected_when_schemes_are_restricted(url: str) -> None:
353
+ with pytest.raises(HTTPValidationError):
354
+ validate_url(
355
+ url,
356
+ ascii_only=False,
357
+ enforce_sanitization=False,
358
+ ports=[],
359
+ schemes=["https"],
360
+ )
361
+
362
+
363
+ def test_allowed_scheme_still_passes() -> None:
364
+ validate_url(
365
+ "https://good.example/jwks",
366
+ ascii_only=False,
367
+ enforce_sanitization=False,
368
+ ports=[],
369
+ schemes=["https"],
370
+ )
371
+
372
+
373
+ @pytest.mark.asyncio
374
+ async def test_relative_redirect_keeps_the_scheme_of_its_base() -> None:
375
+ async def handle_start(_req: web.Request) -> web.Response:
376
+ return web.Response(status=302, headers={"Location": "/final"})
377
+
378
+ async def handle_final(_req: web.Request) -> web.Response:
379
+ return web.Response(status=200, text="ok")
380
+
381
+ server = await _build_server({"/start": handle_start, "/final": handle_final})
382
+ try:
383
+ response = await _permissive_request(
384
+ _make_server_url(server, "/start"),
385
+ allow_redirects=True,
386
+ schemes=["http"],
387
+ )
388
+ assert response.status == 200
389
+ assert await response.text() == "ok"
390
+ finally:
391
+ await server.close()
392
+
393
+ @pytest.mark.asyncio
394
+ @pytest.mark.parametrize("compressed", [False, True])
395
+ async def test_cap_admits_a_body_of_exactly_the_limit(compressed: bool) -> None:
396
+ body = _gzip_bomb(_CAP) if compressed else b"a" * _CAP
397
+ headers = {"Content-Encoding": "gzip"} if compressed else {}
398
+ server = await _build_body_server(body, headers)
399
+ try:
400
+ response = await _capped_request(
401
+ _make_server_url(server, "/body"), max_response_bytes=_CAP
402
+ )
403
+ assert len(await response.text()) == _CAP
404
+ finally:
405
+ await server.close()
406
+
407
+
408
+ @pytest.mark.asyncio
409
+ async def test_cap_bounds_each_hop_rather_than_the_whole_chain() -> None:
410
+ # The accepted multiplier: every hop may serve just under the cap, so a
411
+ # full chain moves _MAX_HOPS times the limit and still succeeds.
412
+ body = b"a" * (_CAP - 1)
413
+
414
+ async def handle_hop(req: web.Request) -> web.Response:
415
+ hop = int(req.query.get("n", "0"))
416
+ if hop >= _MAX_HOPS - 1:
417
+ return web.Response(body=body)
418
+ return web.Response(
419
+ status=302, body=body, headers={"Location": f"/hop?n={hop + 1}"}
420
+ )
421
+
422
+ server = await _build_server({"/hop": handle_hop})
423
+ try:
424
+ response = await _capped_request(
425
+ _make_server_url(server, "/hop?n=0"),
426
+ max_response_bytes=_CAP,
427
+ allow_redirects=True,
428
+ )
429
+ assert response.status == 200
430
+ assert len(await response.text()) == _CAP - 1
431
+ finally:
432
+ await server.close()
433
+
434
+ def test_response_too_large_stays_catchable_as_a_client_error() -> None:
435
+ # Callers degrade gracefully on transport failures; narrowing this base
436
+ # converts their soft fallbacks into client-facing errors, and in one
437
+ # fire-and-forget path drops the log entirely.
438
+ assert issubclass(ResponseTooLargeError, aiohttp.ClientError)
439
+
440
+
441
+ @pytest.mark.asyncio
442
+ async def test_concurrent_requests_do_not_share_a_cap() -> None:
443
+ # The cap reaches the response through context, so overlapping requests
444
+ # must not observe each other's limit.
445
+ server = await _build_body_server(
446
+ _gzip_bomb(4 * 1024 * 1024), {"Content-Encoding": "gzip"}
447
+ )
448
+ url = _make_server_url(server, "/body")
449
+ try:
450
+ tight, generous = await asyncio.gather(
451
+ _capped_request(url, max_response_bytes=1024 * 1024),
452
+ _capped_request(url, max_response_bytes=32 * 1024 * 1024),
453
+ return_exceptions=True,
454
+ )
455
+ assert isinstance(tight, ResponseTooLargeError)
456
+ assert not isinstance(generous, BaseException)
457
+ assert len(await generous.text()) == 4 * 1024 * 1024
458
+ finally:
459
+ await server.close()
@@ -217,7 +217,7 @@ wheels = [
217
217
 
218
218
  [[package]]
219
219
  name = "fluidattacks-core-http"
220
- version = "12.0.0"
220
+ version = "12.2.0"
221
221
  source = { editable = "." }
222
222
  dependencies = [
223
223
  { name = "aiohttp" },
@@ -230,6 +230,8 @@ dev = [
230
230
  { name = "deptry" },
231
231
  { name = "import-linter" },
232
232
  { name = "mypy" },
233
+ { name = "pytest" },
234
+ { name = "pytest-asyncio" },
233
235
  { name = "ruff" },
234
236
  ]
235
237
 
@@ -245,6 +247,8 @@ dev = [
245
247
  { name = "deptry", specifier = ">=0.23.1" },
246
248
  { name = "import-linter", specifier = "==2.11" },
247
249
  { name = "mypy", specifier = "==1.19.1" },
250
+ { name = "pytest", specifier = "==9.0.3" },
251
+ { name = "pytest-asyncio", specifier = ">=1.1.0" },
248
252
  { name = "ruff", specifier = "==0.15.0" },
249
253
  ]
250
254
 
@@ -465,6 +469,15 @@ wheels = [
465
469
  { url = "https://files.pythonhosted.org/packages/e9/aa/2ed2c89543632ded7196e0d93dcc6c7fe87769e88391a648c4a298ea864a/import_linter-2.11-py3-none-any.whl", hash = "sha256:3dc54cae933bae3430358c30989762b721c77aa99d424f56a08265be0eeaa465", size = 637315, upload-time = "2026-03-06T12:11:36.599Z" },
466
470
  ]
467
471
 
472
+ [[package]]
473
+ name = "iniconfig"
474
+ version = "2.3.0"
475
+ source = { registry = "https://pypi.org/simple" }
476
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
477
+ wheels = [
478
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
479
+ ]
480
+
468
481
  [[package]]
469
482
  name = "librt"
470
483
  version = "0.13.0"
@@ -744,6 +757,15 @@ wheels = [
744
757
  { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
745
758
  ]
746
759
 
760
+ [[package]]
761
+ name = "pluggy"
762
+ version = "1.6.0"
763
+ source = { registry = "https://pypi.org/simple" }
764
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
765
+ wheels = [
766
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
767
+ ]
768
+
747
769
  [[package]]
748
770
  name = "propcache"
749
771
  version = "0.5.2"
@@ -864,6 +886,35 @@ wheels = [
864
886
  { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
865
887
  ]
866
888
 
889
+ [[package]]
890
+ name = "pytest"
891
+ version = "9.0.3"
892
+ source = { registry = "https://pypi.org/simple" }
893
+ dependencies = [
894
+ { name = "colorama", marker = "sys_platform == 'win32'" },
895
+ { name = "iniconfig" },
896
+ { name = "packaging" },
897
+ { name = "pluggy" },
898
+ { name = "pygments" },
899
+ ]
900
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
901
+ wheels = [
902
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
903
+ ]
904
+
905
+ [[package]]
906
+ name = "pytest-asyncio"
907
+ version = "1.4.0"
908
+ source = { registry = "https://pypi.org/simple" }
909
+ dependencies = [
910
+ { name = "pytest" },
911
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
912
+ ]
913
+ sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
914
+ wheels = [
915
+ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
916
+ ]
917
+
867
918
  [[package]]
868
919
  name = "requirements-parser"
869
920
  version = "0.13.1"
@@ -1,20 +0,0 @@
1
- { src, workspace_root }:
2
- {
3
- inherit src workspace_root;
4
- root_path = "develops/fluidattacks-core/python/http";
5
- module_name = "fluidattacks_core";
6
- pypi_token = {
7
- method = "oidc";
8
- };
9
- override = {
10
- checks = {
11
- basedpyright = false;
12
- pytest = false;
13
- };
14
- configs = {
15
- import_linter = ../import-linter.cfg;
16
- mypy = ../mypy.ini;
17
- ruff = ../ruff.toml;
18
- };
19
- };
20
- }
@@ -1,12 +0,0 @@
1
- path_filter: src:
2
- path_filter {
3
- root = src;
4
- include = [
5
- "fluidattacks_core"
6
- "import-linter.cfg"
7
- "mypy.ini"
8
- "pyproject.toml"
9
- "ruff.toml"
10
- "uv.lock"
11
- ];
12
- }
@@ -1,303 +0,0 @@
1
- {
2
- "nodes": {
3
- "flake-parts": {
4
- "inputs": {
5
- "nixpkgs-lib": "nixpkgs-lib"
6
- },
7
- "locked": {
8
- "lastModified": 1762810396,
9
- "narHash": "sha256-dxFVgQPG+R72dkhXTtqUm7KpxElw3u6E+YlQ2WaDgt8=",
10
- "owner": "hercules-ci",
11
- "repo": "flake-parts",
12
- "rev": "0bdadb1b265fb4143a75bd1ec7d8c915898a9923",
13
- "type": "github"
14
- },
15
- "original": {
16
- "owner": "hercules-ci",
17
- "repo": "flake-parts",
18
- "type": "github"
19
- }
20
- },
21
- "nix_filter": {
22
- "locked": {
23
- "lastModified": 1757882181,
24
- "narHash": "sha256-+cCxYIh2UNalTz364p+QYmWHs0P+6wDhiWR4jDIKQIU=",
25
- "owner": "numtide",
26
- "repo": "nix-filter",
27
- "rev": "59c44d1909c72441144b93cf0f054be7fe764de5",
28
- "type": "github"
29
- },
30
- "original": {
31
- "owner": "numtide",
32
- "repo": "nix-filter",
33
- "type": "github"
34
- }
35
- },
36
- "nixpkgs": {
37
- "locked": {
38
- "lastModified": 1758690382,
39
- "narHash": "sha256-NY3kSorgqE5LMm1LqNwGne3ZLMF2/ILgLpFr1fS4X3o=",
40
- "owner": "nixos",
41
- "repo": "nixpkgs",
42
- "rev": "e643668fd71b949c53f8626614b21ff71a07379d",
43
- "type": "github"
44
- },
45
- "original": {
46
- "owner": "nixos",
47
- "ref": "nixos-unstable",
48
- "repo": "nixpkgs",
49
- "type": "github"
50
- }
51
- },
52
- "nixpkgs-lib": {
53
- "locked": {
54
- "lastModified": 1761765539,
55
- "narHash": "sha256-b0yj6kfvO8ApcSE+QmA6mUfu8IYG6/uU28OFn4PaC8M=",
56
- "owner": "nix-community",
57
- "repo": "nixpkgs.lib",
58
- "rev": "719359f4562934ae99f5443f20aa06c2ffff91fc",
59
- "type": "github"
60
- },
61
- "original": {
62
- "owner": "nix-community",
63
- "repo": "nixpkgs.lib",
64
- "type": "github"
65
- }
66
- },
67
- "nixpkgs_2": {
68
- "locked": {
69
- "lastModified": 1758690382,
70
- "narHash": "sha256-NY3kSorgqE5LMm1LqNwGne3ZLMF2/ILgLpFr1fS4X3o=",
71
- "owner": "nixos",
72
- "repo": "nixpkgs",
73
- "rev": "e643668fd71b949c53f8626614b21ff71a07379d",
74
- "type": "github"
75
- },
76
- "original": {
77
- "owner": "nixos",
78
- "ref": "nixos-unstable",
79
- "repo": "nixpkgs",
80
- "type": "github"
81
- }
82
- },
83
- "nixpkgs_3": {
84
- "locked": {
85
- "lastModified": 1761373498,
86
- "narHash": "sha256-Q/uhWNvd7V7k1H1ZPMy/vkx3F8C13ZcdrKjO7Jv7v0c=",
87
- "owner": "nixos",
88
- "repo": "nixpkgs",
89
- "rev": "6a08e6bb4e46ff7fcbb53d409b253f6bad8a28ce",
90
- "type": "github"
91
- },
92
- "original": {
93
- "owner": "nixos",
94
- "ref": "nixos-unstable",
95
- "repo": "nixpkgs",
96
- "type": "github"
97
- }
98
- },
99
- "nixpkgs_flake": {
100
- "locked": {
101
- "lastModified": 1778253243,
102
- "narHash": "sha256-KkH2QvU6iRLDTGGEwH5jnMqHWj4zMTuHxi1S1zdr01w=",
103
- "owner": "nixos",
104
- "repo": "nixpkgs",
105
- "rev": "4fcda7103530b23c5ca44286344912082aad4220",
106
- "type": "github"
107
- },
108
- "original": {
109
- "owner": "nixos",
110
- "repo": "nixpkgs",
111
- "type": "github"
112
- }
113
- },
114
- "pyproject-build-systems": {
115
- "inputs": {
116
- "nixpkgs": "nixpkgs",
117
- "pyproject-nix": "pyproject-nix",
118
- "uv2nix": "uv2nix"
119
- },
120
- "locked": {
121
- "lastModified": 1761781027,
122
- "narHash": "sha256-YDvxPAm2WnxrznRqWwHLjryBGG5Ey1ATEJXrON+TWt8=",
123
- "owner": "pyproject-nix",
124
- "repo": "build-system-pkgs",
125
- "rev": "795a980d25301e5133eca37adae37283ec3c8e66",
126
- "type": "github"
127
- },
128
- "original": {
129
- "owner": "pyproject-nix",
130
- "repo": "build-system-pkgs",
131
- "type": "github"
132
- }
133
- },
134
- "pyproject-nix": {
135
- "inputs": {
136
- "nixpkgs": [
137
- "python_builder",
138
- "pyproject-build-systems",
139
- "nixpkgs"
140
- ]
141
- },
142
- "locked": {
143
- "lastModified": 1758265079,
144
- "narHash": "sha256-amLaLNwKSZPShQHzfgmc/9o76dU8xzN0743dWgvYlr8=",
145
- "owner": "nix-community",
146
- "repo": "pyproject.nix",
147
- "rev": "02e9418fd4af638447dca4b17b1280da95527fc9",
148
- "type": "github"
149
- },
150
- "original": {
151
- "owner": "nix-community",
152
- "repo": "pyproject.nix",
153
- "type": "github"
154
- }
155
- },
156
- "pyproject-nix_2": {
157
- "inputs": {
158
- "nixpkgs": "nixpkgs_2"
159
- },
160
- "locked": {
161
- "lastModified": 1762427963,
162
- "narHash": "sha256-CkPlAbIQ87wmjy5qHibfzk4DmMGBNqFer+lLfXjpP5M=",
163
- "owner": "pyproject-nix",
164
- "repo": "pyproject.nix",
165
- "rev": "4540ea004e04fcd12dd2738d51383d10f956f7b9",
166
- "type": "github"
167
- },
168
- "original": {
169
- "owner": "pyproject-nix",
170
- "repo": "pyproject.nix",
171
- "type": "github"
172
- }
173
- },
174
- "pyproject-nix_3": {
175
- "inputs": {
176
- "nixpkgs": [
177
- "python_builder",
178
- "uv2nix",
179
- "nixpkgs"
180
- ]
181
- },
182
- "locked": {
183
- "lastModified": 1760402624,
184
- "narHash": "sha256-jF6UKLs2uGc2rtved8Vrt58oTWjTQoAssuYs/0578Z4=",
185
- "owner": "pyproject-nix",
186
- "repo": "pyproject.nix",
187
- "rev": "84c4ea102127c77058ea1ed7be7300261fafc7d2",
188
- "type": "github"
189
- },
190
- "original": {
191
- "owner": "pyproject-nix",
192
- "repo": "pyproject.nix",
193
- "type": "github"
194
- }
195
- },
196
- "python_builder": {
197
- "inputs": {
198
- "nix_filter": "nix_filter",
199
- "nixpkgs_flake": "nixpkgs_flake",
200
- "pyproject-build-systems": "pyproject-build-systems",
201
- "pyproject-nix": "pyproject-nix_2",
202
- "shell-helpers": "shell-helpers",
203
- "uv2nix": "uv2nix_2"
204
- },
205
- "locked": {
206
- "dir": "common/utils/python-builder",
207
- "lastModified": 1787078362,
208
- "narHash": "sha256-HYL7Rn6SuNjYxkt/PwFLksZjsauCiY+ENbOWryLsIlw=",
209
- "ref": "refs/heads/trunk",
210
- "rev": "09fd25a13309eb5e3b5b2a061bb340df3ae5d281",
211
- "shallow": true,
212
- "type": "git",
213
- "url": "ssh://git@gitlab.com/fluidattacks/universe"
214
- },
215
- "original": {
216
- "dir": "common/utils/python-builder",
217
- "rev": "09fd25a13309eb5e3b5b2a061bb340df3ae5d281",
218
- "shallow": true,
219
- "type": "git",
220
- "url": "ssh://git@gitlab.com/fluidattacks/universe"
221
- }
222
- },
223
- "root": {
224
- "inputs": {
225
- "python_builder": "python_builder"
226
- }
227
- },
228
- "shell-helpers": {
229
- "inputs": {
230
- "flake-parts": "flake-parts",
231
- "nixpkgs": [
232
- "python_builder",
233
- "nixpkgs_flake"
234
- ]
235
- },
236
- "locked": {
237
- "dir": "common/utils/shell-helpers",
238
- "lastModified": 1762806066,
239
- "narHash": "sha256-Wz99Fl7SpPU1NkTHJ36XOo6DjOEAMXCw3ThRABOS2Hs=",
240
- "ref": "refs/heads/trunk",
241
- "rev": "d73cfdc7ae9e7f547e22a4481d4c2ccce38b26a8",
242
- "shallow": true,
243
- "type": "git",
244
- "url": "ssh://git@gitlab.com/fluidattacks/universe"
245
- },
246
- "original": {
247
- "dir": "common/utils/shell-helpers",
248
- "rev": "d73cfdc7ae9e7f547e22a4481d4c2ccce38b26a8",
249
- "shallow": true,
250
- "type": "git",
251
- "url": "ssh://git@gitlab.com/fluidattacks/universe"
252
- }
253
- },
254
- "uv2nix": {
255
- "inputs": {
256
- "nixpkgs": [
257
- "python_builder",
258
- "pyproject-build-systems",
259
- "nixpkgs"
260
- ],
261
- "pyproject-nix": [
262
- "python_builder",
263
- "pyproject-build-systems",
264
- "pyproject-nix"
265
- ]
266
- },
267
- "locked": {
268
- "lastModified": 1758933732,
269
- "narHash": "sha256-HAmm1GBS1myZCFuog0DC2ZLaynvZtiUI2Crmo+cdQI0=",
270
- "owner": "pyproject-nix",
271
- "repo": "uv2nix",
272
- "rev": "273ce18f913d8559e0d04f820d724308966d7c4d",
273
- "type": "github"
274
- },
275
- "original": {
276
- "owner": "pyproject-nix",
277
- "repo": "uv2nix",
278
- "type": "github"
279
- }
280
- },
281
- "uv2nix_2": {
282
- "inputs": {
283
- "nixpkgs": "nixpkgs_3",
284
- "pyproject-nix": "pyproject-nix_3"
285
- },
286
- "locked": {
287
- "lastModified": 1761872265,
288
- "narHash": "sha256-i25GRgp2vUOebY70L3NTAgkd+Pr1hnn5xM3qHxH0ONU=",
289
- "owner": "pyproject-nix",
290
- "repo": "uv2nix",
291
- "rev": "74dfb62871be152ad3673b143b0cc56105a4f3c5",
292
- "type": "github"
293
- },
294
- "original": {
295
- "owner": "pyproject-nix",
296
- "repo": "uv2nix",
297
- "type": "github"
298
- }
299
- }
300
- },
301
- "root": "root",
302
- "version": 7
303
- }
@@ -1,29 +0,0 @@
1
- {
2
- description = "Fluid Attacks Core http package.";
3
-
4
- inputs = {
5
- python_builder = {
6
- url = "git+ssh://git@gitlab.com/fluidattacks/universe?shallow=1&rev=09fd25a13309eb5e3b5b2a061bb340df3ae5d281&dir=common/utils/python-builder";
7
- };
8
- };
9
-
10
- outputs =
11
- { self, ... }@inputs:
12
- let
13
- build_args =
14
- {
15
- system,
16
- python_version,
17
- nixpkgs,
18
- builders,
19
- scripts,
20
- }:
21
- import ./build {
22
- src = import ./build/filter.nix nixpkgs.nix-filter self;
23
- workspace_root = ./.;
24
- };
25
- in
26
- {
27
- packages = inputs.python_builder.outputs.build build_args;
28
- };
29
- }
@@ -1,5 +0,0 @@
1
- from .client import (
2
- request,
3
- )
4
-
5
- __all__ = ["request"]
@@ -1,90 +0,0 @@
1
- import ipaddress
2
- import ssl
3
- from collections.abc import Sequence
4
- from typing import (
5
- Any,
6
- Literal,
7
- )
8
-
9
- import aiohttp
10
- import certifi
11
-
12
- from .validations import (
13
- validate_local_request,
14
- validate_url,
15
- )
16
-
17
-
18
- def get_secure_connector(
19
- *,
20
- allow_local_network: bool,
21
- allow_localhost: bool,
22
- ) -> type[aiohttp.TCPConnector]:
23
- class SecureTCPConnector(aiohttp.TCPConnector):
24
- async def _resolve_host(
25
- self,
26
- host: str,
27
- port: int,
28
- traces: Sequence[aiohttp.tracing.Trace] | None = None,
29
- ) -> list[aiohttp.abc.ResolveResult]:
30
- hosts = await super()._resolve_host(host, port, traces)
31
- resolved_ips = [ipaddress.ip_address(host["host"]) for host in hosts]
32
- validate_local_request(
33
- resolved_ips,
34
- allow_local_network=allow_local_network,
35
- allow_localhost=allow_localhost,
36
- )
37
- return hosts
38
-
39
- return SecureTCPConnector
40
-
41
-
42
- async def request( # noqa: PLR0913
43
- url: str,
44
- *,
45
- method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"],
46
- allow_local_network: bool = False,
47
- allow_localhost: bool = False,
48
- ascii_only: bool = False,
49
- dns_rebind_protection: bool = True,
50
- enforce_sanitization: bool = False,
51
- headers: dict[str, str] | None = None,
52
- json: Any | None = None, # noqa: ANN401
53
- ports: list[int] | None = None,
54
- schemes: list[str] | None = None,
55
- timeout: int = 10, # noqa: ASYNC109
56
- ) -> aiohttp.ClientResponse:
57
- validate_url(
58
- url,
59
- ascii_only=ascii_only,
60
- enforce_sanitization=enforce_sanitization,
61
- ports=ports or [],
62
- schemes=schemes or [],
63
- )
64
- connector = (
65
- get_secure_connector(
66
- allow_local_network=allow_local_network,
67
- allow_localhost=allow_localhost,
68
- )
69
- if dns_rebind_protection
70
- else aiohttp.TCPConnector
71
- )
72
- connection = connector(
73
- ssl=ssl.create_default_context(cafile=certifi.where()),
74
- )
75
-
76
- async with (
77
- aiohttp.ClientSession(
78
- connector=connection,
79
- headers=headers,
80
- ) as session,
81
- session.request(
82
- method,
83
- url,
84
- allow_redirects=not dns_rebind_protection,
85
- json=json,
86
- timeout=aiohttp.ClientTimeout(total=timeout),
87
- ) as response,
88
- ):
89
- await response.read()
90
- return response