sud-cli 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.
- sud_cli/__init__.py +3 -0
- sud_cli/__main__.py +4 -0
- sud_cli/_wm.py +118 -0
- sud_cli/agenda.py +90 -0
- sud_cli/autofill.py +192 -0
- sud_cli/autofill_markers.json +56 -0
- sud_cli/backup.py +256 -0
- sud_cli/calendar_ics.py +191 -0
- sud_cli/case_diff.py +165 -0
- sud_cli/case_search_bridge.py +1322 -0
- sud_cli/chain.py +226 -0
- sud_cli/cli.py +3721 -0
- sud_cli/completeness.py +105 -0
- sud_cli/config.py +20 -0
- sud_cli/conflict.py +210 -0
- sud_cli/conflicts.py +207 -0
- sud_cli/court_alerts.py +156 -0
- sud_cli/court_source.py +108 -0
- sud_cli/data/completeness_rules.json +18 -0
- sud_cli/data/key_rates.json +59 -0
- sud_cli/data/npa_historical_editions.json +19 -0
- sud_cli/db.py +2072 -0
- sud_cli/deadline_matrix.py +99 -0
- sud_cli/deadline_state.py +25 -0
- sud_cli/deadlines.py +8 -0
- sud_cli/deadlines_state.py +1 -0
- sud_cli/deferral.py +5 -0
- sud_cli/efrsb_manual.py +85 -0
- sud_cli/efrsb_public.py +210 -0
- sud_cli/egrul_nalog.py +150 -0
- sud_cli/explain.py +190 -0
- sud_cli/fees_extra.py +104 -0
- sud_cli/forms.py +204 -0
- sud_cli/fssp_manual.py +91 -0
- sud_cli/fssp_semi.py +285 -0
- sud_cli/fssp_stats.py +105 -0
- sud_cli/government_transfers.json +8 -0
- sud_cli/holidays_rf.json +62 -0
- sud_cli/idempotency.py +78 -0
- sud_cli/importing.py +575 -0
- sud_cli/interest.py +144 -0
- sud_cli/jurisdiction.py +177 -0
- sud_cli/jurisdiction_rules.json +265 -0
- sud_cli/legal_deadlines.py +278 -0
- sud_cli/legal_engine.py +558 -0
- sud_cli/legal_rules.json +1058 -0
- sud_cli/limitation.py +70 -0
- sud_cli/mcp_server.py +1594 -0
- sud_cli/metrics.py +142 -0
- sud_cli/npa_as_of.py +21 -0
- sud_cli/output.py +89 -0
- sud_cli/payment.py +24 -0
- sud_cli/pdn.py +259 -0
- sud_cli/plenum_corpus.py +134 -0
- sud_cli/plenums_seed.json +45 -0
- sud_cli/practice_status.py +136 -0
- sud_cli/pravo_publication.py +361 -0
- sud_cli/pretrial.py +151 -0
- sud_cli/production_calendar.py +233 -0
- sud_cli/providers.py +95 -0
- sud_cli/readiness.py +234 -0
- sud_cli/regional_holidays.py +37 -0
- sud_cli/reminders.py +92 -0
- sud_cli/reports.py +349 -0
- sud_cli/sud_netguard.py +943 -0
- sud_cli/timeline_audit.py +80 -0
- sud_cli/verify.py +201 -0
- sud_cli-0.1.1.dist-info/METADATA +119 -0
- sud_cli-0.1.1.dist-info/RECORD +74 -0
- sud_cli-0.1.1.dist-info/WHEEL +4 -0
- sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
- sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
- sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
- sud_cli-0.1.1.dist-info/licenses/NOTICE +7 -0
sud_cli/sud_netguard.py
ADDED
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sud_netguard — общий защитный сетевой слой the project (egress guard + per-host breaker + spotlighting).
|
|
3
|
+
|
|
4
|
+
Возможности:
|
|
5
|
+
1. Egress guard (fail-closed): запросы в loopback/приватные сети/link-local/
|
|
6
|
+
облачную метадату (169.254.169.254) запрещены, кроме явного trust-списка.
|
|
7
|
+
DNS-имена резолвятся и проверяется КАЖДЫЙ адрес (защита от DNS-rebinding).
|
|
8
|
+
2. Per-host breaker: при 403/429/капче хост уходит в cooldown (по умолчанию 60 с),
|
|
9
|
+
состояние крос-процессное (SQLite), чтобы CLI-запуски не долбили хост лавиной.
|
|
10
|
+
3. Spotlighting: обёртка недоверенного контента в маркированные теги
|
|
11
|
+
<external_untrusted_data> с экранированием попыток побега из тега.
|
|
12
|
+
|
|
13
|
+
Только stdlib (кроме ленивого import requests в guarded_get).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import http.client
|
|
19
|
+
import ipaddress
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import socket
|
|
23
|
+
import sqlite3
|
|
24
|
+
import ssl
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
import urllib.request
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, NamedTuple, Optional
|
|
30
|
+
|
|
31
|
+
NETGUARD_VERSION = "0.3.2"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EgressBlockedError(RuntimeError):
|
|
35
|
+
"""Запрос в запрещённую сеть (fail-closed)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BreakerOpenError(RuntimeError):
|
|
39
|
+
"""Хост в cooldown после 4xx/капчи; повтор позже."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, host: str, remaining: float, reason: str = ""):
|
|
42
|
+
self.host = host
|
|
43
|
+
self.remaining = remaining
|
|
44
|
+
self.reason = reason
|
|
45
|
+
super().__init__(
|
|
46
|
+
f"breaker open for {host}: cooldown {remaining:.0f}s remaining"
|
|
47
|
+
+ (f" (last reason: {reason})" if reason else "")
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _ValidatedTarget(NamedTuple):
|
|
52
|
+
hostport: str
|
|
53
|
+
scheme: str
|
|
54
|
+
hostname: str
|
|
55
|
+
port: int
|
|
56
|
+
address: str
|
|
57
|
+
authority: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- Egress guard -----------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
# Доверенные внутренние endpoint'ы (host или host:port). Расширяется через env
|
|
63
|
+
# SUD_NETGUARD_TRUSTED="host[:port],host[:port]".
|
|
64
|
+
_TRUSTED_DEFAULT: set[str] = set() # trusted-эндпоинты — только явным env (audit 25.10)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _trusted() -> set[str]:
|
|
68
|
+
extra = os.environ.get("SUD_NETGUARD_TRUSTED", "")
|
|
69
|
+
out = set(_TRUSTED_DEFAULT)
|
|
70
|
+
for part in extra.split(","):
|
|
71
|
+
part = part.strip().lower()
|
|
72
|
+
if part:
|
|
73
|
+
out.add(part)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_denied_addr(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
|
78
|
+
if addr.is_loopback or addr.is_private or addr.is_link_local:
|
|
79
|
+
return True
|
|
80
|
+
if addr.is_unspecified or addr.is_multicast or addr.is_reserved:
|
|
81
|
+
return True
|
|
82
|
+
# Облачная метадата и CGNAT-диапазоны — не наши цели.
|
|
83
|
+
if addr.version == 4 and str(addr).startswith("169.254.169.254"):
|
|
84
|
+
return True
|
|
85
|
+
if addr.version == 4 and addr in ipaddress.ip_network("100.64.0.0/10"):
|
|
86
|
+
return True
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _validated_target(url: str, *, resolve: bool = True) -> _ValidatedTarget:
|
|
91
|
+
"""Parse, validate every DNS answer, and retain one exact address to pin."""
|
|
92
|
+
from urllib.parse import urlsplit
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
parts = urlsplit(url)
|
|
96
|
+
scheme = parts.scheme.lower()
|
|
97
|
+
hostname = parts.hostname
|
|
98
|
+
explicit_port = parts.port
|
|
99
|
+
except Exception as exc: # noqa: BLE001
|
|
100
|
+
raise EgressBlockedError(f"unparseable url: {url!r}") from exc
|
|
101
|
+
if scheme not in ("http", "https"):
|
|
102
|
+
raise EgressBlockedError(f"scheme not allowed: {scheme!r} ({url!r})")
|
|
103
|
+
if not hostname or parts.username is not None or parts.password is not None:
|
|
104
|
+
raise EgressBlockedError(f"invalid or credentialed hostname in url: {url!r}")
|
|
105
|
+
|
|
106
|
+
host = hostname.lower().rstrip(".")
|
|
107
|
+
port = explicit_port if explicit_port is not None else (443 if scheme == "https" else 80)
|
|
108
|
+
if not 1 <= port <= 65535:
|
|
109
|
+
raise EgressBlockedError(f"bad port in url: {url!r}")
|
|
110
|
+
hostport = f"{host}:{port}"
|
|
111
|
+
trusted_hosts = _trusted()
|
|
112
|
+
trusted = host in trusted_hosts or hostport in trusted_hosts
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
literal = ipaddress.ip_address(host)
|
|
116
|
+
except ValueError:
|
|
117
|
+
literal = None
|
|
118
|
+
if literal is not None:
|
|
119
|
+
if _is_denied_addr(literal) and not trusted:
|
|
120
|
+
raise EgressBlockedError(f"egress denied to {hostport} (non-public address)")
|
|
121
|
+
return _ValidatedTarget(hostport, scheme, host, port, str(literal), parts.netloc)
|
|
122
|
+
|
|
123
|
+
if not resolve:
|
|
124
|
+
return _ValidatedTarget(hostport, scheme, host, port, "", parts.netloc)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
|
128
|
+
except Exception as exc: # noqa: BLE001
|
|
129
|
+
raise EgressBlockedError(f"DNS failure for {host} (fail-closed)") from exc
|
|
130
|
+
if not infos:
|
|
131
|
+
raise EgressBlockedError(f"DNS returned no addresses for {host} (fail-closed)")
|
|
132
|
+
|
|
133
|
+
addresses: list[str] = []
|
|
134
|
+
for info in infos:
|
|
135
|
+
try:
|
|
136
|
+
family, _socktype, _proto, _canonname, sockaddr = info
|
|
137
|
+
if family not in (socket.AF_INET, socket.AF_INET6) or not sockaddr:
|
|
138
|
+
raise ValueError("unsupported address family or empty sockaddr")
|
|
139
|
+
candidate = sockaddr[0]
|
|
140
|
+
if not isinstance(candidate, str) or "%" in candidate:
|
|
141
|
+
raise ValueError("malformed or scoped address")
|
|
142
|
+
addr = ipaddress.ip_address(candidate)
|
|
143
|
+
if (family == socket.AF_INET and addr.version != 4) or (
|
|
144
|
+
family == socket.AF_INET6 and addr.version != 6
|
|
145
|
+
):
|
|
146
|
+
raise ValueError("address family does not match answer")
|
|
147
|
+
except Exception as exc: # noqa: BLE001
|
|
148
|
+
raise EgressBlockedError(f"malformed DNS answer for {host}") from exc
|
|
149
|
+
if _is_denied_addr(addr) and not trusted:
|
|
150
|
+
raise EgressBlockedError(f"egress denied: {host} resolves to non-public {addr}")
|
|
151
|
+
addresses.append(str(addr))
|
|
152
|
+
|
|
153
|
+
return _ValidatedTarget(hostport, scheme, host, port, addresses[0], parts.netloc)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def guard_url(url: str, *, resolve: bool = True) -> str:
|
|
157
|
+
"""Проверить URL и вернуть нормализованный host[:port]."""
|
|
158
|
+
return _validated_target(url, resolve=resolve).hostport
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --- Per-host breaker (крос-процессный, SQLite) ------------------------------
|
|
162
|
+
|
|
163
|
+
_DEFAULT_DB = "netguard_breaker.sqlite3"
|
|
164
|
+
|
|
165
|
+
# Суффиксы хостов, к которым применяется breaker. Env:
|
|
166
|
+
# SUD_NETGUARD_BREAKER_SUFFIXES=".cian.ru,.avito.ru,sudrf.ru"
|
|
167
|
+
_SUFFIX_DEFAULT = (
|
|
168
|
+
".cian.ru", "cian.ru", ".avito.ru", "avito.ru",
|
|
169
|
+
"sudrf.ru", "sudact.ru", ".sudact.ru",
|
|
170
|
+
"rosreestr.gov.ru", ".rosreestr.gov.ru",
|
|
171
|
+
"pravo.gov.ru", ".pravo.gov.ru",
|
|
172
|
+
# v2.3.3 (review R7): efrsb_public дёргает breaker_trip для fedresurs, а хоста
|
|
173
|
+
# не было в списке — per-host cooldown молча не работал.
|
|
174
|
+
"bankrot.fedresurs.ru", ".bankrot.fedresurs.ru",
|
|
175
|
+
"fssp.gov.ru", ".fssp.gov.ru", "api.fssp.gov.ru",
|
|
176
|
+
"vsrf.ru", ".vsrf.ru",
|
|
177
|
+
"mos-gorsud.ru", ".mos-gorsud.ru",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _breaker_db() -> str:
|
|
182
|
+
return os.environ.get("SUD_NETGUARD_DB", _DEFAULT_DB)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _breaker_suffixes() -> tuple[str, ...]:
|
|
186
|
+
extra = os.environ.get("SUD_NETGUARD_BREAKER_SUFFIXES", "")
|
|
187
|
+
base = list(_SUFFIX_DEFAULT)
|
|
188
|
+
for part in extra.split(","):
|
|
189
|
+
part = part.strip().lower()
|
|
190
|
+
if part and part not in base:
|
|
191
|
+
base.append(part)
|
|
192
|
+
return tuple(base)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _host_key(hostport_or_host: str) -> str:
|
|
196
|
+
"""Выделить hostname из 'host', 'host:port' ИЛИ полного URL."""
|
|
197
|
+
value = (hostport_or_host or "").strip()
|
|
198
|
+
if "://" in value:
|
|
199
|
+
from urllib.parse import urlsplit
|
|
200
|
+
|
|
201
|
+
netloc = urlsplit(value).netloc
|
|
202
|
+
value = netloc or value
|
|
203
|
+
host = value.split("/")[0].split(":")[0].lower().rstrip(".")
|
|
204
|
+
return host
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _breaker_conn() -> sqlite3.Connection:
|
|
208
|
+
path = _breaker_db()
|
|
209
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
conn = sqlite3.connect(path, timeout=5.0)
|
|
211
|
+
conn.execute(
|
|
212
|
+
"CREATE TABLE IF NOT EXISTS breaker_state ("
|
|
213
|
+
" host TEXT PRIMARY KEY, open_until REAL NOT NULL, reason TEXT)"
|
|
214
|
+
)
|
|
215
|
+
conn.execute(
|
|
216
|
+
"CREATE TABLE IF NOT EXISTS breaker_soft ("
|
|
217
|
+
" host TEXT PRIMARY KEY, failures INTEGER NOT NULL, reason TEXT)"
|
|
218
|
+
)
|
|
219
|
+
return conn
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _applies_to(host: str) -> bool:
|
|
223
|
+
host = _host_key(host)
|
|
224
|
+
for suf in _breaker_suffixes():
|
|
225
|
+
bare = suf.lstrip(".")
|
|
226
|
+
if host == bare:
|
|
227
|
+
return True
|
|
228
|
+
if host.endswith("." + bare) or host.endswith("--" + bare):
|
|
229
|
+
return True
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def breaker_remaining(host: str) -> float:
|
|
234
|
+
"""Сколько секунд cooldown осталось у хоста (0 = можно ходить)."""
|
|
235
|
+
if not _applies_to(host):
|
|
236
|
+
return 0.0
|
|
237
|
+
try:
|
|
238
|
+
with _breaker_conn() as conn:
|
|
239
|
+
row = conn.execute(
|
|
240
|
+
"SELECT open_until, reason FROM breaker_state WHERE host = ?",
|
|
241
|
+
(_host_key(host),),
|
|
242
|
+
).fetchone()
|
|
243
|
+
except sqlite3.Error:
|
|
244
|
+
return 0.0 # state-файл недоступен: не блокируем работу (breaker — оптимизация)
|
|
245
|
+
if not row:
|
|
246
|
+
return 0.0
|
|
247
|
+
remaining = float(row[0]) - time.time()
|
|
248
|
+
return remaining if remaining > 0 else 0.0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def breaker_trip(host: str, *, seconds: float = 60.0, reason: str = "") -> None:
|
|
252
|
+
"""Включить cooldown для хоста (жёсткий блок: 403/429/капча)."""
|
|
253
|
+
if not _applies_to(host):
|
|
254
|
+
return
|
|
255
|
+
try:
|
|
256
|
+
with _breaker_conn() as conn:
|
|
257
|
+
conn.execute(
|
|
258
|
+
"INSERT INTO breaker_state (host, open_until, reason) VALUES (?, ?, ?) "
|
|
259
|
+
"ON CONFLICT(host) DO UPDATE SET open_until = excluded.open_until, "
|
|
260
|
+
"reason = excluded.reason",
|
|
261
|
+
(_host_key(host), time.time() + seconds, reason or "hard_block"),
|
|
262
|
+
)
|
|
263
|
+
except sqlite3.Error:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def breaker_soft_fail(host: str, *, threshold: int = 3,
|
|
268
|
+
cooldown: float = 60.0, reason: str = "") -> None:
|
|
269
|
+
"""Мягкий сигнал (challenge-маркер и т.п.): триггер после N повторов.
|
|
270
|
+
|
|
271
|
+
Важно: trip выполняется ПОСЛЕ закрытия соединения состояния, иначе
|
|
272
|
+
вложенное соединение к тому же файлу ловит database-is-locked.
|
|
273
|
+
"""
|
|
274
|
+
if not _applies_to(host):
|
|
275
|
+
return
|
|
276
|
+
key = _host_key(host)
|
|
277
|
+
n = 1
|
|
278
|
+
try:
|
|
279
|
+
with _breaker_conn() as conn:
|
|
280
|
+
row = conn.execute(
|
|
281
|
+
"SELECT failures FROM breaker_soft WHERE host = ?", (key,)
|
|
282
|
+
).fetchone()
|
|
283
|
+
n = (int(row[0]) + 1) if row else 1
|
|
284
|
+
if n >= threshold:
|
|
285
|
+
conn.execute("DELETE FROM breaker_soft WHERE host = ?", (key,))
|
|
286
|
+
else:
|
|
287
|
+
conn.execute(
|
|
288
|
+
"INSERT INTO breaker_soft (host, failures, reason) VALUES (?, ?, ?) "
|
|
289
|
+
"ON CONFLICT(host) DO UPDATE SET failures = excluded.failures",
|
|
290
|
+
(key, n, reason or "soft"),
|
|
291
|
+
)
|
|
292
|
+
except sqlite3.Error:
|
|
293
|
+
return
|
|
294
|
+
if n >= threshold:
|
|
295
|
+
breaker_trip(key, seconds=cooldown, reason=reason or f"soft_{n}")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def breaker_success(host: str) -> None:
|
|
299
|
+
key = _host_key(host)
|
|
300
|
+
try:
|
|
301
|
+
with _breaker_conn() as conn:
|
|
302
|
+
conn.execute("DELETE FROM breaker_soft WHERE host = ?", (key,))
|
|
303
|
+
except sqlite3.Error:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _check_target(target: _ValidatedTarget) -> None:
|
|
308
|
+
remaining = breaker_remaining(target.hostport)
|
|
309
|
+
if remaining > 0:
|
|
310
|
+
raise BreakerOpenError(
|
|
311
|
+
_host_key(target.hostport), remaining, reason=_last_reason(target.hostport)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def check_before_fetch(url: str) -> str:
|
|
316
|
+
"""Полная пред-проверка: egress-guard + breaker. Возвращает hostport."""
|
|
317
|
+
target = _validated_target(url)
|
|
318
|
+
_check_target(target)
|
|
319
|
+
return target.hostport
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _last_reason(host: str) -> str:
|
|
323
|
+
try:
|
|
324
|
+
with _breaker_conn() as conn:
|
|
325
|
+
row = conn.execute(
|
|
326
|
+
"SELECT reason FROM breaker_state WHERE host = ?", (_host_key(host),)
|
|
327
|
+
).fetchone()
|
|
328
|
+
return str(row[0]) if row else ""
|
|
329
|
+
except sqlite3.Error:
|
|
330
|
+
return ""
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# --- Транспортные обёртки ----------------------------------------------------
|
|
334
|
+
|
|
335
|
+
_TRIP_STATUSES = {403, 429}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class _PinnedHTTPConnection(http.client.HTTPConnection):
|
|
339
|
+
def __init__(self, host: str, *, pinned_address: str, **kwargs: Any):
|
|
340
|
+
self._pinned_address = pinned_address
|
|
341
|
+
super().__init__(host, **kwargs)
|
|
342
|
+
|
|
343
|
+
def connect(self) -> None:
|
|
344
|
+
if self._tunnel_host:
|
|
345
|
+
raise EgressBlockedError("HTTP proxy tunnels are unsupported")
|
|
346
|
+
self.sock = socket.create_connection(
|
|
347
|
+
(self._pinned_address, self.port), self.timeout, self.source_address
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class _PinnedHTTPSConnection(http.client.HTTPSConnection):
|
|
352
|
+
def __init__(self, host: str, *, pinned_address: str, tls_hostname: str,
|
|
353
|
+
**kwargs: Any):
|
|
354
|
+
self._pinned_address = pinned_address
|
|
355
|
+
self._tls_hostname = tls_hostname
|
|
356
|
+
super().__init__(host, **kwargs)
|
|
357
|
+
|
|
358
|
+
def connect(self) -> None:
|
|
359
|
+
if self._tunnel_host:
|
|
360
|
+
raise EgressBlockedError("HTTPS proxy tunnels are unsupported")
|
|
361
|
+
raw_sock = socket.create_connection(
|
|
362
|
+
(self._pinned_address, self.port), self.timeout, self.source_address
|
|
363
|
+
)
|
|
364
|
+
# HTTPSConnection's verified context is retained; only the TCP target is pinned.
|
|
365
|
+
self.sock = self._context.wrap_socket(
|
|
366
|
+
raw_sock, server_hostname=self._tls_hostname
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _assert_request_matches(req: Any, target: _ValidatedTarget) -> None:
|
|
371
|
+
from urllib.parse import urlsplit
|
|
372
|
+
|
|
373
|
+
if req.has_proxy():
|
|
374
|
+
raise EgressBlockedError("proxy requests are unsupported by the pinned transport")
|
|
375
|
+
try:
|
|
376
|
+
parts = urlsplit(req.full_url)
|
|
377
|
+
request_port = parts.port if parts.port is not None else (
|
|
378
|
+
443 if parts.scheme.lower() == "https" else 80
|
|
379
|
+
)
|
|
380
|
+
except Exception as exc: # noqa: BLE001
|
|
381
|
+
raise EgressBlockedError("malformed request reached pinned transport") from exc
|
|
382
|
+
request_host = (parts.hostname or "").lower().rstrip(".")
|
|
383
|
+
if (parts.scheme.lower() != target.scheme or request_host != target.hostname
|
|
384
|
+
or request_port != target.port):
|
|
385
|
+
raise EgressBlockedError("request target changed after DNS validation")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class _PinnedHTTPHandler(urllib.request.HTTPHandler):
|
|
389
|
+
def __init__(self, target: _ValidatedTarget):
|
|
390
|
+
super().__init__()
|
|
391
|
+
self._target = target
|
|
392
|
+
|
|
393
|
+
def http_open(self, req: Any):
|
|
394
|
+
_assert_request_matches(req, self._target)
|
|
395
|
+
factory = lambda host, **kwargs: _PinnedHTTPConnection(
|
|
396
|
+
host, pinned_address=self._target.address, **kwargs
|
|
397
|
+
)
|
|
398
|
+
return self.do_open(factory, req)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
|
|
402
|
+
def __init__(self, target: _ValidatedTarget):
|
|
403
|
+
self._target = target
|
|
404
|
+
context = ssl.create_default_context()
|
|
405
|
+
super().__init__(context=context)
|
|
406
|
+
|
|
407
|
+
def https_open(self, req: Any):
|
|
408
|
+
_assert_request_matches(req, self._target)
|
|
409
|
+
factory = lambda host, **kwargs: _PinnedHTTPSConnection(
|
|
410
|
+
host,
|
|
411
|
+
pinned_address=self._target.address,
|
|
412
|
+
tls_hostname=self._target.hostname,
|
|
413
|
+
**kwargs,
|
|
414
|
+
)
|
|
415
|
+
return self.do_open(factory, req, context=self._context)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class _RejectRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
419
|
+
def redirect_request(self, req: Any, fp: Any, code: int, msg: str,
|
|
420
|
+
headers: Any, newurl: str):
|
|
421
|
+
raise EgressBlockedError("redirects are disabled by the pinned transport")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _origin_headers(headers: Optional[dict], authority: str) -> dict:
|
|
425
|
+
result = {key: value for key, value in (headers or {}).items()
|
|
426
|
+
if key.lower() != "host"}
|
|
427
|
+
result["Host"] = authority
|
|
428
|
+
return result
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _pinned_open(target: _ValidatedTarget, url: str, timeout: float,
|
|
432
|
+
headers: Optional[dict]):
|
|
433
|
+
req = urllib.request.Request(url, headers=_origin_headers(headers, target.authority))
|
|
434
|
+
opener = urllib.request.build_opener(
|
|
435
|
+
urllib.request.ProxyHandler({}),
|
|
436
|
+
_PinnedHTTPHandler(target),
|
|
437
|
+
_PinnedHTTPSHandler(target),
|
|
438
|
+
_RejectRedirectHandler(),
|
|
439
|
+
)
|
|
440
|
+
return opener.open(req, timeout=timeout)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _requests_url_matches(url: str, target: _ValidatedTarget) -> None:
|
|
444
|
+
from urllib.parse import urlsplit
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
parts = urlsplit(url)
|
|
448
|
+
port = parts.port if parts.port is not None else (
|
|
449
|
+
443 if parts.scheme.lower() == "https" else 80
|
|
450
|
+
)
|
|
451
|
+
except Exception as exc: # noqa: BLE001
|
|
452
|
+
raise EgressBlockedError("malformed request reached pinned requests transport") from exc
|
|
453
|
+
if ((parts.scheme or "").lower() != target.scheme
|
|
454
|
+
or (parts.hostname or "").lower().rstrip(".") != target.hostname
|
|
455
|
+
or port != target.port):
|
|
456
|
+
raise EgressBlockedError("request target changed after DNS validation")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def guarded_get(url: str, *, timeout: float = 30.0, headers: Optional[dict] = None,
|
|
460
|
+
stream: bool = False, session: Any = None,
|
|
461
|
+
challenge_marker: str = "") -> Any:
|
|
462
|
+
"""GET with a requests-compatible response over a DNS-pinned connection.
|
|
463
|
+
|
|
464
|
+
The connection pool is keyed by the validated address, while the HTTP Host,
|
|
465
|
+
TLS SNI, and certificate hostname remain the original URL hostname. Redirects
|
|
466
|
+
are returned to the caller and never followed automatically.
|
|
467
|
+
"""
|
|
468
|
+
try:
|
|
469
|
+
import requests
|
|
470
|
+
from requests.adapters import HTTPAdapter
|
|
471
|
+
from requests.cookies import extract_cookies_to_jar
|
|
472
|
+
from requests.sessions import dispatch_hook
|
|
473
|
+
except ImportError as exc: # noqa: BLE001
|
|
474
|
+
raise EgressBlockedError("requests is required for guarded_get") from exc
|
|
475
|
+
|
|
476
|
+
target = _validated_target(url)
|
|
477
|
+
_check_target(target)
|
|
478
|
+
|
|
479
|
+
owns_session = session is None
|
|
480
|
+
session_close_attempted = False
|
|
481
|
+
created_adapters = []
|
|
482
|
+
if owns_session:
|
|
483
|
+
session = requests.Session.__new__(requests.Session)
|
|
484
|
+
original_mount = session.mount
|
|
485
|
+
|
|
486
|
+
def track_mount(prefix, adapter):
|
|
487
|
+
if all(existing is not adapter for existing in created_adapters):
|
|
488
|
+
created_adapters.append(adapter)
|
|
489
|
+
return original_mount(prefix, adapter)
|
|
490
|
+
|
|
491
|
+
session.mount = track_mount
|
|
492
|
+
try:
|
|
493
|
+
requests.Session.__init__(session)
|
|
494
|
+
except BaseException:
|
|
495
|
+
for adapter in created_adapters:
|
|
496
|
+
try:
|
|
497
|
+
adapter.close()
|
|
498
|
+
except BaseException:
|
|
499
|
+
pass
|
|
500
|
+
raise
|
|
501
|
+
finally:
|
|
502
|
+
session.__dict__.pop("mount", None)
|
|
503
|
+
elif type(session) is not requests.Session:
|
|
504
|
+
raise EgressBlockedError("custom requests sessions are unsupported by the pinned transport")
|
|
505
|
+
|
|
506
|
+
def close_owned_session():
|
|
507
|
+
nonlocal session_close_attempted
|
|
508
|
+
if not owns_session or session_close_attempted:
|
|
509
|
+
return
|
|
510
|
+
session_close_attempted = True
|
|
511
|
+
|
|
512
|
+
attempted_adapters = []
|
|
513
|
+
wrapped_adapters = []
|
|
514
|
+
for owned_adapter in created_adapters:
|
|
515
|
+
original_closer = owned_adapter.close
|
|
516
|
+
had_instance_closer = "close" in owned_adapter.__dict__
|
|
517
|
+
previous_instance_closer = owned_adapter.__dict__.get("close")
|
|
518
|
+
|
|
519
|
+
def track_close(adapter=owned_adapter, closer=original_closer):
|
|
520
|
+
if any(attempted is adapter for attempted in attempted_adapters):
|
|
521
|
+
return None
|
|
522
|
+
attempted_adapters.append(adapter)
|
|
523
|
+
return closer()
|
|
524
|
+
|
|
525
|
+
owned_adapter.close = track_close
|
|
526
|
+
wrapped_adapters.append((
|
|
527
|
+
owned_adapter, original_closer,
|
|
528
|
+
had_instance_closer, previous_instance_closer,
|
|
529
|
+
))
|
|
530
|
+
|
|
531
|
+
session_error = None
|
|
532
|
+
cleanup_error = None
|
|
533
|
+
try:
|
|
534
|
+
session.close()
|
|
535
|
+
except BaseException as exc:
|
|
536
|
+
session_error = exc
|
|
537
|
+
finally:
|
|
538
|
+
for owned_adapter, _, had_instance_closer, previous_instance_closer in wrapped_adapters:
|
|
539
|
+
try:
|
|
540
|
+
if had_instance_closer:
|
|
541
|
+
owned_adapter.close = previous_instance_closer
|
|
542
|
+
else:
|
|
543
|
+
del owned_adapter.close
|
|
544
|
+
except BaseException as exc:
|
|
545
|
+
if cleanup_error is None:
|
|
546
|
+
cleanup_error = exc
|
|
547
|
+
|
|
548
|
+
for owned_adapter, original_closer, _, _ in wrapped_adapters:
|
|
549
|
+
if any(attempted is owned_adapter for attempted in attempted_adapters):
|
|
550
|
+
continue
|
|
551
|
+
try:
|
|
552
|
+
original_closer()
|
|
553
|
+
except BaseException as exc:
|
|
554
|
+
if cleanup_error is None:
|
|
555
|
+
cleanup_error = exc
|
|
556
|
+
|
|
557
|
+
if session_error is not None:
|
|
558
|
+
raise session_error
|
|
559
|
+
if cleanup_error is not None:
|
|
560
|
+
raise cleanup_error
|
|
561
|
+
|
|
562
|
+
# We intentionally do not use any session adapter: only its standard request
|
|
563
|
+
# preparation is reused, so cookies/auth/headers survive without granting a
|
|
564
|
+
# custom adapter control of the socket destination.
|
|
565
|
+
try:
|
|
566
|
+
if any(type(adapter) is not HTTPAdapter for adapter in session.adapters.values()):
|
|
567
|
+
raise EgressBlockedError("custom requests adapters are unsupported by the pinned transport")
|
|
568
|
+
|
|
569
|
+
request = requests.Request("GET", url, headers=headers)
|
|
570
|
+
prepared = session.prepare_request(request)
|
|
571
|
+
_requests_url_matches(prepared.url, target)
|
|
572
|
+
prepared.headers["Host"] = target.authority
|
|
573
|
+
|
|
574
|
+
settings = session.merge_environment_settings(
|
|
575
|
+
prepared.url, {}, stream=stream, verify=session.verify, cert=session.cert
|
|
576
|
+
)
|
|
577
|
+
if settings.get("proxies"):
|
|
578
|
+
raise EgressBlockedError("proxy requests are unsupported by the pinned transport")
|
|
579
|
+
except BaseException:
|
|
580
|
+
if owns_session:
|
|
581
|
+
try:
|
|
582
|
+
close_owned_session()
|
|
583
|
+
except BaseException:
|
|
584
|
+
pass
|
|
585
|
+
raise
|
|
586
|
+
|
|
587
|
+
class _PinnedRequestsAdapter(HTTPAdapter):
|
|
588
|
+
def __init__(self):
|
|
589
|
+
try:
|
|
590
|
+
super().__init__()
|
|
591
|
+
except BaseException:
|
|
592
|
+
try:
|
|
593
|
+
self.close()
|
|
594
|
+
except BaseException:
|
|
595
|
+
pass
|
|
596
|
+
raise
|
|
597
|
+
|
|
598
|
+
def _pool_for(self, request_or_url: Any, verify: Any, cert: Any):
|
|
599
|
+
request_url = getattr(request_or_url, "url", request_or_url)
|
|
600
|
+
_requests_url_matches(request_url, target)
|
|
601
|
+
if not hasattr(self, "build_connection_pool_key_attributes"):
|
|
602
|
+
pool_kwargs = {}
|
|
603
|
+
if target.scheme == "https":
|
|
604
|
+
pool_kwargs.update(
|
|
605
|
+
assert_hostname=target.hostname,
|
|
606
|
+
server_hostname=target.hostname,
|
|
607
|
+
)
|
|
608
|
+
return self.poolmanager.connection_from_host(
|
|
609
|
+
host=target.address,
|
|
610
|
+
port=target.port,
|
|
611
|
+
scheme=target.scheme,
|
|
612
|
+
pool_kwargs=pool_kwargs,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
request_for_key = request_or_url
|
|
616
|
+
if not hasattr(request_for_key, "url"):
|
|
617
|
+
request_for_key = type("PreparedURL", (), {"url": request_url})()
|
|
618
|
+
host_params, pool_kwargs = self.build_connection_pool_key_attributes(
|
|
619
|
+
request_for_key, verify, cert
|
|
620
|
+
)
|
|
621
|
+
host_params = dict(host_params)
|
|
622
|
+
pool_kwargs = dict(pool_kwargs)
|
|
623
|
+
host_params["host"] = target.address
|
|
624
|
+
if target.scheme == "https":
|
|
625
|
+
# urllib3 connects to the pool host (the IP), but uses these
|
|
626
|
+
# explicit names for SNI and certificate hostname verification.
|
|
627
|
+
pool_kwargs["assert_hostname"] = target.hostname
|
|
628
|
+
pool_kwargs["server_hostname"] = target.hostname
|
|
629
|
+
return self.poolmanager.connection_from_host(
|
|
630
|
+
**host_params, pool_kwargs=pool_kwargs
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None):
|
|
634
|
+
if proxies:
|
|
635
|
+
raise EgressBlockedError("proxy requests are unsupported by the pinned transport")
|
|
636
|
+
return self._pool_for(request, verify, cert)
|
|
637
|
+
|
|
638
|
+
def get_connection(self, url, proxies=None):
|
|
639
|
+
if proxies:
|
|
640
|
+
raise EgressBlockedError("proxy requests are unsupported by the pinned transport")
|
|
641
|
+
return self._pool_for(url, self.config.get("verify", True), None)
|
|
642
|
+
|
|
643
|
+
adapter = None
|
|
644
|
+
response = None
|
|
645
|
+
response_close_installed = False
|
|
646
|
+
owned_responses = []
|
|
647
|
+
owned_raws = []
|
|
648
|
+
cleaned_raws = []
|
|
649
|
+
|
|
650
|
+
def remember_raw(raw):
|
|
651
|
+
if raw is not None and all(existing is not raw for existing in owned_raws):
|
|
652
|
+
owned_raws.append(raw)
|
|
653
|
+
|
|
654
|
+
def remember_response_raw(candidate):
|
|
655
|
+
try:
|
|
656
|
+
raw = getattr(candidate, "raw", None)
|
|
657
|
+
except BaseException:
|
|
658
|
+
return
|
|
659
|
+
remember_raw(raw)
|
|
660
|
+
|
|
661
|
+
def cleanup_raw_resource(raw):
|
|
662
|
+
"""Best-effort raw cleanup, returning its first introspection/close error."""
|
|
663
|
+
if raw is None:
|
|
664
|
+
return None
|
|
665
|
+
if any(cleaned is raw for cleaned in cleaned_raws):
|
|
666
|
+
return None
|
|
667
|
+
cleaned_raws.append(raw)
|
|
668
|
+
try:
|
|
669
|
+
raw_closer = getattr(raw, "close", None)
|
|
670
|
+
except BaseException as exc:
|
|
671
|
+
return exc
|
|
672
|
+
if not callable(raw_closer):
|
|
673
|
+
return None
|
|
674
|
+
first_error = None
|
|
675
|
+
try:
|
|
676
|
+
raw_closed = bool(getattr(raw, "closed", False))
|
|
677
|
+
except BaseException as exc:
|
|
678
|
+
first_error = exc
|
|
679
|
+
raw_closed = False
|
|
680
|
+
if not raw_closed:
|
|
681
|
+
try:
|
|
682
|
+
raw_closer()
|
|
683
|
+
except BaseException as exc:
|
|
684
|
+
if first_error is None:
|
|
685
|
+
first_error = exc
|
|
686
|
+
return first_error
|
|
687
|
+
|
|
688
|
+
def cleanup_raw_response(owned_response):
|
|
689
|
+
try:
|
|
690
|
+
raw = getattr(owned_response, "raw", None)
|
|
691
|
+
except BaseException as exc:
|
|
692
|
+
return exc
|
|
693
|
+
return cleanup_raw_resource(raw)
|
|
694
|
+
|
|
695
|
+
def remember_response(candidate):
|
|
696
|
+
if candidate is not None and all(
|
|
697
|
+
existing is not candidate for existing in owned_responses
|
|
698
|
+
):
|
|
699
|
+
owned_responses.append(candidate)
|
|
700
|
+
|
|
701
|
+
def track_response_hook(hook):
|
|
702
|
+
def tracked_hook(hook_response, **kwargs):
|
|
703
|
+
remember_response(hook_response)
|
|
704
|
+
remember_response_raw(hook_response)
|
|
705
|
+
try:
|
|
706
|
+
result = hook(hook_response, **kwargs)
|
|
707
|
+
finally:
|
|
708
|
+
remember_response_raw(hook_response)
|
|
709
|
+
if result is not None:
|
|
710
|
+
remember_response(result)
|
|
711
|
+
remember_response_raw(result)
|
|
712
|
+
return result
|
|
713
|
+
|
|
714
|
+
return tracked_hook
|
|
715
|
+
|
|
716
|
+
try:
|
|
717
|
+
adapter = _PinnedRequestsAdapter()
|
|
718
|
+
response = adapter.send(
|
|
719
|
+
prepared,
|
|
720
|
+
stream=stream,
|
|
721
|
+
timeout=timeout,
|
|
722
|
+
verify=settings["verify"],
|
|
723
|
+
cert=settings["cert"],
|
|
724
|
+
proxies={},
|
|
725
|
+
)
|
|
726
|
+
remember_response(response)
|
|
727
|
+
remember_response_raw(response)
|
|
728
|
+
extract_cookies_to_jar(session.cookies, prepared, response.raw)
|
|
729
|
+
|
|
730
|
+
# Apply host cooldowns to the actual transport response before user hooks can
|
|
731
|
+
# replace it or raise. Inspecting a requested challenge marker may buffer a
|
|
732
|
+
# streaming body, but requests keeps that content available to the caller.
|
|
733
|
+
if response.status_code in _TRIP_STATUSES:
|
|
734
|
+
breaker_trip(url, reason=f"http_{response.status_code}")
|
|
735
|
+
elif challenge_marker and challenge_marker.casefold() in response.text.casefold():
|
|
736
|
+
breaker_soft_fail(target.hostport, reason="challenge_marker")
|
|
737
|
+
else:
|
|
738
|
+
breaker_success(target.hostport)
|
|
739
|
+
|
|
740
|
+
hooks = prepared.hooks
|
|
741
|
+
response_hooks = hooks.get("response") if hooks else None
|
|
742
|
+
if response_hooks:
|
|
743
|
+
if hasattr(response_hooks, "__call__"):
|
|
744
|
+
response_hooks = [response_hooks]
|
|
745
|
+
hooks = dict(hooks)
|
|
746
|
+
hooks["response"] = [
|
|
747
|
+
track_response_hook(hook) for hook in response_hooks
|
|
748
|
+
]
|
|
749
|
+
response = dispatch_hook(
|
|
750
|
+
"response", hooks, response,
|
|
751
|
+
stream=stream, timeout=timeout, verify=settings["verify"],
|
|
752
|
+
cert=settings["cert"], proxies={},
|
|
753
|
+
)
|
|
754
|
+
remember_response(response)
|
|
755
|
+
remember_response_raw(response)
|
|
756
|
+
if not stream:
|
|
757
|
+
response.content
|
|
758
|
+
response.history = []
|
|
759
|
+
response._netguard_adapter = adapter
|
|
760
|
+
|
|
761
|
+
# Retain the original close methods before wrapping the final response.
|
|
762
|
+
# Every response stays usable until the caller closes this final response.
|
|
763
|
+
close_order = [response] + [
|
|
764
|
+
owned for owned in owned_responses if owned is not response
|
|
765
|
+
]
|
|
766
|
+
response_closers = [owned.close for owned in close_order]
|
|
767
|
+
response_closed = False
|
|
768
|
+
response_close_lock = threading.Lock()
|
|
769
|
+
response_close_complete = threading.Event()
|
|
770
|
+
response_close_error = None
|
|
771
|
+
|
|
772
|
+
def close_response():
|
|
773
|
+
nonlocal response_closed, response_close_error
|
|
774
|
+
is_cleanup_owner = False
|
|
775
|
+
wait_for_cleanup = False
|
|
776
|
+
with response_close_lock:
|
|
777
|
+
if response_closed:
|
|
778
|
+
wait_for_cleanup = not response_close_complete.is_set()
|
|
779
|
+
else:
|
|
780
|
+
response_closed = True
|
|
781
|
+
is_cleanup_owner = True
|
|
782
|
+
if not is_cleanup_owner:
|
|
783
|
+
# In-flight waiters observe the first error; later closes are idempotent.
|
|
784
|
+
if wait_for_cleanup:
|
|
785
|
+
response_close_complete.wait()
|
|
786
|
+
with response_close_lock:
|
|
787
|
+
completed_error = response_close_error
|
|
788
|
+
if completed_error is not None:
|
|
789
|
+
raise completed_error
|
|
790
|
+
return
|
|
791
|
+
|
|
792
|
+
first_error = None
|
|
793
|
+
try:
|
|
794
|
+
for owned_response, response_closer in zip(close_order, response_closers):
|
|
795
|
+
is_base_close = False
|
|
796
|
+
try:
|
|
797
|
+
is_base_close = (
|
|
798
|
+
getattr(response_closer, "__func__", None)
|
|
799
|
+
is requests.Response.close
|
|
800
|
+
)
|
|
801
|
+
except BaseException:
|
|
802
|
+
pass
|
|
803
|
+
skip_base_close = False
|
|
804
|
+
if is_base_close:
|
|
805
|
+
try:
|
|
806
|
+
response_raw = getattr(owned_response, "raw", None)
|
|
807
|
+
except BaseException:
|
|
808
|
+
response_raw = None
|
|
809
|
+
skip_base_close = response_raw is not None and any(
|
|
810
|
+
cleaned is response_raw for cleaned in cleaned_raws
|
|
811
|
+
)
|
|
812
|
+
if not skip_base_close:
|
|
813
|
+
try:
|
|
814
|
+
response_closer()
|
|
815
|
+
except BaseException as exc:
|
|
816
|
+
if first_error is None:
|
|
817
|
+
first_error = exc
|
|
818
|
+
raw_error = cleanup_raw_response(owned_response)
|
|
819
|
+
if first_error is None and raw_error is not None:
|
|
820
|
+
first_error = raw_error
|
|
821
|
+
for raw in owned_raws:
|
|
822
|
+
raw_error = cleanup_raw_resource(raw)
|
|
823
|
+
if first_error is None and raw_error is not None:
|
|
824
|
+
first_error = raw_error
|
|
825
|
+
try:
|
|
826
|
+
adapter.close()
|
|
827
|
+
except BaseException as exc:
|
|
828
|
+
if first_error is None:
|
|
829
|
+
first_error = exc
|
|
830
|
+
except BaseException as exc:
|
|
831
|
+
if first_error is None:
|
|
832
|
+
first_error = exc
|
|
833
|
+
finally:
|
|
834
|
+
with response_close_lock:
|
|
835
|
+
response_close_error = first_error
|
|
836
|
+
response_close_complete.set()
|
|
837
|
+
if first_error is not None:
|
|
838
|
+
raise first_error
|
|
839
|
+
|
|
840
|
+
response.close = close_response
|
|
841
|
+
response_close_installed = True
|
|
842
|
+
if owns_session:
|
|
843
|
+
close_owned_session()
|
|
844
|
+
return response
|
|
845
|
+
except BaseException:
|
|
846
|
+
if response_close_installed:
|
|
847
|
+
try:
|
|
848
|
+
response.close()
|
|
849
|
+
except BaseException:
|
|
850
|
+
pass
|
|
851
|
+
else:
|
|
852
|
+
for owned_response in owned_responses:
|
|
853
|
+
try:
|
|
854
|
+
owned_response.close()
|
|
855
|
+
except BaseException:
|
|
856
|
+
pass
|
|
857
|
+
cleanup_raw_response(owned_response)
|
|
858
|
+
for raw in owned_raws:
|
|
859
|
+
cleanup_raw_resource(raw)
|
|
860
|
+
if adapter is not None:
|
|
861
|
+
try:
|
|
862
|
+
adapter.close()
|
|
863
|
+
except BaseException:
|
|
864
|
+
pass
|
|
865
|
+
if owns_session and not session_close_attempted:
|
|
866
|
+
try:
|
|
867
|
+
close_owned_session()
|
|
868
|
+
except BaseException:
|
|
869
|
+
pass
|
|
870
|
+
raise
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def guarded_urlopen(url: str, *, timeout: float = 30.0,
|
|
874
|
+
headers: Optional[dict] = None) -> bytes:
|
|
875
|
+
"""Pinned urllib fetch returning body bytes; redirects fail closed."""
|
|
876
|
+
target = _validated_target(url)
|
|
877
|
+
_check_target(target)
|
|
878
|
+
try:
|
|
879
|
+
with _pinned_open(target, url, timeout, headers) as resp:
|
|
880
|
+
body = resp.read()
|
|
881
|
+
except urllib.error.HTTPError as exc:
|
|
882
|
+
if exc.code in _TRIP_STATUSES:
|
|
883
|
+
breaker_trip(url, reason=f"http_{exc.code}")
|
|
884
|
+
raise
|
|
885
|
+
breaker_success(target.hostport)
|
|
886
|
+
return body
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def guarded_open(url: str, *, timeout: float = 30.0, headers: Optional[dict] = None):
|
|
890
|
+
"""Pinned urllib fetch returning a streaming response; redirects fail closed."""
|
|
891
|
+
target = _validated_target(url)
|
|
892
|
+
_check_target(target)
|
|
893
|
+
try:
|
|
894
|
+
return _pinned_open(target, url, timeout, headers)
|
|
895
|
+
except urllib.error.HTTPError as exc:
|
|
896
|
+
if exc.code in _TRIP_STATUSES:
|
|
897
|
+
breaker_trip(url, reason=f"http_{exc.code}")
|
|
898
|
+
raise
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
# --- Spotlighting -------------------------------------------------------------
|
|
902
|
+
|
|
903
|
+
_MARKER_OPEN = "<external_untrusted_data source='{source}' type='{data_type}'>"
|
|
904
|
+
_MARKER_CLOSE = "</external_untrusted_data>"
|
|
905
|
+
_ESCAPE_RE = re.compile(r"</\s*external_untrusted_data\s*>", re.IGNORECASE)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def spotlight(raw: str, *, source: str = "web", data_type: str = "text") -> str:
|
|
909
|
+
"""Обернуть недоверенный контент в маркированные теги.
|
|
910
|
+
|
|
911
|
+
Все попытки преждевременно закрыть тег экранируются, чтобы контент
|
|
912
|
+
не мог «выйти» из песочницы разметки и притвориться инструкцией.
|
|
913
|
+
"""
|
|
914
|
+
sanitized = _ESCAPE_RE.sub("</external_untrusted_data>", raw or "")
|
|
915
|
+
return (
|
|
916
|
+
_MARKER_OPEN.format(source=source, data_type=data_type)
|
|
917
|
+
+ sanitized
|
|
918
|
+
+ _MARKER_CLOSE
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
_MARKED_PREFIX_RE = re.compile(
|
|
923
|
+
r"^<external_untrusted_data source='[^']*' type='[^']*'>"
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def is_spotlighted(text: str) -> bool:
|
|
928
|
+
"""True, если текст уже обёрнут в spotlight-маркер."""
|
|
929
|
+
return bool(text) and bool(_MARKED_PREFIX_RE.match(text.lstrip()))
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def strip_spotlight(text: str) -> str:
|
|
933
|
+
"""Снять spotlight-маркеры (для human-facing рендера). Безопасно на чистом тексте."""
|
|
934
|
+
if not text:
|
|
935
|
+
return text
|
|
936
|
+
body = text
|
|
937
|
+
lead = ""
|
|
938
|
+
while body and body[0].isspace():
|
|
939
|
+
lead += body[0]
|
|
940
|
+
body = body[1:]
|
|
941
|
+
body = _MARKED_PREFIX_RE.sub("", body, count=1)
|
|
942
|
+
body = body.replace(_MARKER_CLOSE, "")
|
|
943
|
+
return lead + body
|