vpndetection-fastapi 2.0.9__tar.gz → 2.1.1__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.
@@ -2,6 +2,18 @@
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.1 - 2026-10-04
6
+
7
+ ### Fixes
8
+
9
+ - Require vpndetection 5.6.1, re-pinned to spec 2026.10.03 ([`8184aea`](https://github.com/vpndetection-io/sdk-python-fastapi/commit/8184aeab72f9a64b9abc2eb8ae6e550e05941e1f))
10
+
11
+ ## 2.1.0 - 2026-10-03
12
+
13
+ ### Features
14
+
15
+ - Add block_if, a FastAPI dependency refusing one endpoint to a matching visitor ([`c91eb21`](https://github.com/vpndetection-io/sdk-python-fastapi/commit/c91eb2118550e18ba6a5072193717c0e97f4f81c))
16
+
5
17
  ## 2.0.9 - 2026-10-02
6
18
 
7
19
  ### Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vpndetection-fastapi
3
- Version: 2.0.9
3
+ Version: 2.1.1
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.5.3
23
+ Requires-Dist: vpndetection<6,>=5.6.1
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.5.3,<6", "starlette>=0.37"]
27
+ dependencies = ["vpndetection>=5.6.1,<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"]
@@ -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.9"
48
+ __version__ = "2.1.1"
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"]