vpndetection-fastapi 1.0.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-1.0.0/.github/workflows/ci.yml +25 -0
- vpndetection_fastapi-1.0.0/.github/workflows/release.yml +36 -0
- vpndetection_fastapi-1.0.0/.gitignore +17 -0
- vpndetection_fastapi-1.0.0/LICENSE +21 -0
- vpndetection_fastapi-1.0.0/PKG-INFO +151 -0
- vpndetection_fastapi-1.0.0/README.md +122 -0
- vpndetection_fastapi-1.0.0/pyproject.toml +47 -0
- vpndetection_fastapi-1.0.0/src/vpndetection_fastapi/__init__.py +127 -0
- vpndetection_fastapi-1.0.0/src/vpndetection_fastapi/py.typed +0 -0
- vpndetection_fastapi-1.0.0/testdata/bogons.json +60 -0
- vpndetection_fastapi-1.0.0/testdata/testdata.json +1735 -0
- vpndetection_fastapi-1.0.0/tests/test_middleware.py +245 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python: ['3.11', '3.12', '3.13']
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v7
|
|
16
|
+
- uses: actions/setup-python@v7
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python }}
|
|
19
|
+
cache: pip
|
|
20
|
+
cache-dependency-path: pyproject.toml
|
|
21
|
+
- run: pip install -e ".[dev]"
|
|
22
|
+
- run: ruff check .
|
|
23
|
+
- run: ruff format --check .
|
|
24
|
+
- run: mypy src
|
|
25
|
+
- run: pytest
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ['v*']
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
# PyPI trusted publishing exchanges this OIDC token for a short-lived upload
|
|
13
|
+
# token. No API token in a repository secret.
|
|
14
|
+
id-token: write
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v7
|
|
17
|
+
- uses: actions/setup-python@v7
|
|
18
|
+
with:
|
|
19
|
+
python-version: '3.13'
|
|
20
|
+
cache: pip
|
|
21
|
+
cache-dependency-path: pyproject.toml
|
|
22
|
+
# The tag only TRIGGERS this; PyPI publishes whatever the built artifact says.
|
|
23
|
+
# Without this check, tagging v1.0.1 against a 1.0.0 package silently
|
|
24
|
+
# republishes 1.0.0, or fails as "already exists" with a confusing message.
|
|
25
|
+
- name: Tag must match the package version
|
|
26
|
+
run: |
|
|
27
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
28
|
+
pkg="$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' src/vpndetection_fastapi/__init__.py)"
|
|
29
|
+
if [ "$tag" != "$pkg" ] ; then
|
|
30
|
+
echo "tag $tag does not match __version__ $pkg" >&2
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
- run: pip install -e ".[dev]" build
|
|
34
|
+
- run: pytest
|
|
35
|
+
- run: python -m build
|
|
36
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
|
|
4
|
+
# Build output.
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
|
|
9
|
+
# Virtualenvs, including any made to run codegen. Keep them out of the tree entirely.
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
.venv-*/
|
|
13
|
+
|
|
14
|
+
# Tool caches.
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mslm Dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vpndetection-fastapi
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official FastAPI and Starlette middleware for the VPNDetection API. Detect VPNs, proxies, Tor, hosting and CDN visitors on every request.
|
|
5
|
+
Project-URL: Homepage, https://vpndetection.io
|
|
6
|
+
Project-URL: Source, https://github.com/vpndetection-io/sdk-python-fastapi
|
|
7
|
+
Author-email: Mslm Dev <support@vpndetection.io>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: fraud-prevention,ip-intelligence,middleware,proxy-detection,tor,vpn,vpn-detection
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Internet
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: starlette>=0.37
|
|
23
|
+
Requires-Dist: vpndetection<4,>=2.1.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# [<img src="https://s3.vpndetection.io/vpndetection-public/brand/mark.svg" alt="VPNDetection" width="24"/>](https://vpndetection.io/) VPNDetection FastAPI Middleware
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/vpndetection-fastapi/)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
|
|
35
|
+
The official FastAPI Middleware for the [VPNDetection](https://vpndetection.io) API.
|
|
36
|
+
|
|
37
|
+
It classifies the visitor behind each request — VPN, residential proxy, Tor, hosting, CDN, relay — and hands the answer to your code. Blocking is opt-in.
|
|
38
|
+
|
|
39
|
+
## Getting Started
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install vpndetection-fastapi
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Requires Python 3.11 or newer.
|
|
46
|
+
|
|
47
|
+
You need an API key. Create one in the [console](https://app.vpndetection.io); the free tier's allowance is counted per source address, and a server is a single source address, so a key is what makes this usable in production rather than optional.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from fastapi import FastAPI
|
|
51
|
+
from vpndetection_fastapi import VPNDetectionMiddleware
|
|
52
|
+
|
|
53
|
+
app = FastAPI()
|
|
54
|
+
app.add_middleware(VPNDetectionMiddleware, api_key=os.environ["VPNDETECTION_API_KEY"])
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Pure ASGI, so this works on Starlette and Litestar too, not only FastAPI.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from fastapi import Request
|
|
61
|
+
from vpndetection_fastapi import lookup
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.get("/")
|
|
65
|
+
async def index(request: Request):
|
|
66
|
+
found = lookup(request)
|
|
67
|
+
return {"vpn": found.result.is_vpn}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
By default nothing is blocked. Every request gets an answer and your own code decides what that means — which is usually what you want, because whether a VPN visitor is a problem depends entirely on what they are doing.
|
|
71
|
+
|
|
72
|
+
## Blocking
|
|
73
|
+
|
|
74
|
+
Pass a `block_condition` and a matching request is answered with `403` and never reaches your code.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
block_condition = {"is_vpn": True}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A condition is written in the shape of a result, keyed by the same names the API uses, and only the members you name are considered. That lets it reach the evidence, not just the flags:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
{"is_vpn": True, "vpn": {"provider": "nordvpn"}} # one provider
|
|
84
|
+
{"is_resproxy": True, "resproxy": {"hits": {"gte": 5}}} # a numeric threshold
|
|
85
|
+
{"vpn": {"confidence": ["high", "medium"]}} # any of these
|
|
86
|
+
[{"is_tor": True}, {"is_resproxy": True}] # a list is OR
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Values are matched by equality, strings without regard to case. A list means any-of. A dict of `gte`/`gt`/`lte`/`lt` compares numbers, and every bound you give must hold, so two of them are a range. Members set to `False` or `None` are ignored, so a condition states the signals you act on; one that constrains nothing would match every request, and is refused when the middleware is built rather than silently blocking all your traffic.
|
|
90
|
+
|
|
91
|
+
Replace the refusal with `on_blocked`.
|
|
92
|
+
|
|
93
|
+
## Where the client address comes from
|
|
94
|
+
|
|
95
|
+
This is the setting that decides whether any of the above works, and it is the one thing only you can get right.
|
|
96
|
+
|
|
97
|
+
By default the middleware uses `request.client.host`, which is the socket peer unless the server was started with proxy-header support. Behind a load balancer without it, every visitor looks like the load balancer — a datacenter address, so a hosting rule would block all of them. Starting uvicorn with `--proxy-headers --forwarded-allow-ips=<your proxy>` is the server's own answer, and the one that knows your topology.
|
|
98
|
+
|
|
99
|
+
For an edge that writes the address into its own header, name the header:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from python_fastapi import header_ip_selector
|
|
103
|
+
|
|
104
|
+
ip_selector = header_ip_selector("CF-Connecting-IP") # or True-Client-IP, or your own
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`xff_ip_selector()` reads `X-Forwarded-For` directly. Be aware that the left-most entry is whatever the caller sent, because proxies append to that header — it is only trustworthy when an edge you control overwrites it. If you know how many proxies sit in front, count from the right instead: `xff_ip_selector(1)` is the address your nearest proxy saw.
|
|
108
|
+
|
|
109
|
+
Anything else, pass your own callable. It receives the request and returns an address.
|
|
110
|
+
|
|
111
|
+
If the address resolves to a private one, the middleware says so once through the `vpndetection` logger. That is expected on localhost and is the signal to fix your configuration anywhere else.
|
|
112
|
+
|
|
113
|
+
## When a lookup fails
|
|
114
|
+
|
|
115
|
+
The request is let through, and the reason is recorded on the answer's `error`. Our outage should not become yours, so a network failure, an exhausted quota or a rejected key all fail open. Pass `fail_closed=True` to block instead. Private addresses are answered locally and never fail, so this will not lock you out in development.
|
|
116
|
+
|
|
117
|
+
## Cost and latency
|
|
118
|
+
|
|
119
|
+
Answers are cached for an hour, so a returning visitor costs nothing, and private addresses never leave the process. A cache miss is one request to our API, bounded at 2.5 seconds by default and not retried — on a request path, failing open quickly beats holding a visitor while we try again. Both are adjustable, as is the cache, through a `vpndetection` client you build yourself and pass as `client`.
|
|
120
|
+
|
|
121
|
+
Skip what you do not care about:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
skip = lambda request: request.url.path.startswith("/static")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Beyond a few million distinct visitors a day, stop calling the API per request: [download the dataset](https://vpndetection.io/databases) and look addresses up locally instead.
|
|
128
|
+
|
|
129
|
+
## Absent is not false
|
|
130
|
+
|
|
131
|
+
Only `ip` and `is_vpn` come back on every plan. A field your plan does not include is `None`, which means "not in your plan" rather than "checked, and no".
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
lookup(request).result.is_hosting # None when your plan does not include it
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A `block_condition` naming a member your plan does not serve can never match, so the middleware warns once instead of failing silently. Pass `on_missing_field="raise"` to make it an error.
|
|
138
|
+
|
|
139
|
+
## Other Libraries
|
|
140
|
+
|
|
141
|
+
There are official VPNDetection client libraries available for many languages including PHP, Python, Go, Java, Ruby, and many popular frameworks such as Django, Rails, and Laravel. See our GitHub at https://github.com/vpndetection-io for more.
|
|
142
|
+
|
|
143
|
+
## About VPNDetection
|
|
144
|
+
|
|
145
|
+
VPN Detection API: Accurate anonymity detection identifying VPNs, residential proxies, hosting servers, Tor nodes, CDNs, relays and more.
|
|
146
|
+
|
|
147
|
+
[<img src="https://s3.vpndetection.io/vpndetection-public/brand/mark.svg" alt="VPNDetection" width="96"/>](https://vpndetection.io/)
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
This project is licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# [<img src="https://s3.vpndetection.io/vpndetection-public/brand/mark.svg" alt="VPNDetection" width="24"/>](https://vpndetection.io/) VPNDetection FastAPI Middleware
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/vpndetection-fastapi/)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
The official FastAPI Middleware for the [VPNDetection](https://vpndetection.io) API.
|
|
7
|
+
|
|
8
|
+
It classifies the visitor behind each request — VPN, residential proxy, Tor, hosting, CDN, relay — and hands the answer to your code. Blocking is opt-in.
|
|
9
|
+
|
|
10
|
+
## Getting Started
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install vpndetection-fastapi
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Requires Python 3.11 or newer.
|
|
17
|
+
|
|
18
|
+
You need an API key. Create one in the [console](https://app.vpndetection.io); the free tier's allowance is counted per source address, and a server is a single source address, so a key is what makes this usable in production rather than optional.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from fastapi import FastAPI
|
|
22
|
+
from vpndetection_fastapi import VPNDetectionMiddleware
|
|
23
|
+
|
|
24
|
+
app = FastAPI()
|
|
25
|
+
app.add_middleware(VPNDetectionMiddleware, api_key=os.environ["VPNDETECTION_API_KEY"])
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Pure ASGI, so this works on Starlette and Litestar too, not only FastAPI.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from fastapi import Request
|
|
32
|
+
from vpndetection_fastapi import lookup
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.get("/")
|
|
36
|
+
async def index(request: Request):
|
|
37
|
+
found = lookup(request)
|
|
38
|
+
return {"vpn": found.result.is_vpn}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
By default nothing is blocked. Every request gets an answer and your own code decides what that means — which is usually what you want, because whether a VPN visitor is a problem depends entirely on what they are doing.
|
|
42
|
+
|
|
43
|
+
## Blocking
|
|
44
|
+
|
|
45
|
+
Pass a `block_condition` and a matching request is answered with `403` and never reaches your code.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
block_condition = {"is_vpn": True}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A condition is written in the shape of a result, keyed by the same names the API uses, and only the members you name are considered. That lets it reach the evidence, not just the flags:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
{"is_vpn": True, "vpn": {"provider": "nordvpn"}} # one provider
|
|
55
|
+
{"is_resproxy": True, "resproxy": {"hits": {"gte": 5}}} # a numeric threshold
|
|
56
|
+
{"vpn": {"confidence": ["high", "medium"]}} # any of these
|
|
57
|
+
[{"is_tor": True}, {"is_resproxy": True}] # a list is OR
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Values are matched by equality, strings without regard to case. A list means any-of. A dict of `gte`/`gt`/`lte`/`lt` compares numbers, and every bound you give must hold, so two of them are a range. Members set to `False` or `None` are ignored, so a condition states the signals you act on; one that constrains nothing would match every request, and is refused when the middleware is built rather than silently blocking all your traffic.
|
|
61
|
+
|
|
62
|
+
Replace the refusal with `on_blocked`.
|
|
63
|
+
|
|
64
|
+
## Where the client address comes from
|
|
65
|
+
|
|
66
|
+
This is the setting that decides whether any of the above works, and it is the one thing only you can get right.
|
|
67
|
+
|
|
68
|
+
By default the middleware uses `request.client.host`, which is the socket peer unless the server was started with proxy-header support. Behind a load balancer without it, every visitor looks like the load balancer — a datacenter address, so a hosting rule would block all of them. Starting uvicorn with `--proxy-headers --forwarded-allow-ips=<your proxy>` is the server's own answer, and the one that knows your topology.
|
|
69
|
+
|
|
70
|
+
For an edge that writes the address into its own header, name the header:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from python_fastapi import header_ip_selector
|
|
74
|
+
|
|
75
|
+
ip_selector = header_ip_selector("CF-Connecting-IP") # or True-Client-IP, or your own
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`xff_ip_selector()` reads `X-Forwarded-For` directly. Be aware that the left-most entry is whatever the caller sent, because proxies append to that header — it is only trustworthy when an edge you control overwrites it. If you know how many proxies sit in front, count from the right instead: `xff_ip_selector(1)` is the address your nearest proxy saw.
|
|
79
|
+
|
|
80
|
+
Anything else, pass your own callable. It receives the request and returns an address.
|
|
81
|
+
|
|
82
|
+
If the address resolves to a private one, the middleware says so once through the `vpndetection` logger. That is expected on localhost and is the signal to fix your configuration anywhere else.
|
|
83
|
+
|
|
84
|
+
## When a lookup fails
|
|
85
|
+
|
|
86
|
+
The request is let through, and the reason is recorded on the answer's `error`. Our outage should not become yours, so a network failure, an exhausted quota or a rejected key all fail open. Pass `fail_closed=True` to block instead. Private addresses are answered locally and never fail, so this will not lock you out in development.
|
|
87
|
+
|
|
88
|
+
## Cost and latency
|
|
89
|
+
|
|
90
|
+
Answers are cached for an hour, so a returning visitor costs nothing, and private addresses never leave the process. A cache miss is one request to our API, bounded at 2.5 seconds by default and not retried — on a request path, failing open quickly beats holding a visitor while we try again. Both are adjustable, as is the cache, through a `vpndetection` client you build yourself and pass as `client`.
|
|
91
|
+
|
|
92
|
+
Skip what you do not care about:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
skip = lambda request: request.url.path.startswith("/static")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Beyond a few million distinct visitors a day, stop calling the API per request: [download the dataset](https://vpndetection.io/databases) and look addresses up locally instead.
|
|
99
|
+
|
|
100
|
+
## Absent is not false
|
|
101
|
+
|
|
102
|
+
Only `ip` and `is_vpn` come back on every plan. A field your plan does not include is `None`, which means "not in your plan" rather than "checked, and no".
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
lookup(request).result.is_hosting # None when your plan does not include it
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A `block_condition` naming a member your plan does not serve can never match, so the middleware warns once instead of failing silently. Pass `on_missing_field="raise"` to make it an error.
|
|
109
|
+
|
|
110
|
+
## Other Libraries
|
|
111
|
+
|
|
112
|
+
There are official VPNDetection client libraries available for many languages including PHP, Python, Go, Java, Ruby, and many popular frameworks such as Django, Rails, and Laravel. See our GitHub at https://github.com/vpndetection-io for more.
|
|
113
|
+
|
|
114
|
+
## About VPNDetection
|
|
115
|
+
|
|
116
|
+
VPN Detection API: Accurate anonymity detection identifying VPNs, residential proxies, hosting servers, Tor nodes, CDNs, relays and more.
|
|
117
|
+
|
|
118
|
+
[<img src="https://s3.vpndetection.io/vpndetection-public/brand/mark.svg" alt="VPNDetection" width="96"/>](https://vpndetection.io/)
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
This project is licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vpndetection-fastapi"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official FastAPI and Starlette middleware for the VPNDetection API. Detect VPNs, proxies, Tor, hosting and CDN visitors on every request."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
authors = [{ name = "Mslm Dev", email = "support@vpndetection.io" }]
|
|
14
|
+
keywords = ["vpn", "vpn-detection", "proxy-detection", "tor", "ip-intelligence", "fraud-prevention", "middleware"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Internet",
|
|
24
|
+
"Topic :: Security",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["vpndetection>=2.1.0,<4", "starlette>=0.37"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://vpndetection.io"
|
|
31
|
+
Source = "https://github.com/vpndetection-io/sdk-python-fastapi"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = ["pytest>=8", "mypy>=1.11", "ruff>=0.6"]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/vpndetection_fastapi"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.version]
|
|
40
|
+
path = "src/vpndetection_fastapi/__init__.py"
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 96
|
|
44
|
+
|
|
45
|
+
[tool.mypy]
|
|
46
|
+
python_version = "3.11"
|
|
47
|
+
strict = true
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Official FastAPI and Starlette middleware for the VPNDetection API.
|
|
2
|
+
|
|
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.
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from vpndetection_fastapi import VPNDetectionMiddleware
|
|
8
|
+
|
|
9
|
+
app = FastAPI()
|
|
10
|
+
app.add_middleware(VPNDetectionMiddleware, api_key=os.environ["VPNDETECTION_API_KEY"])
|
|
11
|
+
|
|
12
|
+
Pure ASGI, so this covers Starlette and Litestar too, not only FastAPI. The
|
|
13
|
+
framework-agnostic half lives in ``vpndetection.middleware``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Awaitable, Callable
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from starlette.requests import Request
|
|
22
|
+
from starlette.responses import JSONResponse, Response
|
|
23
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
24
|
+
from vpndetection.middleware import (
|
|
25
|
+
AsyncCore,
|
|
26
|
+
IpSelector,
|
|
27
|
+
Lookup,
|
|
28
|
+
Options,
|
|
29
|
+
RequestView,
|
|
30
|
+
Selectors,
|
|
31
|
+
bind_selectors,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"VPNDetectionMiddleware",
|
|
36
|
+
"default_ip_selector",
|
|
37
|
+
"header_ip_selector",
|
|
38
|
+
"lookup",
|
|
39
|
+
"xff_ip_selector",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
__version__ = "1.0.0"
|
|
43
|
+
|
|
44
|
+
_SELECTORS: Selectors[Request] = bind_selectors(
|
|
45
|
+
lambda request: RequestView(
|
|
46
|
+
header=lambda name: request.headers.get(name),
|
|
47
|
+
framework_ip=lambda: request.client.host if request.client else None,
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
#: ``request.client.host``, which is the socket peer unless the app was started with
|
|
52
|
+
#: uvicorn's ``--proxy-headers``.
|
|
53
|
+
#:
|
|
54
|
+
#: Behind a load balancer without it, every visitor looks like the load balancer - a
|
|
55
|
+
#: datacenter address a hosting rule would block them all for. If you are behind one,
|
|
56
|
+
#: pass ``--proxy-headers --forwarded-allow-ips=<your proxy>`` (the server's own answer,
|
|
57
|
+
#: and the one that knows your topology) or use :func:`header_ip_selector`.
|
|
58
|
+
default_ip_selector: IpSelector[Request] = _SELECTORS.default
|
|
59
|
+
|
|
60
|
+
#: An address from ``X-Forwarded-For``.
|
|
61
|
+
#:
|
|
62
|
+
#: The LEFT-MOST entry (``depth`` 0) is whatever the caller sent, because proxies
|
|
63
|
+
#: append to this header. It is only trustworthy when an edge you control overwrites
|
|
64
|
+
#: it. When you know how many proxies sit in front, count from the right:
|
|
65
|
+
#: ``xff_ip_selector(1)`` is the address your nearest proxy saw.
|
|
66
|
+
xff_ip_selector = _SELECTORS.xff
|
|
67
|
+
|
|
68
|
+
#: An address from a single-value header your edge writes -
|
|
69
|
+
#: ``header_ip_selector("CF-Connecting-IP")`` behind Cloudflare. Falls back to
|
|
70
|
+
#: ``request.client.host`` when the header is absent.
|
|
71
|
+
header_ip_selector = _SELECTORS.header
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def lookup(request: Request) -> Lookup | None:
|
|
75
|
+
"""What the middleware found out about this visitor.
|
|
76
|
+
|
|
77
|
+
None when the middleware has not run for this route, or when ``skip`` claimed it.
|
|
78
|
+
"""
|
|
79
|
+
return getattr(request.state, "vpndetection", None)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class VPNDetectionMiddleware:
|
|
83
|
+
"""Classify the visitor, and optionally refuse the request.
|
|
84
|
+
|
|
85
|
+
Takes everything :class:`vpndetection.middleware.Options` does, plus ``on_blocked``.
|
|
86
|
+
|
|
87
|
+
Written as raw ASGI rather than on ``BaseHTTPMiddleware``, which wraps the response
|
|
88
|
+
in a streaming bridge that breaks background tasks and server-sent events - a cost
|
|
89
|
+
no middleware that only reads the request should impose.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
app: ASGIApp,
|
|
95
|
+
*,
|
|
96
|
+
on_blocked: Callable[[Request, Lookup], Response | Awaitable[Response]] | None = None,
|
|
97
|
+
**options: Any,
|
|
98
|
+
) -> None:
|
|
99
|
+
self.app = app
|
|
100
|
+
self._on_blocked = on_blocked or _refuse
|
|
101
|
+
self._core: AsyncCore[Request] = AsyncCore(Options(**options), default_ip_selector)
|
|
102
|
+
|
|
103
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
104
|
+
if scope["type"] != "http":
|
|
105
|
+
await self.app(scope, receive, send)
|
|
106
|
+
return
|
|
107
|
+
request = Request(scope, receive=receive)
|
|
108
|
+
found = await self._core.evaluate(request)
|
|
109
|
+
if found is None:
|
|
110
|
+
await self.app(scope, receive, send)
|
|
111
|
+
return
|
|
112
|
+
# request.state is backed by scope["state"], so this reaches the Request the
|
|
113
|
+
# endpoint builds later over the same scope even though it is a different
|
|
114
|
+
# object.
|
|
115
|
+
request.state.vpndetection = found
|
|
116
|
+
if found.blocked:
|
|
117
|
+
response = self._on_blocked(request, found)
|
|
118
|
+
if isinstance(response, Response):
|
|
119
|
+
await response(scope, receive, send)
|
|
120
|
+
else:
|
|
121
|
+
await (await response)(scope, receive, send)
|
|
122
|
+
return
|
|
123
|
+
await self.app(scope, receive, send)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _refuse(_request: Request, _lookup: Lookup) -> Response:
|
|
127
|
+
return JSONResponse({"error": "access denied"}, status_code=403)
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"v4": [
|
|
3
|
+
"0.0.0.0/8",
|
|
4
|
+
"10.0.0.0/8",
|
|
5
|
+
"100.64.0.0/10",
|
|
6
|
+
"127.0.0.0/8",
|
|
7
|
+
"169.254.0.0/16",
|
|
8
|
+
"172.16.0.0/12",
|
|
9
|
+
"192.0.0.0/24",
|
|
10
|
+
"192.0.2.0/24",
|
|
11
|
+
"192.168.0.0/16",
|
|
12
|
+
"198.18.0.0/15",
|
|
13
|
+
"198.51.100.0/24",
|
|
14
|
+
"203.0.113.0/24",
|
|
15
|
+
"224.0.0.0/4",
|
|
16
|
+
"240.0.0.0/4",
|
|
17
|
+
"255.255.255.255/32"
|
|
18
|
+
],
|
|
19
|
+
"v6": [
|
|
20
|
+
"::/128",
|
|
21
|
+
"::1/128",
|
|
22
|
+
"::ffff:0:0/96",
|
|
23
|
+
"::/96",
|
|
24
|
+
"100::/64",
|
|
25
|
+
"2001:10::/28",
|
|
26
|
+
"2001:db8::/32",
|
|
27
|
+
"fc00::/7",
|
|
28
|
+
"fe80::/10",
|
|
29
|
+
"fec0::/10",
|
|
30
|
+
"ff00::/8",
|
|
31
|
+
"2002::/24",
|
|
32
|
+
"2002:a00::/24",
|
|
33
|
+
"2002:7f00::/24",
|
|
34
|
+
"2002:a9fe::/32",
|
|
35
|
+
"2002:ac10::/28",
|
|
36
|
+
"2002:c000::/40",
|
|
37
|
+
"2002:c000:200::/40",
|
|
38
|
+
"2002:c0a8::/32",
|
|
39
|
+
"2002:c612::/31",
|
|
40
|
+
"2002:c633:6400::/40",
|
|
41
|
+
"2002:cb00:7100::/40",
|
|
42
|
+
"2002:e000::/20",
|
|
43
|
+
"2002:f000::/20",
|
|
44
|
+
"2002:ffff:ffff::/48",
|
|
45
|
+
"2001::/40",
|
|
46
|
+
"2001:0:a00::/40",
|
|
47
|
+
"2001:0:7f00::/40",
|
|
48
|
+
"2001:0:a9fe::/48",
|
|
49
|
+
"2001:0:ac10::/44",
|
|
50
|
+
"2001:0:c000::/56",
|
|
51
|
+
"2001:0:c000:200::/56",
|
|
52
|
+
"2001:0:c0a8::/48",
|
|
53
|
+
"2001:0:c612::/47",
|
|
54
|
+
"2001:0:c633:6400::/56",
|
|
55
|
+
"2001:0:cb00:7100::/56",
|
|
56
|
+
"2001:0:e000::/36",
|
|
57
|
+
"2001:0:f000::/36",
|
|
58
|
+
"2001:0:ffff:ffff::/64"
|
|
59
|
+
]
|
|
60
|
+
}
|