abstractgateway 0.1.0__py3-none-any.whl → 0.1.1__py3-none-any.whl
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.
- abstractgateway/__init__.py +1 -2
- abstractgateway/__main__.py +7 -0
- abstractgateway/app.py +4 -4
- abstractgateway/cli.py +568 -8
- abstractgateway/config.py +15 -5
- abstractgateway/embeddings_config.py +45 -0
- abstractgateway/host_metrics.py +274 -0
- abstractgateway/hosts/bundle_host.py +528 -55
- abstractgateway/hosts/visualflow_host.py +30 -3
- abstractgateway/integrations/__init__.py +2 -0
- abstractgateway/integrations/email_bridge.py +782 -0
- abstractgateway/integrations/telegram_bridge.py +534 -0
- abstractgateway/maintenance/__init__.py +5 -0
- abstractgateway/maintenance/action_tokens.py +100 -0
- abstractgateway/maintenance/backlog_exec_runner.py +1592 -0
- abstractgateway/maintenance/backlog_parser.py +184 -0
- abstractgateway/maintenance/draft_generator.py +451 -0
- abstractgateway/maintenance/llm_assist.py +212 -0
- abstractgateway/maintenance/notifier.py +109 -0
- abstractgateway/maintenance/process_manager.py +1064 -0
- abstractgateway/maintenance/report_models.py +81 -0
- abstractgateway/maintenance/report_parser.py +219 -0
- abstractgateway/maintenance/text_similarity.py +123 -0
- abstractgateway/maintenance/triage.py +507 -0
- abstractgateway/maintenance/triage_queue.py +142 -0
- abstractgateway/migrate.py +155 -0
- abstractgateway/routes/__init__.py +2 -2
- abstractgateway/routes/gateway.py +10817 -179
- abstractgateway/routes/triage.py +118 -0
- abstractgateway/runner.py +689 -14
- abstractgateway/security/gateway_security.py +425 -110
- abstractgateway/service.py +213 -6
- abstractgateway/stores.py +64 -4
- abstractgateway/workflow_deprecations.py +225 -0
- abstractgateway-0.1.1.dist-info/METADATA +135 -0
- abstractgateway-0.1.1.dist-info/RECORD +40 -0
- abstractgateway-0.1.0.dist-info/METADATA +0 -101
- abstractgateway-0.1.0.dist-info/RECORD +0 -18
- {abstractgateway-0.1.0.dist-info → abstractgateway-0.1.1.dist-info}/WHEEL +0 -0
- {abstractgateway-0.1.0.dist-info → abstractgateway-0.1.1.dist-info}/entry_points.txt +0 -0
|
@@ -13,18 +13,25 @@ Design constraints:
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import asyncio
|
|
16
|
+
import datetime
|
|
17
|
+
import fnmatch
|
|
16
18
|
import hmac
|
|
19
|
+
import hashlib
|
|
17
20
|
import json
|
|
18
21
|
import logging
|
|
19
22
|
import os
|
|
20
23
|
import threading
|
|
21
24
|
import time
|
|
25
|
+
import uuid
|
|
22
26
|
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
23
28
|
from typing import Any, Dict, Iterable, Optional, Tuple
|
|
24
29
|
|
|
25
30
|
|
|
26
31
|
logger = logging.getLogger(__name__)
|
|
27
32
|
|
|
33
|
+
_AUDIT_LOCK = threading.Lock()
|
|
34
|
+
|
|
28
35
|
|
|
29
36
|
def _as_bool(raw: Any, default: bool = False) -> bool:
|
|
30
37
|
if raw is None:
|
|
@@ -68,6 +75,96 @@ def _env(name: str, fallback: Optional[str] = None) -> Optional[str]:
|
|
|
68
75
|
return None
|
|
69
76
|
|
|
70
77
|
|
|
78
|
+
def _now_utc_iso() -> str:
|
|
79
|
+
try:
|
|
80
|
+
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
81
|
+
except Exception:
|
|
82
|
+
# Best-effort: avoid ever throwing in security middleware.
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _sha256_hex(text: str) -> str:
|
|
87
|
+
try:
|
|
88
|
+
h = hashlib.sha256()
|
|
89
|
+
h.update(str(text or "").encode("utf-8", errors="ignore"))
|
|
90
|
+
return h.hexdigest()
|
|
91
|
+
except Exception:
|
|
92
|
+
return ""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _audit_data_dir_from_env() -> Path:
|
|
96
|
+
raw = (
|
|
97
|
+
os.getenv("ABSTRACTGATEWAY_DATA_DIR")
|
|
98
|
+
or os.getenv("ABSTRACTFLOW_RUNTIME_DIR")
|
|
99
|
+
or os.getenv("ABSTRACTFLOW_GATEWAY_DATA_DIR")
|
|
100
|
+
or "./runtime"
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
return Path(str(raw)).expanduser().resolve()
|
|
104
|
+
except Exception:
|
|
105
|
+
return Path("./runtime").resolve()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _audit_log_enabled(*, default: bool) -> bool:
|
|
109
|
+
raw = os.getenv("ABSTRACTGATEWAY_AUDIT_LOG")
|
|
110
|
+
if raw is None:
|
|
111
|
+
return bool(default)
|
|
112
|
+
return _as_bool(raw, bool(default))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _audit_log_max_bytes() -> int:
|
|
116
|
+
raw = os.getenv("ABSTRACTGATEWAY_AUDIT_LOG_MAX_BYTES") or ""
|
|
117
|
+
try:
|
|
118
|
+
v = int(str(raw).strip())
|
|
119
|
+
return max(0, v)
|
|
120
|
+
except Exception:
|
|
121
|
+
# Default: 50MB (bounded, but large enough for multi-day local dev).
|
|
122
|
+
return 50 * 1024 * 1024
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _audit_log_rotations() -> int:
|
|
126
|
+
raw = os.getenv("ABSTRACTGATEWAY_AUDIT_LOG_ROTATIONS") or ""
|
|
127
|
+
try:
|
|
128
|
+
v = int(str(raw).strip())
|
|
129
|
+
return max(0, min(200, v))
|
|
130
|
+
except Exception:
|
|
131
|
+
return 10
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _audit_header_allowlist() -> Tuple[str, ...]:
|
|
135
|
+
raw = os.getenv("ABSTRACTGATEWAY_AUDIT_LOG_HEADERS") or ""
|
|
136
|
+
if not str(raw).strip():
|
|
137
|
+
return ("x-client-id", "x-client-version", "x-forwarded-for")
|
|
138
|
+
out: list[str] = []
|
|
139
|
+
for part in str(raw).split(","):
|
|
140
|
+
p = part.strip().lower()
|
|
141
|
+
if p:
|
|
142
|
+
out.append(p)
|
|
143
|
+
return tuple(out)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _audit_redact_query(query_string: bytes) -> str:
|
|
147
|
+
# Do not log full query params by default (can contain secrets). Keep only keys.
|
|
148
|
+
try:
|
|
149
|
+
qs = (query_string or b"").decode("utf-8", errors="replace")
|
|
150
|
+
except Exception:
|
|
151
|
+
return ""
|
|
152
|
+
if not qs:
|
|
153
|
+
return ""
|
|
154
|
+
# Parse best-effort without importing urllib for speed/robustness.
|
|
155
|
+
parts = []
|
|
156
|
+
for kv in qs.split("&"):
|
|
157
|
+
if not kv:
|
|
158
|
+
continue
|
|
159
|
+
k = kv.split("=", 1)[0].strip()
|
|
160
|
+
if k:
|
|
161
|
+
parts.append(k)
|
|
162
|
+
if not parts:
|
|
163
|
+
return ""
|
|
164
|
+
parts = parts[:50]
|
|
165
|
+
return "&".join([f"{k}=<redacted>" for k in parts])
|
|
166
|
+
|
|
167
|
+
|
|
71
168
|
@dataclass(frozen=True)
|
|
72
169
|
class GatewayAuthPolicy:
|
|
73
170
|
"""Configuration for the Run Gateway security layer."""
|
|
@@ -91,6 +188,12 @@ class GatewayAuthPolicy:
|
|
|
91
188
|
|
|
92
189
|
# Abuse resistance
|
|
93
190
|
max_body_bytes: int = 256_000
|
|
191
|
+
# Upload endpoints can legitimately exceed `max_body_bytes` (multipart). 0 means "auto"
|
|
192
|
+
# (derived from the explicit endpoint caps).
|
|
193
|
+
max_upload_body_bytes: int = 0
|
|
194
|
+
# Endpoint caps (mirrors /attachments/upload and /bundles/upload defaults).
|
|
195
|
+
max_attachment_bytes: int = 25 * 1024 * 1024
|
|
196
|
+
max_bundle_bytes: int = 75 * 1024 * 1024
|
|
94
197
|
max_concurrency: int = 64
|
|
95
198
|
max_sse_connections: int = 32
|
|
96
199
|
|
|
@@ -114,6 +217,9 @@ def load_gateway_auth_policy_from_env() -> GatewayAuthPolicy:
|
|
|
114
217
|
- ABSTRACTGATEWAY_DEV_READ_NO_AUTH=1|0 (loopback only)
|
|
115
218
|
- ABSTRACTGATEWAY_ALLOWED_ORIGINS (comma-separated; supports '*' suffix wildcard)
|
|
116
219
|
- ABSTRACTGATEWAY_MAX_BODY_BYTES
|
|
220
|
+
- ABSTRACTGATEWAY_MAX_UPLOAD_BODY_BYTES
|
|
221
|
+
- ABSTRACTGATEWAY_MAX_ATTACHMENT_BYTES
|
|
222
|
+
- ABSTRACTGATEWAY_MAX_BUNDLE_BYTES
|
|
117
223
|
- ABSTRACTGATEWAY_MAX_CONCURRENCY
|
|
118
224
|
- ABSTRACTGATEWAY_MAX_SSE
|
|
119
225
|
- ABSTRACTGATEWAY_LOCKOUT_AFTER
|
|
@@ -168,6 +274,17 @@ def load_gateway_auth_policy_from_env() -> GatewayAuthPolicy:
|
|
|
168
274
|
return default
|
|
169
275
|
|
|
170
276
|
max_body = _as_int("ABSTRACTGATEWAY_MAX_BODY_BYTES", "ABSTRACTFLOW_GATEWAY_MAX_BODY_BYTES", 256_000)
|
|
277
|
+
max_upload_body = _as_int("ABSTRACTGATEWAY_MAX_UPLOAD_BODY_BYTES", "ABSTRACTFLOW_GATEWAY_MAX_UPLOAD_BODY_BYTES", 0)
|
|
278
|
+
max_attach = _as_int(
|
|
279
|
+
"ABSTRACTGATEWAY_MAX_ATTACHMENT_BYTES",
|
|
280
|
+
"ABSTRACTFLOW_GATEWAY_MAX_ATTACHMENT_BYTES",
|
|
281
|
+
25 * 1024 * 1024,
|
|
282
|
+
)
|
|
283
|
+
max_bundle = _as_int(
|
|
284
|
+
"ABSTRACTGATEWAY_MAX_BUNDLE_BYTES",
|
|
285
|
+
"ABSTRACTFLOW_GATEWAY_MAX_BUNDLE_BYTES",
|
|
286
|
+
75 * 1024 * 1024,
|
|
287
|
+
)
|
|
171
288
|
max_conc = _as_int("ABSTRACTGATEWAY_MAX_CONCURRENCY", "ABSTRACTFLOW_GATEWAY_MAX_CONCURRENCY", 64)
|
|
172
289
|
max_sse = _as_int("ABSTRACTGATEWAY_MAX_SSE", "ABSTRACTFLOW_GATEWAY_MAX_SSE", 32)
|
|
173
290
|
lockout_after = _as_int("ABSTRACTGATEWAY_LOCKOUT_AFTER", "ABSTRACTFLOW_GATEWAY_LOCKOUT_AFTER", 5)
|
|
@@ -183,6 +300,9 @@ def load_gateway_auth_policy_from_env() -> GatewayAuthPolicy:
|
|
|
183
300
|
dev_allow_unauthenticated_reads_on_loopback=bool(dev_read_no_auth),
|
|
184
301
|
allowed_origins=tuple(allowed_origins),
|
|
185
302
|
max_body_bytes=max(0, int(max_body)),
|
|
303
|
+
max_upload_body_bytes=max(0, int(max_upload_body)),
|
|
304
|
+
max_attachment_bytes=max(1, int(max_attach)) if int(max_attach) > 0 else 25 * 1024 * 1024,
|
|
305
|
+
max_bundle_bytes=max(1, int(max_bundle)) if int(max_bundle) > 0 else 75 * 1024 * 1024,
|
|
186
306
|
max_concurrency=max(1, int(max_conc)),
|
|
187
307
|
max_sse_connections=max(1, int(max_sse)),
|
|
188
308
|
lockout_after_failures=max(1, int(lockout_after)),
|
|
@@ -273,6 +393,69 @@ class GatewaySecurityMiddleware:
|
|
|
273
393
|
"(ABSTRACTGATEWAY_AUTH_TOKEN). Mutating endpoints will be rejected."
|
|
274
394
|
)
|
|
275
395
|
|
|
396
|
+
self._audit_enabled = _audit_log_enabled(default=bool(policy.enabled))
|
|
397
|
+
self._audit_max_bytes = int(_audit_log_max_bytes())
|
|
398
|
+
self._audit_rotations = int(_audit_log_rotations())
|
|
399
|
+
self._audit_headers = _audit_header_allowlist()
|
|
400
|
+
|
|
401
|
+
def _audit_append(self, entry: Dict[str, Any]) -> None:
|
|
402
|
+
if not self._audit_enabled:
|
|
403
|
+
return
|
|
404
|
+
try:
|
|
405
|
+
line = json.dumps(entry, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
406
|
+
except Exception:
|
|
407
|
+
return
|
|
408
|
+
data = line.encode("utf-8", errors="replace")
|
|
409
|
+
try:
|
|
410
|
+
with _AUDIT_LOCK:
|
|
411
|
+
path = (_audit_data_dir_from_env() / "audit_log.jsonl").resolve()
|
|
412
|
+
try:
|
|
413
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
414
|
+
except Exception:
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
# Rotate if needed (best-effort).
|
|
418
|
+
try:
|
|
419
|
+
max_bytes = int(self._audit_max_bytes)
|
|
420
|
+
except Exception:
|
|
421
|
+
max_bytes = 0
|
|
422
|
+
if max_bytes > 0 and path.exists():
|
|
423
|
+
try:
|
|
424
|
+
size = int(path.stat().st_size)
|
|
425
|
+
except Exception:
|
|
426
|
+
size = 0
|
|
427
|
+
if size >= max_bytes:
|
|
428
|
+
ts = _now_utc_iso().replace(":", "").replace("-", "")
|
|
429
|
+
rotated = path.with_name(f"audit_log.{ts}.jsonl")
|
|
430
|
+
try:
|
|
431
|
+
path.replace(rotated)
|
|
432
|
+
except Exception:
|
|
433
|
+
# If rotation fails, keep appending to the same file.
|
|
434
|
+
pass
|
|
435
|
+
else:
|
|
436
|
+
# Best-effort pruning: keep only N rotated files.
|
|
437
|
+
keep = int(self._audit_rotations)
|
|
438
|
+
if keep > 0:
|
|
439
|
+
try:
|
|
440
|
+
olds = sorted(
|
|
441
|
+
path.parent.glob("audit_log.*.jsonl"),
|
|
442
|
+
key=lambda p: p.name,
|
|
443
|
+
reverse=True,
|
|
444
|
+
)
|
|
445
|
+
for p in olds[keep:]:
|
|
446
|
+
try:
|
|
447
|
+
p.unlink()
|
|
448
|
+
except Exception:
|
|
449
|
+
pass
|
|
450
|
+
except Exception:
|
|
451
|
+
pass
|
|
452
|
+
|
|
453
|
+
with open(path, "ab") as f:
|
|
454
|
+
f.write(data)
|
|
455
|
+
except Exception:
|
|
456
|
+
# Never break request handling due to audit logging.
|
|
457
|
+
return
|
|
458
|
+
|
|
276
459
|
# ---------------------------
|
|
277
460
|
# Helpers
|
|
278
461
|
# ---------------------------
|
|
@@ -312,8 +495,11 @@ class GatewaySecurityMiddleware:
|
|
|
312
495
|
continue
|
|
313
496
|
if p == "*":
|
|
314
497
|
return True
|
|
315
|
-
|
|
316
|
-
|
|
498
|
+
# Glob-style matching (fnmatch): supports patterns like
|
|
499
|
+
# - http://localhost:*
|
|
500
|
+
# - https://*.ngrok-free.app
|
|
501
|
+
if any(ch in p for ch in ("*", "?", "[")):
|
|
502
|
+
if fnmatch.fnmatchcase(o, p):
|
|
317
503
|
return True
|
|
318
504
|
continue
|
|
319
505
|
if o == p:
|
|
@@ -377,128 +563,257 @@ class GatewaySecurityMiddleware:
|
|
|
377
563
|
is_write = method in {"POST", "PUT", "PATCH", "DELETE"}
|
|
378
564
|
ip = self._client_ip(scope)
|
|
379
565
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
await self._reject(send, status=403, detail="Forbidden (origin not allowed)")
|
|
384
|
-
return
|
|
566
|
+
request_id = (self._header(scope, "x-request-id") or "").strip()
|
|
567
|
+
if not request_id:
|
|
568
|
+
request_id = uuid.uuid4().hex
|
|
385
569
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
status=429,
|
|
392
|
-
detail="Too Many Requests (auth lockout)",
|
|
393
|
-
headers=[(b"retry-after", str(int(locked)).encode("utf-8"))],
|
|
394
|
-
)
|
|
395
|
-
return
|
|
396
|
-
|
|
397
|
-
# Auth decision.
|
|
398
|
-
auth_required = False
|
|
399
|
-
if is_write and self._policy.protect_write_endpoints:
|
|
400
|
-
auth_required = True
|
|
401
|
-
if is_read and self._policy.protect_read_endpoints:
|
|
402
|
-
# Optional dev escape hatch (loopback-only).
|
|
403
|
-
if self._policy.dev_allow_unauthenticated_reads_on_loopback and _is_loopback_ip(ip):
|
|
404
|
-
auth_required = False
|
|
405
|
-
else:
|
|
406
|
-
auth_required = True
|
|
570
|
+
started_at_s = time.time()
|
|
571
|
+
status_code: Optional[int] = None
|
|
572
|
+
error: Optional[str] = None
|
|
573
|
+
auth_required: bool = False
|
|
574
|
+
presented_token_fp: str = ""
|
|
407
575
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
576
|
+
async def _send_wrapped(message: dict) -> None:
|
|
577
|
+
nonlocal status_code
|
|
578
|
+
if message.get("type") == "http.response.start":
|
|
579
|
+
try:
|
|
580
|
+
status_code = int(message.get("status") or 0)
|
|
581
|
+
except Exception:
|
|
582
|
+
status_code = 0
|
|
411
583
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
584
|
+
try:
|
|
585
|
+
hdrs = list(message.get("headers") or [])
|
|
586
|
+
except Exception:
|
|
587
|
+
hdrs = []
|
|
588
|
+
has_rid = False
|
|
589
|
+
for k, _v in hdrs:
|
|
590
|
+
try:
|
|
591
|
+
if bytes(k).lower() == b"x-request-id":
|
|
592
|
+
has_rid = True
|
|
593
|
+
break
|
|
594
|
+
except Exception:
|
|
595
|
+
continue
|
|
596
|
+
if not has_rid:
|
|
597
|
+
hdrs.append((b"x-request-id", str(request_id).encode("utf-8")))
|
|
598
|
+
message = dict(message)
|
|
599
|
+
message["headers"] = hdrs
|
|
600
|
+
await send(message)
|
|
601
|
+
|
|
602
|
+
def _finalize_audit() -> None:
|
|
603
|
+
if not is_write:
|
|
415
604
|
return
|
|
416
|
-
|
|
417
|
-
token = ""
|
|
418
|
-
if auth.lower().startswith("bearer "):
|
|
419
|
-
token = auth.split(" ", 1)[1].strip()
|
|
420
|
-
if not token or not self._token_valid(token):
|
|
421
|
-
lock = self._lockouts.record_failure(ip)
|
|
422
|
-
if lock is not None and lock > 0:
|
|
423
|
-
await self._reject(
|
|
424
|
-
send,
|
|
425
|
-
status=429,
|
|
426
|
-
detail="Too Many Requests (auth lockout)",
|
|
427
|
-
headers=[(b"retry-after", str(int(lock)).encode("utf-8"))],
|
|
428
|
-
)
|
|
429
|
-
return
|
|
430
|
-
await self._reject(
|
|
431
|
-
send,
|
|
432
|
-
status=401,
|
|
433
|
-
detail="Unauthorized",
|
|
434
|
-
headers=[(b"www-authenticate", b"Bearer")],
|
|
435
|
-
)
|
|
605
|
+
if not self._audit_enabled:
|
|
436
606
|
return
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
607
|
+
now = time.time()
|
|
608
|
+
duration_ms = int(max(0.0, (now - float(started_at_s))) * 1000.0)
|
|
609
|
+
qs = scope.get("query_string") or b""
|
|
610
|
+
entry: Dict[str, Any] = {
|
|
611
|
+
"ts": _now_utc_iso(),
|
|
612
|
+
"request_id": str(request_id),
|
|
613
|
+
"ip": str(ip),
|
|
614
|
+
"method": str(method),
|
|
615
|
+
"path": str(path),
|
|
616
|
+
"query": _audit_redact_query(qs if isinstance(qs, (bytes, bytearray)) else b""),
|
|
617
|
+
"status": int(status_code or 0),
|
|
618
|
+
"duration_ms": int(duration_ms),
|
|
619
|
+
"auth_required": bool(auth_required),
|
|
620
|
+
}
|
|
621
|
+
if presented_token_fp:
|
|
622
|
+
entry["auth_token_fp"] = str(presented_token_fp)
|
|
623
|
+
|
|
624
|
+
origin = self._header(scope, "origin")
|
|
625
|
+
if origin:
|
|
626
|
+
entry["origin"] = str(origin)
|
|
627
|
+
ua = self._header(scope, "user-agent")
|
|
628
|
+
if ua:
|
|
629
|
+
entry["user_agent"] = str(ua)
|
|
442
630
|
cl = self._header(scope, "content-length")
|
|
443
|
-
if cl
|
|
631
|
+
if cl and str(cl).strip().isdigit():
|
|
444
632
|
try:
|
|
445
|
-
|
|
446
|
-
await self._reject(send, status=413, detail="Payload Too Large")
|
|
447
|
-
return
|
|
633
|
+
entry["bytes_in"] = int(str(cl).strip())
|
|
448
634
|
except Exception:
|
|
449
635
|
pass
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
return
|
|
468
|
-
buffered_body = b"".join(chunks)
|
|
469
|
-
|
|
470
|
-
# Concurrency limits: separate pool for SSE streams.
|
|
471
|
-
is_sse = path.endswith("/ledger/stream")
|
|
472
|
-
sem = self._sse_sema if is_sse else self._sema
|
|
473
|
-
acquired = await self._try_acquire(sem)
|
|
474
|
-
if not acquired:
|
|
475
|
-
await self._reject(
|
|
476
|
-
send,
|
|
477
|
-
status=429,
|
|
478
|
-
detail="Too Many Requests (concurrency limit)",
|
|
479
|
-
headers=[(b"retry-after", b"1")],
|
|
480
|
-
)
|
|
481
|
-
return
|
|
636
|
+
|
|
637
|
+
headers_out: Dict[str, str] = {}
|
|
638
|
+
for hn in self._audit_headers:
|
|
639
|
+
try:
|
|
640
|
+
v = self._header(scope, hn)
|
|
641
|
+
except Exception:
|
|
642
|
+
v = None
|
|
643
|
+
if v is None:
|
|
644
|
+
continue
|
|
645
|
+
headers_out[str(hn)] = str(v)
|
|
646
|
+
if headers_out:
|
|
647
|
+
entry["headers"] = headers_out
|
|
648
|
+
|
|
649
|
+
if error:
|
|
650
|
+
entry["error"] = str(error)
|
|
651
|
+
|
|
652
|
+
self._audit_append(entry)
|
|
482
653
|
|
|
483
654
|
try:
|
|
484
|
-
|
|
485
|
-
|
|
655
|
+
# Origin checks (only when Origin is present).
|
|
656
|
+
origin = self._header(scope, "origin")
|
|
657
|
+
if origin is not None and not self._origin_allowed(origin):
|
|
658
|
+
await self._reject(_send_wrapped, status=403, detail="Forbidden (origin not allowed)")
|
|
659
|
+
return
|
|
486
660
|
|
|
487
|
-
#
|
|
488
|
-
|
|
661
|
+
# Lockout handling (only meaningful when auth is enabled).
|
|
662
|
+
locked = self._lockouts.check_locked(ip)
|
|
663
|
+
if locked is not None and locked > 0:
|
|
664
|
+
await self._reject(
|
|
665
|
+
_send_wrapped,
|
|
666
|
+
status=429,
|
|
667
|
+
detail="Too Many Requests (auth lockout)",
|
|
668
|
+
headers=[(b"retry-after", str(int(locked)).encode("utf-8"))],
|
|
669
|
+
)
|
|
670
|
+
return
|
|
489
671
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
672
|
+
# Auth decision.
|
|
673
|
+
auth_required = False
|
|
674
|
+
if is_write and self._policy.protect_write_endpoints:
|
|
675
|
+
auth_required = True
|
|
676
|
+
if is_read and self._policy.protect_read_endpoints:
|
|
677
|
+
# Optional dev escape hatch (loopback-only).
|
|
678
|
+
if self._policy.dev_allow_unauthenticated_reads_on_loopback and _is_loopback_ip(ip):
|
|
679
|
+
auth_required = False
|
|
680
|
+
else:
|
|
681
|
+
auth_required = True
|
|
682
|
+
|
|
683
|
+
# OPTIONS preflight: allow through (but still origin-checked above).
|
|
684
|
+
if method == "OPTIONS":
|
|
685
|
+
return await self._app(scope, receive, _send_wrapped)
|
|
686
|
+
|
|
687
|
+
if auth_required:
|
|
688
|
+
if not self._policy.tokens:
|
|
689
|
+
await self._reject(_send_wrapped, status=503, detail="Gateway auth required but no token configured")
|
|
690
|
+
return
|
|
691
|
+
auth = self._header(scope, "authorization") or ""
|
|
692
|
+
token = ""
|
|
693
|
+
if auth.lower().startswith("bearer "):
|
|
694
|
+
token = auth.split(" ", 1)[1].strip()
|
|
695
|
+
if token:
|
|
696
|
+
presented_token_fp = _sha256_hex(token)[:12]
|
|
697
|
+
if not token or not self._token_valid(token):
|
|
698
|
+
lock = self._lockouts.record_failure(ip)
|
|
699
|
+
if lock is not None and lock > 0:
|
|
700
|
+
await self._reject(
|
|
701
|
+
_send_wrapped,
|
|
702
|
+
status=429,
|
|
703
|
+
detail="Too Many Requests (auth lockout)",
|
|
704
|
+
headers=[(b"retry-after", str(int(lock)).encode("utf-8"))],
|
|
705
|
+
)
|
|
706
|
+
return
|
|
707
|
+
await self._reject(
|
|
708
|
+
_send_wrapped,
|
|
709
|
+
status=401,
|
|
710
|
+
detail="Unauthorized",
|
|
711
|
+
headers=[(b"www-authenticate", b"Bearer")],
|
|
712
|
+
)
|
|
713
|
+
return
|
|
714
|
+
self._lockouts.record_success(ip)
|
|
715
|
+
|
|
716
|
+
def _upload_kind(path: str) -> str:
|
|
717
|
+
p = str(path or "").rstrip("/")
|
|
718
|
+
if p.endswith("/attachments/upload"):
|
|
719
|
+
return "attachments"
|
|
720
|
+
if p.endswith("/bundles/upload"):
|
|
721
|
+
return "bundles"
|
|
722
|
+
return ""
|
|
723
|
+
|
|
724
|
+
# Body size limits (for mutating endpoints).
|
|
725
|
+
buffered_body: Optional[bytes] = None
|
|
726
|
+
max_body_bytes = int(self._policy.max_body_bytes)
|
|
727
|
+
upload_kind = _upload_kind(path)
|
|
728
|
+
if upload_kind:
|
|
729
|
+
# Multipart requests include boundary + part headers in `Content-Length`.
|
|
730
|
+
# This overhead is typically tiny (KBs), but we allow a conservative cushion so the
|
|
731
|
+
# security layer cannot reject a valid upload that is still within the endpoint's
|
|
732
|
+
# per-file cap (e.g. 25MB for /attachments/upload).
|
|
733
|
+
overhead = 2 * 1024 * 1024
|
|
734
|
+
endpoint_cap = (
|
|
735
|
+
int(self._policy.max_attachment_bytes)
|
|
736
|
+
if upload_kind == "attachments"
|
|
737
|
+
else int(self._policy.max_bundle_bytes)
|
|
738
|
+
)
|
|
739
|
+
max_body_bytes = max(0, int(endpoint_cap) + int(overhead))
|
|
740
|
+
if int(self._policy.max_upload_body_bytes) > 0:
|
|
741
|
+
max_body_bytes = min(max_body_bytes, int(self._policy.max_upload_body_bytes))
|
|
742
|
+
|
|
743
|
+
if is_write and max_body_bytes > 0:
|
|
744
|
+
cl = self._header(scope, "content-length")
|
|
745
|
+
if cl is not None:
|
|
746
|
+
try:
|
|
747
|
+
content_length = int(cl)
|
|
748
|
+
if content_length > int(max_body_bytes):
|
|
749
|
+
await self._reject(
|
|
750
|
+
_send_wrapped,
|
|
751
|
+
status=413,
|
|
752
|
+
detail=f"Payload Too Large ({content_length} bytes > {int(max_body_bytes)} bytes)",
|
|
753
|
+
)
|
|
754
|
+
return
|
|
755
|
+
except Exception:
|
|
756
|
+
pass
|
|
757
|
+
else:
|
|
758
|
+
# No content-length: buffer up to limit+1, then replay.
|
|
759
|
+
limit = int(max_body_bytes)
|
|
760
|
+
chunks: list[bytes] = []
|
|
761
|
+
size = 0
|
|
762
|
+
more = True
|
|
763
|
+
while more:
|
|
764
|
+
message = await receive()
|
|
765
|
+
if message.get("type") != "http.request":
|
|
766
|
+
continue
|
|
767
|
+
body = message.get("body", b"") or b""
|
|
768
|
+
more = bool(message.get("more_body", False))
|
|
769
|
+
if body:
|
|
770
|
+
chunks.append(body)
|
|
771
|
+
size += len(body)
|
|
772
|
+
if size > limit:
|
|
773
|
+
await self._reject(
|
|
774
|
+
_send_wrapped, status=413, detail=f"Payload Too Large ({size} bytes > {limit} bytes)"
|
|
775
|
+
)
|
|
776
|
+
return
|
|
777
|
+
buffered_body = b"".join(chunks)
|
|
778
|
+
|
|
779
|
+
# Concurrency limits: separate pool for SSE streams.
|
|
780
|
+
is_sse = path.endswith("/ledger/stream")
|
|
781
|
+
sem = self._sse_sema if is_sse else self._sema
|
|
782
|
+
acquired = await self._try_acquire(sem)
|
|
783
|
+
if not acquired:
|
|
784
|
+
await self._reject(
|
|
785
|
+
_send_wrapped,
|
|
786
|
+
status=429,
|
|
787
|
+
detail="Too Many Requests (concurrency limit)",
|
|
788
|
+
headers=[(b"retry-after", b"1")],
|
|
789
|
+
)
|
|
790
|
+
return
|
|
496
791
|
|
|
497
|
-
return await self._app(scope, _replay_receive, send)
|
|
498
|
-
finally:
|
|
499
792
|
try:
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
pass
|
|
793
|
+
if buffered_body is None:
|
|
794
|
+
return await self._app(scope, receive, _send_wrapped)
|
|
503
795
|
|
|
796
|
+
# Replay buffered body to downstream app.
|
|
797
|
+
sent = False
|
|
504
798
|
|
|
799
|
+
async def _replay_receive():
|
|
800
|
+
nonlocal sent
|
|
801
|
+
if sent:
|
|
802
|
+
return {"type": "http.request", "body": b"", "more_body": False}
|
|
803
|
+
sent = True
|
|
804
|
+
return {"type": "http.request", "body": buffered_body, "more_body": False}
|
|
805
|
+
|
|
806
|
+
return await self._app(scope, _replay_receive, _send_wrapped)
|
|
807
|
+
finally:
|
|
808
|
+
try:
|
|
809
|
+
sem.release()
|
|
810
|
+
except Exception:
|
|
811
|
+
pass
|
|
812
|
+
except Exception as e: # pragma: no cover
|
|
813
|
+
try:
|
|
814
|
+
error = str(e)
|
|
815
|
+
except Exception:
|
|
816
|
+
error = "error"
|
|
817
|
+
raise
|
|
818
|
+
finally:
|
|
819
|
+
_finalize_audit()
|