easyapi-django 1.3.2__tar.gz → 1.5.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.
- {easyapi_django-1.3.2/easyapi_django.egg-info → easyapi_django-1.5.0}/PKG-INFO +19 -5
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/README.md +18 -4
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/base.py +268 -11
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/blocking.py +25 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/settings/settings.py.j2 +14 -9
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/rate_limit.py +28 -9
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/security.py +106 -24
- {easyapi_django-1.3.2 → easyapi_django-1.5.0/easyapi_django.egg-info}/PKG-INFO +19 -5
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/pyproject.toml +1 -1
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/LICENSE +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/MANIFEST.in +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/__init__.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/auth.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/bearer.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/calc.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/calc_resource.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/client_ip.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/constants.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/core/__init__.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/core/endpoint.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/core/resource.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/dates.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/exception.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/filters.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/__init__.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/cli.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/config.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/generator.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/inspector/__init__.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/inspector/base.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/inspector/mysql.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/inspector/postgres.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/README.md.j2 +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/asgi.py.j2 +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/env.example.j2 +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/gitignore.j2 +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/manage.py.j2 +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/module/models.py.j2 +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/module/resources.py.j2 +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/requirements.txt.j2 +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/router/endpoints.py.j2 +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/router/urls.py.j2 +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/run.sh.j2 +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/gen/templates/settings/env.py.j2 +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/helpers.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/management/__init__.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/management/commands/__init__.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/management/commands/mcp_serve.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/mcp/__init__.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/mcp/protocol.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/mcp/schema.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/mcp/server.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/mcp/testing.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/mcp/tool.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/mcp/tools.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/middleware.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/__init__.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/_common.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/apps.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/clients.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/codes.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/constants.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/devices.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/discovery.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/migrations/0001_initial.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/migrations/__init__.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/models.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resolver.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resources/__init__.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resources/authorize.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resources/device.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resources/device_authorization.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resources/register.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resources/revoke.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/resources/token.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/routes.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/session.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/settings_helper.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/templates/zeromcp_oauth/consent.html +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/templates/zeromcp_oauth/device.html +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/oauth/tokens.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/openapi.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/orm/__init__.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/redis_config.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/routes.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/schemas.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/sensitive.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/serializer.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/settings_helper.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/tenant/__init__.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/tenant/db_router.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/tenant/registry.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/tenant/tenant.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi/util.py +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi_django.egg-info/SOURCES.txt +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi_django.egg-info/dependency_links.txt +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi_django.egg-info/entry_points.txt +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi_django.egg-info/requires.txt +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/easyapi_django.egg-info/top_level.txt +0 -0
- {easyapi_django-1.3.2 → easyapi_django-1.5.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: easyapi-django
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Zero-effort MCP server (and REST API) generator from Django models.
|
|
5
5
|
Author-email: Stamatios Stamou Jr <bushier.outsets.0c@icloud.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -170,7 +170,9 @@ REST is mostly a solved problem now. The new pain is MCP — most teams are rebu
|
|
|
170
170
|
|
|
171
171
|
**Shared (`CoreResource`)**
|
|
172
172
|
- Bearer + `X-Api-Key` + session-cookie auth.
|
|
173
|
-
-
|
|
173
|
+
- Pre-auth per-IP throttling; authenticated abuse blocking by tenant/user or
|
|
174
|
+
API-key digest (anonymous traffic falls back to IP).
|
|
175
|
+
- Scanner blocking and semantic invalid-request detection.
|
|
174
176
|
- Multi-tenant DB routing. One call switches the connection for the request.
|
|
175
177
|
- Sliding session TTLs via Redis `GETEX`. Configure with `SESSION_TTL` (default 1800s) and `API_SESSION_TTL` (default 300s).
|
|
176
178
|
- Sensitive-field scrubbing (`password`, `api_key`, `token` baseline plus your additions) — applied on both REST and MCP responses.
|
|
@@ -233,11 +235,24 @@ Defaults are demo-friendly. Production deployments should:
|
|
|
233
235
|
- **Per-resource ownership.** Set `owner_field = 'owner_id'` on resources where rows belong to a single user — restricts every CRUD operation to the row's owner. `POST` always forces `owner_id` to the caller (override with `allow_owner_override = True` for admin paths).
|
|
234
236
|
- **Authenticated cache.** If you turn on `cache = True` for an authenticated resource, set `session_cache = True` or `cache_scope_fields = (...)` — otherwise responses can leak across users. The framework warns at runtime when it detects this combination.
|
|
235
237
|
- **Tune session TTLs.** `SESSION_TTL` (cookies, default 1800s) and `API_SESSION_TTL` (api-key cache, default 300s) both slide on use. Pick numbers that match your security/UX trade-off.
|
|
236
|
-
- **Never ship `DEBUG = True`.** Under `DEBUG`, edge security (scanner blocking,
|
|
237
|
-
- **Trust your proxies explicitly.**
|
|
238
|
+
- **Never ship `DEBUG = True`.** Under `DEBUG`, edge security (scanner blocking, semantic invalid-request detection), rate limiting, credential-path defense and abuse blocking are all bypassed for dev ergonomics. The framework logs a loud startup warning when the bypass is active. To decouple from `DEBUG` — e.g. a staging box mirroring production — set `EASYAPI = {'SECURITY_BYPASS': False}` to force protections on (or `True` to bypass without `DEBUG`).
|
|
239
|
+
- **Trust your proxies explicitly.** Pre-auth throttles, scanner blocking and anonymous abuse detection key on the client IP. `X-Forwarded-For` / `X-Real-IP` are only honored when `REMOTE_ADDR` is in `TRUSTED_PROXIES = ['10.0.0.0/8', ...]`; otherwise `REMOTE_ADDR` wins. Leave it unset behind a proxy and every request looks like it comes from the proxy.
|
|
240
|
+
- **Authenticated blocks follow the principal.** The fast pre-auth throttle,
|
|
241
|
+
login defense and path/User-Agent scanner signatures remain IP-based.
|
|
242
|
+
After successful authentication, semantic events and sustained abuse use a
|
|
243
|
+
tenant-scoped user digest or API-key digest; anonymous requests keep using
|
|
244
|
+
IP. This prevents one authenticated user behind a shared NAT from blocking
|
|
245
|
+
everyone else while still stopping the same principal after an IP change.
|
|
246
|
+
- **Bound external Bearer resolvers.** Query-contract failures are attributed
|
|
247
|
+
after authentication, so an unknown/protected parameter carrying a Bearer
|
|
248
|
+
token can invoke `BEARER_RESOLVER` before the request is rejected. The
|
|
249
|
+
default `NO_RESOLVER` path performs no external call. Custom network-backed
|
|
250
|
+
resolvers should enforce a short timeout, cache safely where appropriate,
|
|
251
|
+
and be latency/load-tested behind the pre-auth IP throttle.
|
|
238
252
|
- **`/metrics` is opt-in.** `get_routes` doesn't mount it unless you pass `metrics_view=Metrics` (the generic, unscoped resource — `from easyapi import Metrics`) or a custom row-scoped subclass. Once mounted, bound which models it can query: set `METRICS_MODELS = ['app_Model', ...]` to allowlist them. References to credential-named columns (in filters, groups, order or calc) are always rejected regardless — the per-resource escape hatch does not reach `/metrics`.
|
|
239
253
|
- **Bound the metrics fields, per model.** `METRICS_MODELS` only gates *which models* — every non-credential field on an allowed model is still fair game for `/metrics` (filter, group, order, calc), including arbitrary FK traversal. Set `METRICS_FIELDS = {'app_Model': ['status', 'owner__name', ...]}` to also allowlist *which field paths*, per model. Unlike REST's `filter_fields` (root-segment match), this checks the **full** `__`-joined path — `'owner'` in the list does not imply `'owner__anything'` is allowed. `filter_by` references tolerate one trailing Django lookup (`owner__name__icontains` matches an allowed `owner__name`) since those reach `Q(**kwargs)` directly; `calc`/`group_by`/`order`/`additional_fields`/`conditions` don't take lookups and are matched literally. A model absent from the dict, or the setting left unset, stays unrestricted — opt-in, same as `METRICS_MODELS`. If you already maintain a `bi_fields`/`related_fields` map for a frontend field picker, `easyapi.util.flatten_include_fields(model, bag)` turns that same data into the flat path list this setting expects, so there's one source of truth instead of two.
|
|
240
254
|
- **Keep the escape hatch narrow.** Columns whose names look like credentials (`password`, `api_key`, `token`, `otp`, `secret`, …) are masked as `'*********'` on every response, on top of whatever `sensitive_fields` lists. When the heuristic catches a false positive, opt it out per surface: `edit_allow_sensitive_fields = ['token_count']` returns it verbatim on `GET /resource/<id>/` while LIST still masks it. `list_allow_sensitive_fields` does the same for LIST, where every row carries the value — reach for the detail-only form first.
|
|
255
|
+
- **Keep query capabilities separate.** Query-string names outside the framework contract are rejected. Declare custom names in `extra_query_params`. A protected name allowed in a LIST response through `list_allow_sensitive_fields` is still forbidden as a direct/JSON filter; add it separately to `allow_protected_query_fields` only when filtering by that value is intentional. Existing resources using the response escape hatch may need this explicit filter capability after upgrading.
|
|
241
256
|
- **OAuth clients are public by default.** MCP clients (Claude.ai, ChatGPT, ...) authenticate with PKCE — list them under `EASYAPI['OAUTH_CLIENTS']` with no `client_secret`. A client becomes confidential only with an explicit secret; `/register` never hands out a confidential secret (provision those out of band).
|
|
242
257
|
|
|
243
258
|
---
|
|
@@ -262,4 +277,3 @@ There's no CLA, no contributor matrix, no roadmap voting. Just open an issue and
|
|
|
262
277
|
- **Python** — 3.10+
|
|
263
278
|
- **Django** — 5.0+
|
|
264
279
|
- **Repo** — github.com/ssjunior/easyapi-django
|
|
265
|
-
|
|
@@ -126,7 +126,9 @@ REST is mostly a solved problem now. The new pain is MCP — most teams are rebu
|
|
|
126
126
|
|
|
127
127
|
**Shared (`CoreResource`)**
|
|
128
128
|
- Bearer + `X-Api-Key` + session-cookie auth.
|
|
129
|
-
-
|
|
129
|
+
- Pre-auth per-IP throttling; authenticated abuse blocking by tenant/user or
|
|
130
|
+
API-key digest (anonymous traffic falls back to IP).
|
|
131
|
+
- Scanner blocking and semantic invalid-request detection.
|
|
130
132
|
- Multi-tenant DB routing. One call switches the connection for the request.
|
|
131
133
|
- Sliding session TTLs via Redis `GETEX`. Configure with `SESSION_TTL` (default 1800s) and `API_SESSION_TTL` (default 300s).
|
|
132
134
|
- Sensitive-field scrubbing (`password`, `api_key`, `token` baseline plus your additions) — applied on both REST and MCP responses.
|
|
@@ -189,11 +191,24 @@ Defaults are demo-friendly. Production deployments should:
|
|
|
189
191
|
- **Per-resource ownership.** Set `owner_field = 'owner_id'` on resources where rows belong to a single user — restricts every CRUD operation to the row's owner. `POST` always forces `owner_id` to the caller (override with `allow_owner_override = True` for admin paths).
|
|
190
192
|
- **Authenticated cache.** If you turn on `cache = True` for an authenticated resource, set `session_cache = True` or `cache_scope_fields = (...)` — otherwise responses can leak across users. The framework warns at runtime when it detects this combination.
|
|
191
193
|
- **Tune session TTLs.** `SESSION_TTL` (cookies, default 1800s) and `API_SESSION_TTL` (api-key cache, default 300s) both slide on use. Pick numbers that match your security/UX trade-off.
|
|
192
|
-
- **Never ship `DEBUG = True`.** Under `DEBUG`, edge security (scanner blocking,
|
|
193
|
-
- **Trust your proxies explicitly.**
|
|
194
|
+
- **Never ship `DEBUG = True`.** Under `DEBUG`, edge security (scanner blocking, semantic invalid-request detection), rate limiting, credential-path defense and abuse blocking are all bypassed for dev ergonomics. The framework logs a loud startup warning when the bypass is active. To decouple from `DEBUG` — e.g. a staging box mirroring production — set `EASYAPI = {'SECURITY_BYPASS': False}` to force protections on (or `True` to bypass without `DEBUG`).
|
|
195
|
+
- **Trust your proxies explicitly.** Pre-auth throttles, scanner blocking and anonymous abuse detection key on the client IP. `X-Forwarded-For` / `X-Real-IP` are only honored when `REMOTE_ADDR` is in `TRUSTED_PROXIES = ['10.0.0.0/8', ...]`; otherwise `REMOTE_ADDR` wins. Leave it unset behind a proxy and every request looks like it comes from the proxy.
|
|
196
|
+
- **Authenticated blocks follow the principal.** The fast pre-auth throttle,
|
|
197
|
+
login defense and path/User-Agent scanner signatures remain IP-based.
|
|
198
|
+
After successful authentication, semantic events and sustained abuse use a
|
|
199
|
+
tenant-scoped user digest or API-key digest; anonymous requests keep using
|
|
200
|
+
IP. This prevents one authenticated user behind a shared NAT from blocking
|
|
201
|
+
everyone else while still stopping the same principal after an IP change.
|
|
202
|
+
- **Bound external Bearer resolvers.** Query-contract failures are attributed
|
|
203
|
+
after authentication, so an unknown/protected parameter carrying a Bearer
|
|
204
|
+
token can invoke `BEARER_RESOLVER` before the request is rejected. The
|
|
205
|
+
default `NO_RESOLVER` path performs no external call. Custom network-backed
|
|
206
|
+
resolvers should enforce a short timeout, cache safely where appropriate,
|
|
207
|
+
and be latency/load-tested behind the pre-auth IP throttle.
|
|
194
208
|
- **`/metrics` is opt-in.** `get_routes` doesn't mount it unless you pass `metrics_view=Metrics` (the generic, unscoped resource — `from easyapi import Metrics`) or a custom row-scoped subclass. Once mounted, bound which models it can query: set `METRICS_MODELS = ['app_Model', ...]` to allowlist them. References to credential-named columns (in filters, groups, order or calc) are always rejected regardless — the per-resource escape hatch does not reach `/metrics`.
|
|
195
209
|
- **Bound the metrics fields, per model.** `METRICS_MODELS` only gates *which models* — every non-credential field on an allowed model is still fair game for `/metrics` (filter, group, order, calc), including arbitrary FK traversal. Set `METRICS_FIELDS = {'app_Model': ['status', 'owner__name', ...]}` to also allowlist *which field paths*, per model. Unlike REST's `filter_fields` (root-segment match), this checks the **full** `__`-joined path — `'owner'` in the list does not imply `'owner__anything'` is allowed. `filter_by` references tolerate one trailing Django lookup (`owner__name__icontains` matches an allowed `owner__name`) since those reach `Q(**kwargs)` directly; `calc`/`group_by`/`order`/`additional_fields`/`conditions` don't take lookups and are matched literally. A model absent from the dict, or the setting left unset, stays unrestricted — opt-in, same as `METRICS_MODELS`. If you already maintain a `bi_fields`/`related_fields` map for a frontend field picker, `easyapi.util.flatten_include_fields(model, bag)` turns that same data into the flat path list this setting expects, so there's one source of truth instead of two.
|
|
196
210
|
- **Keep the escape hatch narrow.** Columns whose names look like credentials (`password`, `api_key`, `token`, `otp`, `secret`, …) are masked as `'*********'` on every response, on top of whatever `sensitive_fields` lists. When the heuristic catches a false positive, opt it out per surface: `edit_allow_sensitive_fields = ['token_count']` returns it verbatim on `GET /resource/<id>/` while LIST still masks it. `list_allow_sensitive_fields` does the same for LIST, where every row carries the value — reach for the detail-only form first.
|
|
211
|
+
- **Keep query capabilities separate.** Query-string names outside the framework contract are rejected. Declare custom names in `extra_query_params`. A protected name allowed in a LIST response through `list_allow_sensitive_fields` is still forbidden as a direct/JSON filter; add it separately to `allow_protected_query_fields` only when filtering by that value is intentional. Existing resources using the response escape hatch may need this explicit filter capability after upgrading.
|
|
197
212
|
- **OAuth clients are public by default.** MCP clients (Claude.ai, ChatGPT, ...) authenticate with PKCE — list them under `EASYAPI['OAUTH_CLIENTS']` with no `client_secret`. A client becomes confidential only with an explicit secret; `/register` never hands out a confidential secret (provision those out of band).
|
|
198
213
|
|
|
199
214
|
---
|
|
@@ -218,4 +233,3 @@ There's no CLA, no contributor matrix, no roadmap voting. Just open an issue and
|
|
|
218
233
|
- **Python** — 3.10+
|
|
219
234
|
- **Django** — 5.0+
|
|
220
235
|
- **Repo** — github.com/ssjunior/easyapi-django
|
|
221
|
-
|
|
@@ -98,6 +98,14 @@ RATE_LIMITS = get_setting('RATE_LIMITS', default={
|
|
|
98
98
|
],
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
+
_STANDARD_QUERY_PARAMS = frozenset({
|
|
102
|
+
'search', 'fields', 'page', 'limit', 'order_by', 'filter', 'count',
|
|
103
|
+
'normalize', 'normalize_list', 'context', 'tags', 'tags_operator',
|
|
104
|
+
})
|
|
105
|
+
_PRIVILEGED_QUERY_FIELDS = frozenset({
|
|
106
|
+
'is_staff', 'is_superuser', 'groups', 'user_permissions', 'permissions',
|
|
107
|
+
})
|
|
108
|
+
|
|
101
109
|
|
|
102
110
|
logger = logging.getLogger(__name__)
|
|
103
111
|
_WARNED_UNSCOPED_AUTH_CACHE = set()
|
|
@@ -239,6 +247,15 @@ class BaseResource(CoreResource):
|
|
|
239
247
|
filter_fields = None
|
|
240
248
|
queryset_filter = None
|
|
241
249
|
|
|
250
|
+
# Query-string contract. Framework-owned parameters and ``filter_fields``
|
|
251
|
+
# are accepted automatically; custom routes/resources must declare any
|
|
252
|
+
# additional names here. Protected credential/privilege fields remain
|
|
253
|
+
# denied as direct filters unless explicitly listed in
|
|
254
|
+
# ``allow_protected_query_fields``. Response projection via ``fields=``
|
|
255
|
+
# has a separate capability: ``list_allow_sensitive_fields``.
|
|
256
|
+
extra_query_params = None
|
|
257
|
+
allow_protected_query_fields = None
|
|
258
|
+
|
|
242
259
|
# Querystring param that resolves to a server-side stored filter
|
|
243
260
|
# expression (e.g. ``'segment_id'``, ``'view_id'``). When set, the
|
|
244
261
|
# framework reads ``request.GET[stored_filter_param]`` and calls
|
|
@@ -293,6 +310,8 @@ class BaseResource(CoreResource):
|
|
|
293
310
|
user = None
|
|
294
311
|
account = None
|
|
295
312
|
body = None
|
|
313
|
+
auth_method = None
|
|
314
|
+
security_identifier = None
|
|
296
315
|
|
|
297
316
|
def __init__(self):
|
|
298
317
|
|
|
@@ -304,6 +323,13 @@ class BaseResource(CoreResource):
|
|
|
304
323
|
self.list_related_fields = dict(self.list_related_fields) if self.list_related_fields else {}
|
|
305
324
|
self.many_to_many_models = dict(self.many_to_many_models) if self.many_to_many_models else {}
|
|
306
325
|
self.filter_fields = list(self.filter_fields) if self.filter_fields else []
|
|
326
|
+
self.extra_query_params = (
|
|
327
|
+
set(self.extra_query_params) if self.extra_query_params else set()
|
|
328
|
+
)
|
|
329
|
+
self.allow_protected_query_fields = (
|
|
330
|
+
set(self.allow_protected_query_fields)
|
|
331
|
+
if self.allow_protected_query_fields else set()
|
|
332
|
+
)
|
|
307
333
|
self.queryset_filter = dict(self.queryset_filter) if self.queryset_filter else {}
|
|
308
334
|
self.search_fields = list(self.search_fields) if self.search_fields else []
|
|
309
335
|
self.order_fields = list(self.order_fields) if self.order_fields else []
|
|
@@ -329,6 +355,8 @@ class BaseResource(CoreResource):
|
|
|
329
355
|
self.cache_scope_fields
|
|
330
356
|
)
|
|
331
357
|
self.diff = {}
|
|
358
|
+
self.auth_method = None
|
|
359
|
+
self.security_identifier = None
|
|
332
360
|
|
|
333
361
|
if self.model:
|
|
334
362
|
fields = []
|
|
@@ -400,10 +428,10 @@ class BaseResource(CoreResource):
|
|
|
400
428
|
|
|
401
429
|
raise HTTPException(403, 'Not allowed')
|
|
402
430
|
|
|
403
|
-
async def block(self, identifier):
|
|
431
|
+
async def block(self, identifier, reason='api_rate_limit'):
|
|
404
432
|
logger.warning('Blocking %s', identifier)
|
|
405
433
|
redis = get_redis()
|
|
406
|
-
await block_identifier(redis, identifier,
|
|
434
|
+
await block_identifier(redis, identifier, reason, ttl=86400)
|
|
407
435
|
raise HTTPException(403, 'Blocked due to misbehavior')
|
|
408
436
|
|
|
409
437
|
async def check_is_blocked(self, identifier):
|
|
@@ -411,6 +439,92 @@ class BaseResource(CoreResource):
|
|
|
411
439
|
if await is_blocked(redis, identifier):
|
|
412
440
|
raise HTTPException(403, 'Blocked due to misbehavior')
|
|
413
441
|
|
|
442
|
+
@staticmethod
|
|
443
|
+
def _safe_log_values(values):
|
|
444
|
+
"""Bound and strip control characters from attacker-owned log data."""
|
|
445
|
+
safe = []
|
|
446
|
+
for value in values:
|
|
447
|
+
value = re.sub(r'[\x00-\x1f\x7f]', '?', str(value))
|
|
448
|
+
safe.append(value[:128])
|
|
449
|
+
return tuple(safe)
|
|
450
|
+
|
|
451
|
+
def _log_security_event(self, event, request, values):
|
|
452
|
+
# SecurityMiddleware consumes one principal event after the response
|
|
453
|
+
# is rendered. Store only classification/count on the request; raw
|
|
454
|
+
# attacker-controlled values remain in the sanitized application log.
|
|
455
|
+
request._easyapi_security_event = {
|
|
456
|
+
'event': event,
|
|
457
|
+
'count': max(len(values), 1),
|
|
458
|
+
}
|
|
459
|
+
logger.warning(
|
|
460
|
+
'security_event=%s ip=%s identity=%s method=%s path=%r values=%r',
|
|
461
|
+
event,
|
|
462
|
+
getattr(self, 'identifier', ''),
|
|
463
|
+
getattr(self, 'security_identifier', ''),
|
|
464
|
+
getattr(request, 'method', ''),
|
|
465
|
+
self._safe_log_values([getattr(request, 'path', '')])[0],
|
|
466
|
+
self._safe_log_values(values),
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
def _query_filter_field(self, key):
|
|
470
|
+
"""Mirror the legacy query-filter lookup normalization."""
|
|
471
|
+
if search_regex.search(key):
|
|
472
|
+
keys = key.split('__')
|
|
473
|
+
if len(keys) == 3:
|
|
474
|
+
return f'{keys[0]}__{keys[1]}'
|
|
475
|
+
return keys[0]
|
|
476
|
+
return key
|
|
477
|
+
|
|
478
|
+
def _is_protected_field(self, field, allowed_fields):
|
|
479
|
+
root = field.split('__', 1)[0]
|
|
480
|
+
if field in allowed_fields or root in allowed_fields:
|
|
481
|
+
return False
|
|
482
|
+
return (
|
|
483
|
+
is_sensitive(field, self.sensitive_fields)
|
|
484
|
+
or is_sensitive(root, self.sensitive_fields)
|
|
485
|
+
or root.lower() in _PRIVILEGED_QUERY_FIELDS
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def _is_protected_query_field(self, field):
|
|
489
|
+
"""Whether a field is forbidden as a direct query-string filter."""
|
|
490
|
+
return self._is_protected_field(
|
|
491
|
+
field, self.allow_protected_query_fields,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def _is_protected_response_field(self, field):
|
|
495
|
+
"""Whether ``fields=`` may project a field into a list response."""
|
|
496
|
+
return self._is_protected_field(
|
|
497
|
+
field, self.list_allow_sensitive_fields,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
def _validate_query_parameters(self, request):
|
|
501
|
+
"""Reject query-string names outside the resource's public contract."""
|
|
502
|
+
invalid = []
|
|
503
|
+
protected = []
|
|
504
|
+
allowed = _STANDARD_QUERY_PARAMS | self.extra_query_params
|
|
505
|
+
if self.stored_filter_param:
|
|
506
|
+
allowed = allowed | {self.stored_filter_param}
|
|
507
|
+
|
|
508
|
+
for key in request.GET:
|
|
509
|
+
field = self._query_filter_field(key)
|
|
510
|
+
if self._is_protected_query_field(field):
|
|
511
|
+
protected.append(key)
|
|
512
|
+
continue
|
|
513
|
+
if key in allowed or field in self.filter_fields:
|
|
514
|
+
continue
|
|
515
|
+
invalid.append(key)
|
|
516
|
+
|
|
517
|
+
if protected:
|
|
518
|
+
self._log_security_event(
|
|
519
|
+
'protected_query_field', request, protected,
|
|
520
|
+
)
|
|
521
|
+
elif invalid:
|
|
522
|
+
self._log_security_event(
|
|
523
|
+
'unknown_query_parameter', request, invalid,
|
|
524
|
+
)
|
|
525
|
+
if protected or invalid:
|
|
526
|
+
raise HTTPException(400, 'Invalid query parameters')
|
|
527
|
+
|
|
414
528
|
async def _enforce_rate_limit(self, request):
|
|
415
529
|
# Local development bypasses rate limiting and abuse blocking.
|
|
416
530
|
# Hammering ``/docs`` while wiring up auth would otherwise trigger
|
|
@@ -441,12 +555,30 @@ class BaseResource(CoreResource):
|
|
|
441
555
|
await self.block(self.identifier)
|
|
442
556
|
return
|
|
443
557
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
558
|
+
# Cheap pre-auth pass: only the short API windows use IP. The
|
|
559
|
+
# sustained abuse windows run after authentication with the real
|
|
560
|
+
# principal, preventing one user behind a shared NAT from blocking
|
|
561
|
+
# everyone on that IP.
|
|
562
|
+
result = await RateLimiter.api_limited(
|
|
563
|
+
self.identifier, RATE_LIMITS, include_abuse=False,
|
|
564
|
+
)
|
|
447
565
|
if result['rate_limited']:
|
|
448
566
|
raise HTTPException(429, 'Slow down, too many requests. You will be blocked.')
|
|
449
567
|
|
|
568
|
+
async def _enforce_identity_abuse_limit(self, request):
|
|
569
|
+
"""Apply abuse windows to an authenticated principal or anonymous IP."""
|
|
570
|
+
if security_bypassed() or request.path.startswith('/login'):
|
|
571
|
+
return
|
|
572
|
+
result = await RateLimiter.abuse_limited(
|
|
573
|
+
self.security_identifier, RATE_LIMITS,
|
|
574
|
+
)
|
|
575
|
+
if result.get('blocked'):
|
|
576
|
+
raise HTTPException(403, 'Blocked due to misbehavior')
|
|
577
|
+
if result['abuse']:
|
|
578
|
+
await self.block(
|
|
579
|
+
self.security_identifier, reason='identity_abuse_rate_limit',
|
|
580
|
+
)
|
|
581
|
+
|
|
450
582
|
async def _enforce_resource_rate_limits(self):
|
|
451
583
|
"""Declarative ``self.rate_limits`` buckets — runs after auth so
|
|
452
584
|
keys derived from ``self.user`` resolve. Keys returning ``None``
|
|
@@ -496,6 +628,7 @@ class BaseResource(CoreResource):
|
|
|
496
628
|
request.headers.get('Authorization'),
|
|
497
629
|
)
|
|
498
630
|
if code == _bearer.OK:
|
|
631
|
+
self.auth_method = 'bearer'
|
|
499
632
|
self._activate_session(session)
|
|
500
633
|
await apply_session_to_request(request, session)
|
|
501
634
|
return session, None
|
|
@@ -517,6 +650,7 @@ class BaseResource(CoreResource):
|
|
|
517
650
|
if request.headers.get('X-Api-Key'):
|
|
518
651
|
session = await get_api_session(request.headers.get('X-Api-Key'))
|
|
519
652
|
if session:
|
|
653
|
+
self.auth_method = 'api_key'
|
|
520
654
|
self._activate_session(session)
|
|
521
655
|
await apply_session_to_request(request, session)
|
|
522
656
|
return session, None
|
|
@@ -527,6 +661,7 @@ class BaseResource(CoreResource):
|
|
|
527
661
|
|
|
528
662
|
existing = getattr(request, 'session', None)
|
|
529
663
|
if existing:
|
|
664
|
+
self.auth_method = 'session'
|
|
530
665
|
self._activate_session(existing)
|
|
531
666
|
return existing, session_key
|
|
532
667
|
|
|
@@ -538,10 +673,55 @@ class BaseResource(CoreResource):
|
|
|
538
673
|
return None, None
|
|
539
674
|
|
|
540
675
|
session = json.loads(raw)
|
|
676
|
+
self.auth_method = 'session'
|
|
541
677
|
self._activate_session(session)
|
|
542
678
|
await apply_session_to_request(request, session)
|
|
543
679
|
return session, session_key
|
|
544
680
|
|
|
681
|
+
@staticmethod
|
|
682
|
+
def _identity_digest(value):
|
|
683
|
+
return hashlib.sha256(str(value).encode('utf-8')).hexdigest()[:32]
|
|
684
|
+
|
|
685
|
+
def _set_security_identifier(self, request, session, session_key):
|
|
686
|
+
"""Derive the stable Redis identity after authentication.
|
|
687
|
+
|
|
688
|
+
API keys stay independent; Bearer/cookie sessions converge on the
|
|
689
|
+
tenant-scoped user so token/session rotation cannot bypass a block.
|
|
690
|
+
Raw credentials and session ids never enter Redis keys.
|
|
691
|
+
"""
|
|
692
|
+
kind = 'ip'
|
|
693
|
+
identifier = self.identifier
|
|
694
|
+
|
|
695
|
+
if session and self.auth_method == 'api_key':
|
|
696
|
+
api_key = request.headers.get('X-Api-Key', '')
|
|
697
|
+
kind = 'api_key'
|
|
698
|
+
identifier = f'apikey:{self._identity_digest(api_key)}'
|
|
699
|
+
elif session:
|
|
700
|
+
user = session.get('user') if isinstance(session, dict) else None
|
|
701
|
+
account = session.get('account') if isinstance(session, dict) else None
|
|
702
|
+
user_id = user.get('id') if isinstance(user, dict) else None
|
|
703
|
+
account_id = account.get('id') if isinstance(account, dict) else 'single'
|
|
704
|
+
if user_id is not None:
|
|
705
|
+
kind = 'user'
|
|
706
|
+
principal = json.dumps(
|
|
707
|
+
[account_id, user_id],
|
|
708
|
+
separators=(',', ':'),
|
|
709
|
+
default=str,
|
|
710
|
+
)
|
|
711
|
+
identifier = f'user:{self._identity_digest(principal)}'
|
|
712
|
+
elif session_key:
|
|
713
|
+
kind = 'session'
|
|
714
|
+
identifier = f'session:{self._identity_digest(session_key)}'
|
|
715
|
+
elif self.auth_method == 'bearer':
|
|
716
|
+
kind = 'bearer'
|
|
717
|
+
auth_header = request.headers.get('Authorization', '')
|
|
718
|
+
identifier = f'bearer:{self._identity_digest(auth_header)}'
|
|
719
|
+
|
|
720
|
+
self.security_identifier = identifier
|
|
721
|
+
request._easyapi_security_identifier = identifier
|
|
722
|
+
request._easyapi_security_identity_kind = kind
|
|
723
|
+
return identifier
|
|
724
|
+
|
|
545
725
|
def _activate_session(self, session):
|
|
546
726
|
self.user = session['user']
|
|
547
727
|
self.tz = get_tz(self.user.get('timezone', 'UTC'))
|
|
@@ -834,6 +1014,15 @@ class BaseResource(CoreResource):
|
|
|
834
1014
|
await self._enforce_rate_limit(request)
|
|
835
1015
|
|
|
836
1016
|
session, session_key = await self._authenticate(request)
|
|
1017
|
+
self._set_security_identifier(request, session, session_key)
|
|
1018
|
+
|
|
1019
|
+
# IP blocks are checked before authentication; principal blocks can
|
|
1020
|
+
# only be checked after the real identity is known. This second gate
|
|
1021
|
+
# prevents a blocked user/API key from bypassing via a new IP.
|
|
1022
|
+
if self.security_identifier != self.identifier:
|
|
1023
|
+
await self.check_is_blocked(self.security_identifier)
|
|
1024
|
+
|
|
1025
|
+
self._validate_query_parameters(request)
|
|
837
1026
|
|
|
838
1027
|
if self.authenticated and not session:
|
|
839
1028
|
raise HTTPException(401, 'Not authorized')
|
|
@@ -843,6 +1032,7 @@ class BaseResource(CoreResource):
|
|
|
843
1032
|
if ALLOWED_ORIGINS:
|
|
844
1033
|
self.get_allowed_domain(request)
|
|
845
1034
|
|
|
1035
|
+
await self._enforce_identity_abuse_limit(request)
|
|
846
1036
|
await self._enforce_resource_rate_limits()
|
|
847
1037
|
|
|
848
1038
|
if session and session.get('account'):
|
|
@@ -1144,8 +1334,43 @@ class BaseResource(CoreResource):
|
|
|
1144
1334
|
|
|
1145
1335
|
self.count_results = {'count': count}
|
|
1146
1336
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1337
|
+
@staticmethod
|
|
1338
|
+
def _condition_field_names(conditions):
|
|
1339
|
+
fields = []
|
|
1340
|
+
|
|
1341
|
+
def collect(node):
|
|
1342
|
+
if isinstance(node, dict):
|
|
1343
|
+
field = node.get('field')
|
|
1344
|
+
if field:
|
|
1345
|
+
fields.append(field)
|
|
1346
|
+
for rule in node.get('rules') or []:
|
|
1347
|
+
collect(rule)
|
|
1348
|
+
elif isinstance(node, list):
|
|
1349
|
+
for item in node:
|
|
1350
|
+
collect(item)
|
|
1351
|
+
|
|
1352
|
+
collect(conditions)
|
|
1353
|
+
return fields
|
|
1354
|
+
|
|
1355
|
+
def _validate_filter_fields(self, conditions, request):
|
|
1356
|
+
fields = self._condition_field_names(conditions)
|
|
1357
|
+
protected = [
|
|
1358
|
+
field for field in fields
|
|
1359
|
+
if self._is_protected_query_field(field)
|
|
1360
|
+
]
|
|
1361
|
+
if protected:
|
|
1362
|
+
self._log_security_event(
|
|
1363
|
+
'protected_filter_field', request, protected,
|
|
1364
|
+
)
|
|
1365
|
+
raise HTTPException(403, 'Filtering not allowed')
|
|
1366
|
+
|
|
1367
|
+
try:
|
|
1368
|
+
validate_conditions(conditions, self.filter_fields)
|
|
1369
|
+
except HTTPException as exc:
|
|
1370
|
+
self._log_security_event(
|
|
1371
|
+
'forbidden_filter_field', request, fields or ['filter'],
|
|
1372
|
+
)
|
|
1373
|
+
raise HTTPException(403, 'Filtering not allowed') from exc
|
|
1149
1374
|
|
|
1150
1375
|
async def resolve_stored_filter(self, value, **kwargs):
|
|
1151
1376
|
"""Hook: turn a stored-filter id into a Layer-2 conditions dict.
|
|
@@ -1186,12 +1411,18 @@ class BaseResource(CoreResource):
|
|
|
1186
1411
|
groups.append(stored_conditions)
|
|
1187
1412
|
|
|
1188
1413
|
if filter_:
|
|
1189
|
-
|
|
1414
|
+
try:
|
|
1415
|
+
url_conditions = json.loads(filter_)
|
|
1416
|
+
except (json.JSONDecodeError, TypeError):
|
|
1417
|
+
self._log_security_event(
|
|
1418
|
+
'invalid_filter_syntax', request, ['filter'],
|
|
1419
|
+
)
|
|
1420
|
+
raise HTTPException(400, 'Invalid filter') from None
|
|
1190
1421
|
if url_conditions:
|
|
1191
1422
|
# ``?filter=`` is caller-controlled — must pass the
|
|
1192
1423
|
# whitelist. Stored filters are trusted because the hook
|
|
1193
1424
|
# owns the lookup (and any validation, scoping, RBAC).
|
|
1194
|
-
self._validate_filter_fields(url_conditions)
|
|
1425
|
+
self._validate_filter_fields(url_conditions, request)
|
|
1195
1426
|
groups.append(url_conditions)
|
|
1196
1427
|
|
|
1197
1428
|
if not groups:
|
|
@@ -1318,10 +1549,36 @@ class BaseResource(CoreResource):
|
|
|
1318
1549
|
results = []
|
|
1319
1550
|
|
|
1320
1551
|
fields = request.GET.get('fields')
|
|
1321
|
-
if fields:
|
|
1552
|
+
if 'fields' in request.GET:
|
|
1322
1553
|
requested = [f.strip() for f in fields.split(',') if f.strip()]
|
|
1323
1554
|
allowed = set(self.list_fields or [])
|
|
1324
|
-
|
|
1555
|
+
wildcard = [
|
|
1556
|
+
f for f in requested
|
|
1557
|
+
if f.lower() in {'*', 'all', '__all__'}
|
|
1558
|
+
]
|
|
1559
|
+
protected = [
|
|
1560
|
+
f for f in requested
|
|
1561
|
+
if f not in wildcard and self._is_protected_response_field(f)
|
|
1562
|
+
]
|
|
1563
|
+
unknown = [
|
|
1564
|
+
f for f in requested
|
|
1565
|
+
if f not in wildcard and f not in protected and f not in allowed
|
|
1566
|
+
]
|
|
1567
|
+
if not requested:
|
|
1568
|
+
self._log_security_event(
|
|
1569
|
+
'invalid_fields_syntax', request, ['fields'],
|
|
1570
|
+
)
|
|
1571
|
+
elif wildcard:
|
|
1572
|
+
self._log_security_event('wildcard_field_probe', request, wildcard)
|
|
1573
|
+
elif protected:
|
|
1574
|
+
self._log_security_event('protected_field_probe', request, protected)
|
|
1575
|
+
elif unknown:
|
|
1576
|
+
self._log_security_event('unknown_response_field', request, unknown)
|
|
1577
|
+
if not requested or wildcard or protected or unknown:
|
|
1578
|
+
raise HTTPException(400, 'Invalid fields')
|
|
1579
|
+
list_fields = requested
|
|
1580
|
+
if self.normalize_list and 'id' not in list_fields:
|
|
1581
|
+
list_fields = ['id'] + list_fields
|
|
1325
1582
|
related = False
|
|
1326
1583
|
else:
|
|
1327
1584
|
list_fields = self.list_fields
|
|
@@ -5,6 +5,8 @@ key, so ``SecurityMiddleware`` and ``BaseResource`` agree on format and
|
|
|
5
5
|
TTL semantics.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from redis.exceptions import WatchError
|
|
9
|
+
|
|
8
10
|
from .redis_config import KEY_PREFIX
|
|
9
11
|
|
|
10
12
|
|
|
@@ -27,6 +29,29 @@ async def block(redis, identifier, reason=None, ttl=86400):
|
|
|
27
29
|
await redis.setex(block_key(identifier), ttl, reason or '1')
|
|
28
30
|
|
|
29
31
|
|
|
32
|
+
async def block_at_least(redis, identifier, reason=None, ttl=86400):
|
|
33
|
+
"""Block without ever replacing a longer existing block with a shorter one.
|
|
34
|
+
|
|
35
|
+
``WATCH`` keeps concurrent short/long escalation decisions from racing in
|
|
36
|
+
a way that accidentally reduces a 24-hour block to one hour.
|
|
37
|
+
"""
|
|
38
|
+
key = block_key(identifier)
|
|
39
|
+
while True:
|
|
40
|
+
async with redis.pipeline(transaction=True) as pipeline:
|
|
41
|
+
try:
|
|
42
|
+
await pipeline.watch(key)
|
|
43
|
+
current_ttl = await pipeline.ttl(key)
|
|
44
|
+
if current_ttl >= ttl:
|
|
45
|
+
await pipeline.unwatch()
|
|
46
|
+
return
|
|
47
|
+
pipeline.multi()
|
|
48
|
+
pipeline.setex(key, ttl, reason or '1')
|
|
49
|
+
await pipeline.execute()
|
|
50
|
+
return
|
|
51
|
+
except WatchError:
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
|
|
30
55
|
async def get_block_reason(redis, identifier):
|
|
31
56
|
"""Return the stored block reason, or ``None`` when not blocked."""
|
|
32
57
|
return await redis.get(block_key(identifier))
|
|
@@ -128,10 +128,10 @@ MCP = {
|
|
|
128
128
|
# 'CREDENTIAL_RATE_LIMIT': {'limit': 5, 'window': 30}, # 5 hits / 30s per IP
|
|
129
129
|
|
|
130
130
|
# ── Rate limit & abuse blocking ──────────────────────────────────
|
|
131
|
-
#
|
|
132
|
-
# ``
|
|
133
|
-
#
|
|
134
|
-
#
|
|
131
|
+
# ``api`` is the cheap pre-auth IP throttle; ``login`` stays per-IP.
|
|
132
|
+
# ``abuse`` runs after authentication and keys on tenant+user, API-key
|
|
133
|
+
# digest, or IP only for anonymous requests. Each list is "if more than
|
|
134
|
+
# ``limit`` requests in ``interval`` ms, slow down or block".
|
|
135
135
|
# 'RATE_LIMITS': {
|
|
136
136
|
# 'api': [{'interval': 1000, 'limit': 4},
|
|
137
137
|
# {'interval': 5000, 'limit': 20}],
|
|
@@ -140,11 +140,16 @@ MCP = {
|
|
|
140
140
|
# 'abuse': [{'interval': 5000, 'limit': 20},
|
|
141
141
|
# {'interval': 3600000, 'limit': 200}],
|
|
142
142
|
# },
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
#
|
|
143
|
+
# Semantic abuse detection. Protected-field/wildcard probes block
|
|
144
|
+
# immediately for BLOCK_DURATION_SECONDS. Generic invalid requests
|
|
145
|
+
# tolerate one occurrence; the second inside the low-event window
|
|
146
|
+
# blocks briefly, and a recurrence during the escalation window
|
|
147
|
+
# receives the full block.
|
|
148
|
+
# 'SECURITY_LOW_EVENT_LIMIT': 2,
|
|
149
|
+
# 'SECURITY_LOW_EVENT_WINDOW_SECONDS': 900, # 15 min
|
|
150
|
+
# 'SECURITY_SHORT_BLOCK_SECONDS': 3600, # 1h
|
|
151
|
+
# 'SECURITY_ESCALATION_WINDOW_SECONDS': 86400,
|
|
152
|
+
# 'BLOCK_DURATION_SECONDS': 86400, # 24h
|
|
148
153
|
# Pre-emptive bans by URL path or User-Agent regex (vulnerability
|
|
149
154
|
# scanners, known bad bots). ``None`` keeps the framework's
|
|
150
155
|
# baseline list; pass ``[...]`` to replace, ``[]`` to disable.
|