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.
- {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/.gitignore +4 -0
- {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/PKG-INFO +1 -1
- fluidattacks_core_http-12.2.0/fluidattacks_core/http/__init__.py +7 -0
- fluidattacks_core_http-12.2.0/fluidattacks_core/http/client.py +211 -0
- {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/fluidattacks_core/http/validations.py +1 -1
- {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/import-linter.cfg +1 -10
- {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/pyproject.toml +3 -1
- fluidattacks_core_http-12.2.0/tests/__init__.py +1 -0
- fluidattacks_core_http-12.2.0/tests/test_client.py +459 -0
- {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/uv.lock +52 -1
- fluidattacks_core_http-12.0.0/build/default.nix +0 -20
- fluidattacks_core_http-12.0.0/build/filter.nix +0 -12
- fluidattacks_core_http-12.0.0/flake.lock +0 -303
- fluidattacks_core_http-12.0.0/flake.nix +0 -29
- fluidattacks_core_http-12.0.0/fluidattacks_core/http/__init__.py +0 -5
- fluidattacks_core_http-12.0.0/fluidattacks_core/http/client.py +0 -90
- {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/.envrc +0 -0
- {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/fluidattacks_core/http/py.typed +0 -0
- {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/mypy.ini +0 -0
- {fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/ruff.toml +0 -0
|
@@ -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
|
|
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.
|
|
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.
|
|
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,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,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
|
|
File without changes
|
{fluidattacks_core_http-12.0.0 → fluidattacks_core_http-12.2.0}/fluidattacks_core/http/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|