vpndetection-fastapi 2.0.9__tar.gz → 2.1.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.
- {vpndetection_fastapi-2.0.9 → vpndetection_fastapi-2.1.0}/CHANGELOG.md +6 -0
- {vpndetection_fastapi-2.0.9 → vpndetection_fastapi-2.1.0}/PKG-INFO +18 -2
- {vpndetection_fastapi-2.0.9 → vpndetection_fastapi-2.1.0}/README.md +15 -0
- {vpndetection_fastapi-2.0.9 → vpndetection_fastapi-2.1.0}/pyproject.toml +2 -2
- {vpndetection_fastapi-2.0.9 → vpndetection_fastapi-2.1.0}/src/vpndetection_fastapi/__init__.py +66 -2
- {vpndetection_fastapi-2.0.9 → vpndetection_fastapi-2.1.0}/tests/test_middleware.py +116 -0
- {vpndetection_fastapi-2.0.9 → vpndetection_fastapi-2.1.0}/.github/workflows/ci.yml +0 -0
- {vpndetection_fastapi-2.0.9 → vpndetection_fastapi-2.1.0}/.github/workflows/release.yml +0 -0
- {vpndetection_fastapi-2.0.9 → vpndetection_fastapi-2.1.0}/.gitignore +0 -0
- {vpndetection_fastapi-2.0.9 → vpndetection_fastapi-2.1.0}/LICENSE +0 -0
- {vpndetection_fastapi-2.0.9 → vpndetection_fastapi-2.1.0}/src/vpndetection_fastapi/py.typed +0 -0
- {vpndetection_fastapi-2.0.9 → vpndetection_fastapi-2.1.0}/testdata/bogons.json +0 -0
- {vpndetection_fastapi-2.0.9 → vpndetection_fastapi-2.1.0}/testdata/testdata.json +0 -0
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
What each release changed for you, newest first. Each line is a commit's summary, linked to its full description and diff. Releases before 2.0.5 are described by their release commits.
|
|
4
4
|
|
|
5
|
+
## 2.1.0 - 2026-10-03
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- Add block_if, a FastAPI dependency refusing one endpoint to a matching visitor ([`c91eb21`](https://github.com/vpndetection-io/sdk-python-fastapi/commit/c91eb2118550e18ba6a5072193717c0e97f4f81c))
|
|
10
|
+
|
|
5
11
|
## 2.0.9 - 2026-10-02
|
|
6
12
|
|
|
7
13
|
### Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: vpndetection-fastapi
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Official FastAPI and Starlette middleware for the VPNDetection API. Detect VPNs, proxies, Tor, hosting and CDN visitors on every request.
|
|
5
5
|
Project-URL: Homepage, https://vpndetection.io
|
|
6
6
|
Project-URL: Source, https://github.com/vpndetection-io/sdk-python-fastapi
|
|
@@ -20,8 +20,9 @@ Classifier: Topic :: Security
|
|
|
20
20
|
Classifier: Typing :: Typed
|
|
21
21
|
Requires-Python: >=3.11
|
|
22
22
|
Requires-Dist: starlette>=0.37
|
|
23
|
-
Requires-Dist: vpndetection<6,>=5.
|
|
23
|
+
Requires-Dist: vpndetection<6,>=5.6.0
|
|
24
24
|
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: fastapi>=0.115; extra == 'dev'
|
|
25
26
|
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
26
27
|
Requires-Dist: pytest>=8; extra == 'dev'
|
|
27
28
|
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
@@ -92,6 +93,21 @@ Values are matched by equality, strings without regard to case. A list means any
|
|
|
92
93
|
|
|
93
94
|
Replace the refusal with `on_blocked`.
|
|
94
95
|
|
|
96
|
+
### Blocking one endpoint
|
|
97
|
+
|
|
98
|
+
`block_condition` applies to every endpoint. In FastAPI, refuse a visitor on some endpoints only with the `block_if` dependency, on a path operation, a router or the app:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from fastapi import Depends
|
|
102
|
+
from vpndetection_fastapi import block_if
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.get("/checkout", dependencies=[Depends(block_if({"is_vpn": True}))])
|
|
106
|
+
async def checkout(): ...
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
A refused request gets an `HTTPException`, `403` with `{"detail": "access denied"}` unless you pass `status_code` and `detail`. Declared as a parameter, the dependency hands the endpoint the answer. `block_if` takes the same condition, `fail_closed` and `on_missing_field`. It judges the answer the middleware already attached, so a visitor is looked up once however many endpoints check them, and it warns once for a member your plan doesn't include. A request `skip` claimed reaches the endpoint. The middleware still has to be added: without it, the dependency raises `RuntimeError` instead of letting everyone through.
|
|
110
|
+
|
|
95
111
|
## Where the client address comes from
|
|
96
112
|
|
|
97
113
|
This is the setting that decides whether any of the above works, and it is the one thing only you can get right.
|
|
@@ -63,6 +63,21 @@ Values are matched by equality, strings without regard to case. A list means any
|
|
|
63
63
|
|
|
64
64
|
Replace the refusal with `on_blocked`.
|
|
65
65
|
|
|
66
|
+
### Blocking one endpoint
|
|
67
|
+
|
|
68
|
+
`block_condition` applies to every endpoint. In FastAPI, refuse a visitor on some endpoints only with the `block_if` dependency, on a path operation, a router or the app:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from fastapi import Depends
|
|
72
|
+
from vpndetection_fastapi import block_if
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.get("/checkout", dependencies=[Depends(block_if({"is_vpn": True}))])
|
|
76
|
+
async def checkout(): ...
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
A refused request gets an `HTTPException`, `403` with `{"detail": "access denied"}` unless you pass `status_code` and `detail`. Declared as a parameter, the dependency hands the endpoint the answer. `block_if` takes the same condition, `fail_closed` and `on_missing_field`. It judges the answer the middleware already attached, so a visitor is looked up once however many endpoints check them, and it warns once for a member your plan doesn't include. A request `skip` claimed reaches the endpoint. The middleware still has to be added: without it, the dependency raises `RuntimeError` instead of letting everyone through.
|
|
80
|
+
|
|
66
81
|
## Where the client address comes from
|
|
67
82
|
|
|
68
83
|
This is the setting that decides whether any of the above works, and it is the one thing only you can get right.
|
|
@@ -24,14 +24,14 @@ classifiers = [
|
|
|
24
24
|
"Topic :: Security",
|
|
25
25
|
"Typing :: Typed",
|
|
26
26
|
]
|
|
27
|
-
dependencies = ["vpndetection>=5.
|
|
27
|
+
dependencies = ["vpndetection>=5.6.0,<6", "starlette>=0.37"]
|
|
28
28
|
|
|
29
29
|
[project.urls]
|
|
30
30
|
Homepage = "https://vpndetection.io"
|
|
31
31
|
Source = "https://github.com/vpndetection-io/sdk-python-fastapi"
|
|
32
32
|
|
|
33
33
|
[project.optional-dependencies]
|
|
34
|
-
dev = ["pytest>=8", "mypy>=1.11", "ruff>=0.6"]
|
|
34
|
+
dev = ["pytest>=8", "mypy>=1.11", "ruff>=0.6", "fastapi>=0.115"]
|
|
35
35
|
|
|
36
36
|
[tool.hatch.build.targets.wheel]
|
|
37
37
|
packages = ["src/vpndetection_fastapi"]
|
{vpndetection_fastapi-2.0.9 → vpndetection_fastapi-2.1.0}/src/vpndetection_fastapi/__init__.py
RENAMED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Official FastAPI and Starlette middleware for the VPNDetection API.
|
|
2
2
|
|
|
3
3
|
Classifies the visitor behind each request and hangs the answer off
|
|
4
|
-
``request.state.vpndetection``, where your endpoints can read it. Blocking is opt-in
|
|
4
|
+
``request.state.vpndetection``, where your endpoints can read it. Blocking is opt-in, for
|
|
5
|
+
every endpoint or, in FastAPI, for one with the :func:`block_if` dependency.
|
|
5
6
|
|
|
6
7
|
from fastapi import FastAPI
|
|
7
8
|
from vpndetection_fastapi import VPNDetectionMiddleware
|
|
@@ -18,13 +19,17 @@ from __future__ import annotations
|
|
|
18
19
|
from collections.abc import Awaitable, Callable
|
|
19
20
|
from typing import Any
|
|
20
21
|
|
|
22
|
+
from starlette.exceptions import HTTPException
|
|
21
23
|
from starlette.requests import Request
|
|
22
24
|
from starlette.responses import JSONResponse, Response
|
|
23
25
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
24
26
|
from vpndetection.middleware import (
|
|
25
27
|
AsyncCore,
|
|
28
|
+
Conditions,
|
|
29
|
+
Guard,
|
|
26
30
|
IpSelector,
|
|
27
31
|
Lookup,
|
|
32
|
+
MissingFieldAction,
|
|
28
33
|
Options,
|
|
29
34
|
RequestView,
|
|
30
35
|
Selectors,
|
|
@@ -33,13 +38,18 @@ from vpndetection.middleware import (
|
|
|
33
38
|
|
|
34
39
|
__all__ = [
|
|
35
40
|
"VPNDetectionMiddleware",
|
|
41
|
+
"block_if",
|
|
36
42
|
"default_ip_selector",
|
|
37
43
|
"header_ip_selector",
|
|
38
44
|
"lookup",
|
|
39
45
|
"xff_ip_selector",
|
|
40
46
|
]
|
|
41
47
|
|
|
42
|
-
__version__ = "2.0
|
|
48
|
+
__version__ = "2.1.0"
|
|
49
|
+
|
|
50
|
+
# Set on a request ``skip`` claimed, so block_if can tell it from one the middleware
|
|
51
|
+
# never saw.
|
|
52
|
+
_SKIPPED = "_vpndetection_skipped"
|
|
43
53
|
|
|
44
54
|
_SELECTORS: Selectors[Request] = bind_selectors(
|
|
45
55
|
lambda request: RequestView(
|
|
@@ -79,6 +89,59 @@ def lookup(request: Request) -> Lookup | None:
|
|
|
79
89
|
return getattr(request.state, "vpndetection", None)
|
|
80
90
|
|
|
81
91
|
|
|
92
|
+
def block_if(
|
|
93
|
+
condition: Conditions,
|
|
94
|
+
*,
|
|
95
|
+
status_code: int = 403,
|
|
96
|
+
detail: Any = "access denied",
|
|
97
|
+
fail_closed: bool = False,
|
|
98
|
+
on_missing_field: MissingFieldAction = "warn",
|
|
99
|
+
on_warn: Callable[[str], None] | None = None,
|
|
100
|
+
) -> Callable[[Request], Awaitable[Lookup | None]]:
|
|
101
|
+
"""A FastAPI dependency refusing one endpoint to a visitor matching ``condition``.
|
|
102
|
+
|
|
103
|
+
The middleware's ``block_condition`` refuses on every endpoint; this refuses on the
|
|
104
|
+
path operations, routers or apps that depend on it, with an ``HTTPException`` of
|
|
105
|
+
``status_code`` and ``detail``::
|
|
106
|
+
|
|
107
|
+
@app.get("/checkout", dependencies=[Depends(block_if({"is_vpn": True}))])
|
|
108
|
+
async def checkout(): ...
|
|
109
|
+
|
|
110
|
+
As a parameter it hands the endpoint the answer, or None for a request ``skip``
|
|
111
|
+
claimed. It judges the answer the middleware already attached, so the visitor is not
|
|
112
|
+
looked up again, and a member your plan does not serve is reported once, as the
|
|
113
|
+
middleware's own condition reports it. A condition that constrains nothing is
|
|
114
|
+
refused when the dependency is built.
|
|
115
|
+
|
|
116
|
+
A request ``skip`` claimed carries no answer and reaches the endpoint, and so does
|
|
117
|
+
one whose lookup failed unless you set ``fail_closed``. A request the middleware
|
|
118
|
+
never saw raises ``RuntimeError``: a check that silently never ran would be worse
|
|
119
|
+
than none.
|
|
120
|
+
"""
|
|
121
|
+
guard = Guard(
|
|
122
|
+
condition,
|
|
123
|
+
fail_closed=fail_closed,
|
|
124
|
+
on_missing_field=on_missing_field,
|
|
125
|
+
on_warn=on_warn,
|
|
126
|
+
name="block_if",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def dependency(request: Request) -> Lookup | None:
|
|
130
|
+
found = lookup(request)
|
|
131
|
+
if found is None:
|
|
132
|
+
if getattr(request.state, _SKIPPED, False):
|
|
133
|
+
return None
|
|
134
|
+
raise RuntimeError(
|
|
135
|
+
"vpndetection: block_if found no answer on this request; add "
|
|
136
|
+
"VPNDetectionMiddleware to the app"
|
|
137
|
+
)
|
|
138
|
+
if guard.blocks(found):
|
|
139
|
+
raise HTTPException(status_code=status_code, detail=detail)
|
|
140
|
+
return found
|
|
141
|
+
|
|
142
|
+
return dependency
|
|
143
|
+
|
|
144
|
+
|
|
82
145
|
class VPNDetectionMiddleware:
|
|
83
146
|
"""Classify the visitor, and optionally refuse the request.
|
|
84
147
|
|
|
@@ -107,6 +170,7 @@ class VPNDetectionMiddleware:
|
|
|
107
170
|
request = Request(scope, receive=receive)
|
|
108
171
|
found = await self._core.evaluate(request)
|
|
109
172
|
if found is None:
|
|
173
|
+
setattr(request.state, _SKIPPED, True)
|
|
110
174
|
await self.app(scope, receive, send)
|
|
111
175
|
return
|
|
112
176
|
# request.state is backed by scope["state"], so this reaches the Request the
|
|
@@ -13,6 +13,7 @@ from typing import Any
|
|
|
13
13
|
|
|
14
14
|
import httpx
|
|
15
15
|
import pytest
|
|
16
|
+
from fastapi import Depends, FastAPI
|
|
16
17
|
from starlette.applications import Starlette
|
|
17
18
|
from starlette.requests import Request
|
|
18
19
|
from starlette.responses import JSONResponse
|
|
@@ -22,6 +23,7 @@ from vpndetection import AsyncVPNDetection
|
|
|
22
23
|
|
|
23
24
|
from vpndetection_fastapi import (
|
|
24
25
|
VPNDetectionMiddleware,
|
|
26
|
+
block_if,
|
|
25
27
|
header_ip_selector,
|
|
26
28
|
lookup,
|
|
27
29
|
xff_ip_selector,
|
|
@@ -243,3 +245,117 @@ def test_corpus_conditions(case: dict[str, Any]) -> None:
|
|
|
243
245
|
assert len(reported) == (1 if case["expect"]["missing"] else 0), case["why"]
|
|
244
246
|
for member in case["expect"]["missing"]:
|
|
245
247
|
assert member in reported[0], case["why"]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def fastapi_with(guard: dict[str, Any] | None = None, **options: Any) -> FastAPI:
|
|
251
|
+
"""A FastAPI app with the middleware, whose ``/guarded`` depends on
|
|
252
|
+
``block_if(**guard)`` and ``/`` on nothing."""
|
|
253
|
+
app = FastAPI()
|
|
254
|
+
# A default rather than Annotated: under postponed annotations FastAPI resolves an
|
|
255
|
+
# annotation in module globals, where this per-app dependency does not live.
|
|
256
|
+
check = block_if(**(guard or {"condition": {"is_vpn": True}}))
|
|
257
|
+
app.add_api_route("/", index)
|
|
258
|
+
|
|
259
|
+
@app.get("/guarded")
|
|
260
|
+
async def guarded(found: Any = Depends(check)) -> dict[str, Any]: # noqa: B008
|
|
261
|
+
return {"attached": found is not None, "ip": found.ip if found else None}
|
|
262
|
+
|
|
263
|
+
app.add_middleware(VPNDetectionMiddleware, **options)
|
|
264
|
+
return app
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def fetch(app: Any, url: str) -> tuple[int, dict[str, Any]]:
|
|
268
|
+
with TestClient(app, client=("127.0.0.1", 12345)) as client:
|
|
269
|
+
response = client.get(url)
|
|
270
|
+
return response.status_code, response.json()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_block_if_refuses_only_the_endpoint_that_depends_on_it() -> None:
|
|
274
|
+
vpn = serving({"is_vpn": True, "vpn": {"provider": "nordvpn"}})
|
|
275
|
+
app = fastapi_with(client=vpn, ip_selector=fixed_ip)
|
|
276
|
+
status, body = fetch(app, "/guarded")
|
|
277
|
+
assert status == 403
|
|
278
|
+
assert body == {"detail": "access denied"}
|
|
279
|
+
status, body = fetch(app, "/")
|
|
280
|
+
assert status == 200, "the middleware itself has no condition, so other endpoints are open"
|
|
281
|
+
assert body["is_vpn"] is True
|
|
282
|
+
assert vpn.asked == [PUBLIC_IP, PUBLIC_IP], "one lookup per request, none by the dependency"
|
|
283
|
+
|
|
284
|
+
clean = serving({"is_vpn": False, "vpn": {}})
|
|
285
|
+
status, body = fetch(fastapi_with(client=clean, ip_selector=fixed_ip), "/guarded")
|
|
286
|
+
assert status == 200
|
|
287
|
+
assert body == {"attached": True, "ip": PUBLIC_IP}, "the dependency hands over the answer"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_block_if_takes_its_status_and_detail() -> None:
|
|
291
|
+
vpn = serving({"is_vpn": True, "vpn": {"provider": "nordvpn"}})
|
|
292
|
+
guard = {"condition": {"is_vpn": True}, "status_code": 451, "detail": "not from a VPN"}
|
|
293
|
+
status, body = fetch(fastapi_with(guard, client=vpn, ip_selector=fixed_ip), "/guarded")
|
|
294
|
+
assert status == 451
|
|
295
|
+
assert body == {"detail": "not from a VPN"}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_block_if_lets_a_skipped_request_through() -> None:
|
|
299
|
+
vpn = serving({"is_vpn": True})
|
|
300
|
+
app = fastapi_with(client=vpn, ip_selector=fixed_ip, skip=lambda request: True)
|
|
301
|
+
status, body = fetch(app, "/guarded")
|
|
302
|
+
assert status == 200
|
|
303
|
+
assert body == {"attached": False, "ip": None}
|
|
304
|
+
assert vpn.asked == []
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_block_if_fails_open_unless_told_to_fail_closed() -> None:
|
|
308
|
+
failing = serving({"error": "boom"}, status=500)
|
|
309
|
+
status, _ = fetch(fastapi_with(client=failing, ip_selector=fixed_ip), "/guarded")
|
|
310
|
+
assert status == 200
|
|
311
|
+
|
|
312
|
+
guard = {"condition": {"is_vpn": True}, "fail_closed": True}
|
|
313
|
+
status, body = fetch(fastapi_with(guard, client=failing, ip_selector=fixed_ip), "/guarded")
|
|
314
|
+
assert status == 403
|
|
315
|
+
assert body == {"detail": "access denied"}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_block_if_without_the_middleware_is_an_error() -> None:
|
|
319
|
+
app = FastAPI()
|
|
320
|
+
check = block_if({"is_vpn": True})
|
|
321
|
+
|
|
322
|
+
@app.get("/guarded")
|
|
323
|
+
async def guarded(found: Any = Depends(check)) -> None: # noqa: B008
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
with pytest.raises(RuntimeError, match="add VPNDetectionMiddleware"):
|
|
327
|
+
fetch(app, "/guarded")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_block_if_refuses_a_condition_that_constrains_nothing() -> None:
|
|
331
|
+
with pytest.raises(ValueError, match="constrains nothing"):
|
|
332
|
+
block_if({"is_vpn": False})
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_block_if_warns_once() -> None:
|
|
336
|
+
free = serving({"is_vpn": True})
|
|
337
|
+
warnings: list[str] = []
|
|
338
|
+
guard = {"condition": {"is_hosting": True}, "on_warn": warnings.append}
|
|
339
|
+
app = fastapi_with(guard, client=free, ip_selector=fixed_ip)
|
|
340
|
+
fetch(app, "/guarded")
|
|
341
|
+
fetch(app, "/guarded")
|
|
342
|
+
assert len(warnings) == 1
|
|
343
|
+
assert warnings[0].startswith("block_if names is_hosting")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@pytest.mark.parametrize("case", MIDDLEWARE["conditions"], ids=lambda c: c["name"])
|
|
347
|
+
def test_corpus_conditions_through_block_if(case: dict[str, Any]) -> None:
|
|
348
|
+
ip = case.get("bogon") or case["body"]["ip"]
|
|
349
|
+
client = serving({k: v for k, v in (case.get("body") or {}).items() if k != "ip"})
|
|
350
|
+
warnings: list[str] = []
|
|
351
|
+
app = fastapi_with(
|
|
352
|
+
{"condition": case["condition"], "on_warn": warnings.append},
|
|
353
|
+
client=client,
|
|
354
|
+
ip_selector=lambda _request, ip=ip: ip,
|
|
355
|
+
)
|
|
356
|
+
status, _ = fetch(app, "/guarded")
|
|
357
|
+
assert status == (403 if case["expect"]["blocked"] else 200), case["why"]
|
|
358
|
+
reported = [w for w in warnings if "does not include" in w]
|
|
359
|
+
assert len(reported) == (1 if case["expect"]["missing"] else 0), case["why"]
|
|
360
|
+
for member in case["expect"]["missing"]:
|
|
361
|
+
assert member in reported[0], case["why"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|