primedefender-fastapi 0.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.
- primedefender_fastapi-0.1.0/.gitignore +34 -0
- primedefender_fastapi-0.1.0/LICENSE +21 -0
- primedefender_fastapi-0.1.0/PKG-INFO +186 -0
- primedefender_fastapi-0.1.0/README.md +154 -0
- primedefender_fastapi-0.1.0/primedefender_fastapi/__init__.py +18 -0
- primedefender_fastapi-0.1.0/primedefender_fastapi/config.py +159 -0
- primedefender_fastapi-0.1.0/primedefender_fastapi/detectors.py +219 -0
- primedefender_fastapi-0.1.0/primedefender_fastapi/geo.py +72 -0
- primedefender_fastapi-0.1.0/primedefender_fastapi/middleware.py +148 -0
- primedefender_fastapi-0.1.0/primedefender_fastapi/py.typed +0 -0
- primedefender_fastapi-0.1.0/primedefender_fastapi/rate_limit.py +19 -0
- primedefender_fastapi-0.1.0/primedefender_fastapi/reporter.py +148 -0
- primedefender_fastapi-0.1.0/pyproject.toml +62 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
*.so
|
|
5
|
+
.Python
|
|
6
|
+
build/
|
|
7
|
+
develop-eggs/
|
|
8
|
+
dist/
|
|
9
|
+
downloads/
|
|
10
|
+
eggs/
|
|
11
|
+
.eggs/
|
|
12
|
+
lib/
|
|
13
|
+
lib64/
|
|
14
|
+
parts/
|
|
15
|
+
sdist/
|
|
16
|
+
var/
|
|
17
|
+
wheels/
|
|
18
|
+
*.egg-info/
|
|
19
|
+
.installed.cfg
|
|
20
|
+
*.egg
|
|
21
|
+
.venv/
|
|
22
|
+
venv/
|
|
23
|
+
ENV/
|
|
24
|
+
.env
|
|
25
|
+
.env.local
|
|
26
|
+
.idea/
|
|
27
|
+
.vscode/
|
|
28
|
+
*.swp
|
|
29
|
+
.pytest_cache/
|
|
30
|
+
.mypy_cache/
|
|
31
|
+
.ruff_cache/
|
|
32
|
+
htmlcov/
|
|
33
|
+
.coverage
|
|
34
|
+
coverage.xml
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PrimeDefender contributors
|
|
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,186 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: primedefender-fastapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PrimeDefender security middleware for FastAPI: WAF-style detection, blocking, and incident reporting to the PrimeDefender bridge.
|
|
5
|
+
Project-URL: Homepage, https://github.com/primedefender/primedefender-fastapi
|
|
6
|
+
Project-URL: Repository, https://github.com/primedefender/primedefender-fastapi
|
|
7
|
+
Project-URL: Issues, https://github.com/primedefender/primedefender-fastapi/issues
|
|
8
|
+
Author: PrimeDefender contributors
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: fastapi,intrusion-detection,middleware,primedefender,security,sqli,waf,xss
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Topic :: Security
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: fastapi>=0.100.0
|
|
27
|
+
Requires-Dist: httpx>=0.25.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# primedefender-fastapi
|
|
34
|
+
|
|
35
|
+
**PrimeDefender** adds a security layer to [FastAPI](https://fastapi.tiangolo.com/) applications: it inspects incoming requests for common attack patterns, optionally blocks them, and sends structured incidents to your **PrimeDefender bridge** (for example for a live monitoring map).
|
|
36
|
+
|
|
37
|
+
This package publishes to PyPI as **`primedefender-fastapi`**. The import name is **`primedefender_fastapi`**.
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
| Detection | Notes |
|
|
42
|
+
|-----------|--------|
|
|
43
|
+
| SQL injection | Signature-based |
|
|
44
|
+
| XSS | Signature-based |
|
|
45
|
+
| Brute force | Configurable window on auth paths |
|
|
46
|
+
| Path traversal | Signature-based |
|
|
47
|
+
| Command injection | Signature-based |
|
|
48
|
+
| File inclusion | Signature-based |
|
|
49
|
+
| DDoS / flood | Per-IP sliding window |
|
|
50
|
+
| Bot activity | User-agent heuristics + rate limit |
|
|
51
|
+
| Scanner | UA + path probes + rate limit |
|
|
52
|
+
| Suspicious request | Method / query / body heuristics (observe by default) |
|
|
53
|
+
| Auth bypass probe | Header / query patterns (observe by default) |
|
|
54
|
+
|
|
55
|
+
Blocked requests return JSON with HTTP `403` or `429` as appropriate. Incidents are **POST**ed to the bridge (default path **`/ingest`** if your `PRIMEDEFENDER_BRIDGE_URL` has no path).
|
|
56
|
+
|
|
57
|
+
## Requirements
|
|
58
|
+
|
|
59
|
+
- Python **3.10+**
|
|
60
|
+
- A running **PrimeDefender bridge** that accepts the incident JSON (see your dashboard docs).
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install primedefender-fastapi
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
For a local editable install while developing the package:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install -e ./primedefender-fastapi
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Environment variables
|
|
75
|
+
|
|
76
|
+
Set these in your process environment or load them with `python-dotenv` **before** the app imports settings (see below).
|
|
77
|
+
|
|
78
|
+
| Variable | Required | Description |
|
|
79
|
+
|----------|----------|-------------|
|
|
80
|
+
| `PRIMEDEFENDER_BRIDGE_URL` | Yes* | Bridge base URL, e.g. `http://localhost:3000` (path `/ingest` is added if missing) |
|
|
81
|
+
| `PRIMEDEFENDER_API_KEY` | Yes* | API key sent as `X-Api-Key` / `Authorization: Bearer` |
|
|
82
|
+
| `PRIMEDEFENDER_SITE_ID` | Yes* | Site identifier in payloads |
|
|
83
|
+
| `PRIMEDEFENDER_SITE_LAT` | Recommended | Target latitude for map “to” pin |
|
|
84
|
+
| `PRIMEDEFENDER_SITE_LON` | Recommended | Target longitude |
|
|
85
|
+
| `PRIMEDEFENDER_SITE_REGION_LABEL` | Optional | Human label, e.g. `Indonesia, Bali` → `targetLabel = "{site_id} · {label}"` |
|
|
86
|
+
| `PRIMEDEFENDER_PRIVATE_SOURCE_LABEL` | Optional | Label for private/loopback IPs |
|
|
87
|
+
| `PRIMEDEFENDER_AUTH_BYPASS_MODE` | Optional | `observe` (default) or `block` |
|
|
88
|
+
| `PRIMEDEFENDER_SUSPICIOUS_REQUEST_MODE` | Optional | `observe` (default) or `block` |
|
|
89
|
+
|
|
90
|
+
\*If bridge URL, API key, or site id is missing, reporting is disabled (middleware still runs detections).
|
|
91
|
+
|
|
92
|
+
See `.env.example` in this repository for tuning knobs (`PRIMEDEFENDER_BODY_CAP_BYTES`, rate limits, GeoIP TTL, etc.).
|
|
93
|
+
|
|
94
|
+
## FastAPI usage
|
|
95
|
+
|
|
96
|
+
**Minimal** (configuration only from environment):
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from fastapi import FastAPI
|
|
100
|
+
from primedefender_fastapi import PrimeDefenderMiddleware
|
|
101
|
+
|
|
102
|
+
app = FastAPI()
|
|
103
|
+
app.add_middleware(PrimeDefenderMiddleware)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Load `.env` early so variables exist when settings are first read:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from pathlib import Path
|
|
110
|
+
from dotenv import load_dotenv
|
|
111
|
+
|
|
112
|
+
load_dotenv(Path(__file__).resolve().parent / ".env")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Optional constructor overrides** (other fields still come from the environment):
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
app.add_middleware(
|
|
119
|
+
PrimeDefenderMiddleware,
|
|
120
|
+
site_label="Indonesia, Bali",
|
|
121
|
+
auth_bypass_mode="observe",
|
|
122
|
+
suspicious_request_mode="block",
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Explicit settings object** (e.g. tests or multi-tenant):
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from primedefender_fastapi import PrimeDefenderMiddleware, PrimeDefenderSettings
|
|
130
|
+
|
|
131
|
+
settings = PrimeDefenderSettings.from_env()
|
|
132
|
+
app.add_middleware(PrimeDefenderMiddleware, settings=settings)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Connect to the PrimeDefender bridge
|
|
136
|
+
|
|
137
|
+
1. Run your bridge (often on port `3000` or behind HTTPS).
|
|
138
|
+
2. Set `PRIMEDEFENDER_BRIDGE_URL` to that origin, e.g. `http://localhost:3000`.
|
|
139
|
+
3. Ensure the bridge exposes **`POST /ingest`** (or set the full URL including path).
|
|
140
|
+
4. Use a valid `PRIMEDEFENDER_API_KEY` accepted by the bridge.
|
|
141
|
+
|
|
142
|
+
Health check (typical): `GET http://localhost:3000/health`.
|
|
143
|
+
|
|
144
|
+
## Test SQLi / XSS locally
|
|
145
|
+
|
|
146
|
+
With the API on port `8000`:
|
|
147
|
+
|
|
148
|
+
**SQL injection (query)**
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
curl "http://127.0.0.1:8000/auth/login?next=' OR 1=1 --"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**XSS**
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
curl "http://127.0.0.1:8000/?q=%3Cscript%3Ealert(1)%3C/script%3E"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**Map labels (optional test headers)**
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
curl "http://127.0.0.1:8000/auth/login?next=' OR 1=1 --" \
|
|
164
|
+
-H "X-Prime-Source-Lat: 34.6937" \
|
|
165
|
+
-H "X-Prime-Source-Lon: 135.5023" \
|
|
166
|
+
-H "X-Prime-Source-Label: Japan, Osaka"
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Building and publishing
|
|
170
|
+
|
|
171
|
+
Uses **hatchling** (PEP 517):
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
pip install build
|
|
175
|
+
python -m build
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Upload `dist/*` to PyPI with `twine` (use API tokens and trusted publishing in CI in production).
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT — see `LICENSE`.
|
|
183
|
+
|
|
184
|
+
## Repository
|
|
185
|
+
|
|
186
|
+
Placeholder links are set in `pyproject.toml` (`Homepage` / `Repository`). Replace with your real GitHub URL before publishing.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# primedefender-fastapi
|
|
2
|
+
|
|
3
|
+
**PrimeDefender** adds a security layer to [FastAPI](https://fastapi.tiangolo.com/) applications: it inspects incoming requests for common attack patterns, optionally blocks them, and sends structured incidents to your **PrimeDefender bridge** (for example for a live monitoring map).
|
|
4
|
+
|
|
5
|
+
This package publishes to PyPI as **`primedefender-fastapi`**. The import name is **`primedefender_fastapi`**.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
| Detection | Notes |
|
|
10
|
+
|-----------|--------|
|
|
11
|
+
| SQL injection | Signature-based |
|
|
12
|
+
| XSS | Signature-based |
|
|
13
|
+
| Brute force | Configurable window on auth paths |
|
|
14
|
+
| Path traversal | Signature-based |
|
|
15
|
+
| Command injection | Signature-based |
|
|
16
|
+
| File inclusion | Signature-based |
|
|
17
|
+
| DDoS / flood | Per-IP sliding window |
|
|
18
|
+
| Bot activity | User-agent heuristics + rate limit |
|
|
19
|
+
| Scanner | UA + path probes + rate limit |
|
|
20
|
+
| Suspicious request | Method / query / body heuristics (observe by default) |
|
|
21
|
+
| Auth bypass probe | Header / query patterns (observe by default) |
|
|
22
|
+
|
|
23
|
+
Blocked requests return JSON with HTTP `403` or `429` as appropriate. Incidents are **POST**ed to the bridge (default path **`/ingest`** if your `PRIMEDEFENDER_BRIDGE_URL` has no path).
|
|
24
|
+
|
|
25
|
+
## Requirements
|
|
26
|
+
|
|
27
|
+
- Python **3.10+**
|
|
28
|
+
- A running **PrimeDefender bridge** that accepts the incident JSON (see your dashboard docs).
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install primedefender-fastapi
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
For a local editable install while developing the package:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install -e ./primedefender-fastapi
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Environment variables
|
|
43
|
+
|
|
44
|
+
Set these in your process environment or load them with `python-dotenv` **before** the app imports settings (see below).
|
|
45
|
+
|
|
46
|
+
| Variable | Required | Description |
|
|
47
|
+
|----------|----------|-------------|
|
|
48
|
+
| `PRIMEDEFENDER_BRIDGE_URL` | Yes* | Bridge base URL, e.g. `http://localhost:3000` (path `/ingest` is added if missing) |
|
|
49
|
+
| `PRIMEDEFENDER_API_KEY` | Yes* | API key sent as `X-Api-Key` / `Authorization: Bearer` |
|
|
50
|
+
| `PRIMEDEFENDER_SITE_ID` | Yes* | Site identifier in payloads |
|
|
51
|
+
| `PRIMEDEFENDER_SITE_LAT` | Recommended | Target latitude for map “to” pin |
|
|
52
|
+
| `PRIMEDEFENDER_SITE_LON` | Recommended | Target longitude |
|
|
53
|
+
| `PRIMEDEFENDER_SITE_REGION_LABEL` | Optional | Human label, e.g. `Indonesia, Bali` → `targetLabel = "{site_id} · {label}"` |
|
|
54
|
+
| `PRIMEDEFENDER_PRIVATE_SOURCE_LABEL` | Optional | Label for private/loopback IPs |
|
|
55
|
+
| `PRIMEDEFENDER_AUTH_BYPASS_MODE` | Optional | `observe` (default) or `block` |
|
|
56
|
+
| `PRIMEDEFENDER_SUSPICIOUS_REQUEST_MODE` | Optional | `observe` (default) or `block` |
|
|
57
|
+
|
|
58
|
+
\*If bridge URL, API key, or site id is missing, reporting is disabled (middleware still runs detections).
|
|
59
|
+
|
|
60
|
+
See `.env.example` in this repository for tuning knobs (`PRIMEDEFENDER_BODY_CAP_BYTES`, rate limits, GeoIP TTL, etc.).
|
|
61
|
+
|
|
62
|
+
## FastAPI usage
|
|
63
|
+
|
|
64
|
+
**Minimal** (configuration only from environment):
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from fastapi import FastAPI
|
|
68
|
+
from primedefender_fastapi import PrimeDefenderMiddleware
|
|
69
|
+
|
|
70
|
+
app = FastAPI()
|
|
71
|
+
app.add_middleware(PrimeDefenderMiddleware)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Load `.env` early so variables exist when settings are first read:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from pathlib import Path
|
|
78
|
+
from dotenv import load_dotenv
|
|
79
|
+
|
|
80
|
+
load_dotenv(Path(__file__).resolve().parent / ".env")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Optional constructor overrides** (other fields still come from the environment):
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
app.add_middleware(
|
|
87
|
+
PrimeDefenderMiddleware,
|
|
88
|
+
site_label="Indonesia, Bali",
|
|
89
|
+
auth_bypass_mode="observe",
|
|
90
|
+
suspicious_request_mode="block",
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Explicit settings object** (e.g. tests or multi-tenant):
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from primedefender_fastapi import PrimeDefenderMiddleware, PrimeDefenderSettings
|
|
98
|
+
|
|
99
|
+
settings = PrimeDefenderSettings.from_env()
|
|
100
|
+
app.add_middleware(PrimeDefenderMiddleware, settings=settings)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Connect to the PrimeDefender bridge
|
|
104
|
+
|
|
105
|
+
1. Run your bridge (often on port `3000` or behind HTTPS).
|
|
106
|
+
2. Set `PRIMEDEFENDER_BRIDGE_URL` to that origin, e.g. `http://localhost:3000`.
|
|
107
|
+
3. Ensure the bridge exposes **`POST /ingest`** (or set the full URL including path).
|
|
108
|
+
4. Use a valid `PRIMEDEFENDER_API_KEY` accepted by the bridge.
|
|
109
|
+
|
|
110
|
+
Health check (typical): `GET http://localhost:3000/health`.
|
|
111
|
+
|
|
112
|
+
## Test SQLi / XSS locally
|
|
113
|
+
|
|
114
|
+
With the API on port `8000`:
|
|
115
|
+
|
|
116
|
+
**SQL injection (query)**
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
curl "http://127.0.0.1:8000/auth/login?next=' OR 1=1 --"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**XSS**
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
curl "http://127.0.0.1:8000/?q=%3Cscript%3Ealert(1)%3C/script%3E"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Map labels (optional test headers)**
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
curl "http://127.0.0.1:8000/auth/login?next=' OR 1=1 --" \
|
|
132
|
+
-H "X-Prime-Source-Lat: 34.6937" \
|
|
133
|
+
-H "X-Prime-Source-Lon: 135.5023" \
|
|
134
|
+
-H "X-Prime-Source-Label: Japan, Osaka"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Building and publishing
|
|
138
|
+
|
|
139
|
+
Uses **hatchling** (PEP 517):
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
pip install build
|
|
143
|
+
python -m build
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Upload `dist/*` to PyPI with `twine` (use API tokens and trusted publishing in CI in production).
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT — see `LICENSE`.
|
|
151
|
+
|
|
152
|
+
## Repository
|
|
153
|
+
|
|
154
|
+
Placeholder links are set in `pyproject.toml` (`Homepage` / `Repository`). Replace with your real GitHub URL before publishing.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PrimeDefender FastAPI middleware: request inspection, blocking, and bridge reporting.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from primedefender_fastapi.config import PrimeDefenderSettings, clear_settings_cache, load_settings
|
|
6
|
+
from primedefender_fastapi.detectors import Detection
|
|
7
|
+
from primedefender_fastapi.middleware import PrimeDefenderMiddleware
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"PrimeDefenderMiddleware",
|
|
13
|
+
"PrimeDefenderSettings",
|
|
14
|
+
"Detection",
|
|
15
|
+
"load_settings",
|
|
16
|
+
"clear_settings_cache",
|
|
17
|
+
"__version__",
|
|
18
|
+
]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import Any, Literal, Tuple
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
Mode = Literal["observe", "block"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _env_str(key: str, default: str = "") -> str:
|
|
13
|
+
raw = os.getenv(key)
|
|
14
|
+
if raw is None:
|
|
15
|
+
return default
|
|
16
|
+
return raw.strip()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _env_float(key: str, default: float) -> float:
|
|
20
|
+
raw = os.getenv(key)
|
|
21
|
+
if raw is None or raw == "":
|
|
22
|
+
return default
|
|
23
|
+
try:
|
|
24
|
+
return float(raw)
|
|
25
|
+
except ValueError:
|
|
26
|
+
return default
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _env_int(key: str, default: int) -> int:
|
|
30
|
+
raw = os.getenv(key)
|
|
31
|
+
if raw is None or raw == "":
|
|
32
|
+
return default
|
|
33
|
+
try:
|
|
34
|
+
return int(raw)
|
|
35
|
+
except ValueError:
|
|
36
|
+
return default
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _env_bool(key: str, default: bool = False) -> bool:
|
|
40
|
+
raw = os.getenv(key)
|
|
41
|
+
if raw is None or raw == "":
|
|
42
|
+
return default
|
|
43
|
+
return raw.lower() in ("1", "true", "yes", "on")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _env_mode(key: str, default: Mode) -> Mode:
|
|
47
|
+
raw = (os.getenv(key) or "").strip().lower()
|
|
48
|
+
if raw in ("observe", "block"):
|
|
49
|
+
return raw # type: ignore[return-value]
|
|
50
|
+
return default
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class PrimeDefenderSettings:
|
|
55
|
+
"""Configuration for middleware and bridge reporting. Usually built via ``from_env()``."""
|
|
56
|
+
|
|
57
|
+
bridge_url: str
|
|
58
|
+
api_key: str
|
|
59
|
+
site_id: str
|
|
60
|
+
site_lat: float
|
|
61
|
+
site_lon: float
|
|
62
|
+
site_region_label: str
|
|
63
|
+
private_source_label: str
|
|
64
|
+
body_cap_bytes: int
|
|
65
|
+
geoip_ttl_seconds: int
|
|
66
|
+
geoip_timeout_seconds: float
|
|
67
|
+
bridge_timeout_seconds: float
|
|
68
|
+
flood_window_seconds: int
|
|
69
|
+
flood_max_requests: int
|
|
70
|
+
brute_force_window_seconds: int
|
|
71
|
+
brute_force_max_attempts: int
|
|
72
|
+
bot_window_seconds: int
|
|
73
|
+
bot_max_requests: int
|
|
74
|
+
scanner_window_seconds: int
|
|
75
|
+
scanner_max_requests: int
|
|
76
|
+
auth_bypass_mode: Mode
|
|
77
|
+
suspicious_request_mode: Mode
|
|
78
|
+
debug: bool
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def observe_only_detections(self) -> Tuple[str, ...]:
|
|
82
|
+
names: list[str] = []
|
|
83
|
+
if self.auth_bypass_mode == "observe":
|
|
84
|
+
names.append("auth_bypass")
|
|
85
|
+
if self.suspicious_request_mode == "observe":
|
|
86
|
+
names.append("suspicious_request")
|
|
87
|
+
return tuple(names)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def resolved_bridge_url(self) -> str:
|
|
91
|
+
"""POST target. If env is only origin (path empty or `/`), ``/ingest`` is appended."""
|
|
92
|
+
raw = (self.bridge_url or "").strip()
|
|
93
|
+
if not raw:
|
|
94
|
+
return raw
|
|
95
|
+
parsed = urlparse(raw)
|
|
96
|
+
path = parsed.path or ""
|
|
97
|
+
if path in ("", "/"):
|
|
98
|
+
return raw.rstrip("/") + "/ingest"
|
|
99
|
+
return raw
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def enabled(self) -> bool:
|
|
103
|
+
return bool(self.bridge_url and self.api_key and self.site_id)
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_env(cls, **overrides: Any) -> PrimeDefenderSettings:
|
|
107
|
+
"""Load from environment variables, then apply ``overrides`` (non-``None`` keys win)."""
|
|
108
|
+
data: dict[str, Any] = {
|
|
109
|
+
"bridge_url": _env_str("PRIMEDEFENDER_BRIDGE_URL"),
|
|
110
|
+
"api_key": _env_str("PRIMEDEFENDER_API_KEY"),
|
|
111
|
+
"site_id": _env_str("PRIMEDEFENDER_SITE_ID", "primestudio-api"),
|
|
112
|
+
"site_lat": _env_float("PRIMEDEFENDER_SITE_LAT", -8.6705),
|
|
113
|
+
"site_lon": _env_float("PRIMEDEFENDER_SITE_LON", 115.2126),
|
|
114
|
+
"site_region_label": _env_str("PRIMEDEFENDER_SITE_REGION_LABEL", "Indonesia, Bali") or "Indonesia, Bali",
|
|
115
|
+
"private_source_label": _env_str("PRIMEDEFENDER_PRIVATE_SOURCE_LABEL", "Local / private network")
|
|
116
|
+
or "Local / private network",
|
|
117
|
+
"body_cap_bytes": _env_int("PRIMEDEFENDER_BODY_CAP_BYTES", 16_384),
|
|
118
|
+
"geoip_ttl_seconds": _env_int("PRIMEDEFENDER_GEOIP_TTL_SECONDS", 3600),
|
|
119
|
+
"geoip_timeout_seconds": _env_float("PRIMEDEFENDER_GEOIP_TIMEOUT_SECONDS", 2.5),
|
|
120
|
+
"bridge_timeout_seconds": _env_float("PRIMEDEFENDER_BRIDGE_TIMEOUT_SECONDS", 3.0),
|
|
121
|
+
"flood_window_seconds": _env_int("PRIMEDEFENDER_FLOOD_WINDOW_SECONDS", 10),
|
|
122
|
+
"flood_max_requests": _env_int("PRIMEDEFENDER_FLOOD_MAX_REQUESTS", 60),
|
|
123
|
+
"brute_force_window_seconds": _env_int("PRIMEDEFENDER_BRUTE_WINDOW_SECONDS", 300),
|
|
124
|
+
"brute_force_max_attempts": _env_int("PRIMEDEFENDER_BRUTE_MAX_ATTEMPTS", 12),
|
|
125
|
+
"bot_window_seconds": _env_int("PRIMEDEFENDER_BOT_WINDOW_SECONDS", 60),
|
|
126
|
+
"bot_max_requests": _env_int("PRIMEDEFENDER_BOT_MAX_REQUESTS", 30),
|
|
127
|
+
"scanner_window_seconds": _env_int("PRIMEDEFENDER_SCANNER_WINDOW_SECONDS", 300),
|
|
128
|
+
"scanner_max_requests": _env_int("PRIMEDEFENDER_SCANNER_MAX_REQUESTS", 12),
|
|
129
|
+
"auth_bypass_mode": _env_mode("PRIMEDEFENDER_AUTH_BYPASS_MODE", "observe"),
|
|
130
|
+
"suspicious_request_mode": _env_mode("PRIMEDEFENDER_SUSPICIOUS_REQUEST_MODE", "observe"),
|
|
131
|
+
"debug": _env_bool("PRIMEDEFENDER_DEBUG"),
|
|
132
|
+
}
|
|
133
|
+
alias_map = {
|
|
134
|
+
"site_label": "site_region_label",
|
|
135
|
+
}
|
|
136
|
+
for key, value in overrides.items():
|
|
137
|
+
if value is None:
|
|
138
|
+
continue
|
|
139
|
+
target = alias_map.get(key, key)
|
|
140
|
+
if target not in data:
|
|
141
|
+
continue
|
|
142
|
+
if target in ("auth_bypass_mode", "suspicious_request_mode"):
|
|
143
|
+
v = str(value).lower()
|
|
144
|
+
if v in ("observe", "block"):
|
|
145
|
+
data[target] = v
|
|
146
|
+
continue
|
|
147
|
+
data[target] = value
|
|
148
|
+
return cls(**data) # type: ignore[arg-type]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@lru_cache(maxsize=1)
|
|
152
|
+
def load_settings() -> PrimeDefenderSettings:
|
|
153
|
+
"""Cached settings from environment (call after ``load_dotenv()`` in the host app)."""
|
|
154
|
+
return PrimeDefenderSettings.from_env()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def clear_settings_cache() -> None:
|
|
158
|
+
"""Mainly for tests."""
|
|
159
|
+
load_settings.cache_clear()
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Dict, List, Optional, Pattern
|
|
6
|
+
|
|
7
|
+
from primedefender_fastapi.config import PrimeDefenderSettings
|
|
8
|
+
from primedefender_fastapi.rate_limit import SlidingWindowLimiter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Detection:
|
|
13
|
+
name: str
|
|
14
|
+
category: str
|
|
15
|
+
severity: str
|
|
16
|
+
blocked: bool
|
|
17
|
+
action: str
|
|
18
|
+
status_code: int
|
|
19
|
+
detail: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
SQLI_PATTERNS = (
|
|
23
|
+
r"(?i)\bunion\b.{0,20}\bselect\b",
|
|
24
|
+
r"(?i)\bselect\b.{0,20}\bfrom\b",
|
|
25
|
+
r"(?i)\binformation_schema\b",
|
|
26
|
+
r"(?i)\bor\b\s+1=1",
|
|
27
|
+
r"(?i)'\s*or\s*'1'='1",
|
|
28
|
+
r"(?i)\bsleep\s*\(",
|
|
29
|
+
r"(?i)\bbenchmark\s*\(",
|
|
30
|
+
r"(?i)\bdrop\s+table\b",
|
|
31
|
+
r"(?i)\bwaitfor\s+delay\b",
|
|
32
|
+
)
|
|
33
|
+
XSS_PATTERNS = (
|
|
34
|
+
r"(?i)<script\b",
|
|
35
|
+
r"(?i)javascript:",
|
|
36
|
+
r"(?i)onerror\s*=",
|
|
37
|
+
r"(?i)onload\s*=",
|
|
38
|
+
r"(?i)<svg\b",
|
|
39
|
+
r"(?i)<img\b",
|
|
40
|
+
r"(?i)document\.cookie",
|
|
41
|
+
r"(?i)alert\s*\(",
|
|
42
|
+
)
|
|
43
|
+
PATH_TRAVERSAL_PATTERNS = (
|
|
44
|
+
r"\.\./",
|
|
45
|
+
r"\.\.\\",
|
|
46
|
+
r"%2e%2e%2f",
|
|
47
|
+
r"%252e%252e%252f",
|
|
48
|
+
r"/etc/passwd",
|
|
49
|
+
r"win\.ini",
|
|
50
|
+
r"/proc/self/environ",
|
|
51
|
+
)
|
|
52
|
+
COMMAND_INJECTION_PATTERNS = (
|
|
53
|
+
r"(?i)(?:;|\|\||&&)\s*(?:curl|wget|bash|sh|powershell|cmd\.exe|nc)\b",
|
|
54
|
+
r"`[^`]+`",
|
|
55
|
+
r"\$\([^)]+\)",
|
|
56
|
+
r"(?i)\b(?:cmd\.exe|/bin/sh|powershell)\b",
|
|
57
|
+
)
|
|
58
|
+
FILE_INCLUSION_PATTERNS = (
|
|
59
|
+
r"(?i)\b(?:php|file|zip|data|expect)://",
|
|
60
|
+
r"(?i)\b(?:web-inf|phpmyadmin|wp-config\.php|\.git/config)\b",
|
|
61
|
+
r"(?i)\b(?:include|require)(_once)?\b",
|
|
62
|
+
)
|
|
63
|
+
SCANNER_UA_MARKERS = (
|
|
64
|
+
"sqlmap",
|
|
65
|
+
"nikto",
|
|
66
|
+
"nmap",
|
|
67
|
+
"nessus",
|
|
68
|
+
"dirbuster",
|
|
69
|
+
"gobuster",
|
|
70
|
+
"feroxbuster",
|
|
71
|
+
"wafw00f",
|
|
72
|
+
"masscan",
|
|
73
|
+
"nuclei",
|
|
74
|
+
"acunetix",
|
|
75
|
+
"burp",
|
|
76
|
+
"zaproxy",
|
|
77
|
+
)
|
|
78
|
+
BOT_UA_MARKERS = (
|
|
79
|
+
"python-requests",
|
|
80
|
+
"curl/",
|
|
81
|
+
"wget/",
|
|
82
|
+
"aiohttp",
|
|
83
|
+
"scrapy",
|
|
84
|
+
"httpclient",
|
|
85
|
+
"okhttp",
|
|
86
|
+
"libwww",
|
|
87
|
+
"urllib",
|
|
88
|
+
"bot",
|
|
89
|
+
"crawler",
|
|
90
|
+
"spider",
|
|
91
|
+
)
|
|
92
|
+
SCANNER_PATH_MARKERS = (
|
|
93
|
+
"/.env",
|
|
94
|
+
"/.git",
|
|
95
|
+
"/wp-admin",
|
|
96
|
+
"/wp-login.php",
|
|
97
|
+
"/phpmyadmin",
|
|
98
|
+
"/cgi-bin",
|
|
99
|
+
"/actuator",
|
|
100
|
+
"/vendor/phpunit",
|
|
101
|
+
"/boaform",
|
|
102
|
+
)
|
|
103
|
+
AUTH_BYPASS_MARKERS = (
|
|
104
|
+
"x-original-url",
|
|
105
|
+
"x-rewrite-url",
|
|
106
|
+
"x-forwarded-host",
|
|
107
|
+
"x-host",
|
|
108
|
+
"x-http-method-override",
|
|
109
|
+
)
|
|
110
|
+
AUTH_PATHS = (
|
|
111
|
+
"/auth/login",
|
|
112
|
+
"/auth/register",
|
|
113
|
+
"/auth/forgot-password",
|
|
114
|
+
"/auth/reset-password",
|
|
115
|
+
"/auth/reset-password-with-code",
|
|
116
|
+
"/auth/verify-reset-code",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def compile_pattern_buckets() -> Dict[str, List[Pattern[str]]]:
|
|
121
|
+
return {
|
|
122
|
+
"sqli": [re.compile(p) for p in SQLI_PATTERNS],
|
|
123
|
+
"xss": [re.compile(p) for p in XSS_PATTERNS],
|
|
124
|
+
"path_traversal": [re.compile(p, re.IGNORECASE) for p in PATH_TRAVERSAL_PATTERNS],
|
|
125
|
+
"command_injection": [re.compile(p) for p in COMMAND_INJECTION_PATTERNS],
|
|
126
|
+
"file_inclusion": [re.compile(p) for p in FILE_INCLUSION_PATTERNS],
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class RequestInspector:
|
|
131
|
+
"""Stateful rate limits + signature detection for one app instance."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, settings: PrimeDefenderSettings) -> None:
|
|
134
|
+
self.settings = settings
|
|
135
|
+
self.flood_limiter = SlidingWindowLimiter()
|
|
136
|
+
self.brute_force_limiter = SlidingWindowLimiter()
|
|
137
|
+
self.bot_limiter = SlidingWindowLimiter()
|
|
138
|
+
self.scanner_limiter = SlidingWindowLimiter()
|
|
139
|
+
self._compiled = compile_pattern_buckets()
|
|
140
|
+
|
|
141
|
+
def inspect(self, meta: Dict[str, Any]) -> Optional[Detection]:
|
|
142
|
+
ip = meta["client_ip"]
|
|
143
|
+
method = meta["method"]
|
|
144
|
+
path = meta["decoded_path"].lower()
|
|
145
|
+
query = meta["decoded_query"].lower()
|
|
146
|
+
combined = meta["combined"]
|
|
147
|
+
ua = meta["user_agent"].lower()
|
|
148
|
+
headers = meta["headers"]
|
|
149
|
+
|
|
150
|
+
flood_count = self.flood_limiter.hit(ip, self.settings.flood_window_seconds)
|
|
151
|
+
if flood_count > self.settings.flood_max_requests:
|
|
152
|
+
return self._decision("ddos", "Application flood limit exceeded.", blocked=True)
|
|
153
|
+
|
|
154
|
+
if method == "POST" and path in AUTH_PATHS:
|
|
155
|
+
brute_count = self.brute_force_limiter.hit(
|
|
156
|
+
f"{ip}:{path}", self.settings.brute_force_window_seconds
|
|
157
|
+
)
|
|
158
|
+
if brute_count > self.settings.brute_force_max_attempts:
|
|
159
|
+
return self._decision("brute_force_login", "Brute force login pattern detected.", blocked=True)
|
|
160
|
+
|
|
161
|
+
if any(marker in ua for marker in SCANNER_UA_MARKERS) or any(
|
|
162
|
+
marker in path for marker in SCANNER_PATH_MARKERS
|
|
163
|
+
):
|
|
164
|
+
scanner_count = self.scanner_limiter.hit(ip, self.settings.scanner_window_seconds)
|
|
165
|
+
if scanner_count >= self.settings.scanner_max_requests:
|
|
166
|
+
return self._decision("scanner_activity", "Scanner activity detected.", blocked=True)
|
|
167
|
+
|
|
168
|
+
if any(marker in ua for marker in BOT_UA_MARKERS):
|
|
169
|
+
bot_count = self.bot_limiter.hit(ip, self.settings.bot_window_seconds)
|
|
170
|
+
if bot_count > self.settings.bot_max_requests:
|
|
171
|
+
return self._decision("bot_activity", "Automated bot activity detected.", blocked=True)
|
|
172
|
+
|
|
173
|
+
if self._matches("path_traversal", combined):
|
|
174
|
+
return self._decision("path_traversal", "Path traversal signature matched.", blocked=True)
|
|
175
|
+
if self._matches("file_inclusion", combined):
|
|
176
|
+
return self._decision("file_inclusion", "File inclusion signature matched.", blocked=True)
|
|
177
|
+
if self._matches("command_injection", combined):
|
|
178
|
+
return self._decision("command_injection", "Command injection signature matched.", blocked=True)
|
|
179
|
+
if self._matches("sqli", combined):
|
|
180
|
+
return self._decision("sqli", "SQL injection signature matched.", blocked=True)
|
|
181
|
+
if self._matches("xss", combined):
|
|
182
|
+
return self._decision("xss", "XSS signature matched.", blocked=True)
|
|
183
|
+
|
|
184
|
+
if (
|
|
185
|
+
any(header in headers for header in AUTH_BYPASS_MARKERS)
|
|
186
|
+
or "admin=true" in query
|
|
187
|
+
or "role=admin" in query
|
|
188
|
+
):
|
|
189
|
+
return self._decision("auth_bypass", "Authorization bypass probe observed.", blocked=False)
|
|
190
|
+
|
|
191
|
+
suspicious_method = method in {"TRACE", "CONNECT"}
|
|
192
|
+
suspicious_query = len(meta["query"]) > 2048 or "%25" in meta["query"]
|
|
193
|
+
suspicious_body = meta["body_size"] > self.settings.body_cap_bytes
|
|
194
|
+
if suspicious_method or suspicious_query or suspicious_body or not ua:
|
|
195
|
+
return self._decision("suspicious_request", "Suspicious request observed.", blocked=False)
|
|
196
|
+
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
def _decision(self, name: str, detail: str, blocked: bool) -> Detection:
|
|
200
|
+
observe_only = name in self.settings.observe_only_detections
|
|
201
|
+
effective_blocked = blocked and not observe_only
|
|
202
|
+
action = "blocked" if effective_blocked else "observed"
|
|
203
|
+
|
|
204
|
+
if name == "ddos":
|
|
205
|
+
return Detection(name, "ddos", "critical", effective_blocked, action, 429, detail)
|
|
206
|
+
if name in {"scanner_activity", "brute_force_login"}:
|
|
207
|
+
return Detection(name, "intrusion", "high", effective_blocked, action, 429, detail)
|
|
208
|
+
if name == "bot_activity":
|
|
209
|
+
return Detection(name, "botnet", "medium", effective_blocked, action, 429, detail)
|
|
210
|
+
if name == "command_injection":
|
|
211
|
+
return Detection(name, "malware", "critical", effective_blocked, action, 403, detail)
|
|
212
|
+
if name in {"sqli", "xss", "path_traversal", "file_inclusion"}:
|
|
213
|
+
return Detection(name, "intrusion", "high", effective_blocked, action, 403, detail)
|
|
214
|
+
if name == "auth_bypass":
|
|
215
|
+
return Detection(name, "intrusion", "medium", effective_blocked, action, 403, detail)
|
|
216
|
+
return Detection(name, "unknown", "low", effective_blocked, action, 403, detail)
|
|
217
|
+
|
|
218
|
+
def _matches(self, bucket: str, content: str) -> bool:
|
|
219
|
+
return any(pattern.search(content) for pattern in self._compiled[bucket])
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import time
|
|
5
|
+
from typing import Dict, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_private_ip(ip: str) -> bool:
|
|
11
|
+
try:
|
|
12
|
+
return ipaddress.ip_address(ip).is_private
|
|
13
|
+
except ValueError:
|
|
14
|
+
return True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_ip_location_label(
|
|
18
|
+
country: Optional[str],
|
|
19
|
+
region_name: Optional[str],
|
|
20
|
+
city: Optional[str],
|
|
21
|
+
) -> Optional[str]:
|
|
22
|
+
"""Human-readable place name, e.g. 'Japan, Osaka' or 'United States, Virginia'."""
|
|
23
|
+
if not country:
|
|
24
|
+
return None
|
|
25
|
+
if country == "United States":
|
|
26
|
+
second = region_name or city
|
|
27
|
+
else:
|
|
28
|
+
second = city or region_name
|
|
29
|
+
if second:
|
|
30
|
+
return f"{country}, {second}"
|
|
31
|
+
return country
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class GeoIPCache:
|
|
35
|
+
def __init__(self, ttl_seconds: int, timeout_seconds: float) -> None:
|
|
36
|
+
self.ttl_seconds = ttl_seconds
|
|
37
|
+
self.timeout_seconds = timeout_seconds
|
|
38
|
+
self._cache: Dict[str, Tuple[float, Tuple[Optional[float], Optional[float], Optional[str]]]] = {}
|
|
39
|
+
|
|
40
|
+
async def get(self, ip: str) -> Tuple[Optional[float], Optional[float], Optional[str]]:
|
|
41
|
+
if not ip or is_private_ip(ip):
|
|
42
|
+
return (None, None, None)
|
|
43
|
+
|
|
44
|
+
now = time.time()
|
|
45
|
+
cached = self._cache.get(ip)
|
|
46
|
+
if cached and cached[0] > now:
|
|
47
|
+
return cached[1]
|
|
48
|
+
|
|
49
|
+
lat: Optional[float] = None
|
|
50
|
+
lon: Optional[float] = None
|
|
51
|
+
label: Optional[str] = None
|
|
52
|
+
url = f"http://ip-api.com/json/{ip}?fields=status,country,regionName,city,lat,lon"
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
|
|
56
|
+
response = await client.get(url)
|
|
57
|
+
response.raise_for_status()
|
|
58
|
+
data = response.json()
|
|
59
|
+
if data.get("status") == "success":
|
|
60
|
+
lat = data.get("lat")
|
|
61
|
+
lon = data.get("lon")
|
|
62
|
+
label = format_ip_location_label(
|
|
63
|
+
data.get("country"),
|
|
64
|
+
data.get("regionName"),
|
|
65
|
+
data.get("city"),
|
|
66
|
+
)
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
triple = (lat, lon, label)
|
|
71
|
+
self._cache[ip] = (now + self.ttl_seconds, triple)
|
|
72
|
+
return triple
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any, Dict, Literal, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
8
|
+
from starlette.requests import Request
|
|
9
|
+
from starlette.types import ASGIApp
|
|
10
|
+
from urllib.parse import unquote_plus
|
|
11
|
+
|
|
12
|
+
from primedefender_fastapi.config import PrimeDefenderSettings, load_settings
|
|
13
|
+
from primedefender_fastapi.geo import GeoIPCache
|
|
14
|
+
from primedefender_fastapi.reporter import report_incident
|
|
15
|
+
from primedefender_fastapi.detectors import RequestInspector
|
|
16
|
+
|
|
17
|
+
Mode = Literal["observe", "block"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PrimeDefenderMiddleware(BaseHTTPMiddleware):
|
|
21
|
+
"""
|
|
22
|
+
Security inspection + optional blocking + PrimeDefender bridge reporting.
|
|
23
|
+
|
|
24
|
+
Usage::
|
|
25
|
+
|
|
26
|
+
app.add_middleware(PrimeDefenderMiddleware)
|
|
27
|
+
|
|
28
|
+
With optional overrides (still reads other options from environment)::
|
|
29
|
+
|
|
30
|
+
app.add_middleware(
|
|
31
|
+
PrimeDefenderMiddleware,
|
|
32
|
+
site_label=\"Indonesia, Bali\",
|
|
33
|
+
auth_bypass_mode=\"observe\",
|
|
34
|
+
suspicious_request_mode=\"block\",
|
|
35
|
+
)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
app: ASGIApp,
|
|
41
|
+
*,
|
|
42
|
+
settings: Optional[PrimeDefenderSettings] = None,
|
|
43
|
+
site_label: Optional[str] = None,
|
|
44
|
+
auth_bypass_mode: Optional[Mode] = None,
|
|
45
|
+
suspicious_request_mode: Optional[Mode] = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
super().__init__(app)
|
|
48
|
+
if settings is not None:
|
|
49
|
+
self.settings = settings
|
|
50
|
+
else:
|
|
51
|
+
overrides: Dict[str, Any] = {}
|
|
52
|
+
if site_label is not None:
|
|
53
|
+
overrides["site_region_label"] = site_label
|
|
54
|
+
if auth_bypass_mode is not None:
|
|
55
|
+
overrides["auth_bypass_mode"] = auth_bypass_mode
|
|
56
|
+
if suspicious_request_mode is not None:
|
|
57
|
+
overrides["suspicious_request_mode"] = suspicious_request_mode
|
|
58
|
+
self.settings = (
|
|
59
|
+
PrimeDefenderSettings.from_env(**overrides) if overrides else load_settings()
|
|
60
|
+
)
|
|
61
|
+
self.geoip = GeoIPCache(
|
|
62
|
+
ttl_seconds=self.settings.geoip_ttl_seconds,
|
|
63
|
+
timeout_seconds=self.settings.geoip_timeout_seconds,
|
|
64
|
+
)
|
|
65
|
+
self._inspector = RequestInspector(self.settings)
|
|
66
|
+
|
|
67
|
+
async def dispatch(self, request: Request, call_next):
|
|
68
|
+
if request.method.upper() == "OPTIONS":
|
|
69
|
+
return await call_next(request)
|
|
70
|
+
|
|
71
|
+
raw_body = await request.body()
|
|
72
|
+
inspected_body = raw_body[: self.settings.body_cap_bytes]
|
|
73
|
+
body_text = inspected_body.decode("utf-8", errors="ignore")
|
|
74
|
+
request = self._clone_request(request, raw_body)
|
|
75
|
+
|
|
76
|
+
client_ip = self._extract_client_ip(request)
|
|
77
|
+
metadata = self._build_metadata(request, client_ip, body_text, len(raw_body))
|
|
78
|
+
detection = self._inspector.inspect(metadata)
|
|
79
|
+
|
|
80
|
+
if detection:
|
|
81
|
+
if detection.blocked:
|
|
82
|
+
await report_incident(self.settings, self.geoip, detection, metadata)
|
|
83
|
+
return JSONResponse(
|
|
84
|
+
status_code=detection.status_code,
|
|
85
|
+
content={
|
|
86
|
+
"detail": "Request blocked by PrimeDefender security middleware.",
|
|
87
|
+
"detection": detection.name,
|
|
88
|
+
"action": detection.action,
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
asyncio.create_task(report_incident(self.settings, self.geoip, detection, metadata))
|
|
92
|
+
|
|
93
|
+
return await call_next(request)
|
|
94
|
+
|
|
95
|
+
def _clone_request(self, request: Request, body: bytes) -> Request:
|
|
96
|
+
async def receive() -> Dict[str, Any]:
|
|
97
|
+
return {"type": "http.request", "body": body, "more_body": False}
|
|
98
|
+
|
|
99
|
+
return Request(request.scope, receive)
|
|
100
|
+
|
|
101
|
+
def _build_metadata(
|
|
102
|
+
self, request: Request, client_ip: str, body_text: str, body_size: int
|
|
103
|
+
) -> Dict[str, Any]:
|
|
104
|
+
headers = {k.lower(): v for k, v in request.headers.items()}
|
|
105
|
+
method = request.method.upper()
|
|
106
|
+
path = request.url.path
|
|
107
|
+
query = request.url.query or ""
|
|
108
|
+
decoded_query = unquote_plus(query)
|
|
109
|
+
decoded_path = unquote_plus(path)
|
|
110
|
+
user_agent = headers.get("user-agent", "")
|
|
111
|
+
combined = "\n".join(
|
|
112
|
+
[
|
|
113
|
+
decoded_path,
|
|
114
|
+
decoded_query,
|
|
115
|
+
body_text,
|
|
116
|
+
" ".join(f"{k}:{v}" for k, v in headers.items()),
|
|
117
|
+
]
|
|
118
|
+
)
|
|
119
|
+
return {
|
|
120
|
+
"method": method,
|
|
121
|
+
"path": path,
|
|
122
|
+
"decoded_path": decoded_path,
|
|
123
|
+
"query": query,
|
|
124
|
+
"decoded_query": decoded_query,
|
|
125
|
+
"headers": headers,
|
|
126
|
+
"user_agent": user_agent,
|
|
127
|
+
"body_text": body_text,
|
|
128
|
+
"body_size": body_size,
|
|
129
|
+
"client_ip": client_ip,
|
|
130
|
+
"combined": combined,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
def _extract_client_ip(self, request: Request) -> str:
|
|
134
|
+
hdr = request.headers
|
|
135
|
+
for name in ("cf-connecting-ip", "x-real-ip"):
|
|
136
|
+
value = hdr.get(name)
|
|
137
|
+
if value:
|
|
138
|
+
return value.strip()
|
|
139
|
+
|
|
140
|
+
forwarded_for = hdr.get("x-forwarded-for")
|
|
141
|
+
if forwarded_for:
|
|
142
|
+
first = forwarded_for.split(",")[0].strip()
|
|
143
|
+
if first:
|
|
144
|
+
return first
|
|
145
|
+
|
|
146
|
+
if request.client and request.client.host:
|
|
147
|
+
return request.client.host
|
|
148
|
+
return "unknown"
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections import defaultdict, deque
|
|
5
|
+
from typing import Deque, Dict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SlidingWindowLimiter:
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self._events: Dict[str, Deque[float]] = defaultdict(deque)
|
|
11
|
+
|
|
12
|
+
def hit(self, key: str, window_seconds: int) -> int:
|
|
13
|
+
now = time.time()
|
|
14
|
+
bucket = self._events[key]
|
|
15
|
+
cutoff = now - window_seconds
|
|
16
|
+
while bucket and bucket[0] < cutoff:
|
|
17
|
+
bucket.popleft()
|
|
18
|
+
bucket.append(now)
|
|
19
|
+
return len(bucket)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Dict, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from primedefender_fastapi.config import PrimeDefenderSettings
|
|
10
|
+
from primedefender_fastapi.detectors import Detection
|
|
11
|
+
from primedefender_fastapi.geo import GeoIPCache, is_private_ip
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_header_float(raw: Optional[str]) -> Optional[float]:
|
|
17
|
+
if raw is None or not str(raw).strip():
|
|
18
|
+
return None
|
|
19
|
+
try:
|
|
20
|
+
return float(str(raw).strip())
|
|
21
|
+
except ValueError:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def prime_header_overrides(meta: Dict[str, Any]) -> Tuple[Optional[float], Optional[float], str]:
|
|
26
|
+
h = meta["headers"]
|
|
27
|
+
lat = parse_header_float(h.get("x-prime-source-lat"))
|
|
28
|
+
lon = parse_header_float(h.get("x-prime-source-lon"))
|
|
29
|
+
label = (h.get("x-prime-source-label") or "").strip()
|
|
30
|
+
return lat, lon, label
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def report_incident(
|
|
34
|
+
settings: PrimeDefenderSettings,
|
|
35
|
+
geo: GeoIPCache,
|
|
36
|
+
detection: Detection,
|
|
37
|
+
meta: Dict[str, Any],
|
|
38
|
+
) -> None:
|
|
39
|
+
if not settings.enabled:
|
|
40
|
+
logger.debug("PrimeDefender: skip bridge (set PRIMEDEFENDER_BRIDGE_URL, API_KEY, SITE_ID)")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
url = settings.resolved_bridge_url
|
|
44
|
+
if settings.debug:
|
|
45
|
+
logger.debug("PrimeDefender: POST %s detection=%s", url, detection.name)
|
|
46
|
+
|
|
47
|
+
client_ip = meta["client_ip"]
|
|
48
|
+
oh_lat, oh_lon, oh_label = prime_header_overrides(meta)
|
|
49
|
+
has_coord_override = oh_lat is not None and oh_lon is not None
|
|
50
|
+
has_label_override = bool(oh_label)
|
|
51
|
+
|
|
52
|
+
geo_lat: Optional[float] = None
|
|
53
|
+
geo_lon: Optional[float] = None
|
|
54
|
+
geo_label: Optional[str] = None
|
|
55
|
+
if not has_coord_override:
|
|
56
|
+
geo_lat, geo_lon, geo_label = await geo.get(client_ip)
|
|
57
|
+
|
|
58
|
+
if has_coord_override:
|
|
59
|
+
attacker_lat, attacker_lon = oh_lat, oh_lon
|
|
60
|
+
else:
|
|
61
|
+
attacker_lat, attacker_lon = geo_lat, geo_lon
|
|
62
|
+
|
|
63
|
+
if attacker_lat is None or attacker_lon is None:
|
|
64
|
+
attacker_lat = 0.0
|
|
65
|
+
attacker_lon = 0.0
|
|
66
|
+
|
|
67
|
+
if has_label_override:
|
|
68
|
+
source_label = oh_label
|
|
69
|
+
elif has_coord_override and not has_label_override:
|
|
70
|
+
source_label = f"{float(attacker_lat):.3f}, {float(attacker_lon):.3f}"
|
|
71
|
+
elif is_private_ip(client_ip):
|
|
72
|
+
source_label = settings.private_source_label
|
|
73
|
+
elif geo_label:
|
|
74
|
+
source_label = geo_label
|
|
75
|
+
else:
|
|
76
|
+
source_label = f"Unknown location ({client_ip})"
|
|
77
|
+
|
|
78
|
+
site_region = settings.site_region_label
|
|
79
|
+
target_label = f"{settings.site_id} · {site_region}"
|
|
80
|
+
|
|
81
|
+
payload: Dict[str, Any] = {
|
|
82
|
+
"from": {
|
|
83
|
+
"lat": float(attacker_lat),
|
|
84
|
+
"lon": float(attacker_lon),
|
|
85
|
+
},
|
|
86
|
+
"to": {
|
|
87
|
+
"lat": float(settings.site_lat),
|
|
88
|
+
"lon": float(settings.site_lon),
|
|
89
|
+
},
|
|
90
|
+
"category": detection.category,
|
|
91
|
+
"severity": detection.severity,
|
|
92
|
+
"sourceLabel": source_label,
|
|
93
|
+
"targetLabel": target_label,
|
|
94
|
+
"siteId": settings.site_id,
|
|
95
|
+
"createdAt": int(time.time() * 1000),
|
|
96
|
+
"blocked": detection.blocked,
|
|
97
|
+
"action": detection.action,
|
|
98
|
+
"path": meta["path"],
|
|
99
|
+
"method": meta["method"],
|
|
100
|
+
"attackerIp": client_ip,
|
|
101
|
+
"userAgent": meta["user_agent"],
|
|
102
|
+
"detection": detection.name,
|
|
103
|
+
}
|
|
104
|
+
if detection.category == "ddos":
|
|
105
|
+
payload["ddos"] = {"vector": "application"}
|
|
106
|
+
|
|
107
|
+
headers = {
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
"X-Api-Key": settings.api_key,
|
|
110
|
+
"Authorization": f"Bearer {settings.api_key}",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
async with httpx.AsyncClient(timeout=settings.bridge_timeout_seconds) as client:
|
|
115
|
+
response = await client.post(url, json=payload, headers=headers)
|
|
116
|
+
except httpx.HTTPError as exc:
|
|
117
|
+
logger.warning(
|
|
118
|
+
"PrimeDefender: bridge HTTP error posting to %s: %s",
|
|
119
|
+
url,
|
|
120
|
+
exc,
|
|
121
|
+
exc_info=settings.debug,
|
|
122
|
+
)
|
|
123
|
+
return
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
logger.warning(
|
|
126
|
+
"PrimeDefender: bridge unexpected error posting to %s: %s",
|
|
127
|
+
url,
|
|
128
|
+
exc,
|
|
129
|
+
exc_info=True,
|
|
130
|
+
)
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
log_msg = (
|
|
134
|
+
"PrimeDefender: bridge status=%s from=%s to=%s sourceLabel=%r targetLabel=%r detection=%s blocked=%s"
|
|
135
|
+
% (
|
|
136
|
+
response.status_code,
|
|
137
|
+
payload["from"],
|
|
138
|
+
payload["to"],
|
|
139
|
+
source_label,
|
|
140
|
+
target_label,
|
|
141
|
+
detection.name,
|
|
142
|
+
detection.blocked,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
if response.is_success:
|
|
146
|
+
logger.info(log_msg)
|
|
147
|
+
else:
|
|
148
|
+
logger.warning("%s url=%s body=%r", log_msg, url, (response.text or "")[:800])
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "primedefender-fastapi"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "PrimeDefender security middleware for FastAPI: WAF-style detection, blocking, and incident reporting to the PrimeDefender bridge."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "PrimeDefender contributors" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"fastapi",
|
|
15
|
+
"security",
|
|
16
|
+
"middleware",
|
|
17
|
+
"waf",
|
|
18
|
+
"primedefender",
|
|
19
|
+
"sqli",
|
|
20
|
+
"xss",
|
|
21
|
+
"intrusion-detection",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
25
|
+
"Framework :: FastAPI",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"License :: OSI Approved :: MIT License",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.10",
|
|
31
|
+
"Programming Language :: Python :: 3.11",
|
|
32
|
+
"Programming Language :: Python :: 3.12",
|
|
33
|
+
"Programming Language :: Python :: 3.13",
|
|
34
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
35
|
+
"Topic :: Security",
|
|
36
|
+
"Typing :: Typed",
|
|
37
|
+
]
|
|
38
|
+
dependencies = [
|
|
39
|
+
"fastapi>=0.100.0",
|
|
40
|
+
"httpx>=0.25.0",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
dev = [
|
|
45
|
+
"pytest>=7.0.0",
|
|
46
|
+
"ruff>=0.1.0",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[project.urls]
|
|
50
|
+
Homepage = "https://github.com/primedefender/primedefender-fastapi"
|
|
51
|
+
Repository = "https://github.com/primedefender/primedefender-fastapi"
|
|
52
|
+
Issues = "https://github.com/primedefender/primedefender-fastapi/issues"
|
|
53
|
+
|
|
54
|
+
[tool.hatch.build.targets.wheel]
|
|
55
|
+
packages = ["primedefender_fastapi"]
|
|
56
|
+
|
|
57
|
+
[tool.hatch.build.targets.sdist]
|
|
58
|
+
include = [
|
|
59
|
+
"/primedefender_fastapi",
|
|
60
|
+
"/README.md",
|
|
61
|
+
"/LICENSE",
|
|
62
|
+
]
|