standkit 0.3.7__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.
- standkit/__init__.py +11 -0
- standkit/health.py +163 -0
- standkit/lifecycle.py +191 -0
- standkit/logs.py +221 -0
- standkit/models.py +211 -0
- standkit/platform.py +174 -0
- standkit/registry.py +223 -0
- standkit/secrets.py +144 -0
- standkit-0.3.7.dist-info/METADATA +155 -0
- standkit-0.3.7.dist-info/RECORD +38 -0
- standkit-0.3.7.dist-info/WHEEL +5 -0
- standkit-0.3.7.dist-info/entry_points.txt +4 -0
- standkit-0.3.7.dist-info/licenses/LICENSE +21 -0
- standkit-0.3.7.dist-info/top_level.txt +3 -0
- standkit_agent/__init__.py +9 -0
- standkit_agent/__main__.py +192 -0
- standkit_agent/audit.py +93 -0
- standkit_agent/security.py +300 -0
- standkit_agent/server.py +386 -0
- standkit_hub/__init__.py +13 -0
- standkit_hub/__main__.py +131 -0
- standkit_hub/agent_control.py +216 -0
- standkit_hub/assets/bpmkit-icon.ico +0 -0
- standkit_hub/assets/icon.png +0 -0
- standkit_hub/client.py +159 -0
- standkit_hub/config.py +156 -0
- standkit_hub/logs_browser.py +174 -0
- standkit_hub/redis_min.py +326 -0
- standkit_hub/security.py +146 -0
- standkit_hub/server.py +881 -0
- standkit_hub/shortcut.py +264 -0
- standkit_hub/web/app.js +803 -0
- standkit_hub/web/bpmkit-logo-dark.svg +5 -0
- standkit_hub/web/bpmkit-logo.svg +5 -0
- standkit_hub/web/favicon.png +0 -0
- standkit_hub/web/favicon.svg +4 -0
- standkit_hub/web/index.html +267 -0
- standkit_hub/web/style.css +843 -0
standkit_agent/audit.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Структурный append-only аудит-лог агента (JSON-lines) — обязателен для
|
|
3
|
+
DevSecOps-эксплуатации headless-агента, управляющего процессами на хосте
|
|
4
|
+
стенда.
|
|
5
|
+
|
|
6
|
+
Формат одной строки (JSON-объект, без вложенных переводов строк):
|
|
7
|
+
{
|
|
8
|
+
"ts": "2026-07-23T12:34:56.789012+00:00", # UTC ISO-8601
|
|
9
|
+
"src_ip": "10.0.0.5",
|
|
10
|
+
"identity": "control", # control|readonly|<CN>|"-"
|
|
11
|
+
"method": "POST",
|
|
12
|
+
"path": "/stand/demo/restart",
|
|
13
|
+
"action": "restart",
|
|
14
|
+
"result": "ok", # ok|denied|error
|
|
15
|
+
"code": 200
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Секреты/токены НИКОГДА не попадают в аудит-запись — только идентичность
|
|
19
|
+
уровня "какой скоуп/CN использован", не сам токен.
|
|
20
|
+
|
|
21
|
+
STDLIB-ONLY: используется ``logging`` с отдельным именованным логгером и
|
|
22
|
+
файловым хендлером (не корневой логгер — чтобы не смешиваться с чужой
|
|
23
|
+
конфигурацией логирования встраивающего процесса).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
DEFAULT_AUDIT_LOG_PATH = Path.home() / ".standkit" / "audit.log"
|
|
35
|
+
|
|
36
|
+
_LOGGER_NAME = "standkit_agent.audit"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_audit_logger(path: Optional[Path] = None) -> logging.Logger:
|
|
40
|
+
"""
|
|
41
|
+
Возвращает (создавая при необходимости) файловый логгер аудита.
|
|
42
|
+
|
|
43
|
+
Идемпотентно: повторный вызов с тем же путём не плодит дублирующиеся
|
|
44
|
+
хендлеры (полезно в тестах, где ``run_server``/фабрики обработчика могут
|
|
45
|
+
вызываться многократно в одном процессе).
|
|
46
|
+
"""
|
|
47
|
+
p = Path(path) if path else DEFAULT_AUDIT_LOG_PATH
|
|
48
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
logger_name = f"{_LOGGER_NAME}.{abs(hash(str(p.resolve())))}"
|
|
51
|
+
logger = logging.getLogger(logger_name)
|
|
52
|
+
logger.setLevel(logging.INFO)
|
|
53
|
+
logger.propagate = False # не утекает в root-логгер встраивающего процесса
|
|
54
|
+
|
|
55
|
+
already_attached = any(
|
|
56
|
+
isinstance(h, logging.FileHandler) and Path(h.baseFilename) == p.resolve()
|
|
57
|
+
for h in logger.handlers
|
|
58
|
+
)
|
|
59
|
+
if not already_attached:
|
|
60
|
+
handler = logging.FileHandler(p, encoding="utf-8")
|
|
61
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
62
|
+
logger.addHandler(handler)
|
|
63
|
+
return logger
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def audit_event(
|
|
67
|
+
logger: logging.Logger,
|
|
68
|
+
*,
|
|
69
|
+
src_ip: str,
|
|
70
|
+
identity: str,
|
|
71
|
+
method: str,
|
|
72
|
+
path: str,
|
|
73
|
+
action: str,
|
|
74
|
+
result: str,
|
|
75
|
+
code: int,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Пишет одну JSON-строку аудита. Никогда не бросает исключений наружу."""
|
|
78
|
+
entry = {
|
|
79
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
80
|
+
"src_ip": src_ip,
|
|
81
|
+
"identity": identity or "-",
|
|
82
|
+
"method": method,
|
|
83
|
+
"path": path,
|
|
84
|
+
"action": action,
|
|
85
|
+
"result": result,
|
|
86
|
+
"code": code,
|
|
87
|
+
}
|
|
88
|
+
try:
|
|
89
|
+
logger.info(json.dumps(entry, ensure_ascii=False))
|
|
90
|
+
except Exception:
|
|
91
|
+
# Аудит-лог не должен ронять обработку запроса ни при каких
|
|
92
|
+
# обстоятельствах (диск полон, права на файл и т.п.).
|
|
93
|
+
pass
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Security-примитивы headless-агента: fail-closed валидация bind-параметров,
|
|
3
|
+
TLS/mTLS-контекст, аутентификация по скоупам (control/readonly) с
|
|
4
|
+
защитой от timing-атак, rate limiting/lockout по source-IP.
|
|
5
|
+
|
|
6
|
+
Агент — RCE-поверхность по дизайну (управляет процессами на хосте стенда:
|
|
7
|
+
start/stop/restart произвольного дистрибутива). Все функции этого модуля
|
|
8
|
+
написаны так, чтобы secure-defaults были ОТКАЗ, а не разрешение (fail-closed):
|
|
9
|
+
там, где нет однозначного подтверждения безопасности конфигурации — агент не
|
|
10
|
+
стартует, а не "стартует и предупреждает".
|
|
11
|
+
|
|
12
|
+
STDLIB-ONLY: ``ssl``, ``hmac``, ``socket``, ``threading``, ``time``, ``re``.
|
|
13
|
+
Никаких сторонних зависимостей.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hmac
|
|
19
|
+
import re
|
|
20
|
+
import ssl
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
# --- Константы secure-defaults ---
|
|
27
|
+
|
|
28
|
+
# Минимум TLS 1.2 — TLS 1.0/1.1 запрещены протоколом (POODLE/BEAST-класс атак).
|
|
29
|
+
MIN_TLS_VERSION = ssl.TLSVersion.TLSv1_2
|
|
30
|
+
|
|
31
|
+
# Разумный набор современных AEAD-шифров (без RC4/3DES/статического RSA-обмена
|
|
32
|
+
# ключами, без экспортных наборов). Список сознательно консервативен —
|
|
33
|
+
# приоритет ECDHE (forward secrecy) + AEAD.
|
|
34
|
+
RECOMMENDED_CIPHERS = (
|
|
35
|
+
"ECDHE-ECDSA-AES256-GCM-SHA384:"
|
|
36
|
+
"ECDHE-RSA-AES256-GCM-SHA384:"
|
|
37
|
+
"ECDHE-ECDSA-AES128-GCM-SHA256:"
|
|
38
|
+
"ECDHE-RSA-AES128-GCM-SHA256:"
|
|
39
|
+
"ECDHE-ECDSA-CHACHA20-POLY1305:"
|
|
40
|
+
"ECDHE-RSA-CHACHA20-POLY1305"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
DEFAULT_MAX_BODY_BYTES = 64 * 1024 # 64 КБ — лимит тела HTTP-запроса
|
|
44
|
+
DEFAULT_MAX_LOGS_N = 10_000 # кап на "n" в GET /stand/{name}/logs
|
|
45
|
+
DEFAULT_SOCKET_TIMEOUT = 30.0 # секунд — таймаут на одно соединение
|
|
46
|
+
|
|
47
|
+
DEFAULT_LOCKOUT_MAX_FAILURES = 5
|
|
48
|
+
DEFAULT_LOCKOUT_WINDOW_SECONDS = 300.0
|
|
49
|
+
|
|
50
|
+
_LOOPBACK_EXACT = {"127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1"}
|
|
51
|
+
|
|
52
|
+
# Валидное имя стенда: то же множество символов, что типично для ключей
|
|
53
|
+
# реестра projects.json (без "/", без пробелов, без управляющих символов).
|
|
54
|
+
_STAND_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
|
|
55
|
+
|
|
56
|
+
# --- Скоупы аутентификации ---
|
|
57
|
+
|
|
58
|
+
SCOPE_CONTROL = "control"
|
|
59
|
+
SCOPE_READONLY = "readonly"
|
|
60
|
+
|
|
61
|
+
READ_ACTIONS = frozenset({"stands", "status", "logs"})
|
|
62
|
+
CONTROL_ACTIONS = frozenset({"start", "stop", "restart"})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class InsecureBindError(Exception):
|
|
66
|
+
"""
|
|
67
|
+
Отказ старта агента: fail-closed проверка bind-параметров не пройдена.
|
|
68
|
+
|
|
69
|
+
Бросается ДО открытия сокета — старт процесса агента прерывается с
|
|
70
|
+
ненулевым кодом возврата, а не "стартует и уязвим".
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_loopback_host(host: str) -> bool:
|
|
75
|
+
"""
|
|
76
|
+
True, если ``host`` — loopback-адрес/имя (127.0.0.1, ::1, localhost,
|
|
77
|
+
127.x.x.x). Используется для fail-closed решения — держим проверку
|
|
78
|
+
консервативной (белый список известных loopback-форм), а не пытаемся
|
|
79
|
+
резолвить произвольные DNS-имена (это была бы сетевая операция внутри
|
|
80
|
+
чистой функции валидации).
|
|
81
|
+
"""
|
|
82
|
+
h = (host or "").strip().lower()
|
|
83
|
+
if h in _LOOPBACK_EXACT:
|
|
84
|
+
return True
|
|
85
|
+
if h.startswith("127."):
|
|
86
|
+
return True
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_bind_security(host: str, *, tls_enabled: bool, insecure: bool) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Fail-closed проверка перед стартом сервера. Чистая функция — НЕ открывает
|
|
93
|
+
сокет, поэтому тестируется без реальной сети.
|
|
94
|
+
|
|
95
|
+
Правила:
|
|
96
|
+
- loopback-хост — открытый HTTP разрешён (обычный dev-сценарий за
|
|
97
|
+
локальным туннелем/управляющим контуром на той же машине);
|
|
98
|
+
- non-loopback хост БЕЗ TLS — отказ (``InsecureBindError``), если не
|
|
99
|
+
передан явный ``insecure=True`` (осознанный обход, только для
|
|
100
|
+
dev/тестовых сценариев — вызывающая сторона обязана громко
|
|
101
|
+
предупредить в stderr, см. ``standkit_agent.server.run_server``);
|
|
102
|
+
- TLS включён — non-loopback разрешён без дополнительных условий
|
|
103
|
+
(транспорт уже защищён).
|
|
104
|
+
"""
|
|
105
|
+
if tls_enabled:
|
|
106
|
+
return
|
|
107
|
+
if is_loopback_host(host):
|
|
108
|
+
return
|
|
109
|
+
if insecure:
|
|
110
|
+
return
|
|
111
|
+
raise InsecureBindError(
|
|
112
|
+
f"Отказ старта: host={host!r} не loopback, TLS не настроен "
|
|
113
|
+
"(--tls-cert/--tls-key) — headless-агент управляет процессами на "
|
|
114
|
+
"хосте стенда (RCE-поверхность), открытый HTTP наружу по умолчанию "
|
|
115
|
+
"запрещён (fail-closed). Варианты: (1) слушать loopback (127.0.0.1) "
|
|
116
|
+
"за управляющим контуром/VPN/SSH-туннелем; (2) настроить TLS/mTLS "
|
|
117
|
+
"(--tls-cert/--tls-key[/--tls-client-ca]); (3) если это осознанный "
|
|
118
|
+
"dev-сценарий — передать --insecure (НЕ для прод, агент выведет "
|
|
119
|
+
"громкое предупреждение)."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _apply_tls_hardening(context: ssl.SSLContext, *, require_client_cert: bool) -> None:
|
|
124
|
+
"""
|
|
125
|
+
Настраивает флаги ``SSLContext`` (минимальная версия протокола, шифры,
|
|
126
|
+
verify_mode) БЕЗ обращения к файловой системе — специально вынесено
|
|
127
|
+
отдельно от ``build_ssl_context``, чтобы это можно было протестировать
|
|
128
|
+
юнит-тестом без реальных сертификатов (см. tests/test_agent_security.py).
|
|
129
|
+
"""
|
|
130
|
+
context.minimum_version = MIN_TLS_VERSION
|
|
131
|
+
try:
|
|
132
|
+
context.set_ciphers(RECOMMENDED_CIPHERS)
|
|
133
|
+
except ssl.SSLError:
|
|
134
|
+
# Набор шифров недоступен в конкретной сборке OpenSSL — не роняем
|
|
135
|
+
# запуск агента из-за этого, минимальная версия протокола важнее.
|
|
136
|
+
pass
|
|
137
|
+
# Явно выключаем сжатие (CRIME-класс атак) и устаревшие опции, если они
|
|
138
|
+
# доступны в текущей сборке OpenSSL.
|
|
139
|
+
context.options |= getattr(ssl, "OP_NO_COMPRESSION", 0)
|
|
140
|
+
if require_client_cert:
|
|
141
|
+
context.verify_mode = ssl.CERT_REQUIRED
|
|
142
|
+
else:
|
|
143
|
+
context.verify_mode = ssl.CERT_NONE
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_ssl_context(
|
|
147
|
+
tls_cert: str,
|
|
148
|
+
tls_key: str,
|
|
149
|
+
tls_client_ca: Optional[str] = None,
|
|
150
|
+
) -> ssl.SSLContext:
|
|
151
|
+
"""
|
|
152
|
+
Строит серверный ``SSLContext`` для оборачивания сокета агента.
|
|
153
|
+
|
|
154
|
+
Если задан ``tls_client_ca`` — включается mTLS: клиенты БЕЗ сертификата,
|
|
155
|
+
подписанного этим CA, отклоняются на уровне TLS-хендшейка, до того как
|
|
156
|
+
запрос доходит до обработчика (``CERT_REQUIRED`` +
|
|
157
|
+
``load_verify_locations``).
|
|
158
|
+
"""
|
|
159
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
160
|
+
_apply_tls_hardening(context, require_client_cert=bool(tls_client_ca))
|
|
161
|
+
context.load_cert_chain(certfile=tls_cert, keyfile=tls_key)
|
|
162
|
+
if tls_client_ca:
|
|
163
|
+
context.load_verify_locations(cafile=tls_client_ca)
|
|
164
|
+
return context
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def peer_identity_from_cert(peer_cert: Optional[dict]) -> Optional[str]:
|
|
168
|
+
"""
|
|
169
|
+
Извлекает CN клиентского сертификата из результата
|
|
170
|
+
``SSLSocket.getpeercert()`` — для аудита (кто предъявил mTLS-сертификат).
|
|
171
|
+
|
|
172
|
+
Возвращает ``None``, если сертификата нет или CN не найден (не бросает
|
|
173
|
+
исключений — это вспомогательная функция для логирования, а не для
|
|
174
|
+
решений о доступе).
|
|
175
|
+
"""
|
|
176
|
+
if not peer_cert:
|
|
177
|
+
return None
|
|
178
|
+
for rdn in peer_cert.get("subject", ()):
|
|
179
|
+
for key, value in rdn:
|
|
180
|
+
if key == "commonName":
|
|
181
|
+
return value
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Authenticator:
|
|
186
|
+
"""
|
|
187
|
+
Проверка Bearer-токена запроса со скоупом control/readonly.
|
|
188
|
+
|
|
189
|
+
Сравнение — ТОЛЬКО через ``hmac.compare_digest`` (защита от timing-атак
|
|
190
|
+
по времени сравнения строк символ-за-символом).
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(self, control_token: Optional[str], readonly_token: Optional[str] = None):
|
|
194
|
+
self._control_token = control_token or None
|
|
195
|
+
self._readonly_token = readonly_token or None
|
|
196
|
+
|
|
197
|
+
def check(self, presented_token: Optional[str]) -> Optional[str]:
|
|
198
|
+
"""
|
|
199
|
+
Возвращает ``SCOPE_CONTROL``/``SCOPE_READONLY`` при совпадении токена,
|
|
200
|
+
иначе ``None``. Пустой/отсутствующий предъявленный токен — всегда
|
|
201
|
+
отказ (fail-closed), без обращения к ``hmac.compare_digest``.
|
|
202
|
+
"""
|
|
203
|
+
if not presented_token:
|
|
204
|
+
return None
|
|
205
|
+
if self._control_token and hmac.compare_digest(presented_token, self._control_token):
|
|
206
|
+
return SCOPE_CONTROL
|
|
207
|
+
if self._readonly_token and hmac.compare_digest(presented_token, self._readonly_token):
|
|
208
|
+
return SCOPE_READONLY
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def scope_allows(scope: Optional[str], action: str) -> bool:
|
|
213
|
+
"""
|
|
214
|
+
Проверяет, достаточно ли ``scope`` для выполнения ``action``.
|
|
215
|
+
|
|
216
|
+
control-скоуп разрешает всё; readonly — только действия из
|
|
217
|
+
``READ_ACTIONS``; отсутствие скоупа (``None``) не разрешает ничего.
|
|
218
|
+
"""
|
|
219
|
+
if scope == SCOPE_CONTROL:
|
|
220
|
+
return True
|
|
221
|
+
if scope == SCOPE_READONLY:
|
|
222
|
+
return action in READ_ACTIONS
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass
|
|
227
|
+
class LockoutTracker:
|
|
228
|
+
"""
|
|
229
|
+
Потокобезопасный счётчик неудачных аутентификаций per source-IP.
|
|
230
|
+
|
|
231
|
+
После ``max_failures`` неудач в скользящем окне ``window_seconds`` —
|
|
232
|
+
IP считается заблокированным (``is_locked`` → True) до истечения окна с
|
|
233
|
+
момента последней неудачи, входящей в счёт. Рассчитан на использование
|
|
234
|
+
из ``ThreadingHTTPServer`` (несколько потоков-обработчиков одновременно).
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
max_failures: int = DEFAULT_LOCKOUT_MAX_FAILURES
|
|
238
|
+
window_seconds: float = DEFAULT_LOCKOUT_WINDOW_SECONDS
|
|
239
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
|
|
240
|
+
_failures: dict = field(default_factory=dict, repr=False, compare=False)
|
|
241
|
+
|
|
242
|
+
def _prune(self, ip: str, now: float) -> list:
|
|
243
|
+
hits = [t for t in self._failures.get(ip, []) if now - t < self.window_seconds]
|
|
244
|
+
self._failures[ip] = hits
|
|
245
|
+
return hits
|
|
246
|
+
|
|
247
|
+
def is_locked(self, ip: str) -> bool:
|
|
248
|
+
with self._lock:
|
|
249
|
+
hits = self._prune(ip, time.monotonic())
|
|
250
|
+
return len(hits) >= self.max_failures
|
|
251
|
+
|
|
252
|
+
def record_failure(self, ip: str) -> None:
|
|
253
|
+
with self._lock:
|
|
254
|
+
now = time.monotonic()
|
|
255
|
+
hits = self._prune(ip, now)
|
|
256
|
+
hits.append(now)
|
|
257
|
+
self._failures[ip] = hits
|
|
258
|
+
|
|
259
|
+
def record_success(self, ip: str) -> None:
|
|
260
|
+
with self._lock:
|
|
261
|
+
self._failures.pop(ip, None)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def validate_stand_name(name: str) -> bool:
|
|
265
|
+
"""Валидация имени стенда из URL — консервативный whitelist символов, кап длины."""
|
|
266
|
+
return bool(name) and bool(_STAND_NAME_RE.match(name))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def clamp_logs_n(raw: str, *, max_n: int = DEFAULT_MAX_LOGS_N) -> int:
|
|
270
|
+
"""
|
|
271
|
+
Парсит и капит параметр ``n`` запроса логов.
|
|
272
|
+
|
|
273
|
+
Бросает ``ValueError`` на некорректный ввод (не число, отрицательное) —
|
|
274
|
+
вызывающая сторона обязана поймать это и вернуть 400, а не 500/креш.
|
|
275
|
+
"""
|
|
276
|
+
n = int(raw)
|
|
277
|
+
if n < 0:
|
|
278
|
+
raise ValueError("n не может быть отрицательным")
|
|
279
|
+
return min(n, max_n)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def validate_content_length(header_value: Optional[str], *, max_bytes: int = DEFAULT_MAX_BODY_BYTES) -> int:
|
|
283
|
+
"""
|
|
284
|
+
Разбирает заголовок ``Content-Length`` и проверяет лимит тела запроса
|
|
285
|
+
ДО его фактического чтения из сокета (input-hardening/DoS-защита).
|
|
286
|
+
|
|
287
|
+
Бросает ``ValueError`` на некорректное, отрицательное или превышающее
|
|
288
|
+
лимит значение — вызывающая сторона обязана поймать это и вернуть 400
|
|
289
|
+
клиенту, а не 500/креш процесса.
|
|
290
|
+
"""
|
|
291
|
+
raw = header_value if header_value not in (None, "") else "0"
|
|
292
|
+
try:
|
|
293
|
+
n = int(raw)
|
|
294
|
+
except (TypeError, ValueError) as exc:
|
|
295
|
+
raise ValueError(f"некорректный Content-Length: {raw!r}") from exc
|
|
296
|
+
if n < 0:
|
|
297
|
+
raise ValueError("Content-Length не может быть отрицательным")
|
|
298
|
+
if n > max_bytes:
|
|
299
|
+
raise ValueError(f"тело запроса превышает лимит {max_bytes} байт")
|
|
300
|
+
return n
|