quash-api 0.1.0__py3-none-any.whl
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.
- api_test_agent/__init__.py +1 -0
- api_test_agent/_arg_masking.py +76 -0
- api_test_agent/_log_redaction.py +161 -0
- api_test_agent/_ssrf.py +122 -0
- api_test_agent/arazzo.py +448 -0
- api_test_agent/billing/__init__.py +3 -0
- api_test_agent/billing/usage_tracker.py +157 -0
- api_test_agent/bundle.py +555 -0
- api_test_agent/capture/__init__.py +20 -0
- api_test_agent/capture/bindings.py +232 -0
- api_test_agent/capture/correlation.py +149 -0
- api_test_agent/capture/generate.py +115 -0
- api_test_agent/capture/har_superset.py +178 -0
- api_test_agent/capture/persistence.py +251 -0
- api_test_agent/capture/redaction.py +798 -0
- api_test_agent/capture/to_flows.py +117 -0
- api_test_agent/capture/to_mocks.py +70 -0
- api_test_agent/ci/__init__.py +17 -0
- api_test_agent/ci/arazzo_import.py +280 -0
- api_test_agent/ci/arazzo_schema.py +29 -0
- api_test_agent/ci/cli.py +169 -0
- api_test_agent/ci/loader.py +158 -0
- api_test_agent/ci/openapi_index.py +43 -0
- api_test_agent/ci/reporters.py +124 -0
- api_test_agent/ci/runner.py +385 -0
- api_test_agent/ci/schemas/arazzo-1.0.1.json +106 -0
- api_test_agent/cloud_rpc.py +871 -0
- api_test_agent/config/__init__.py +17 -0
- api_test_agent/config/models.py +179 -0
- api_test_agent/connector/__init__.py +121 -0
- api_test_agent/connector/allowlist.py +108 -0
- api_test_agent/connector/cloud.py +127 -0
- api_test_agent/connector/exchange.py +133 -0
- api_test_agent/connector/loop.py +134 -0
- api_test_agent/coverage.py +477 -0
- api_test_agent/env_state.py +133 -0
- api_test_agent/events/__init__.py +0 -0
- api_test_agent/events/emitter.py +466 -0
- api_test_agent/events/protocol.py +134 -0
- api_test_agent/flow_requirements.py +153 -0
- api_test_agent/ingest/__init__.py +4 -0
- api_test_agent/ingest/api_details.py +101 -0
- api_test_agent/ingest/curl_parser.py +168 -0
- api_test_agent/ingest/har_parser.py +255 -0
- api_test_agent/ingest/openapi.py +444 -0
- api_test_agent/ingest/postman_parser.py +383 -0
- api_test_agent/ingest/schema_infer.py +247 -0
- api_test_agent/ingest/sources.py +204 -0
- api_test_agent/manage.py +764 -0
- api_test_agent/relay_channel.py +99 -0
- api_test_agent/replay/__init__.py +21 -0
- api_test_agent/replay/bundle.py +2001 -0
- api_test_agent/replay/engine.py +65 -0
- api_test_agent/replay/ignorepaths.py +150 -0
- api_test_agent/replay/mock_store.py +156 -0
- api_test_agent/replay/resolver.py +597 -0
- api_test_agent/report.py +106 -0
- api_test_agent/run_suite.py +50 -0
- api_test_agent/stores/__init__.py +96 -0
- api_test_agent/stores/_atomic.py +54 -0
- api_test_agent/stores/_canonical.py +43 -0
- api_test_agent/stores/capture_provenance.py +66 -0
- api_test_agent/stores/coverage_ledger.py +61 -0
- api_test_agent/stores/environment_store.py +159 -0
- api_test_agent/stores/flow_store.py +270 -0
- api_test_agent/stores/ports.py +206 -0
- api_test_agent/stores/run_store.py +760 -0
- api_test_agent/stores/secret_resolver.py +148 -0
- api_test_agent/stores/spec_store.py +544 -0
- api_test_agent/stores/test_case_store.py +209 -0
- api_test_agent/stores/variable_store.py +120 -0
- api_test_agent/suite.py +270 -0
- api_test_agent/tools/__init__.py +7 -0
- api_test_agent/tools/author/__init__.py +1 -0
- api_test_agent/tools/author/delete_test_case.py +48 -0
- api_test_agent/tools/execute/__init__.py +4 -0
- api_test_agent/tools/execute/_digest.py +84 -0
- api_test_agent/tools/execute/baseline.py +203 -0
- api_test_agent/tools/execute/engine_factory.py +232 -0
- api_test_agent/tools/execute/http_request.py +192 -0
- api_test_agent/tools/execute/run_flow.py +482 -0
- api_test_agent/tools/execute/run_tests.py +510 -0
- api_test_engine/__init__.py +74 -0
- api_test_engine/assertions.py +531 -0
- api_test_engine/budget.py +670 -0
- api_test_engine/confusables.py +138 -0
- api_test_engine/credential_names.py +514 -0
- api_test_engine/diagnostics.py +346 -0
- api_test_engine/engine.py +1981 -0
- api_test_engine/entropy.py +86 -0
- api_test_engine/flow_runner.py +1027 -0
- api_test_engine/junit.py +103 -0
- api_test_engine/masking.py +106 -0
- api_test_engine/matcher_path.py +48 -0
- api_test_engine/models.py +626 -0
- api_test_engine/noise.py +480 -0
- api_test_engine/outcomes.py +408 -0
- api_test_engine/policy.py +319 -0
- api_test_engine/preview.py +167 -0
- api_test_engine/relay.py +296 -0
- api_test_engine/replay_headers.py +64 -0
- api_test_engine/response_diff.py +1085 -0
- api_test_engine/results.py +340 -0
- api_test_engine/run_stamp.py +158 -0
- api_test_engine/shape.py +274 -0
- api_test_engine/signatures.py +890 -0
- api_test_engine/substitution.py +536 -0
- api_test_engine/thirdparty/confusables.txt +9862 -0
- api_test_engine/traffic_fp.py +1030 -0
- api_test_engine/triage.py +185 -0
- quash_api-0.1.0.dist-info/METADATA +112 -0
- quash_api-0.1.0.dist-info/RECORD +115 -0
- quash_api-0.1.0.dist-info/WHEEL +4 -0
- quash_api-0.1.0.dist-info/entry_points.txt +2 -0
- quash_api-0.1.0.dist-info/licenses/LICENSE +7 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API testing agent — LangGraph agent + tools over the pure api_test_engine."""
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Redact secret-carrying tool-call ARGUMENTS before they hit the event wire /
|
|
2
|
+
UI (review finding C2).
|
|
3
|
+
|
|
4
|
+
The parallel tool node streams each tool call's args to stdout/UI *before* the
|
|
5
|
+
tool runs. The LLM's own literal args — a ``set_variable(secret=True,
|
|
6
|
+
value=<literal>)`` or an ``http_request`` ``Authorization`` header — would
|
|
7
|
+
otherwise egress verbatim into LLM context, session history, and the terminal.
|
|
8
|
+
Value-based masking can't help: the literal is usually a BRAND-NEW secret being
|
|
9
|
+
registered, so it isn't in the known-secret list yet. So we redact by ARGUMENT
|
|
10
|
+
SHAPE at the emit boundary. This affects only the EMITTED copy; the tool still
|
|
11
|
+
receives the real args.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
_MASK = "****"
|
|
20
|
+
|
|
21
|
+
# A dict key (a body field, a query param, a nested field) whose NAME marks its
|
|
22
|
+
# value as a credential — masked at ANY depth. Broad on purpose: over-redacting
|
|
23
|
+
# the emitted event costs a little observability; under-redacting leaks a secret.
|
|
24
|
+
_SECRET_KEY_RE = re.compile(
|
|
25
|
+
r"(secret|password|passwd|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token"
|
|
26
|
+
r"|bearer|credential|cred|private[_-]?key|client[_-]?secret|session[_-]?(id|token)|passphrase|otp|pin)",
|
|
27
|
+
re.I,
|
|
28
|
+
)
|
|
29
|
+
# A HEADER name whose value is a credential (broader than the key regex — an
|
|
30
|
+
# ``X-Whatever-Auth`` header is a token; ``Accept``/``Content-Type`` are not).
|
|
31
|
+
_SECRET_HEADER_RE = re.compile(r"(authoriz|cookie|token|api[_-]?key|secret|credential|cred|session|auth|bearer|x-.*-key)", re.I)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _scrub(obj: Any, *, in_headers: bool = False) -> Any:
|
|
35
|
+
"""Recursively redact credential-shaped values in a copy of *obj*."""
|
|
36
|
+
if isinstance(obj, dict):
|
|
37
|
+
out: dict[Any, Any] = {}
|
|
38
|
+
for k, v in obj.items():
|
|
39
|
+
child_headers = bool(isinstance(k, str) and k.lower() == "headers")
|
|
40
|
+
hdr_name = isinstance(k, str) and _SECRET_HEADER_RE.search(k)
|
|
41
|
+
key_secret = isinstance(k, str) and _SECRET_KEY_RE.search(k)
|
|
42
|
+
if (in_headers and hdr_name) or key_secret:
|
|
43
|
+
# Mask a credential STRING; recurse a container; but leave a
|
|
44
|
+
# bool/number/None (a `secret: true` flag, an id) alone — a flag
|
|
45
|
+
# is not a credential, and masking it would corrupt tool logic.
|
|
46
|
+
if isinstance(v, str):
|
|
47
|
+
out[k] = _MASK
|
|
48
|
+
elif isinstance(v, (dict, list)):
|
|
49
|
+
out[k] = _scrub(v, in_headers=child_headers)
|
|
50
|
+
else:
|
|
51
|
+
out[k] = v
|
|
52
|
+
else:
|
|
53
|
+
out[k] = _scrub(v, in_headers=child_headers)
|
|
54
|
+
return out
|
|
55
|
+
if isinstance(obj, list):
|
|
56
|
+
return [_scrub(v, in_headers=in_headers) for v in obj]
|
|
57
|
+
return obj
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def scrub_tool_call_args(name: str, args: Any) -> Any:
|
|
61
|
+
"""Return a COPY of *args* with credential-shaped values redacted at any
|
|
62
|
+
depth (headers by name, secret-named body/query fields, and the documented
|
|
63
|
+
``set_variable`` secret value). Never mutates the input — the tool must still
|
|
64
|
+
receive the real args."""
|
|
65
|
+
if not isinstance(args, dict):
|
|
66
|
+
return args
|
|
67
|
+
out = _scrub(args)
|
|
68
|
+
# set_variable(secret=True, value=...) — the `value` key isn't credential-
|
|
69
|
+
# named, so mask it explicitly. Read the ORIGINAL flag (the scrubbed copy's
|
|
70
|
+
# `secret` is untouched now, but the original is the source of truth).
|
|
71
|
+
if name == "set_variable" and args.get("secret") and "value" in out:
|
|
72
|
+
out["value"] = _MASK
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = ["scrub_tool_call_args"]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""The agent's log lines never carry a request URL's secrets (live smoke B1).
|
|
2
|
+
|
|
3
|
+
httpx logs every request at INFO (``HTTP Request: GET <url> "HTTP/1.1 200"``),
|
|
4
|
+
and the agent's stderr is relayed into the worker's log. :class:`RedactUrls`
|
|
5
|
+
rewrites every record — its message AND its exception traceback — and
|
|
6
|
+
:func:`install` also covers an UNCAUGHT exception (``sys.excepthook``). A URL
|
|
7
|
+
keeps its scheme, host and path and its query keys; a query VALUE stays only
|
|
8
|
+
when it is harmless: a credential-NAMED parameter (the engine's shared
|
|
9
|
+
``credential_names`` rule — the cloud masks results with the same rule) or a
|
|
10
|
+
token-SHAPED value under any name (a captured token in ``?cursor=``) becomes
|
|
11
|
+
``…``; userinfo and fragment are dropped; the run's loaded secret values
|
|
12
|
+
(:func:`set_secret_values`) are masked anywhere."""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
import traceback
|
|
20
|
+
from collections.abc import Callable, Iterable
|
|
21
|
+
from typing import Final
|
|
22
|
+
from urllib.parse import urlsplit
|
|
23
|
+
|
|
24
|
+
from api_test_engine.credential_names import credential_query_name, token_shaped
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _LinearPattern:
|
|
28
|
+
r"""``re.compile(pattern).sub(repl, text)``, exactly, in linear time — for a
|
|
29
|
+
pattern whose every attempt first reads a RUN (here: a URL scheme) to its
|
|
30
|
+
end, and can fail only after it.
|
|
31
|
+
|
|
32
|
+
``re`` tries such a pattern at every start inside a run, and each attempt
|
|
33
|
+
reads the same run to the same end and fails there the same way, so a run
|
|
34
|
+
of starts is quadratic: ``a.a.a…`` (a scheme at every letter, no ``://``
|
|
35
|
+
ever found) costs one scan per start. This attempts the SAME pattern
|
|
36
|
+
(``match``ed in place, so the leading ``\b`` still reads the text before
|
|
37
|
+
it) where the first group of *seek*'s next match begins, and when an
|
|
38
|
+
attempt fails it seeks on from where *run*, read from that start, ends:
|
|
39
|
+
every start in between reads that run to that end and fails there too.
|
|
40
|
+
From wherever the scan resumes (the text's start, a match's end, a run's
|
|
41
|
+
end), *seek* finds the first start the pattern matches from, or an
|
|
42
|
+
earlier one where its first step holds (so a failed attempt is always a
|
|
43
|
+
run that failed at its end). The same matches, in the same order, through
|
|
44
|
+
the same *repl*: the same text, and no bound on any length.
|
|
45
|
+
|
|
46
|
+
Ported from ``quash_cloud.apitest.result_masking._LinearPattern`` (same
|
|
47
|
+
class, same fix for the same regex shape) rather than imported: this
|
|
48
|
+
package must never import ``quash_cloud`` (the import-linter "engine is
|
|
49
|
+
pure"-style layering this repo also applies to the agent; see
|
|
50
|
+
``api_test_agent/cloud_rpc.py``'s own note on the reverse direction)."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, pattern: str, *, seek: str, run: str) -> None:
|
|
53
|
+
self.pattern = re.compile(pattern)
|
|
54
|
+
#: Group 1 marks a start (a group keeps ``re``'s fast literal scan).
|
|
55
|
+
self._seek = re.compile(seek)
|
|
56
|
+
#: ``*``-repeated, so it always matches.
|
|
57
|
+
self._run = re.compile(run)
|
|
58
|
+
|
|
59
|
+
def sub(self, repl: Callable[[re.Match[str]], str], text: str) -> str:
|
|
60
|
+
out: list[str] = []
|
|
61
|
+
kept = pos = 0
|
|
62
|
+
while (seek := self._seek.search(text, pos)) is not None:
|
|
63
|
+
at = seek.start(1)
|
|
64
|
+
match = self.pattern.match(text, at)
|
|
65
|
+
if match is None:
|
|
66
|
+
run = self._run.match(text, at)
|
|
67
|
+
assert run is not None # a ``*``-repeated run always matches
|
|
68
|
+
pos = max(run.end(), at + 1)
|
|
69
|
+
continue
|
|
70
|
+
out += (text[kept:at], repl(match))
|
|
71
|
+
kept = match.end()
|
|
72
|
+
pos = max(kept, at + 1)
|
|
73
|
+
out.append(text[kept:])
|
|
74
|
+
return "".join(out)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
#: Its run is the scheme. It seeks, inside ``re``, the FIRST start of each
|
|
78
|
+
#: scheme run (a letter at a word boundary; the atomic group never retries a
|
|
79
|
+
#: later one) that ``://`` follows: a later start of the same run ends at the
|
|
80
|
+
#: same ``://``, so plain text costs no attempt at all.
|
|
81
|
+
_URL: Final[_LinearPattern] = _LinearPattern(
|
|
82
|
+
r"\b[A-Za-z][A-Za-z0-9+.-]*://[^\s'\"<>]+",
|
|
83
|
+
seek=r"(?<![A-Za-z0-9+.-])(?>[A-Za-z0-9+.-]*?(\b[A-Za-z]))(?=[A-Za-z0-9+.-]*+://)",
|
|
84
|
+
run=r"[A-Za-z0-9+.-]*")
|
|
85
|
+
_HIDDEN = "…"
|
|
86
|
+
_MASK = "****"
|
|
87
|
+
_SECRETS: list[str] = []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def set_secret_values(values: Iterable[str]) -> None:
|
|
91
|
+
"""The run's loaded secret values (masking input only, never logged)."""
|
|
92
|
+
_SECRETS[:] = sorted({str(v) for v in values if v and len(str(v)) >= 4}, key=len,
|
|
93
|
+
reverse=True)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _redact(url: str) -> str:
|
|
97
|
+
try:
|
|
98
|
+
parts = urlsplit(url)
|
|
99
|
+
host = parts.hostname or ""
|
|
100
|
+
port = f":{parts.port}" if parts.port else ""
|
|
101
|
+
except ValueError:
|
|
102
|
+
return _HIDDEN
|
|
103
|
+
if ":" in host: # an IPv6 literal
|
|
104
|
+
host = f"[{host}]"
|
|
105
|
+
out = f"{parts.scheme}://{host}{port}{parts.path}"
|
|
106
|
+
if parts.query:
|
|
107
|
+
pairs = []
|
|
108
|
+
for pair in parts.query.split("&"):
|
|
109
|
+
key, eq, value = pair.partition("=")
|
|
110
|
+
hide = eq and value and (credential_query_name(key) or token_shaped(value))
|
|
111
|
+
pairs.append(f"{key}={_HIDDEN}" if hide else pair)
|
|
112
|
+
out += "?" + "&".join(pairs)
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def redact_urls(text: str) -> str:
|
|
117
|
+
"""*text* with every URL redacted (see the module docstring) and every
|
|
118
|
+
loaded secret value masked."""
|
|
119
|
+
text = _URL.sub(lambda m: _redact(m.group(0)), text)
|
|
120
|
+
for secret in _SECRETS:
|
|
121
|
+
text = text.replace(secret, _MASK)
|
|
122
|
+
return text
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class RedactUrls(logging.Filter):
|
|
126
|
+
"""Rewrites a record's message with :func:`redact_urls` (its args folded
|
|
127
|
+
in first) and its traceback / stack text too."""
|
|
128
|
+
|
|
129
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
130
|
+
try:
|
|
131
|
+
message = record.getMessage()
|
|
132
|
+
except Exception: # noqa: BLE001 — a malformed record is dropped, never raised
|
|
133
|
+
return False
|
|
134
|
+
record.msg = redact_urls(message)
|
|
135
|
+
record.args = ()
|
|
136
|
+
if record.exc_info and not record.exc_text:
|
|
137
|
+
record.exc_text = "".join(traceback.format_exception(*record.exc_info)).rstrip()
|
|
138
|
+
if record.exc_text:
|
|
139
|
+
record.exc_text = redact_urls(record.exc_text)
|
|
140
|
+
if record.stack_info:
|
|
141
|
+
record.stack_info = redact_urls(record.stack_info)
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _excepthook(kind, value, tb) -> None: # noqa: ANN001 — sys.excepthook's signature
|
|
146
|
+
sys.stderr.write(redact_urls("".join(traceback.format_exception(kind, value, tb))))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def install() -> None:
|
|
150
|
+
"""Put :class:`RedactUrls` on every root handler and on the HTTP client
|
|
151
|
+
loggers themselves, and redact an uncaught exception's traceback.
|
|
152
|
+
Idempotent."""
|
|
153
|
+
targets: list[logging.Filterer] = [*logging.getLogger().handlers]
|
|
154
|
+
targets += [logging.getLogger(name) for name in ("httpx", "httpcore")]
|
|
155
|
+
for target in targets:
|
|
156
|
+
if not any(isinstance(f, RedactUrls) for f in target.filters):
|
|
157
|
+
target.addFilter(RedactUrls())
|
|
158
|
+
sys.excepthook = _excepthook
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
__all__ = ["RedactUrls", "install", "redact_urls", "set_secret_values"]
|
api_test_agent/_ssrf.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""SSRF guard for agent-driven URL fetches (review finding C3).
|
|
2
|
+
|
|
3
|
+
Every place the agent — or an imported spec/doc it was steered by (a prompt-
|
|
4
|
+
injection surface) — can make us fetch an arbitrary URL (``fetch_url``,
|
|
5
|
+
``import_openapi`` by URL, the OpenAPI source loader, and the ``manage`` import
|
|
6
|
+
path) must refuse INTERNAL targets. Without this, an LLM-controlled URL reaches
|
|
7
|
+
``169.254.169.254`` (cloud metadata), ``localhost``, or an RFC1918 host — with
|
|
8
|
+
the raw body returned into LLM context.
|
|
9
|
+
|
|
10
|
+
Guard: resolve the host and reject if ANY resolved address is loopback /
|
|
11
|
+
link-local (includes the metadata range) / private / reserved / multicast /
|
|
12
|
+
unspecified; validate the initial URL AND every redirect hop (redirects are
|
|
13
|
+
followed MANUALLY so a public URL cannot 3xx into an internal one). ``http_request``
|
|
14
|
+
keeps its own explicit host-allowlist rail; this guard covers the ingest/doc
|
|
15
|
+
fetchers that had none.
|
|
16
|
+
|
|
17
|
+
Residual (documented, out of scope this pass): DNS rebinding between our
|
|
18
|
+
resolution and httpx's own connect is not closed here — a fully robust version
|
|
19
|
+
pins the resolved IP and connects to it with the original Host header.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import ipaddress
|
|
25
|
+
import socket
|
|
26
|
+
from typing import Any
|
|
27
|
+
from urllib.parse import urljoin, urlparse
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SsrfError(Exception):
|
|
33
|
+
"""A URL was refused because it targets a non-public / internal address."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Ranges the category flags below can miss depending on the Python point-release
|
|
37
|
+
# (RFC 6598 CGNAT hosts Alibaba Cloud metadata 100.100.100.200 and is common for
|
|
38
|
+
# k8s / internal load-balancers). Blocked explicitly so the guard is version-proof.
|
|
39
|
+
_EXTRA_BLOCKED = [
|
|
40
|
+
ipaddress.ip_network("100.64.0.0/10"), # RFC 6598 carrier-grade NAT
|
|
41
|
+
ipaddress.ip_network("192.0.0.0/24"), # IETF protocol assignments
|
|
42
|
+
ipaddress.ip_network("192.88.99.0/24"), # 6to4 relay anycast
|
|
43
|
+
ipaddress.ip_network("::ffff:0:0/96"), # IPv4-mapped IPv6 (defense in depth)
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _addr_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
|
48
|
+
# Allow ONLY globally-routable public addresses. `not is_global` is the
|
|
49
|
+
# allowlist-style backstop that catches anything the explicit category flags
|
|
50
|
+
# miss (CGNAT, benchmarking, future special-use); the flags + explicit nets
|
|
51
|
+
# are belt-and-braces so a Python release that mis-classifies a range can't
|
|
52
|
+
# open a hole.
|
|
53
|
+
if (
|
|
54
|
+
not ip.is_global or ip.is_loopback or ip.is_link_local or ip.is_private
|
|
55
|
+
or ip.is_reserved or ip.is_multicast or ip.is_unspecified
|
|
56
|
+
):
|
|
57
|
+
return True
|
|
58
|
+
return any(ip.version == net.version and ip in net for net in _EXTRA_BLOCKED)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def assert_public_url(url: str) -> None:
|
|
62
|
+
"""Raise :class:`SsrfError` unless *url* is http(s) with a host that resolves
|
|
63
|
+
ENTIRELY to public addresses."""
|
|
64
|
+
parsed = urlparse(url)
|
|
65
|
+
if parsed.scheme not in ("http", "https"):
|
|
66
|
+
raise SsrfError(f"refusing non-http(s) URL scheme '{parsed.scheme or '?'}'")
|
|
67
|
+
host = parsed.hostname
|
|
68
|
+
if not host:
|
|
69
|
+
raise SsrfError("refusing URL with no host")
|
|
70
|
+
try:
|
|
71
|
+
infos = socket.getaddrinfo(
|
|
72
|
+
host, parsed.port or (443 if parsed.scheme == "https" else 80),
|
|
73
|
+
proto=socket.IPPROTO_TCP,
|
|
74
|
+
)
|
|
75
|
+
except socket.gaierror as exc:
|
|
76
|
+
raise SsrfError(f"refusing '{host}': DNS resolution failed ({exc})") from exc
|
|
77
|
+
for info in infos:
|
|
78
|
+
raw = info[4][0].split("%")[0] # strip any IPv6 scope id
|
|
79
|
+
try:
|
|
80
|
+
ip = ipaddress.ip_address(raw)
|
|
81
|
+
except ValueError as exc:
|
|
82
|
+
raise SsrfError(f"refusing '{host}': unparseable address {raw!r}") from exc
|
|
83
|
+
if _addr_blocked(ip):
|
|
84
|
+
raise SsrfError(f"refusing '{host}': resolves to non-public address {ip}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _next_redirect(resp: httpx.Response) -> str | None:
|
|
88
|
+
loc = resp.headers.get("location")
|
|
89
|
+
return urljoin(str(resp.url), loc) if resp.is_redirect and loc else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def safe_get(client: httpx.AsyncClient, url: str, *, max_redirects: int = 5, **kwargs: Any) -> httpx.Response:
|
|
93
|
+
"""``client.get`` that validates the URL AND every redirect hop against
|
|
94
|
+
:func:`assert_public_url`. Redirects are followed manually (never httpx auto)
|
|
95
|
+
so a public URL cannot 3xx into an internal target."""
|
|
96
|
+
kwargs.pop("follow_redirects", None)
|
|
97
|
+
current = url
|
|
98
|
+
for _ in range(max_redirects + 1):
|
|
99
|
+
assert_public_url(current)
|
|
100
|
+
resp = await client.get(current, follow_redirects=False, **kwargs)
|
|
101
|
+
nxt = _next_redirect(resp)
|
|
102
|
+
if nxt is None:
|
|
103
|
+
return resp
|
|
104
|
+
current = nxt
|
|
105
|
+
raise SsrfError(f"too many redirects (> {max_redirects}) starting at {url}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def safe_get_sync(url: str, *, max_redirects: int = 5, **kwargs: Any) -> httpx.Response:
|
|
109
|
+
"""Synchronous counterpart of :func:`safe_get` for the sync ``manage`` path."""
|
|
110
|
+
kwargs.pop("follow_redirects", None)
|
|
111
|
+
current = url
|
|
112
|
+
for _ in range(max_redirects + 1):
|
|
113
|
+
assert_public_url(current)
|
|
114
|
+
resp = httpx.get(current, follow_redirects=False, **kwargs)
|
|
115
|
+
nxt = _next_redirect(resp)
|
|
116
|
+
if nxt is None:
|
|
117
|
+
return resp
|
|
118
|
+
current = nxt
|
|
119
|
+
raise SsrfError(f"too many redirects (> {max_redirects}) starting at {url}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
__all__ = ["SsrfError", "assert_public_url", "safe_get", "safe_get_sync"]
|