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.
Files changed (100) hide show
  1. {easyapi_django-1.1.7/easyapi_django.egg-info → easyapi_django-1.2.0}/PKG-INFO +5 -1
  2. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/README.md +4 -0
  3. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/base.py +37 -9
  4. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/calc.py +71 -5
  5. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/client_ip.py +12 -4
  6. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/config.py +1 -26
  7. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/helpers.py +1 -1
  8. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/mcp/protocol.py +13 -6
  9. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/clients.py +9 -1
  10. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resources/register.py +24 -8
  11. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/settings_helper.py +20 -12
  12. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/security.py +7 -7
  13. easyapi_django-1.2.0/easyapi/sensitive.py +37 -0
  14. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/settings_helper.py +27 -0
  15. {easyapi_django-1.1.7 → easyapi_django-1.2.0/easyapi_django.egg-info}/PKG-INFO +5 -1
  16. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi_django.egg-info/SOURCES.txt +1 -0
  17. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/pyproject.toml +1 -1
  18. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/LICENSE +0 -0
  19. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/MANIFEST.in +0 -0
  20. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/__init__.py +0 -0
  21. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/auth.py +0 -0
  22. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/bearer.py +0 -0
  23. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/blocking.py +0 -0
  24. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/calc_resource.py +0 -0
  25. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/constants.py +0 -0
  26. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/core/__init__.py +0 -0
  27. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/core/endpoint.py +0 -0
  28. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/core/resource.py +0 -0
  29. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/dates.py +0 -0
  30. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/exception.py +0 -0
  31. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/filters.py +0 -0
  32. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/__init__.py +0 -0
  33. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/cli.py +0 -0
  34. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/generator.py +0 -0
  35. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/inspector/__init__.py +0 -0
  36. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/inspector/base.py +0 -0
  37. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/inspector/mysql.py +0 -0
  38. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/inspector/postgres.py +0 -0
  39. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/README.md.j2 +0 -0
  40. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/asgi.py.j2 +0 -0
  41. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/env.example.j2 +0 -0
  42. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/gitignore.j2 +0 -0
  43. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/manage.py.j2 +0 -0
  44. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/module/models.py.j2 +0 -0
  45. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/module/resources.py.j2 +0 -0
  46. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/requirements.txt.j2 +0 -0
  47. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/router/endpoints.py.j2 +0 -0
  48. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/router/urls.py.j2 +0 -0
  49. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/run.sh.j2 +0 -0
  50. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/settings/env.py.j2 +0 -0
  51. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/gen/templates/settings/settings.py.j2 +0 -0
  52. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/management/__init__.py +0 -0
  53. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/management/commands/__init__.py +0 -0
  54. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/management/commands/mcp_serve.py +0 -0
  55. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/mcp/__init__.py +0 -0
  56. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/mcp/schema.py +0 -0
  57. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/mcp/server.py +0 -0
  58. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/mcp/testing.py +0 -0
  59. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/mcp/tool.py +0 -0
  60. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/mcp/tools.py +0 -0
  61. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/middleware.py +0 -0
  62. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/__init__.py +0 -0
  63. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/_common.py +0 -0
  64. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/apps.py +0 -0
  65. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/codes.py +0 -0
  66. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/constants.py +0 -0
  67. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/devices.py +0 -0
  68. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/discovery.py +0 -0
  69. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/migrations/0001_initial.py +0 -0
  70. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/migrations/__init__.py +0 -0
  71. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/models.py +0 -0
  72. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resolver.py +0 -0
  73. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resources/__init__.py +0 -0
  74. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resources/authorize.py +0 -0
  75. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resources/device.py +0 -0
  76. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resources/device_authorization.py +0 -0
  77. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resources/revoke.py +0 -0
  78. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/resources/token.py +0 -0
  79. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/routes.py +0 -0
  80. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/session.py +0 -0
  81. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/templates/zeromcp_oauth/consent.html +0 -0
  82. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/templates/zeromcp_oauth/device.html +0 -0
  83. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/oauth/tokens.py +0 -0
  84. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/openapi.py +0 -0
  85. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/orm/__init__.py +0 -0
  86. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/rate_limit.py +0 -0
  87. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/redis_config.py +0 -0
  88. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/routes.py +0 -0
  89. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/schemas.py +0 -0
  90. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/serializer.py +0 -0
  91. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/tenant/__init__.py +0 -0
  92. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/tenant/db_router.py +0 -0
  93. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/tenant/registry.py +0 -0
  94. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/tenant/tenant.py +0 -0
  95. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi/util.py +0 -0
  96. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi_django.egg-info/dependency_links.txt +0 -0
  97. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi_django.egg-info/entry_points.txt +0 -0
  98. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi_django.egg-info/requires.txt +0 -0
  99. {easyapi_django-1.1.7 → easyapi_django-1.2.0}/easyapi_django.egg-info/top_level.txt +0 -0
  100. {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.1.7
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
- if domain.endswith(origin):
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 with ``DEBUG = True`` bypasses rate limiting
373
- # and abuse blocking. Hammering ``/docs`` while wiring up auth
374
- # would otherwise trigger a 24h block on 127.0.0.1 — annoying
375
- # and almost never the right behaviour for a dev box.
376
- if getattr(django_settings, 'DEBUG', False):
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 getattr(django_settings, 'DEBUG', False):
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 == 'password' or field in self.sensitive_fields:
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 == 'password' or field in self.sensitive_fields:
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
- # Leftmost is the client.
44
- candidate = forwarded_for.split(',')[0].strip()
45
- if candidate:
46
- return candidate
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.0.0.1):*([0-9]+)?$')
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
- if sensitive_fields:
85
- _scrub(payload, set(sensitive_fields))
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 as exc:
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, str(exc))
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
- return 'INTERNAL', message or 'Internal server error'
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
- 'client_secret': os.environ['MCP_CLIENT_SECRET_CLAUDE'],
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
- Looks up case-insensitively against each configured client's ``name``
13
- field (substring match). Projects that need richer matching should
14
- override this module.
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
- cfg_name = (cfg.get('name') or '').lower()
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': 'client_secret_basic',
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
- ``client_secret = None`` marks a public client (device flow).
41
-
42
- Per-client secret defaults to ``MCP['OAUTH_SECRET']`` when the
43
- entry's own ``client_secret`` is empty/missing lets single-owner
44
- deployments share one secret across all confidential clients
45
- (Claude, ChatGPT, Gemini, ...) without listing it three times.
46
- Set the entry's ``client_secret`` explicitly to override or to
47
- rotate per-client.
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
- if 'client_secret' not in conf or conf['client_secret'] == '':
57
- conf['client_secret'] = fallback
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
- # ``DEBUG = True`` projects skip every block path entirely —
108
- # local dev cycles legitimately produce the kind of traffic
109
- # patterns that would otherwise look abusive (404 sprees while
110
- # wiring up routes, repeated 401s while testing auth).
111
- from django.conf import settings as _django_settings
112
- if getattr(_django_settings, 'DEBUG', False):
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.1.7
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
 
@@ -22,6 +22,7 @@ easyapi/redis_config.py
22
22
  easyapi/routes.py
23
23
  easyapi/schemas.py
24
24
  easyapi/security.py
25
+ easyapi/sensitive.py
25
26
  easyapi/serializer.py
26
27
  easyapi/settings_helper.py
27
28
  easyapi/util.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "easyapi-django"
7
- version = "1.1.7"
7
+ version = "1.2.0"
8
8
  authors = [
9
9
  { name="Stamatios Stamou Jr", email="bushier.outsets.0c@icloud.com" },
10
10
  ]
File without changes
File without changes