apifuzz 0.2.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.
@@ -0,0 +1,168 @@
1
+ """Confirm that a scan target is a running local Docker container (the --require-docker mode).
2
+
3
+ Why: a lab target in Docker is the safest thing to point a scanner at. With --require-docker, a
4
+ typo in --base-url (or a .env left over from a real environment) can't send scan traffic anywhere
5
+ except a container running on this machine.
6
+
7
+ How: ask the Docker CLI for the running containers (`docker ps` + `docker inspect`) and check that
8
+ the target's host:port is one they publish. Two kinds of address count:
9
+
10
+ * a loopback address written literally (localhost, 127.0.0.1, ::1) on a port a container
11
+ publishes on all interfaces or on loopback; or
12
+ * a container's own network IP on a port that container exposes (works on Linux bridge networks).
13
+
14
+ Hostnames are never resolved through DNS. Only those literal names count, so a name that happens
15
+ to resolve to 127.0.0.1 can't slip past the check.
16
+ """
17
+
18
+ import json
19
+ import subprocess
20
+ from collections.abc import Callable
21
+ from urllib.parse import urlsplit
22
+
23
+ from pydantic import BaseModel
24
+
25
+ from apifuzz.errors import DockerVerifyError
26
+ from apifuzz.http_client import DEFAULT_PORTS
27
+
28
+ # Hosts that mean "this machine", accepted only as written (lower case, brackets removed).
29
+ _LOOPBACK = {"localhost", "127.0.0.1", "::1"}
30
+ # A port published on one of these host IPs is reachable through loopback.
31
+ _REACHABLE_VIA_LOOPBACK = {"", "0.0.0.0", "::", "127.0.0.1", "::1"}
32
+
33
+ # Runs a docker command and returns its stdout. Tests pass a fake one instead.
34
+ Runner = Callable[[list[str]], str]
35
+
36
+
37
+ class Container(BaseModel):
38
+ """The parts of a running container that matter for matching a target."""
39
+
40
+ id: str
41
+ name: str # without docker's leading "/"
42
+ image: str
43
+ # Published ports: (host_ip, host_port, container_port) e.g. ("0.0.0.0", 8000, "8000/tcp").
44
+ published: list[tuple[str, int, str]] = []
45
+ ips: list[str] = [] # the container's own addresses on its networks
46
+ exposed: list[int] = [] # TCP ports the container exposes (reachable on its own IPs)
47
+
48
+
49
+ class DockerMatch(BaseModel):
50
+ """A target address and the container that serves it."""
51
+
52
+ host: str
53
+ port: int
54
+ container: Container
55
+ how: str # a human-readable explanation, e.g. "publishes 8000/tcp on 0.0.0.0:8000"
56
+
57
+ @property
58
+ def entry(self) -> str:
59
+ """The exact allowlist entry for this match, e.g. "localhost:8000" or "[::1]:8000"."""
60
+ host = f"[{self.host}]" if ":" in self.host else self.host
61
+ return f"{host}:{self.port}"
62
+
63
+
64
+ def _run_docker(args: list[str]) -> str:
65
+ try:
66
+ done = subprocess.run(["docker", *args], capture_output=True, text=True, timeout=20)
67
+ except FileNotFoundError:
68
+ raise DockerVerifyError(
69
+ "can't verify the target: the docker command was not found. Install Docker "
70
+ "(or, for a scan, drop --require-docker)."
71
+ ) from None
72
+ except subprocess.TimeoutExpired:
73
+ raise DockerVerifyError("docker did not answer within 20 seconds; is the Docker daemon running?") from None
74
+ if done.returncode != 0:
75
+ detail = (done.stderr or done.stdout).strip().splitlines()
76
+ raise DockerVerifyError(f"docker {args[0]} failed: {detail[0] if detail else 'no output'}")
77
+ return done.stdout
78
+
79
+
80
+ def running_containers(run: Runner = _run_docker) -> list[Container]:
81
+ """Every running container, read from `docker ps` and `docker inspect`."""
82
+ ids = run(["ps", "-q", "--no-trunc"]).split()
83
+ if not ids:
84
+ return []
85
+ try:
86
+ details = json.loads(run(["inspect", *ids]))
87
+ except json.JSONDecodeError as exc:
88
+ raise DockerVerifyError(f"could not read docker inspect output: {exc}") from exc
89
+ return [_to_container(d) for d in details if (d.get("State") or {}).get("Running")]
90
+
91
+
92
+ def _to_container(d: dict) -> Container:
93
+ net = d.get("NetworkSettings") or {}
94
+ published = []
95
+ for container_port, bindings in (net.get("Ports") or {}).items():
96
+ if not container_port.endswith("/tcp"):
97
+ continue # HTTP runs over TCP; UDP ports can't be a scan target
98
+ for b in bindings or []: # None when the port is exposed but not published
99
+ try:
100
+ published.append((b.get("HostIp") or "", int(b["HostPort"]), container_port))
101
+ except (KeyError, TypeError, ValueError):
102
+ continue # a binding without a usable port number
103
+ exposed = []
104
+ for port in ((d.get("Config") or {}).get("ExposedPorts") or {}):
105
+ number, _, proto = port.partition("/")
106
+ if proto in ("", "tcp") and number.isdigit():
107
+ exposed.append(int(number))
108
+ ips = [n["IPAddress"] for n in (net.get("Networks") or {}).values() if n and n.get("IPAddress")]
109
+ return Container(
110
+ id=str(d.get("Id", ""))[:12],
111
+ name=str(d.get("Name", "")).lstrip("/"),
112
+ image=str((d.get("Config") or {}).get("Image", "")),
113
+ published=published,
114
+ ips=ips,
115
+ exposed=exposed,
116
+ )
117
+
118
+
119
+ def _host_port(target: str) -> tuple[str, int]:
120
+ """Split a URL or "host:port" entry into (host, port), using 80/443 for a URL with no port."""
121
+ parsed = urlsplit(target if "://" in target else f"//{target}")
122
+ try:
123
+ host, port = parsed.hostname, parsed.port
124
+ except ValueError:
125
+ host, port = None, None
126
+ if not host:
127
+ raise DockerVerifyError(f"{target!r} has no usable host")
128
+ if port is None:
129
+ if parsed.scheme not in DEFAULT_PORTS:
130
+ raise DockerVerifyError(
131
+ f"allowlist entry {target!r} has no port; with --require-docker every entry must be host:port"
132
+ )
133
+ port = DEFAULT_PORTS[parsed.scheme]
134
+ return host, port
135
+
136
+
137
+ def match_target(target: str, containers: list[Container]) -> DockerMatch:
138
+ """Return the container serving `target` (a URL or host:port), or raise DockerVerifyError."""
139
+ host, port = _host_port(target)
140
+ for c in containers:
141
+ for host_ip, host_port, container_port in c.published:
142
+ if host_port != port:
143
+ continue
144
+ if (host in _LOOPBACK and host_ip in _REACHABLE_VIA_LOOPBACK) or host == host_ip:
145
+ return DockerMatch(
146
+ host=host, port=port, container=c,
147
+ how=f"publishes {container_port} on {host_ip or '0.0.0.0'}:{host_port}",
148
+ )
149
+ if host in c.ips and port in c.exposed:
150
+ return DockerMatch(host=host, port=port, container=c, how=f"has IP {host} and exposes {port}/tcp")
151
+
152
+ if not containers:
153
+ raise DockerVerifyError(f"{target}: no Docker containers are running")
154
+ if host not in _LOOPBACK and not any(host in c.ips for c in containers):
155
+ raise DockerVerifyError(
156
+ f"{target}: host {host!r} is not localhost, 127.0.0.1, ::1 or a running container's IP"
157
+ )
158
+ raise DockerVerifyError(f"{target}: no running container publishes port {port}")
159
+
160
+
161
+ def verify_targets(base_url: str, extra_entries: list[str], run: Runner = _run_docker) -> list[DockerMatch]:
162
+ """Check the base URL and every extra allowlist entry. All must match a running container.
163
+
164
+ Returns one match per target (base URL first), so the caller can build an allowlist of
165
+ exact host:port entries and show which container each one belongs to.
166
+ """
167
+ containers = running_containers(run)
168
+ return [match_target(t, containers) for t in [base_url, *extra_entries]]
@@ -0,0 +1,180 @@
1
+ """Flatten a resolved OpenAPI/Swagger document into a list of Endpoint objects.
2
+
3
+ Input: the spec as a Python dict, with every $ref already filled in (spec_loader does that).
4
+ Output: one Endpoint per path + method pair, e.g. GET /orders/{id}.
5
+ The security checks in later phases read these Endpoints to decide which requests to send.
6
+ """
7
+
8
+ from typing import Any # type hint meaning "any value" (a spec can contain anything)
9
+
10
+ from pydantic import BaseModel # gives classes typed fields, validation, and free JSON output
11
+
12
+ # The only keys in a path item that are operations. Other keys live next to them
13
+ # ("parameters", "summary", "x-something") and must not be mistaken for HTTP methods.
14
+ HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
15
+
16
+
17
+ class Parameter(BaseModel):
18
+ """A value sent outside the request body: in the URL path, query string, a header or a cookie."""
19
+
20
+ name: str # e.g. "id" or "q"
21
+ location: str # "path", "query", "header" or "cookie" (copied from the spec's "in" field)
22
+ required: bool = False # class default only; _to_parameter always passes the real value
23
+ type: str | None = None # e.g. "integer"; None when the spec doesn't say
24
+
25
+
26
+ class RequestField(BaseModel):
27
+ """One top-level field of a request body (a JSON property or a form field)."""
28
+
29
+ name: str # e.g. "username"
30
+ required: bool = False # True if the body schema lists this name under "required"
31
+ type: str | None = None # e.g. "string", "array[integer]", "object"
32
+
33
+
34
+ class Endpoint(BaseModel):
35
+ """Everything the scanner needs to know about one operation, e.g. PATCH /users/{id}."""
36
+
37
+ path: str # the URL template, placeholders included: "/orders/{id}"
38
+ method: str # upper case: GET, POST...
39
+ operation_id: str | None = None # the spec's optional unique name for the operation
40
+ summary: str | None = None # the spec's optional one-line description
41
+ parameters: list[Parameter] = [] # path/query/header/cookie values (pydantic copies this default safely)
42
+ request_fields: list[RequestField] = [] # fields of the request body, if it has one
43
+ requires_auth: bool = False # True if a login token must be sent
44
+ tags: list[str] = [] # the spec's grouping labels, e.g. ["orders"]; used by --tag
45
+ deprecated: bool = False # the spec marks this operation as deprecated
46
+
47
+
48
+ def build_endpoints(spec: dict[str, Any]) -> list[Endpoint]:
49
+ """List every operation in `spec`, in the order the spec declares them.
50
+
51
+ `spec` must already have its $refs resolved (see spec_loader.load_spec).
52
+ """
53
+ endpoints: list[Endpoint] = []
54
+ # spec["paths"] maps each URL template to a "path item": {"/orders/{id}": {"get": {...}}}.
55
+ # `or {}` covers a spec where "paths" is present but empty (None).
56
+ for path, path_item in (spec.get("paths") or {}).items():
57
+ if not isinstance(path_item, dict): # skip anything malformed instead of crashing
58
+ continue
59
+ # Parameters declared on the path itself apply to every method under that path.
60
+ shared_params = path_item.get("parameters") or []
61
+ for method in HTTP_METHODS:
62
+ operation = path_item.get(method) # None if this path doesn't support this method
63
+ if not isinstance(operation, dict):
64
+ continue
65
+ # Combine the path's shared parameters with the operation's own ones.
66
+ parameters = _merge_parameters(shared_params, operation.get("parameters") or [])
67
+ endpoints.append(
68
+ Endpoint(
69
+ path=path,
70
+ method=method.upper(),
71
+ operation_id=operation.get("operationId"),
72
+ summary=operation.get("summary"),
73
+ # "body" and "formData" are Swagger 2's way of describing the request body.
74
+ # They are body content, not URL parameters, so _request_fields handles them.
75
+ parameters=[_to_parameter(p) for p in parameters if p.get("in") not in ("body", "formData")],
76
+ request_fields=_request_fields(operation, parameters),
77
+ requires_auth=_requires_auth(operation, spec),
78
+ tags=[str(t) for t in operation.get("tags") or []],
79
+ deprecated=bool(operation.get("deprecated", False)),
80
+ )
81
+ )
82
+ return endpoints
83
+
84
+
85
+ def _merge_parameters(path_level: list[dict], operation_level: list[dict]) -> list[dict]:
86
+ # A parameter is identified by (name, location); the operation's version wins over the path's.
87
+ merged: dict[tuple[str, str], dict] = {}
88
+ # Path-level ones go in first, operation-level ones second. Writing to a dict key that
89
+ # already exists replaces the old value, so the operation's version wins.
90
+ for param in [*path_level, *operation_level]:
91
+ if isinstance(param, dict) and "name" in param: # ignore malformed entries
92
+ merged[(param["name"], param.get("in", ""))] = param
93
+ return list(merged.values())
94
+
95
+
96
+ def _to_parameter(param: dict) -> Parameter:
97
+ # OpenAPI 3 nests the type under "schema"; Swagger 2 puts it on the parameter itself.
98
+ # `param.get("schema") or param` handles both: use "schema" if there is one, else the parameter.
99
+ schema = param.get("schema") or param
100
+ location = param["in"]
101
+ return Parameter(
102
+ name=param["name"],
103
+ location=location,
104
+ # Use the spec's "required" value (False if it doesn't say). A path parameter is always
105
+ # required, because the URL can't be built without it.
106
+ required=bool(param.get("required", False)) or location == "path",
107
+ type=_type_label(schema),
108
+ )
109
+
110
+
111
+ def _request_fields(operation: dict, parameters: list[dict]) -> list[RequestField]:
112
+ # OpenAPI 3: requestBody.content.<media type>.schema
113
+ content = (operation.get("requestBody") or {}).get("content") or {}
114
+ if content:
115
+ # Prefer the JSON version of the body; otherwise take the first media type listed.
116
+ media = content.get("application/json") or next(iter(content.values()))
117
+ return _schema_fields((media or {}).get("schema") or {})
118
+
119
+ # Swagger 2: an "in: body" parameter carries a schema; "in: formData" parameters are the fields.
120
+ for param in parameters:
121
+ if param.get("in") == "body":
122
+ return _schema_fields(param.get("schema") or {})
123
+ return [
124
+ RequestField(name=p["name"], required=bool(p.get("required", False)), type=_type_label(p))
125
+ for p in parameters
126
+ if p.get("in") == "formData"
127
+ ] # empty list when the operation has no body at all (a GET, usually)
128
+
129
+
130
+ def _schema_fields(schema: dict) -> list[RequestField]:
131
+ # Turn an object schema into one RequestField per top-level property.
132
+ properties, required = _collect_properties(schema)
133
+ return [
134
+ RequestField(name=name, required=name in required, type=_type_label(sub))
135
+ for name, sub in properties.items()
136
+ ]
137
+
138
+
139
+ def _collect_properties(schema: dict) -> tuple[dict[str, dict], set[str]]:
140
+ """Gather the properties of an object schema, merging allOf branches."""
141
+ properties: dict[str, dict] = {}
142
+ required: set[str] = set(schema.get("required") or []) # a set, so duplicates collapse
143
+ # "allOf" builds a schema out of several pieces that all apply at once (e.g. base fields +
144
+ # extras). This function calls itself on each piece and merges the results.
145
+ for branch in schema.get("allOf") or []:
146
+ sub_properties, sub_required = _collect_properties(branch or {})
147
+ properties.update(sub_properties)
148
+ required |= sub_required # `|=` adds the piece's required names to ours (set union)
149
+ # The schema's own properties go last, so they win over same-named ones from allOf.
150
+ properties.update(schema.get("properties") or {})
151
+ return properties, required
152
+
153
+
154
+ def _type_label(schema: dict) -> str | None:
155
+ # Produce a short, human-readable type like "integer", "array[string]" or "object".
156
+ schema_type = schema.get("type")
157
+ if schema_type == "array":
158
+ # Look inside "items" to say what the array holds.
159
+ item_type = _type_label(schema.get("items") or {})
160
+ return f"array[{item_type}]" if item_type else "array"
161
+ # Specs often leave out "type": "object" when the schema clearly has fields.
162
+ if schema_type is None and ("properties" in schema or "allOf" in schema):
163
+ return "object"
164
+ # OpenAPI 3.1 allows a list like ["string", "null"]. "null" only means "may be empty", so
165
+ # drop it; several real types left are joined, e.g. "string|integer".
166
+ if isinstance(schema_type, list):
167
+ real = [str(t) for t in schema_type if t != "null"]
168
+ return "|".join(real) or None
169
+ return schema_type if isinstance(schema_type, str) else None
170
+
171
+
172
+ def _requires_auth(operation: dict, spec: dict) -> bool:
173
+ # An operation's own "security" replaces the global one, and "security: []" switches auth off.
174
+ # .get(key, default) only uses the default when the key is ABSENT, so an explicit [] is kept.
175
+ security = operation.get("security", spec.get("security"))
176
+ if not security: # None (never mentioned) or [] (explicitly no auth)
177
+ return False
178
+ # "security" is a list of alternatives; meeting any one of them is enough.
179
+ # An empty requirement object ({}) is an alternative that means "no credentials needed".
180
+ return all(bool(requirement) for requirement in security)
apifuzz/errors.py ADDED
@@ -0,0 +1,37 @@
1
+ """Exceptions raised by apifuzz. The CLI turns any ApifuzzError into a one-line message."""
2
+
3
+ # Why custom exception classes? They let the CLI tell "expected problems" (bad spec, blocked
4
+ # host) apart from real bugs. The CLI catches ApifuzzError and prints a friendly message;
5
+ # any other exception is a bug and is allowed to show a full traceback.
6
+
7
+
8
+ class ApifuzzError(Exception):
9
+ """Base class for errors that are safe to show to the user as-is."""
10
+
11
+
12
+ # Each subclass below inherits from ApifuzzError, so `except ApifuzzError` catches all of them,
13
+ # while code that cares about one specific problem can catch just that subclass.
14
+
15
+
16
+ class SpecError(ApifuzzError):
17
+ """The OpenAPI spec could not be read, parsed, resolved or validated."""
18
+
19
+
20
+ class AllowlistError(ApifuzzError):
21
+ """A request was refused because its target is not on the allowlist."""
22
+
23
+
24
+ class DockerVerifyError(ApifuzzError):
25
+ """--require-docker is on, and the target couldn't be confirmed as a running Docker container."""
26
+
27
+
28
+ class RequestError(ApifuzzError):
29
+ """A request to an allowed target failed at the network level (refused, timed out...)."""
30
+
31
+
32
+ class SessionError(ApifuzzError):
33
+ """Logging in a test account failed (bad credentials, no login endpoint, no token in the answer)."""
34
+
35
+
36
+ class RequestLimitError(ApifuzzError):
37
+ """The scan reached --max-requests. It stops there instead of sending more."""
apifuzz/finding.py ADDED
@@ -0,0 +1,55 @@
1
+ """What a check reports when it finds something: a Finding, plus the severity scale.
2
+
3
+ A Finding is deliberately plain data. A check creates it; the reporter displays it. It carries the
4
+ Exchange(s) that back it up, so a reader can see the exact request and response behind the claim.
5
+ """
6
+
7
+ import hashlib # the finding's fingerprint
8
+ from enum import IntEnum # an ordered set of severities, so findings can be sorted worst-first
9
+
10
+ from pydantic import BaseModel, computed_field, field_serializer
11
+
12
+ from apifuzz.http_client import Exchange
13
+
14
+
15
+ class Severity(IntEnum):
16
+ """How serious a finding is. IntEnum so `sorted(..., reverse=True)` puts the worst first."""
17
+
18
+ INFO = 0 # not a flaw: something worth noting, or a check that confirmed good behaviour
19
+ LOW = 1
20
+ MEDIUM = 2
21
+ HIGH = 3
22
+ CRITICAL = 4
23
+
24
+ def __str__(self) -> str: # so it prints as "HIGH", not "Severity.HIGH"
25
+ return self.name
26
+
27
+
28
+ class Finding(BaseModel):
29
+ """One result from one check about one endpoint."""
30
+
31
+ check: str # the id of the check that produced this, e.g. "auth-required"
32
+ owasp: str = "" # the OWASP API Security Top 10 category, e.g. "API1:2023 Broken Object Level Authorization"
33
+ severity: Severity
34
+ title: str # a short headline, e.g. "Endpoint reachable without authentication"
35
+ endpoint: str # which operation, as "METHOD /path", e.g. "GET /orders/{id}"
36
+ detail: str # a sentence or two explaining what was observed and why it matters
37
+ remediation: str = "" # how a developer fixes it, in a sentence or two
38
+ # The request/response pairs that justify the finding. Kept so a report can show evidence and
39
+ # a reader can reproduce it. May be empty for an INFO note that isn't about a specific request.
40
+ evidence: list[Exchange] = []
41
+ # What tells this finding apart from another by the same check on the same endpoint, e.g. the
42
+ # field or parameter name. Empty when a check reports at most one finding per endpoint.
43
+ key: str = ""
44
+
45
+ # A short stable id built from the check, the endpoint and the key. It stays the same from one
46
+ # run to the next, so two reports can be compared and a finding can be marked as accepted.
47
+ @computed_field # included in the JSON output
48
+ @property
49
+ def fingerprint(self) -> str:
50
+ return hashlib.sha256(f"{self.check}|{self.endpoint}|{self.key}".encode()).hexdigest()[:16]
51
+
52
+ # In JSON, write the severity as "HIGH" rather than the number 3, so reports read clearly.
53
+ @field_serializer("severity")
54
+ def _severity_name(self, severity: Severity) -> str:
55
+ return severity.name
apifuzz/http_client.py ADDED
@@ -0,0 +1,221 @@
1
+ """The only place apifuzz sends requests to a target.
2
+
3
+ Every request is checked against an allowlist before it leaves the process, and the raw request
4
+ and response are recorded so a finding can later show its evidence.
5
+ """
6
+
7
+ import time # used to measure how long each request took
8
+ from collections.abc import Iterable # type hint for "anything you can loop over"
9
+ from types import TracebackType # type hint used by __exit__ (the `with` statement support)
10
+ from typing import Any
11
+ from urllib.parse import urlparse # splits a URL into scheme, host, port, path...
12
+
13
+ import httpx # the HTTP library that actually sends requests
14
+ from pydantic import BaseModel # typed record classes with free JSON output
15
+
16
+ from apifuzz import __version__
17
+ from apifuzz.errors import AllowlistError, RequestError, RequestLimitError
18
+
19
+ # The only schemes we allow, and the port each one uses when the URL doesn't name a port.
20
+ DEFAULT_PORTS = {"http": 80, "https": 443}
21
+
22
+
23
+ class Allowlist:
24
+ """Hosts the tool may talk to. Entries are `host` (any port) or `host:port`.
25
+
26
+ Matching is exact on the host name: allowing `example.com` does not allow `api.example.com`.
27
+ An empty allowlist permits nothing.
28
+ """
29
+
30
+ def __init__(self, entries: Iterable[str] = ()) -> None:
31
+ # Stored as (host, port) pairs; port None means "any port on this host".
32
+ # A set makes duplicates disappear and lookups fast.
33
+ self._entries: set[tuple[str, int | None]] = set()
34
+ for entry in entries:
35
+ self._entries.add(self._parse_entry(entry))
36
+
37
+ @staticmethod # doesn't need `self`; it's a helper that belongs with this class
38
+ def _parse_entry(entry: str) -> tuple[str, int | None]:
39
+ text = entry.strip()
40
+ # urlparse only recognises a host when it follows "//". A bare entry like "localhost:8000"
41
+ # would be misread as a path, so we put "//" in front. Full URLs already have "://".
42
+ parsed = urlparse(text if "://" in text else f"//{text}")
43
+ try:
44
+ # .hostname is lower-cased and has [ ] removed from IPv6 addresses.
45
+ # .port raises ValueError for a non-number like "localhost:abc".
46
+ host, port = parsed.hostname, parsed.port
47
+ except ValueError:
48
+ host, port = None, None
49
+ if not host:
50
+ # A bad entry is a configuration mistake: fail loudly rather than allow nothing silently.
51
+ raise ValueError(f"invalid allowlist entry: {entry!r}")
52
+ return host, port
53
+
54
+ def permits(self, url: str) -> bool:
55
+ try:
56
+ parsed = urlparse(url)
57
+ # Using .hostname (not the raw text) defeats tricks like "http://allowed.com@evil.test/":
58
+ # the part before "@" is a username, and the real host is evil.test.
59
+ host, port = parsed.hostname, parsed.port
60
+ except ValueError:
61
+ return False # unparseable URL: refuse
62
+ if parsed.scheme not in DEFAULT_PORTS or not host:
63
+ return False # only http(s) with a real host; no file://, ftp://, etc.
64
+ port = port or DEFAULT_PORTS[parsed.scheme] # no port in the URL -> 80 or 443
65
+ # Allowed if ANY entry has the same host and either no port restriction or the same port.
66
+ return any(host == allowed_host and allowed_port in (None, port) for allowed_host, allowed_port in self._entries)
67
+
68
+ def check(self, url: str) -> None:
69
+ # Same test as permits(), but raises instead of returning False, so callers can't
70
+ # forget to look at the answer.
71
+ if not self.permits(url):
72
+ raise AllowlistError(f"refusing to contact {url}: its host is not on the allowlist")
73
+
74
+
75
+ # The three record classes below are the "evidence" a finding will show in a report.
76
+
77
+
78
+ class RecordedRequest(BaseModel):
79
+ method: str # "GET", "POST"...
80
+ url: str # the full URL as sent, query string included
81
+ headers: dict[str, str] # every header sent, including Authorization
82
+ body: str | None = None # None for requests without a body (a GET, usually)
83
+
84
+
85
+ class RecordedResponse(BaseModel):
86
+ status_code: int # 200, 403, 404...
87
+ headers: dict[str, str]
88
+ body: str # the body as text, cut off at the capture limit
89
+ truncated: bool = False # True when the body was longer than the capture limit
90
+
91
+ @property
92
+ def ok(self) -> bool:
93
+ """True for a 2xx status: the request succeeded."""
94
+ return 200 <= self.status_code < 300
95
+
96
+
97
+ class Exchange(BaseModel):
98
+ """One request and the response it got, as sent and received."""
99
+
100
+ request: RecordedRequest
101
+ response: RecordedResponse
102
+ elapsed_ms: float # how long the round trip took (useful later for the rate-limit check)
103
+
104
+
105
+ class HttpClient:
106
+ """Sends requests to allowlisted hosts and returns them as Exchange records.
107
+
108
+ Redirects are never followed: a redirect could point at a host outside the allowlist, so the
109
+ 3xx response is returned as it is. Request headers (including Authorization) are recorded
110
+ unredacted; redaction belongs to the reporter.
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ allowlist: Allowlist,
116
+ *, # everything after this must be passed by name, e.g. timeout=5
117
+ timeout: float = 10.0, # seconds before a request is abandoned
118
+ max_body_bytes: int = 64 * 1024, # keep at most 64 KB of each response body
119
+ transport: httpx.BaseTransport | None = None, # tests pass a fake network here
120
+ max_requests: int | None = None, # stop the scan after this many requests (None = no cap)
121
+ delay: float = 0.0, # seconds to wait between requests, to go easy on the target
122
+ ) -> None:
123
+ self._allowlist = allowlist
124
+ self._max_body_bytes = max_body_bytes
125
+ self._max_requests = max_requests
126
+ self._delay = delay
127
+ self.requests_sent = 0 # requests that left the process (whether or not they were answered)
128
+ self.responses_received = 0 # requests that got an HTTP answer back
129
+ # One reusable httpx client: it keeps connections open between requests (faster).
130
+ # The User-Agent names the tool, so the target's logs show where the traffic came from.
131
+ self._client = httpx.Client(
132
+ timeout=timeout,
133
+ follow_redirects=False,
134
+ transport=transport,
135
+ headers={"User-Agent": f"apifuzz/{__version__}"},
136
+ )
137
+
138
+ def request(
139
+ self,
140
+ method: str,
141
+ url: str,
142
+ *,
143
+ headers: dict[str, str] | None = None, # e.g. {"Authorization": "Bearer <token>"}
144
+ params: dict[str, Any] | None = None, # query string values, e.g. {"q": "book"}
145
+ json: Any = None, # a body to send as JSON
146
+ data: dict[str, str] | None = None, # a body to send as form fields (some logins need this)
147
+ ) -> Exchange:
148
+ # httpx's `params=` REPLACES a query string already in the URL ("/search?q=a" would lose
149
+ # q=a). Merge instead, so a URL from the spec or the user keeps its own query values.
150
+ full_url = httpx.URL(url).copy_merge_params(params) if params else url
151
+ # Build the request without sending it, so we can inspect the final URL first.
152
+ request = self._client.build_request(method, full_url, headers=headers, json=json, data=data)
153
+ self._allowlist.check(str(request.url)) # before anything is sent
154
+ if self._max_requests is not None and self.requests_sent >= self._max_requests:
155
+ raise RequestLimitError(f"request limit reached ({self._max_requests}); raise it with --max-requests")
156
+ if self._delay and self.requests_sent:
157
+ time.sleep(self._delay)
158
+ self.requests_sent += 1
159
+
160
+ started = time.perf_counter() # a high-precision clock, good for measuring durations
161
+ try:
162
+ # stream=True means "don't download the whole body yet"; we read it ourselves below
163
+ # so we can stop at the size limit instead of loading a huge response into memory.
164
+ response = self._client.send(request, stream=True)
165
+ try:
166
+ body, truncated = self._read_body(response)
167
+ finally:
168
+ response.close() # always release the connection, even if reading failed
169
+ except httpx.HTTPError as exc:
170
+ # Network-level failure (refused, timed out...). Convert it to our own error type.
171
+ # `exc or type(exc).__name__`: some httpx errors have an empty message; fall back to the class name.
172
+ raise RequestError(f"{method.upper()} {request.url}: {exc or type(exc).__name__}") from exc
173
+ elapsed_ms = (time.perf_counter() - started) * 1000 # seconds -> milliseconds
174
+ self.responses_received += 1
175
+
176
+ # Package everything into plain records that a report can show later.
177
+ return Exchange(
178
+ request=RecordedRequest(
179
+ method=request.method,
180
+ url=str(request.url),
181
+ headers=dict(request.headers),
182
+ # request.content is raw bytes; decode to text. errors="replace" swaps any bytes
183
+ # that aren't valid UTF-8 for a placeholder character instead of crashing.
184
+ body=request.content.decode("utf-8", errors="replace") if request.content else None,
185
+ ),
186
+ response=RecordedResponse(
187
+ status_code=response.status_code,
188
+ headers=dict(response.headers),
189
+ body=body,
190
+ truncated=truncated,
191
+ ),
192
+ elapsed_ms=elapsed_ms,
193
+ )
194
+
195
+ def _read_body(self, response: httpx.Response) -> tuple[str, bool]:
196
+ # Read the body in chunks and stop once we have more than the limit.
197
+ received = bytearray() # a growable byte buffer
198
+ for chunk in response.iter_bytes():
199
+ received += chunk
200
+ if len(received) > self._max_body_bytes:
201
+ break
202
+ # "More than the limit" (not "equal to") means something was actually cut off.
203
+ truncated = len(received) > self._max_body_bytes
204
+ return bytes(received[: self._max_body_bytes]).decode("utf-8", errors="replace"), truncated
205
+
206
+ def close(self) -> None:
207
+ self._client.close() # close the open connections
208
+
209
+ # __enter__ and __exit__ let you write `with HttpClient(...) as client:`.
210
+ # The client is then closed automatically when the `with` block ends, even after an error.
211
+
212
+ def __enter__(self) -> "HttpClient":
213
+ return self
214
+
215
+ def __exit__(
216
+ self,
217
+ exc_type: type[BaseException] | None,
218
+ exc: BaseException | None,
219
+ tb: TracebackType | None,
220
+ ) -> None:
221
+ self.close()