easyapi-django 1.1.7__tar.gz → 1.2.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.1.7/easyapi_django.egg-info → easyapi_django-1.2.0}/PKG-INFO +5 -1
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/README.md +4 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/base.py +37 -9
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/calc.py +71 -5
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/client_ip.py +12 -4
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/config.py +1 -26
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/helpers.py +1 -1
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/mcp/protocol.py +13 -6
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/clients.py +9 -1
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resources/register.py +24 -8
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/settings_helper.py +20 -12
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/security.py +7 -7
- easyapi_django-1.2.0/easyapi/sensitive.py +37 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/settings_helper.py +27 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0/easyapi_django.egg-info}/PKG-INFO +5 -1
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi_django.egg-info/SOURCES.txt +1 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/pyproject.toml +1 -1
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/LICENSE +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/MANIFEST.in +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/__init__.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/auth.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/bearer.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/blocking.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/calc_resource.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/constants.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/core/__init__.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/core/endpoint.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/core/resource.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/dates.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/exception.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/filters.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/__init__.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/cli.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/generator.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/inspector/__init__.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/inspector/base.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/inspector/mysql.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/inspector/postgres.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/README.md.j2 +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/asgi.py.j2 +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/env.example.j2 +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/gitignore.j2 +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/manage.py.j2 +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/module/models.py.j2 +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/module/resources.py.j2 +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/requirements.txt.j2 +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/router/endpoints.py.j2 +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/router/urls.py.j2 +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/run.sh.j2 +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/settings/env.py.j2 +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/settings/settings.py.j2 +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/management/__init__.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/management/commands/__init__.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/management/commands/mcp_serve.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/mcp/__init__.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/mcp/schema.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/mcp/server.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/mcp/testing.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/mcp/tool.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/mcp/tools.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/middleware.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/__init__.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/_common.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/apps.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/codes.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/constants.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/devices.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/discovery.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/migrations/0001_initial.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/migrations/__init__.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/models.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resolver.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resources/__init__.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resources/authorize.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resources/device.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resources/device_authorization.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resources/revoke.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resources/token.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/routes.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/session.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/templates/zeromcp_oauth/consent.html +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/templates/zeromcp_oauth/device.html +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/tokens.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/openapi.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/orm/__init__.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/rate_limit.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/redis_config.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/routes.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/schemas.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/serializer.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/tenant/__init__.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/tenant/db_router.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/tenant/registry.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/tenant/tenant.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/util.py +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi_django.egg-info/dependency_links.txt +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi_django.egg-info/entry_points.txt +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi_django.egg-info/requires.txt +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi_django.egg-info/top_level.txt +0 -0
- {easyapi_django-1.1.7 → easyapi_django-1.2.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.2.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
|
|
@@ -233,6 +233,10 @@ Defaults are demo-friendly. Production deployments should:
|
|
|
233
233
|
- **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
234
|
- **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
235
|
- **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, 4xx flood 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 `MCP = {'SECURITY_BYPASS': False}` to force protections on (or `True` to bypass without `DEBUG`).
|
|
237
|
+
- **Trust your proxies explicitly.** IP-based rate limits and abuse blocking 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.
|
|
238
|
+
- **Bound the metrics surface.** The `/metrics` endpoint queries any model in the registry by default. Set `METRICS_MODELS = ['app_Model', ...]` to allowlist which models are queryable. References to credential-named columns (in filters, groups, order or calc) are always rejected.
|
|
239
|
+
- **OAuth clients are public by default.** MCP clients (Claude.ai, ChatGPT, ...) authenticate with PKCE — list them under `MCP['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).
|
|
236
240
|
|
|
237
241
|
---
|
|
238
242
|
|
|
@@ -189,6 +189,10 @@ Defaults are demo-friendly. Production deployments should:
|
|
|
189
189
|
- **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
190
|
- **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
191
|
- **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, 4xx flood 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 `MCP = {'SECURITY_BYPASS': False}` to force protections on (or `True` to bypass without `DEBUG`).
|
|
193
|
+
- **Trust your proxies explicitly.** IP-based rate limits and abuse blocking 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.
|
|
194
|
+
- **Bound the metrics surface.** The `/metrics` endpoint queries any model in the registry by default. Set `METRICS_MODELS = ['app_Model', ...]` to allowlist which models are queryable. References to credential-named columns (in filters, groups, order or calc) are always rejected.
|
|
195
|
+
- **OAuth clients are public by default.** MCP clients (Claude.ai, ChatGPT, ...) authenticate with PKCE — list them under `MCP['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).
|
|
192
196
|
|
|
193
197
|
---
|
|
194
198
|
|
|
@@ -39,6 +39,7 @@ from .helpers import (
|
|
|
39
39
|
search_regex,
|
|
40
40
|
)
|
|
41
41
|
from .schemas import is_pydantic_model, serialize_output, validate_input
|
|
42
|
+
from .sensitive import looks_sensitive
|
|
42
43
|
from .serializer import CustomJSONEncoder
|
|
43
44
|
from .util import validate_session_key
|
|
44
45
|
from .rate_limit import RateLimiter
|
|
@@ -57,6 +58,7 @@ from .settings_helper import (
|
|
|
57
58
|
get_session_ttl,
|
|
58
59
|
get_setting,
|
|
59
60
|
get_token_max_drift_ms,
|
|
61
|
+
security_bypassed,
|
|
60
62
|
)
|
|
61
63
|
|
|
62
64
|
COOKIE_ID = get_cookie_id()
|
|
@@ -121,6 +123,27 @@ def _warn_csrf_unprotected_at_startup():
|
|
|
121
123
|
_warn_csrf_unprotected_at_startup()
|
|
122
124
|
|
|
123
125
|
|
|
126
|
+
def _warn_if_security_bypassed_at_startup():
|
|
127
|
+
"""Loud boot warning when edge security + rate limiting are relaxed.
|
|
128
|
+
|
|
129
|
+
Keyed to ``SECURITY_BYPASS`` (or ``DEBUG`` when unset). A production
|
|
130
|
+
box mis-deployed with ``DEBUG=True`` silently disabled the security
|
|
131
|
+
middleware, rate limiting, credential-path defense and abuse blocking
|
|
132
|
+
all at once — this makes it visible in logs/Sentry.
|
|
133
|
+
"""
|
|
134
|
+
if security_bypassed():
|
|
135
|
+
logger.warning(
|
|
136
|
+
'SECURITY BYPASS ACTIVE — edge security middleware, rate '
|
|
137
|
+
'limiting, credential-path defense and abuse blocking are '
|
|
138
|
+
'DISABLED (SECURITY_BYPASS set, or DEBUG=True with '
|
|
139
|
+
'SECURITY_BYPASS unset). Never run this in production; set '
|
|
140
|
+
"MCP['SECURITY_BYPASS'] = False to force protections on.",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
_warn_if_security_bypassed_at_startup()
|
|
145
|
+
|
|
146
|
+
|
|
124
147
|
class BaseResource(CoreResource):
|
|
125
148
|
# ``True`` historically. Override globally via
|
|
126
149
|
# ``MCP = {'DEFAULT_AUTHENTICATED': False}`` (the generator emits
|
|
@@ -352,7 +375,12 @@ class BaseResource(CoreResource):
|
|
|
352
375
|
if candidate:
|
|
353
376
|
domain = urlparse(candidate).netloc.split(':')[0]
|
|
354
377
|
for origin in ALLOWED_ORIGINS:
|
|
355
|
-
|
|
378
|
+
# Boundary match — a bare ``endswith`` let ``evilexample.com``
|
|
379
|
+
# pass an allowlist of ``example.com``. Accept the domain
|
|
380
|
+
# itself or a subdomain of it. Leading dot in config is
|
|
381
|
+
# tolerated (``.example.com`` == ``example.com``).
|
|
382
|
+
origin = origin.lstrip('.')
|
|
383
|
+
if domain == origin or domain.endswith(f'.{origin}'):
|
|
356
384
|
return
|
|
357
385
|
|
|
358
386
|
raise HTTPException(403, 'Not allowed')
|
|
@@ -369,11 +397,11 @@ class BaseResource(CoreResource):
|
|
|
369
397
|
raise HTTPException(403, 'Blocked due to misbehavior')
|
|
370
398
|
|
|
371
399
|
async def _enforce_rate_limit(self, request):
|
|
372
|
-
# Local development
|
|
373
|
-
#
|
|
374
|
-
#
|
|
375
|
-
#
|
|
376
|
-
if
|
|
400
|
+
# Local development bypasses rate limiting and abuse blocking.
|
|
401
|
+
# Hammering ``/docs`` while wiring up auth would otherwise trigger
|
|
402
|
+
# a 24h block on 127.0.0.1 — annoying and almost never right for a
|
|
403
|
+
# dev box. Controlled by ``SECURITY_BYPASS`` (falls back to DEBUG).
|
|
404
|
+
if security_bypassed():
|
|
377
405
|
return
|
|
378
406
|
|
|
379
407
|
await self.check_is_blocked(self.identifier)
|
|
@@ -409,7 +437,7 @@ class BaseResource(CoreResource):
|
|
|
409
437
|
keys derived from ``self.user`` resolve. Keys returning ``None``
|
|
410
438
|
are skipped (e.g. unauthenticated endpoints that opt-in to a
|
|
411
439
|
per-user bucket as well — only fires when a user is present)."""
|
|
412
|
-
if not self.rate_limits or
|
|
440
|
+
if not self.rate_limits or security_bypassed():
|
|
413
441
|
return
|
|
414
442
|
for spec in self.rate_limits:
|
|
415
443
|
key = spec['key']
|
|
@@ -1294,7 +1322,7 @@ class BaseResource(CoreResource):
|
|
|
1294
1322
|
if field in self.list_exclude_fields:
|
|
1295
1323
|
continue
|
|
1296
1324
|
|
|
1297
|
-
if field
|
|
1325
|
+
if looks_sensitive(field) or field in self.sensitive_fields:
|
|
1298
1326
|
result[field] = '*********'
|
|
1299
1327
|
else:
|
|
1300
1328
|
result[field] = getattr(row, field, None)
|
|
@@ -1418,7 +1446,7 @@ class BaseResource(CoreResource):
|
|
|
1418
1446
|
result[key].append(item)
|
|
1419
1447
|
|
|
1420
1448
|
for field in self.edit_fields:
|
|
1421
|
-
if field
|
|
1449
|
+
if looks_sensitive(field) or field in self.sensitive_fields:
|
|
1422
1450
|
result[field] = '*********'
|
|
1423
1451
|
else:
|
|
1424
1452
|
result[field] = getattr(self.obj, field, None)
|
|
@@ -18,6 +18,7 @@ import pytz
|
|
|
18
18
|
from .dates import Dates
|
|
19
19
|
from .exception import HTTPException
|
|
20
20
|
from .filters import Filter as OrmFilter
|
|
21
|
+
from .sensitive import looks_sensitive
|
|
21
22
|
from .settings_helper import get_metrics_max_execution_ms, get_metrics_models
|
|
22
23
|
from .util import normalize_field
|
|
23
24
|
|
|
@@ -570,6 +571,74 @@ def normalize_groups(
|
|
|
570
571
|
return values, keys
|
|
571
572
|
|
|
572
573
|
|
|
574
|
+
def _collect_condition_fields(node, refs):
|
|
575
|
+
"""Walk a Layer-2 ``conditions`` tree collecting every ``field``."""
|
|
576
|
+
if isinstance(node, dict):
|
|
577
|
+
if 'logical_operator' in node:
|
|
578
|
+
for rule in node.get('rules') or []:
|
|
579
|
+
_collect_condition_fields(rule, refs)
|
|
580
|
+
elif node.get('field'):
|
|
581
|
+
refs.append(node['field'])
|
|
582
|
+
elif isinstance(node, list):
|
|
583
|
+
for item in node:
|
|
584
|
+
_collect_condition_fields(item, refs)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _reject_sensitive_fields(data):
|
|
588
|
+
"""Block any field reference in the metrics body that touches a
|
|
589
|
+
credential-named column.
|
|
590
|
+
|
|
591
|
+
The metrics endpoint references models/fields straight from the
|
|
592
|
+
request body — filters, groups, order and calc targets. Without this
|
|
593
|
+
guard, an authenticated caller can turn counts into a boolean oracle
|
|
594
|
+
(``owner__password__startswith=a`` → read the count) or read values
|
|
595
|
+
directly (``group_by.fields=['owner__api_key']`` → the api_key comes
|
|
596
|
+
back as a group label; the LIST/detail sensitive-field scrub does not
|
|
597
|
+
cover metrics output). We reject any reference whose ``__``-split
|
|
598
|
+
segments include a sensitive name.
|
|
599
|
+
"""
|
|
600
|
+
refs = []
|
|
601
|
+
|
|
602
|
+
calc_cfg = data.get('calc') or {}
|
|
603
|
+
on_field = calc_cfg.get('field')
|
|
604
|
+
if isinstance(on_field, str):
|
|
605
|
+
refs.append(on_field)
|
|
606
|
+
elif isinstance(on_field, list):
|
|
607
|
+
refs += [f for f in on_field if isinstance(f, str)]
|
|
608
|
+
|
|
609
|
+
gb = data.get('group_by') or {}
|
|
610
|
+
refs += [f for f in (gb.get('fields') or []) if isinstance(f, str)]
|
|
611
|
+
date_group = gb.get('date') or {}
|
|
612
|
+
if isinstance(date_group.get('field'), str):
|
|
613
|
+
refs.append(date_group['field'])
|
|
614
|
+
|
|
615
|
+
refs += [f for f in (data.get('additional_fields') or []) if isinstance(f, str)]
|
|
616
|
+
refs += [o.lstrip('-') for o in (data.get('order') or []) if isinstance(o, str)]
|
|
617
|
+
|
|
618
|
+
filter_by = data.get('filter_by') or {}
|
|
619
|
+
for key, value in (filter_by.get('fields') or {}).items():
|
|
620
|
+
if key == 'or_conditions':
|
|
621
|
+
for cond in value or []:
|
|
622
|
+
if isinstance(cond, dict):
|
|
623
|
+
refs += list(cond.keys())
|
|
624
|
+
else:
|
|
625
|
+
refs.append(key)
|
|
626
|
+
period = filter_by.get('period') or {}
|
|
627
|
+
if isinstance(period.get('field'), str):
|
|
628
|
+
refs.append(period['field'])
|
|
629
|
+
|
|
630
|
+
_collect_condition_fields(data.get('conditions'), refs)
|
|
631
|
+
|
|
632
|
+
for ref in refs:
|
|
633
|
+
for segment in str(ref).split('__'):
|
|
634
|
+
if looks_sensitive(segment):
|
|
635
|
+
raise HTTPException(
|
|
636
|
+
403,
|
|
637
|
+
f'Filtering, grouping or ordering on a sensitive '
|
|
638
|
+
f'field is not allowed: {ref}',
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
|
|
573
642
|
async def get_results(timezone, data, base_queryset=None):
|
|
574
643
|
"""Entry point for the metrics endpoint.
|
|
575
644
|
|
|
@@ -589,6 +658,8 @@ async def get_results(timezone, data, base_queryset=None):
|
|
|
589
658
|
if allowed_models is not None and model_label not in allowed_models:
|
|
590
659
|
raise HTTPException(403, f'Metrics not allowed for model "{model_label}"')
|
|
591
660
|
|
|
661
|
+
_reject_sensitive_fields(data)
|
|
662
|
+
|
|
592
663
|
model = get_model(model_label)
|
|
593
664
|
|
|
594
665
|
conditions = data.get('conditions')
|
|
@@ -623,8 +694,6 @@ async def get_results(timezone, data, base_queryset=None):
|
|
|
623
694
|
date_group = groups.get('date', {})
|
|
624
695
|
groups = groups.get('fields', [])
|
|
625
696
|
|
|
626
|
-
extra = data.get('extra')
|
|
627
|
-
|
|
628
697
|
filter_by = data.get('filter_by', {})
|
|
629
698
|
filtered_by_fields = filter_by.get('fields', {})
|
|
630
699
|
|
|
@@ -649,9 +718,6 @@ async def get_results(timezone, data, base_queryset=None):
|
|
|
649
718
|
period_filter, start_date, end_date = get_period(filter_by_period, timezone.zone)
|
|
650
719
|
queryset = queryset.filter(**period_filter)
|
|
651
720
|
|
|
652
|
-
if extra:
|
|
653
|
-
queryset = queryset.extra(**extra)
|
|
654
|
-
|
|
655
721
|
try:
|
|
656
722
|
return await _run_results(
|
|
657
723
|
queryset, on_field, additional_fields, calc, groups, order,
|
|
@@ -40,10 +40,18 @@ def get_client_ip(request):
|
|
|
40
40
|
|
|
41
41
|
forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
|
42
42
|
if forwarded_for:
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
# Walk right-to-left: the rightmost entries are appended by our
|
|
44
|
+
# own (trusted) proxies. The first entry that is NOT a trusted
|
|
45
|
+
# proxy is the real client. Taking the leftmost blindly let an
|
|
46
|
+
# attacker prepend a spoofed value to dodge IP rate limits.
|
|
47
|
+
parts = [p.strip() for p in forwarded_for.split(',') if p.strip()]
|
|
48
|
+
for candidate in reversed(parts):
|
|
49
|
+
if not _is_trusted(candidate):
|
|
50
|
+
return candidate
|
|
51
|
+
# Every hop was a trusted proxy — the client sits behind only
|
|
52
|
+
# our own proxies; the leftmost is the closest we have.
|
|
53
|
+
if parts:
|
|
54
|
+
return parts[0]
|
|
47
55
|
|
|
48
56
|
real_ip = request.META.get('HTTP_X_REAL_IP')
|
|
49
57
|
if real_ip:
|
|
@@ -26,21 +26,7 @@ from __future__ import annotations
|
|
|
26
26
|
from dataclasses import dataclass, field
|
|
27
27
|
from typing import Any
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
_SENSITIVE_EXACT = frozenset({
|
|
31
|
-
'password', 'pwd', 'passwd',
|
|
32
|
-
'secret', 'secret_key',
|
|
33
|
-
'api_key', 'api_secret',
|
|
34
|
-
'token', 'access_token', 'refresh_token',
|
|
35
|
-
'private_key', 'session_key',
|
|
36
|
-
'otp', 'otp_secret', 'two_factor_secret',
|
|
37
|
-
'token_password', 'token_user',
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
# Substring patterns are deliberately narrow — too eager and we'd flag
|
|
41
|
-
# columns like ``force_password_reset`` (a boolean flag) as sensitive.
|
|
42
|
-
_SENSITIVE_PREFIXES = ('password_', 'token_', 'secret_', 'api_key_', 'otp_')
|
|
43
|
-
_SENSITIVE_SUFFIXES = ('_password', '_token', '_secret', '_api_key', '_apikey')
|
|
29
|
+
from ..sensitive import looks_sensitive as _looks_sensitive
|
|
44
30
|
|
|
45
31
|
|
|
46
32
|
_DJANGO_SYSTEM_PREFIXES = (
|
|
@@ -85,17 +71,6 @@ class Config:
|
|
|
85
71
|
tables: dict[str, TableConfig] = field(default_factory=dict)
|
|
86
72
|
|
|
87
73
|
|
|
88
|
-
def _looks_sensitive(column_name: str) -> bool:
|
|
89
|
-
name = column_name.lower()
|
|
90
|
-
if name in _SENSITIVE_EXACT:
|
|
91
|
-
return True
|
|
92
|
-
if any(name.startswith(p) for p in _SENSITIVE_PREFIXES):
|
|
93
|
-
return True
|
|
94
|
-
if any(name.endswith(s) for s in _SENSITIVE_SUFFIXES):
|
|
95
|
-
return True
|
|
96
|
-
return False
|
|
97
|
-
|
|
98
|
-
|
|
99
74
|
def _is_django_system(db_table: str) -> bool:
|
|
100
75
|
"""Tables Django creates for itself (migrations, auth, sessions,
|
|
101
76
|
admin, content types). Default to ``expose: false`` — the user can
|
|
@@ -7,7 +7,7 @@ from django.core.exceptions import ObjectDoesNotExist
|
|
|
7
7
|
from .exception import HTTPException
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
LOCAL_HOST = re.compile(r'^(localhost|127
|
|
10
|
+
LOCAL_HOST = re.compile(r'^(localhost|127\.0\.0\.1)(:[0-9]+)?$')
|
|
11
11
|
|
|
12
12
|
re_id = re.compile(
|
|
13
13
|
r'(.*)\/(?:'
|
|
@@ -31,6 +31,7 @@ import logging
|
|
|
31
31
|
|
|
32
32
|
from pydantic import BaseModel, ValidationError
|
|
33
33
|
|
|
34
|
+
from ..sensitive import looks_sensitive
|
|
34
35
|
from .schema import validate_input
|
|
35
36
|
|
|
36
37
|
logger = logging.getLogger(__name__)
|
|
@@ -81,15 +82,16 @@ def _serialize_result(value, sensitive_fields=()):
|
|
|
81
82
|
payload = [item.model_dump() for item in value]
|
|
82
83
|
else:
|
|
83
84
|
payload = value
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
# Always scrub: the credential-name baseline applies even when the
|
|
86
|
+
# toolset declares no ``sensitive_fields``.
|
|
87
|
+
_scrub(payload, set(sensitive_fields))
|
|
86
88
|
return payload
|
|
87
89
|
|
|
88
90
|
|
|
89
91
|
def _scrub(obj, blocked):
|
|
90
92
|
if isinstance(obj, dict):
|
|
91
93
|
for key in list(obj.keys()):
|
|
92
|
-
if key in blocked:
|
|
94
|
+
if key in blocked or looks_sensitive(key):
|
|
93
95
|
obj.pop(key)
|
|
94
96
|
else:
|
|
95
97
|
_scrub(obj[key], blocked)
|
|
@@ -237,9 +239,12 @@ async def handle_rpc(message, tools, request=None):
|
|
|
237
239
|
else:
|
|
238
240
|
return _rpc_error(rpc_id, JSONRPC_METHOD_NOT_FOUND,
|
|
239
241
|
f'Unknown method: {method}')
|
|
240
|
-
except Exception
|
|
242
|
+
except Exception:
|
|
243
|
+
# Log the full exception; return a generic message so internal
|
|
244
|
+
# detail (paths, SQL, field names) never reaches the client —
|
|
245
|
+
# mirrors the REST dispatch's sanitized 500.
|
|
241
246
|
logger.exception('mcp.rpc.error method=%s', method)
|
|
242
|
-
return _rpc_error(rpc_id, JSONRPC_INTERNAL_ERROR,
|
|
247
|
+
return _rpc_error(rpc_id, JSONRPC_INTERNAL_ERROR, 'Internal server error')
|
|
243
248
|
|
|
244
249
|
if is_notification:
|
|
245
250
|
return None
|
|
@@ -433,7 +438,9 @@ def _map_exception(exc):
|
|
|
433
438
|
return 'METHOD_NOT_ALLOWED', message
|
|
434
439
|
if status == 429:
|
|
435
440
|
return 'RATE_LIMITED', message
|
|
436
|
-
|
|
441
|
+
# Unmapped/unknown exception — never echo ``str(exc)`` to the client;
|
|
442
|
+
# the full exception is logged by the caller.
|
|
443
|
+
return 'INTERNAL', 'Internal server error'
|
|
437
444
|
|
|
438
445
|
|
|
439
446
|
__all__ = ['handle_rpc', 'PROTOCOL_VERSION']
|
|
@@ -5,7 +5,11 @@ The project defines its clients under ``MCP['OAUTH_CLIENTS']``::
|
|
|
5
5
|
MCP = {
|
|
6
6
|
'OAUTH_CLIENTS': {
|
|
7
7
|
'claude': {
|
|
8
|
-
|
|
8
|
+
# Public client — MCP clients (Claude.ai, other LLMs)
|
|
9
|
+
# authenticate with PKCE, no shared secret. Required for
|
|
10
|
+
# dynamic registration (/register), which only provisions
|
|
11
|
+
# public clients.
|
|
12
|
+
'client_secret': None,
|
|
9
13
|
'redirect_uri': 'https://claude.ai/api/mcp/auth_callback',
|
|
10
14
|
'name': 'Claude',
|
|
11
15
|
},
|
|
@@ -17,6 +21,10 @@ The project defines its clients under ``MCP['OAUTH_CLIENTS']``::
|
|
|
17
21
|
},
|
|
18
22
|
},
|
|
19
23
|
}
|
|
24
|
+
|
|
25
|
+
Confidential clients (``client_secret`` set) are still supported for
|
|
26
|
+
the authorization-code/refresh grants, but are provisioned out of band
|
|
27
|
+
— ``/register`` refuses to hand out their secret.
|
|
20
28
|
"""
|
|
21
29
|
from .settings_helper import get_oauth_clients
|
|
22
30
|
|
|
@@ -9,16 +9,18 @@ from ..constants import GRANT_TYPE
|
|
|
9
9
|
def _match_client(name):
|
|
10
10
|
"""Match the registration ``client_name`` to a configured client_id.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
field
|
|
14
|
-
|
|
12
|
+
Exact, case-insensitive match against each configured client's
|
|
13
|
+
``name`` field. Substring matching was a security hole — an
|
|
14
|
+
unauthenticated caller could hit any configured client (and, in the
|
|
15
|
+
old code, extract its secret) by sending a ``client_name`` that
|
|
16
|
+
merely contained or was contained by the configured name. Projects
|
|
17
|
+
that need richer matching should override this module.
|
|
15
18
|
"""
|
|
16
|
-
name = (name or '').lower()
|
|
19
|
+
name = (name or '').strip().lower()
|
|
17
20
|
if not name:
|
|
18
21
|
return None
|
|
19
22
|
for client_id, cfg in MCP_OAUTH_CLIENTS.items():
|
|
20
|
-
|
|
21
|
-
if cfg_name and (cfg_name in name or name in cfg_name):
|
|
23
|
+
if (cfg.get('name') or '').strip().lower() == name:
|
|
22
24
|
return client_id
|
|
23
25
|
return None
|
|
24
26
|
|
|
@@ -49,15 +51,29 @@ class Register(BaseResource):
|
|
|
49
51
|
)
|
|
50
52
|
|
|
51
53
|
client = MCP_OAUTH_CLIENTS[client_id]
|
|
54
|
+
|
|
55
|
+
# Dynamic registration only provisions public clients. A
|
|
56
|
+
# confidential client's secret is provisioned out of band (env)
|
|
57
|
+
# and MUST NOT be handed to an unauthenticated /register caller —
|
|
58
|
+
# doing so leaks the shared secret to anyone. Refuse instead of
|
|
59
|
+
# echoing it. MCP clients (Claude.ai, other LLMs) authenticate
|
|
60
|
+
# with PKCE, so a public client is the correct shape here.
|
|
61
|
+
if client.get('client_secret') is not None:
|
|
62
|
+
raise HTTPException(
|
|
63
|
+
400,
|
|
64
|
+
'invalid_client_metadata: dynamic registration is only '
|
|
65
|
+
'available for public clients; provision confidential '
|
|
66
|
+
'clients out of band',
|
|
67
|
+
)
|
|
68
|
+
|
|
52
69
|
return {
|
|
53
70
|
'client_id': client_id,
|
|
54
|
-
'client_secret': client['client_secret'],
|
|
55
71
|
'client_id_issued_at': int(time.time()),
|
|
56
72
|
'client_secret_expires_at': 0,
|
|
57
73
|
'redirect_uris': [client['redirect_uri']],
|
|
58
74
|
'grant_types': list(GRANT_TYPE.SUPPORTED),
|
|
59
75
|
'response_types': ['code'],
|
|
60
|
-
'token_endpoint_auth_method': '
|
|
76
|
+
'token_endpoint_auth_method': 'none',
|
|
61
77
|
'application_type': application_type,
|
|
62
78
|
'client_name': client['name'],
|
|
63
79
|
}
|
|
@@ -37,24 +37,32 @@ def get_oauth_scopes():
|
|
|
37
37
|
def get_oauth_clients():
|
|
38
38
|
"""Dict ``client_id -> {client_secret, redirect_uri, name}``.
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
40
|
+
Clients are **public by default**: an entry with no ``client_secret``
|
|
41
|
+
(or ``None``) is a public client — identity proven by PKCE, which is
|
|
42
|
+
the correct and safe shape for MCP clients (Claude.ai, ChatGPT,
|
|
43
|
+
Gemini, ...). Public is the default because ``/register`` is
|
|
44
|
+
unauthenticated: a public client has no secret to leak.
|
|
45
|
+
|
|
46
|
+
A client becomes **confidential** only by opting in explicitly:
|
|
47
|
+
|
|
48
|
+
* ``'client_secret': '<value>'`` — that literal secret, or
|
|
49
|
+
* ``'client_secret': ''`` — share ``MCP['OAUTH_SECRET']``
|
|
50
|
+
across confidential clients without repeating it (``None`` when
|
|
51
|
+
``OAUTH_SECRET`` is unset).
|
|
52
|
+
|
|
53
|
+
``client_secret`` is always normalized to be present (``None`` when
|
|
54
|
+
absent) so downstream lookups never ``KeyError``.
|
|
48
55
|
"""
|
|
49
56
|
clients = dict(get_setting('OAUTH_CLIENTS', default={}) or {})
|
|
50
57
|
fallback = get_setting('OAUTH_SECRET')
|
|
51
|
-
if not fallback:
|
|
52
|
-
return clients
|
|
53
58
|
resolved = {}
|
|
54
59
|
for client_id, conf in clients.items():
|
|
55
60
|
conf = dict(conf or {})
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
secret = conf.get('client_secret')
|
|
62
|
+
if secret == '':
|
|
63
|
+
# Explicit opt-in to the shared confidential secret.
|
|
64
|
+
secret = fallback
|
|
65
|
+
conf['client_secret'] = secret
|
|
58
66
|
resolved[client_id] = conf
|
|
59
67
|
return resolved
|
|
60
68
|
|
|
@@ -20,7 +20,7 @@ from .blocking import is_blocked
|
|
|
20
20
|
from .client_ip import get_client_ip
|
|
21
21
|
from .redis_config import KEY_PREFIX, get_redis
|
|
22
22
|
|
|
23
|
-
from .settings_helper import get_setting
|
|
23
|
+
from .settings_helper import get_setting, security_bypassed
|
|
24
24
|
|
|
25
25
|
BLOCK_PATTERNS = get_setting('BLOCK_PATTERNS')
|
|
26
26
|
BLOCK_USER_AGENTS = get_setting('BLOCK_USER_AGENTS')
|
|
@@ -104,12 +104,12 @@ class SecurityMiddleware:
|
|
|
104
104
|
markcoroutinefunction(self)
|
|
105
105
|
|
|
106
106
|
async def __call__(self, request):
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
|
|
112
|
-
if
|
|
107
|
+
# Dev/bypass projects skip every block path entirely — local dev
|
|
108
|
+
# cycles legitimately produce traffic patterns that would look
|
|
109
|
+
# abusive (404 sprees while wiring up routes, repeated 401s while
|
|
110
|
+
# testing auth). Controlled by ``SECURITY_BYPASS`` (falls back to
|
|
111
|
+
# ``DEBUG`` when unset).
|
|
112
|
+
if security_bypassed():
|
|
113
113
|
return await self.get_response(request)
|
|
114
114
|
|
|
115
115
|
ip = get_client_ip(request)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Single source of truth for sensitive-field name detection.
|
|
2
|
+
|
|
3
|
+
Used at generation time (pre-populating ``sensitive_fields`` on
|
|
4
|
+
generated resources) and at runtime (baseline scrub in
|
|
5
|
+
``BaseResource``/MCP serialization, applied regardless of the
|
|
6
|
+
per-resource ``sensitive_fields`` list). Keeping one predicate means
|
|
7
|
+
the framework's promise — never serialize a column that looks like a
|
|
8
|
+
credential — holds even when a resource forgets to enumerate it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
_SENSITIVE_EXACT = frozenset({
|
|
12
|
+
'password', 'pwd', 'passwd',
|
|
13
|
+
'secret', 'secret_key',
|
|
14
|
+
'api_key', 'api_secret',
|
|
15
|
+
'token', 'access_token', 'refresh_token',
|
|
16
|
+
'private_key', 'session_key',
|
|
17
|
+
'otp', 'otp_secret', 'two_factor_secret',
|
|
18
|
+
'token_password', 'token_user',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
# Substring patterns are deliberately narrow — too eager and we'd flag
|
|
22
|
+
# columns like ``force_password_reset`` (a boolean flag) as sensitive.
|
|
23
|
+
_SENSITIVE_PREFIXES = ('password_', 'token_', 'secret_', 'api_key_', 'otp_')
|
|
24
|
+
_SENSITIVE_SUFFIXES = ('_password', '_token', '_secret', '_api_key', '_apikey')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def looks_sensitive(column_name):
|
|
28
|
+
if not isinstance(column_name, str):
|
|
29
|
+
return False
|
|
30
|
+
name = column_name.lower()
|
|
31
|
+
if name in _SENSITIVE_EXACT:
|
|
32
|
+
return True
|
|
33
|
+
if any(name.startswith(p) for p in _SENSITIVE_PREFIXES):
|
|
34
|
+
return True
|
|
35
|
+
if any(name.endswith(s) for s in _SENSITIVE_SUFFIXES):
|
|
36
|
+
return True
|
|
37
|
+
return False
|
|
@@ -61,6 +61,33 @@ def get_read_only():
|
|
|
61
61
|
return get_setting('READ_ONLY', default=False)
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
def get_security_bypass():
|
|
65
|
+
"""Explicit toggle to relax edge security + rate limiting.
|
|
66
|
+
|
|
67
|
+
``None`` (default) → follow ``settings.DEBUG`` (historical behaviour:
|
|
68
|
+
a dev box with ``DEBUG=True`` skips blocking/rate-limit so local 404
|
|
69
|
+
sprees and auth retries don't self-inflict a 24h block).
|
|
70
|
+
|
|
71
|
+
Set ``True`` to force the bypass in a non-DEBUG environment, or
|
|
72
|
+
``False`` to keep protections on even under ``DEBUG=True`` (e.g. a
|
|
73
|
+
staging box that mirrors production).
|
|
74
|
+
"""
|
|
75
|
+
return get_setting('SECURITY_BYPASS', default=None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def security_bypassed():
|
|
79
|
+
"""Resolve whether edge security + rate limiting are bypassed.
|
|
80
|
+
|
|
81
|
+
``SECURITY_BYPASS`` wins when set; otherwise falls back to
|
|
82
|
+
``settings.DEBUG``.
|
|
83
|
+
"""
|
|
84
|
+
explicit = get_security_bypass()
|
|
85
|
+
if explicit is not None:
|
|
86
|
+
return bool(explicit)
|
|
87
|
+
from django.conf import settings as _django_settings
|
|
88
|
+
return bool(getattr(_django_settings, 'DEBUG', False))
|
|
89
|
+
|
|
90
|
+
|
|
64
91
|
def get_metrics_max_execution_ms():
|
|
65
92
|
"""Server-side cap (milliseconds) for metrics aggregation queries,
|
|
66
93
|
applied as a MySQL ``MAX_EXECUTION_TIME`` optimizer hint. The metrics
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: easyapi-django
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.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
|
|
@@ -233,6 +233,10 @@ Defaults are demo-friendly. Production deployments should:
|
|
|
233
233
|
- **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
234
|
- **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
235
|
- **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, 4xx flood 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 `MCP = {'SECURITY_BYPASS': False}` to force protections on (or `True` to bypass without `DEBUG`).
|
|
237
|
+
- **Trust your proxies explicitly.** IP-based rate limits and abuse blocking 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.
|
|
238
|
+
- **Bound the metrics surface.** The `/metrics` endpoint queries any model in the registry by default. Set `METRICS_MODELS = ['app_Model', ...]` to allowlist which models are queryable. References to credential-named columns (in filters, groups, order or calc) are always rejected.
|
|
239
|
+
- **OAuth clients are public by default.** MCP clients (Claude.ai, ChatGPT, ...) authenticate with PKCE — list them under `MCP['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).
|
|
236
240
|
|
|
237
241
|
---
|
|
238
242
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resources/device_authorization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/templates/zeromcp_oauth/consent.html
RENAMED
|
File without changes
|
{easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/templates/zeromcp_oauth/device.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|