xtr-http-foundation 4.0.2__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.
- xtr_http_foundation/.agents/skills/xtr-http-foundation/SKILL.md +204 -0
- xtr_http_foundation/__init__.py +44 -0
- xtr_http_foundation/chain_request_matcher.py +55 -0
- xtr_http_foundation/exception/__init__.py +12 -0
- xtr_http_foundation/exception/http_foundation_error.py +9 -0
- xtr_http_foundation/exception/invalid_argument_error.py +18 -0
- xtr_http_foundation/ip_utils.py +245 -0
- xtr_http_foundation/py.typed +0 -0
- xtr_http_foundation/request_matcher/__init__.py +27 -0
- xtr_http_foundation/request_matcher/_comma_separated.py +32 -0
- xtr_http_foundation/request_matcher/_compiled_regexp.py +37 -0
- xtr_http_foundation/request_matcher/attributes_request_matcher.py +62 -0
- xtr_http_foundation/request_matcher/header_request_matcher.py +50 -0
- xtr_http_foundation/request_matcher/host_request_matcher.py +49 -0
- xtr_http_foundation/request_matcher/ips_request_matcher.py +72 -0
- xtr_http_foundation/request_matcher/is_json_request_matcher.py +53 -0
- xtr_http_foundation/request_matcher/method_request_matcher.py +51 -0
- xtr_http_foundation/request_matcher/path_request_matcher.py +75 -0
- xtr_http_foundation/request_matcher/port_request_matcher.py +57 -0
- xtr_http_foundation/request_matcher/query_parameter_request_matcher.py +59 -0
- xtr_http_foundation/request_matcher/scheme_request_matcher.py +52 -0
- xtr_http_foundation/request_matcher_interface.py +36 -0
- xtr_http_foundation-4.0.2.dist-info/METADATA +239 -0
- xtr_http_foundation-4.0.2.dist-info/RECORD +26 -0
- xtr_http_foundation-4.0.2.dist-info/WHEEL +4 -0
- xtr_http_foundation-4.0.2.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: xtr-http-foundation
|
|
3
|
+
description: How to say "is this request one of mine" with xtr-http-foundation — one matcher per question about a request that arrived (method, path, host, port, scheme, a header, a query parameter, an attribute, the address it came from, a JSON body), chained into one, plus the IpUtils helpers that place an IP in a network, name a private one, or anonymize it. Use when a firewall, webhook endpoint, rate limiter, router or middleware has to decide which requests it claims; when replacing a tangle of ifs over the method and path with something testable; when writing a request parser's matcher; and when checking whether an address is inside a subnet, is reachable only from this network, or must be logged without identifying one machine.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# xtr-http-foundation
|
|
7
|
+
|
|
8
|
+
A matcher answers one question about a request that arrived, and a chain of them answers the
|
|
9
|
+
whole of it — so what a piece of code claims is a value it can be handed rather than a
|
|
10
|
+
condition written into it. Beside them live the address helpers those questions are built on.
|
|
11
|
+
|
|
12
|
+
## Quick reference
|
|
13
|
+
|
|
14
|
+
- Every matcher is a `RequestMatcherInterface` with one member:
|
|
15
|
+
`await matcher.matches(request)` → `bool`. The request is a `ServerRequestInterface`.
|
|
16
|
+
- `matches()` is awaited even on a matcher that only reads the head, so one can be swapped for
|
|
17
|
+
another.
|
|
18
|
+
- `ChainRequestMatcher([...])` claims a request only when all of them do, answering on the
|
|
19
|
+
first refusal. A chain of nothing claims everything.
|
|
20
|
+
- A matcher naming nothing claims everything. One string is as good as a sequence, and any
|
|
21
|
+
entry may hold a comma-separated list.
|
|
22
|
+
- Expressions are compiled when the matcher is built — a bad one is `InvalidArgumentError`
|
|
23
|
+
there — and are *searched for*, never anchored for you.
|
|
24
|
+
- `IpUtils.check_ip`, `check_ip4`, `check_ip6`, `is_private_ip`, `anonymize`,
|
|
25
|
+
`IpUtils.PRIVATE_SUBNETS`. All static; there is nothing to build.
|
|
26
|
+
|
|
27
|
+
## Claim the requests that are yours
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from xtr_http_foundation import (
|
|
31
|
+
ChainRequestMatcher,
|
|
32
|
+
IsJsonRequestMatcher,
|
|
33
|
+
MethodRequestMatcher,
|
|
34
|
+
PathRequestMatcher,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
ours = ChainRequestMatcher(
|
|
38
|
+
[MethodRequestMatcher("POST"), PathRequestMatcher("^/webhook/"), IsJsonRequestMatcher()]
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if await ours.matches(request):
|
|
42
|
+
...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| Matcher | Takes | Claims a request |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| `MethodRequestMatcher(methods)` | one method or several | whose method is one of them; names read in upper case, the request's compared as it arrived |
|
|
48
|
+
| `PathRequestMatcher(regexp)` | one expression | whose path matches it |
|
|
49
|
+
| `HostRequestMatcher(regexp)` | one expression | whose host matches it, without regard to case |
|
|
50
|
+
| `PortRequestMatcher(port)` | one port | asked of that port, the scheme's standard one counting where the URI is silent |
|
|
51
|
+
| `SchemeRequestMatcher(schemes)` | one scheme or several | made over one of them |
|
|
52
|
+
| `HeaderRequestMatcher(headers)` | header names | carrying every one, whatever each holds |
|
|
53
|
+
| `QueryParameterRequestMatcher(parameters)` | parameter names | whose query *opens* with them, in that order |
|
|
54
|
+
| `IpsRequestMatcher(ips)` | addresses or CIDR networks | that came from one of them |
|
|
55
|
+
| `AttributesRequestMatcher(regexps)` | attribute name → expression | whose every named attribute matches its own |
|
|
56
|
+
| `IsJsonRequestMatcher()` | nothing | whose body reads as JSON |
|
|
57
|
+
| `ChainRequestMatcher(matchers)` | matchers | every one of them claims |
|
|
58
|
+
|
|
59
|
+
Three behave in a way worth knowing before you rely on them.
|
|
60
|
+
|
|
61
|
+
- **`IsJsonRequestMatcher` reads the body, not the `Content-Type` header.** A sender that
|
|
62
|
+
announces nothing still sent JSON; one that announces JSON and sent something else did not.
|
|
63
|
+
Any JSON value counts, so this is the rule for "a body worth decoding" rather than for a
|
|
64
|
+
shape. The body is read from its first byte — a stream another matcher already read is
|
|
65
|
+
rewound — so the order of a chain changes nothing, and a body that cannot be read is simply
|
|
66
|
+
not claimed.
|
|
67
|
+
- **`IpsRequestMatcher` reads the address the server accepted the connection from**, which the
|
|
68
|
+
request carries among its server parameters under `client`. A request through a proxy comes
|
|
69
|
+
*from the proxy*; a rule about the original caller belongs where the forwarded headers are
|
|
70
|
+
trusted and resolved.
|
|
71
|
+
- **`PathRequestMatcher` matches the path the application itself sees**, with the mount
|
|
72
|
+
(`root_path` among the server parameters) removed and per cent escapes undone — so
|
|
73
|
+
`/admin/fo o` matches `/admin/fo%20o`, and moving the mount rewrites no matcher. Nothing in
|
|
74
|
+
the expression is a path parameter: `^/{_locale}/login$` matches those very braces.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from xtr_http_foundation import AttributesRequestMatcher, HostRequestMatcher, IpsRequestMatcher
|
|
78
|
+
|
|
79
|
+
internal = IpsRequestMatcher("10.0.0.0/8,192.168.1.7")
|
|
80
|
+
subdomain = HostRequestMatcher(r"\.example\.com$")
|
|
81
|
+
owners = AttributesRequestMatcher({"role": "^owner$"})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
An attribute the request does not carry, or carries as something that is not text, is never
|
|
85
|
+
claimed.
|
|
86
|
+
|
|
87
|
+
## Write a matcher
|
|
88
|
+
|
|
89
|
+
`RequestMatcherInterface` is a `@runtime_checkable` `Protocol`, so no base class is needed:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from typing import final
|
|
93
|
+
|
|
94
|
+
from typing_extensions import override
|
|
95
|
+
from xtr_http_foundation import RequestMatcherInterface
|
|
96
|
+
from xtr_http_message_contracts import ServerRequestInterface
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@final
|
|
100
|
+
class SignedRequestMatcher(RequestMatcherInterface):
|
|
101
|
+
__slots__ = ()
|
|
102
|
+
|
|
103
|
+
@override
|
|
104
|
+
async def matches(self, request: ServerRequestInterface) -> bool:
|
|
105
|
+
return request.has_header("Webhook-Signature")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Place an address
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from xtr_http_foundation import IpUtils
|
|
112
|
+
|
|
113
|
+
IpUtils.check_ip("10.1.2.3", ["192.168.0.0/16", "10.0.0.0/8"]) # True
|
|
114
|
+
IpUtils.check_ip4("10.1.2.3", "10.0.0.0/8") # True
|
|
115
|
+
IpUtils.check_ip6("::1", "::1/128") # True
|
|
116
|
+
IpUtils.is_private_ip("10.1.2.3") # True
|
|
117
|
+
IpUtils.anonymize("192.168.1.42") # "192.168.1.0"
|
|
118
|
+
IpUtils.anonymize("2001:db8::1") # "2001:db8::"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- `check_ip` takes a single address, a CIDR network, or several of either, and picks the IPv4 or
|
|
122
|
+
the IPv6 check by how the address is written.
|
|
123
|
+
- **The checks never raise over unreadable text**: they answer about a request from a stranger,
|
|
124
|
+
and an address nobody can read is one no network holds. A network written wrongly is read as
|
|
125
|
+
no network rather than as a wider one — `" 8"`, `"+8"`, `"8.5"` name no prefix length — and an
|
|
126
|
+
IPv6 zone such as `fe80::1%eth0` is refused, since it names an interface of whichever host
|
|
127
|
+
reads it.
|
|
128
|
+
- `is_private_ip` is the one that **does** raise `InvalidArgumentError` for text that is no
|
|
129
|
+
address: its caller is asking whether an address may be reached out to. It places the address
|
|
130
|
+
in `IpUtils.PRIVATE_SUBNETS` — loopback, private use, link-local, carrier-grade NAT,
|
|
131
|
+
unique-local, multicast, reserved, unspecified, the documentation and benchmarking ranges, and
|
|
132
|
+
the forms an IPv6 address writes an IPv4 one in.
|
|
133
|
+
- `anonymize(ip, v4_bytes=1, v6_bytes=8)` zeroes the last bytes and gives the address back as it
|
|
134
|
+
arrived — bracketed if it was, dotted where it carries an IPv4 one, and never with its zone.
|
|
135
|
+
Fewer than no bytes, or more than the address has, is `InvalidArgumentError`.
|
|
136
|
+
|
|
137
|
+
## Testing
|
|
138
|
+
|
|
139
|
+
A matcher is a value, so a test hands it a request it built and asserts the one answer:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import pytest
|
|
143
|
+
from xtr_http_foundation import IpsRequestMatcher, MethodRequestMatcher
|
|
144
|
+
from xtr_http_message import ServerRequest
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pytest.mark.anyio
|
|
148
|
+
async def test_it_claims_a_post_from_inside_the_network() -> None:
|
|
149
|
+
matcher = IpsRequestMatcher("10.0.0.0/8")
|
|
150
|
+
arrived = ServerRequest("POST", "/metrics", server_params={"client": ("10.1.2.3", 51234)})
|
|
151
|
+
|
|
152
|
+
assert await matcher.matches(arrived)
|
|
153
|
+
assert await MethodRequestMatcher("POST").matches(arrived)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- Build requests with `ServerRequest(method, uri, headers, body, version, server_params)` from
|
|
157
|
+
[xtr-http-message](https://github.com/xterr/python-xtr/tree/main/packages/xtr-http-message)
|
|
158
|
+
— a dev dependency here, since only tests build a request.
|
|
159
|
+
- Give `server_params={"client": (address, port)}` for `IpsRequestMatcher` and
|
|
160
|
+
`server_params={"root_path": "/mounted"}` for `PathRequestMatcher`; without them each reads
|
|
161
|
+
as nothing supplied.
|
|
162
|
+
- Set what `AttributesRequestMatcher` reads with `request.with_attribute(name, value)`.
|
|
163
|
+
- Pass the body as bytes for `IsJsonRequestMatcher`; nothing has to be rewound by hand.
|
|
164
|
+
|
|
165
|
+
## Use in an application
|
|
166
|
+
|
|
167
|
+
This package **ships no bundle and no recipe**: a matcher is a value built where the rule is
|
|
168
|
+
written, not a service to configure.
|
|
169
|
+
|
|
170
|
+
1. **Install** — `uv add xtr-http-foundation`. There are no extras.
|
|
171
|
+
2. **Activate** — nothing to do. Nothing is registered, so nothing is listed in `BUNDLES`.
|
|
172
|
+
3. **Configure** — nothing. Build the matchers where the rule belongs, usually in the class
|
|
173
|
+
that asks the question.
|
|
174
|
+
4. **Environment** and **Ignore** — nothing.
|
|
175
|
+
5. **Check** — the test above: a matcher is testable on its own, which is the point of it.
|
|
176
|
+
6. **Remove** — `uv remove xtr-http-foundation`.
|
|
177
|
+
|
|
178
|
+
Packages that *do* wire these for you say so in their own sections: a
|
|
179
|
+
[xtr-webhook](https://github.com/xterr/python-xtr/tree/main/packages/xtr-webhook) parser
|
|
180
|
+
returns one from `get_request_matcher()`, and
|
|
181
|
+
[xtr-http-client](https://github.com/xterr/python-xtr/tree/main/packages/xtr-http-client)'s
|
|
182
|
+
`NoPrivateNetworkHttpClient` guards `IpUtils.PRIVATE_SUBNETS` by default.
|
|
183
|
+
|
|
184
|
+
## Errors
|
|
185
|
+
|
|
186
|
+
Everything derives from `HttpFoundationError`. There is one: `InvalidArgumentError`, also a
|
|
187
|
+
`ValueError`, raised for an expression that does not read as one, and by `is_private_ip` and
|
|
188
|
+
`anonymize` for text that is no address.
|
|
189
|
+
|
|
190
|
+
## Do not
|
|
191
|
+
|
|
192
|
+
- Do not check `Content-Type` to decide whether a body is JSON — `IsJsonRequestMatcher` reads
|
|
193
|
+
the body, which is the only thing that settles it.
|
|
194
|
+
- Do not trust `IpsRequestMatcher` for the original caller behind a proxy; resolve the forwarded
|
|
195
|
+
headers first, where they are trusted.
|
|
196
|
+
- Do not expect an expression to be anchored: `PathRequestMatcher("/admin")` claims anything
|
|
197
|
+
containing that path. Write `^/admin/` when that is the rule.
|
|
198
|
+
- Do not write path parameters into a `PathRequestMatcher` expression — `{id}` matches braces.
|
|
199
|
+
- Do not pass host names to `IpsRequestMatcher` or to the `IpUtils` checks: they place
|
|
200
|
+
addresses, and a name would have to be resolved first.
|
|
201
|
+
- Do not call `matches()` without awaiting it — every matcher is async, including the ones that
|
|
202
|
+
only read the head.
|
|
203
|
+
- Do not pass an empty matcher expecting it to refuse everything: a matcher naming nothing
|
|
204
|
+
claims every request.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The request surface an incoming HTTP request is matched and measured against.
|
|
2
|
+
|
|
3
|
+
A matcher answers one question about a request that arrived — its method, path, host, port,
|
|
4
|
+
scheme, a header, a query parameter, an attribute, the address it came from, or whether its
|
|
5
|
+
body is JSON — and a chain of them answers the whole of it, so what a piece of code claims is
|
|
6
|
+
a value it can be handed rather than a condition written into it.
|
|
7
|
+
|
|
8
|
+
Beside them are the address helpers those questions are built on: whether an IP falls inside a
|
|
9
|
+
network, and whether it is one only this network can reach.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .chain_request_matcher import ChainRequestMatcher
|
|
15
|
+
from .ip_utils import IpUtils
|
|
16
|
+
from .request_matcher import (
|
|
17
|
+
AttributesRequestMatcher,
|
|
18
|
+
HeaderRequestMatcher,
|
|
19
|
+
HostRequestMatcher,
|
|
20
|
+
IpsRequestMatcher,
|
|
21
|
+
IsJsonRequestMatcher,
|
|
22
|
+
MethodRequestMatcher,
|
|
23
|
+
PathRequestMatcher,
|
|
24
|
+
PortRequestMatcher,
|
|
25
|
+
QueryParameterRequestMatcher,
|
|
26
|
+
SchemeRequestMatcher,
|
|
27
|
+
)
|
|
28
|
+
from .request_matcher_interface import RequestMatcherInterface
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AttributesRequestMatcher",
|
|
32
|
+
"ChainRequestMatcher",
|
|
33
|
+
"HeaderRequestMatcher",
|
|
34
|
+
"HostRequestMatcher",
|
|
35
|
+
"IpUtils",
|
|
36
|
+
"IpsRequestMatcher",
|
|
37
|
+
"IsJsonRequestMatcher",
|
|
38
|
+
"MethodRequestMatcher",
|
|
39
|
+
"PathRequestMatcher",
|
|
40
|
+
"PortRequestMatcher",
|
|
41
|
+
"QueryParameterRequestMatcher",
|
|
42
|
+
"RequestMatcherInterface",
|
|
43
|
+
"SchemeRequestMatcher",
|
|
44
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""A matcher that claims a request only when every matcher it holds does."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from .request_matcher_interface import RequestMatcherInterface
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
|
|
14
|
+
from xtr_http_message_contracts import ServerRequestInterface
|
|
15
|
+
|
|
16
|
+
__all__ = ["ChainRequestMatcher"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@final
|
|
20
|
+
class ChainRequestMatcher(RequestMatcherInterface):
|
|
21
|
+
"""Claims a request only when every matcher it chains claims it.
|
|
22
|
+
|
|
23
|
+
What a piece of code will look at is usually more than one thing — a
|
|
24
|
+
``POST``, carrying JSON, from inside this network — and this is how those
|
|
25
|
+
are said together without any matcher learning about the others.
|
|
26
|
+
|
|
27
|
+
A chain of nothing claims every request: nothing constrains it, so there is
|
|
28
|
+
nothing for it to refuse.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("_matchers",)
|
|
32
|
+
|
|
33
|
+
def __init__(self, matchers: Iterable[RequestMatcherInterface]) -> None:
|
|
34
|
+
"""Record the matchers that must all claim a request.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
matchers: The matchers to chain. Read once and kept, so a chain
|
|
38
|
+
built over a generator answers the same on every request, and
|
|
39
|
+
a sequence the caller goes on to change cannot alter what the
|
|
40
|
+
chain claims.
|
|
41
|
+
"""
|
|
42
|
+
self._matchers: tuple[RequestMatcherInterface, ...] = tuple(matchers)
|
|
43
|
+
|
|
44
|
+
@override
|
|
45
|
+
async def matches(self, request: ServerRequestInterface) -> bool:
|
|
46
|
+
"""Tell whether every chained matcher claims ``request``.
|
|
47
|
+
|
|
48
|
+
The first refusal is the answer, so a matcher that reads the body is
|
|
49
|
+
never asked for a request an earlier one already turned away.
|
|
50
|
+
"""
|
|
51
|
+
for matcher in self._matchers:
|
|
52
|
+
if not await matcher.matches(request):
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
return True
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Every error this library raises.
|
|
2
|
+
|
|
3
|
+
All of them derive from :class:`HttpFoundationError`, so one ``except`` catches anything
|
|
4
|
+
this library refuses, and a narrower one handles a single cause.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .http_foundation_error import HttpFoundationError
|
|
10
|
+
from .invalid_argument_error import InvalidArgumentError
|
|
11
|
+
|
|
12
|
+
__all__ = ["HttpFoundationError", "InvalidArgumentError"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The error every other error of this package derives from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["HttpFoundationError"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HttpFoundationError(Exception):
|
|
9
|
+
"""Something asked of this library that it cannot do — catch this to catch them all."""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""A value handed to this library cannot be used."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .http_foundation_error import HttpFoundationError
|
|
6
|
+
|
|
7
|
+
__all__ = ["InvalidArgumentError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidArgumentError(HttpFoundationError, ValueError):
|
|
11
|
+
"""A value handed to this library cannot be used: text that is not an address.
|
|
12
|
+
|
|
13
|
+
Also a :class:`ValueError`, so code that already guards against a bad value
|
|
14
|
+
catches it without knowing this library exists. It says a caller passed
|
|
15
|
+
something it cannot have meant, which is why the checks that *decide* about a
|
|
16
|
+
request — whether an address falls in a network — answer ``False`` instead of
|
|
17
|
+
raising: an address that does not parse is simply not in the network.
|
|
18
|
+
"""
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Placing an IP address inside a network, and hiding where it came from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network
|
|
7
|
+
from typing import TYPE_CHECKING, Final, final
|
|
8
|
+
|
|
9
|
+
from .exception import InvalidArgumentError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
__all__ = ["IpUtils"]
|
|
15
|
+
|
|
16
|
+
_V4_BYTES: Final = 4
|
|
17
|
+
_V6_BYTES: Final = 16
|
|
18
|
+
_V4_BITS: Final = 32
|
|
19
|
+
_V6_BITS: Final = 128
|
|
20
|
+
|
|
21
|
+
# How an IPv6 address says it carries an IPv4 one: "::ffff:1.2.3.4" is the form in use,
|
|
22
|
+
# "::1.2.3.4" the one that came before it. What identifies an address as either is the
|
|
23
|
+
# bytes in front, so anonymizing one means dropping bytes from its four, not its sixteen.
|
|
24
|
+
_V4_MAPPED: Final = b"\x00" * 10 + b"\xff\xff"
|
|
25
|
+
_V4_COMPATIBLE: Final = b"\x00" * 12
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _as_ipv4(text: str) -> IPv4Address | None:
|
|
29
|
+
"""The IPv4 address ``text`` writes, or ``None`` when it writes none."""
|
|
30
|
+
try:
|
|
31
|
+
return IPv4Address(text)
|
|
32
|
+
except ValueError:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _as_ipv6(text: str) -> IPv6Address | None:
|
|
37
|
+
"""The IPv6 address ``text`` writes, or ``None`` when it writes none.
|
|
38
|
+
|
|
39
|
+
A zone — the ``%eth0`` of ``fe80::1%eth0`` — names an interface of whichever host
|
|
40
|
+
reads it, so the same text means a different address on the next machine. Nothing can
|
|
41
|
+
be decided from one, and it is refused here rather than quietly dropped.
|
|
42
|
+
"""
|
|
43
|
+
if "%" in text:
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
return IPv6Address(text)
|
|
47
|
+
except ValueError:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _prefix_length(text: str, longest: int) -> int | None:
|
|
52
|
+
"""The CIDR prefix length ``text`` writes, or ``None`` when it writes none.
|
|
53
|
+
|
|
54
|
+
Only ASCII digits count, so ``" 8"``, ``"+8"``, ``"8e0"`` and ``"8.5"`` name no prefix
|
|
55
|
+
at all rather than the 8 a lenient reader would take from each of them — a network
|
|
56
|
+
written wrongly must not silently become a wider one.
|
|
57
|
+
"""
|
|
58
|
+
if not (text.isascii() and text.isdigit()):
|
|
59
|
+
return None
|
|
60
|
+
length = int(text)
|
|
61
|
+
return length if length <= longest else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _masked(packed: bytes, mask: bytes) -> bytes:
|
|
65
|
+
"""``packed`` with every byte the mask does not keep zeroed."""
|
|
66
|
+
return bytes(byte & keep for byte, keep in zip(packed, mask, strict=True))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@final
|
|
70
|
+
class IpUtils:
|
|
71
|
+
"""Where an address sits: in a network, in a private one, or hidden by its last bytes.
|
|
72
|
+
|
|
73
|
+
A namespace rather than something to build — every answer here is a function of its
|
|
74
|
+
arguments. Text that is not an address is no error for the checks: they answer about a
|
|
75
|
+
request from a stranger, whose address is theirs to misspell, and an address nobody
|
|
76
|
+
can read is one no network holds.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
__slots__ = ()
|
|
80
|
+
|
|
81
|
+
#: The networks an address in them can only be reached from inside this network.
|
|
82
|
+
PRIVATE_SUBNETS: Final[tuple[str, ...]] = (
|
|
83
|
+
"127.0.0.0/8", # loopback
|
|
84
|
+
"10.0.0.0/8", # private use
|
|
85
|
+
"192.168.0.0/16", # private use
|
|
86
|
+
"192.0.0.0/24", # assigned to protocols rather than to hosts
|
|
87
|
+
"192.0.2.0/24", # reserved for documentation
|
|
88
|
+
"198.51.100.0/24", # reserved for documentation
|
|
89
|
+
"203.0.113.0/24", # reserved for documentation
|
|
90
|
+
"172.16.0.0/12", # private use
|
|
91
|
+
"169.254.0.0/16", # link local
|
|
92
|
+
"192.88.99.0/24", # 6to4 relay anycast, withdrawn
|
|
93
|
+
"198.18.0.0/15", # reserved for benchmarking
|
|
94
|
+
"0.0.0.0/8", # this network
|
|
95
|
+
"240.0.0.0/4", # reserved
|
|
96
|
+
"100.64.0.0/10", # shared address space, between a provider and its subscribers
|
|
97
|
+
"224.0.0.0/4", # multicast
|
|
98
|
+
"::1/128", # loopback
|
|
99
|
+
"fc00::/7", # unique local
|
|
100
|
+
"fe80::/10", # link local
|
|
101
|
+
"::ffff:0:0/96", # an IPv4 address written as an IPv6 one
|
|
102
|
+
"::/128", # unspecified
|
|
103
|
+
"::/96", # an IPv4 address written as an IPv6 one, in the earlier form
|
|
104
|
+
"100::/64", # discard only
|
|
105
|
+
"2002::/16", # 6to4
|
|
106
|
+
"2001::/32", # Teredo tunnelling
|
|
107
|
+
"2001:db8::/32", # reserved for documentation
|
|
108
|
+
"2001:0002::/48", # reserved for benchmarking
|
|
109
|
+
"64:ff9b::/96", # the well-known prefix translating IPv4 to IPv6
|
|
110
|
+
"64:ff9b:1::/48", # the same translation, for local use
|
|
111
|
+
"ff00::/8", # multicast
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def check_ip(request_ip: str, ips: str | Sequence[str]) -> bool:
|
|
116
|
+
"""Whether ``request_ip`` falls in any of ``ips``.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
request_ip: the address to place, written as IPv4 or IPv6.
|
|
120
|
+
ips: a network in CIDR notation, a single address, or several of either.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Whether any of them holds the address; ``False`` for text that is no address.
|
|
124
|
+
"""
|
|
125
|
+
networks = (ips,) if isinstance(ips, str) else ips
|
|
126
|
+
# More than one colon is what only an IPv6 address has — "::1" has two, while an
|
|
127
|
+
# IPv4 address carrying a port would have one.
|
|
128
|
+
check = IpUtils.check_ip6 if request_ip.count(":") > 1 else IpUtils.check_ip4
|
|
129
|
+
return any(check(request_ip, network) for network in networks)
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def check_ip4(request_ip: str, ip: str) -> bool:
|
|
133
|
+
"""Whether ``request_ip`` falls in the IPv4 network or address ``ip``.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
request_ip: the address to place, written as IPv4.
|
|
137
|
+
ip: a network in CIDR notation, or a single address, which stands for itself.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Whether the network holds the address; ``False`` when either is no IPv4 one.
|
|
141
|
+
"""
|
|
142
|
+
address = _as_ipv4(request_ip)
|
|
143
|
+
if address is None:
|
|
144
|
+
return False
|
|
145
|
+
network_text, separator, prefix_text = ip.partition("/")
|
|
146
|
+
network_address = _as_ipv4(network_text)
|
|
147
|
+
if network_address is None:
|
|
148
|
+
return False
|
|
149
|
+
prefix = _prefix_length(prefix_text, _V4_BITS) if separator else _V4_BITS
|
|
150
|
+
if prefix is None:
|
|
151
|
+
return False
|
|
152
|
+
return address in IPv4Network((network_address, prefix), strict=False)
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def check_ip6(request_ip: str, ip: str) -> bool:
|
|
156
|
+
"""Whether ``request_ip`` falls in the IPv6 network or address ``ip``.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
request_ip: the address to place, written as IPv6.
|
|
160
|
+
ip: a network in CIDR notation, or a single address, which stands for itself.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Whether the network holds the address; ``False`` when either is no IPv6 one.
|
|
164
|
+
"""
|
|
165
|
+
address = _as_ipv6(request_ip)
|
|
166
|
+
if address is None:
|
|
167
|
+
return False
|
|
168
|
+
network_text, separator, prefix_text = ip.partition("/")
|
|
169
|
+
network_address = _as_ipv6(network_text)
|
|
170
|
+
if network_address is None:
|
|
171
|
+
return False
|
|
172
|
+
prefix = _prefix_length(prefix_text, _V6_BITS) if separator else _V6_BITS
|
|
173
|
+
if prefix is None:
|
|
174
|
+
return False
|
|
175
|
+
return address in IPv6Network((network_address, prefix), strict=False)
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def anonymize(ip: str, v4_bytes: int = 1, v6_bytes: int = 8) -> str:
|
|
179
|
+
"""``ip`` with its last bytes zeroed, so what is left no longer names one machine.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
ip: the address to hide, bracketed or carrying a zone if that is how it came.
|
|
183
|
+
v4_bytes: how many of an IPv4 address's four bytes to drop.
|
|
184
|
+
v6_bytes: how many of an IPv6 address's sixteen bytes to drop.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
The address written as it arrived: bracketed if it was, and dotted where it
|
|
188
|
+
carries an IPv4 one. A zone is never given back — it names an interface, which
|
|
189
|
+
is as much a trace of who was asked as the bytes just dropped.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
InvalidArgumentError: when asked for fewer than no bytes, for more than the
|
|
193
|
+
address has, or for text that is no address at all.
|
|
194
|
+
"""
|
|
195
|
+
if v4_bytes < 0 or v6_bytes < 0:
|
|
196
|
+
raise InvalidArgumentError("Cannot anonymize less than 0 bytes.")
|
|
197
|
+
if v4_bytes > _V4_BYTES or v6_bytes > _V6_BYTES:
|
|
198
|
+
raise InvalidArgumentError(
|
|
199
|
+
"Cannot anonymize more than 4 bytes for IPv4 and 16 bytes for IPv6."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
text = ip.partition("%")[0]
|
|
203
|
+
wrapped = text.startswith("[") and text.endswith("]")
|
|
204
|
+
if wrapped:
|
|
205
|
+
text = text[1:-1]
|
|
206
|
+
address: IPv4Address | IPv6Address | None = _as_ipv4(text) or _as_ipv6(text)
|
|
207
|
+
if address is None:
|
|
208
|
+
raise InvalidArgumentError(f'"{ip}" is not a valid IP address.')
|
|
209
|
+
|
|
210
|
+
tail = b"\xff" * (_V4_BYTES - v4_bytes) + b"\x00" * v4_bytes
|
|
211
|
+
packed = address.packed
|
|
212
|
+
if isinstance(address, IPv4Address):
|
|
213
|
+
anonymized = str(IPv4Address(_masked(packed, tail)))
|
|
214
|
+
else:
|
|
215
|
+
if packed.startswith(_V4_MAPPED):
|
|
216
|
+
mask = _V4_MAPPED + tail
|
|
217
|
+
elif packed.startswith(_V4_COMPATIBLE):
|
|
218
|
+
mask = _V4_COMPATIBLE + tail
|
|
219
|
+
else:
|
|
220
|
+
mask = b"\xff" * (_V6_BYTES - v6_bytes) + b"\x00" * v6_bytes
|
|
221
|
+
# `ipaddress` writes every group as hexadecimal, which would turn an address
|
|
222
|
+
# carrying an IPv4 one into "::ffff:7bea:eb00" — the same bytes, no longer
|
|
223
|
+
# recognisable as the address that went in. `inet_ntop` keeps the dotted form.
|
|
224
|
+
anonymized = socket.inet_ntop(socket.AF_INET6, _masked(packed, mask))
|
|
225
|
+
return f"[{anonymized}]" if wrapped else anonymized
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def is_private_ip(ip: str) -> bool:
|
|
229
|
+
"""Whether ``ip`` is an address only this network can reach.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
ip: the address to place, written as IPv4 or IPv6.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Whether it falls in any of :attr:`PRIVATE_SUBNETS`.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
InvalidArgumentError: when ``ip`` is not an address written plainly. This one
|
|
239
|
+
refuses where the checks above answer, because its caller is asking
|
|
240
|
+
whether an address may be reached out to, and text nobody can read is not
|
|
241
|
+
an address that failed the question — it is a question nobody can answer.
|
|
242
|
+
"""
|
|
243
|
+
if _as_ipv4(ip) is None and _as_ipv6(ip) is None:
|
|
244
|
+
raise InvalidArgumentError(f'"{ip}" is not a valid IP address.')
|
|
245
|
+
return IpUtils.check_ip(ip, IpUtils.PRIVATE_SUBNETS)
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""One matcher per question worth asking about a request that arrived."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .attributes_request_matcher import AttributesRequestMatcher
|
|
6
|
+
from .header_request_matcher import HeaderRequestMatcher
|
|
7
|
+
from .host_request_matcher import HostRequestMatcher
|
|
8
|
+
from .ips_request_matcher import IpsRequestMatcher
|
|
9
|
+
from .is_json_request_matcher import IsJsonRequestMatcher
|
|
10
|
+
from .method_request_matcher import MethodRequestMatcher
|
|
11
|
+
from .path_request_matcher import PathRequestMatcher
|
|
12
|
+
from .port_request_matcher import PortRequestMatcher
|
|
13
|
+
from .query_parameter_request_matcher import QueryParameterRequestMatcher
|
|
14
|
+
from .scheme_request_matcher import SchemeRequestMatcher
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AttributesRequestMatcher",
|
|
18
|
+
"HeaderRequestMatcher",
|
|
19
|
+
"HostRequestMatcher",
|
|
20
|
+
"IpsRequestMatcher",
|
|
21
|
+
"IsJsonRequestMatcher",
|
|
22
|
+
"MethodRequestMatcher",
|
|
23
|
+
"PathRequestMatcher",
|
|
24
|
+
"PortRequestMatcher",
|
|
25
|
+
"QueryParameterRequestMatcher",
|
|
26
|
+
"SchemeRequestMatcher",
|
|
27
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Reading one name, or several, out of what a matcher was given."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING, Final
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
__all__ = ["comma_separated"]
|
|
12
|
+
|
|
13
|
+
_SEPARATOR: Final = re.compile(r"\s*,\s*")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def comma_separated(given: Sequence[str] | str) -> tuple[str, ...]:
|
|
17
|
+
"""Return every name ``given`` holds, however they were written.
|
|
18
|
+
|
|
19
|
+
A matcher takes one name or a list of them, and each of those may itself
|
|
20
|
+
hold several separated by commas — so a name read from configuration, where
|
|
21
|
+
a list is awkward to write, reaches the matcher the same way a list does.
|
|
22
|
+
Whitespace around a comma belongs to the separator rather than to a name.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
given: One name, or several; any of them may be a comma-separated list.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The names, in the order they were written.
|
|
29
|
+
"""
|
|
30
|
+
values = (given,) if isinstance(given, str) else given
|
|
31
|
+
|
|
32
|
+
return tuple(name for value in values for name in _SEPARATOR.split(value))
|