rabbitkit 0.15.0__tar.gz → 0.17.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.
- {rabbitkit-0.15.0/src/rabbitkit.egg-info → rabbitkit-0.17.0}/PKG-INFO +4 -4
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/pyproject.toml +13 -4
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/_version.py +1 -1
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/async_/broker.py +2 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/topology.py +21 -2
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/concurrency.py +19 -3
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/config.py +9 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/logging.py +120 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/pipeline.py +34 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/registry.py +8 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/route.py +36 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/router.py +2 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/sanitizer.py +24 -2
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/compression.py +40 -18
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/deduplication.py +21 -5
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/signing.py +114 -21
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/sync/broker.py +2 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/testing/broker.py +2 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0/src/rabbitkit.egg-info}/PKG-INFO +4 -4
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit.egg-info/requires.txt +3 -3
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/LICENSE +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/README.md +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/setup.cfg +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/aio/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/async_/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/async_/batch.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/async_/connection.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/async_/pool.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/async_/transport.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/asyncapi/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/asyncapi/generator.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/asyncapi/schema.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/_utils.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/dlq.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/health.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/migrate.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/routes.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/run.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/cli/commands/shell.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/app.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/bulk.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/bulk_runner.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/env_config.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/errors.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/message.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/path.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/profiles.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/protocols.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/retry_handoff.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/settlement.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/topology.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/topology_dispatch.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/core/types.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/dashboard/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/dashboard/app.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/di/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/di/context.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/di/depends.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/di/resolver.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/dlq.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/experimental/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/fastapi.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/health.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/highload/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/highload/async_acker.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/highload/backpressure.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/highload/batch.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/locking.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/management.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/base.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/circuit_breaker.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/error_classifier.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/exception.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/metrics.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/otel.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/rate_limit.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/retry.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/middleware/timeout.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/py.typed +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/queue_metrics.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/results/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/results/backend.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/results/middleware.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/rpc.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/serialization/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/serialization/base.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/serialization/json.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/serialization/msgspec.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/serialization/pipeline.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/streams.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/sync/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/sync/batch.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/sync/connection.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/sync/pool.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/sync/transport.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/testing/__init__.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/testing/app.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit/testing/fixtures.py +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit.egg-info/SOURCES.txt +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit.egg-info/dependency_links.txt +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit.egg-info/entry_points.txt +0 -0
- {rabbitkit-0.15.0 → rabbitkit-0.17.0}/src/rabbitkit.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rabbitkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.0
|
|
4
4
|
Summary: Production-grade RabbitMQ toolkit — sync/async, decorator routing, retry, compression, full configurability
|
|
5
5
|
Author-email: Talaat Magdy <talaatmagdy75@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -33,7 +33,7 @@ Requires-Dist: rabbitkit[async,sync]; extra == "all-brokers"
|
|
|
33
33
|
Provides-Extra: redis
|
|
34
34
|
Requires-Dist: redis<9.0.0,>=5.0.0; extra == "redis"
|
|
35
35
|
Provides-Extra: pydantic
|
|
36
|
-
Requires-Dist: pydantic<3.0.0,>=2.
|
|
36
|
+
Requires-Dist: pydantic<3.0.0,>=2.4.0; extra == "pydantic"
|
|
37
37
|
Provides-Extra: msgspec
|
|
38
38
|
Requires-Dist: msgspec<1.0.0,>=0.18.0; extra == "msgspec"
|
|
39
39
|
Provides-Extra: fastapi
|
|
@@ -41,7 +41,7 @@ Requires-Dist: fastapi<1.0.0,>=0.111.0; extra == "fastapi"
|
|
|
41
41
|
Provides-Extra: compression
|
|
42
42
|
Requires-Dist: zstandard<1.0.0,>=0.22.0; extra == "compression"
|
|
43
43
|
Provides-Extra: management
|
|
44
|
-
Requires-Dist: aiohttp<4.0.0,>=3.
|
|
44
|
+
Requires-Dist: aiohttp<4.0.0,>=3.14.3; extra == "management"
|
|
45
45
|
Provides-Extra: otel
|
|
46
46
|
Requires-Dist: opentelemetry-api<2,>=1.20; extra == "otel"
|
|
47
47
|
Provides-Extra: settings
|
|
@@ -49,7 +49,7 @@ Requires-Dist: pydantic-settings<3.0.0,>=2.0.0; extra == "settings"
|
|
|
49
49
|
Provides-Extra: cli
|
|
50
50
|
Requires-Dist: typer<1.0.0,>=0.12.0; extra == "cli"
|
|
51
51
|
Provides-Extra: dashboard
|
|
52
|
-
Requires-Dist: starlette<2.0.0,>=
|
|
52
|
+
Requires-Dist: starlette<2.0.0,>=1.4.0; extra == "dashboard"
|
|
53
53
|
Requires-Dist: uvicorn<1.0.0,>=0.29.0; extra == "dashboard"
|
|
54
54
|
Provides-Extra: reload
|
|
55
55
|
Requires-Dist: watchfiles<2.0.0,>=0.21.0; extra == "reload"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "rabbitkit"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.17.0"
|
|
4
4
|
description = "Production-grade RabbitMQ toolkit — sync/async, decorator routing, retry, compression, full configurability"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -34,15 +34,24 @@ sync = ["pika>=1.3.0,<2.0.0"]
|
|
|
34
34
|
async = ["aio-pika>=9.1.0,<10.0.0"]
|
|
35
35
|
all-brokers = ["rabbitkit[sync,async]"]
|
|
36
36
|
redis = ["redis>=5.0.0,<9.0.0"]
|
|
37
|
-
|
|
37
|
+
# Floor is the first release with no open advisory (verified against OSV):
|
|
38
|
+
# 2.0.0-2.3.x carry GHSA-mr82-8j83-vxmv / PYSEC-2026-1812.
|
|
39
|
+
pydantic = ["pydantic>=2.4.0,<3.0.0"]
|
|
38
40
|
msgspec = ["msgspec>=0.18.0,<1.0.0"]
|
|
39
41
|
fastapi = ["fastapi>=0.111.0,<1.0.0"]
|
|
40
42
|
compression = ["zstandard>=0.22.0,<1.0.0"]
|
|
41
|
-
|
|
43
|
+
# 3.14.3 is the first aiohttp with a clean advisory set (verified against
|
|
44
|
+
# OSV: 3.14.0 -> 22, 3.14.1 -> 6, 3.14.2 -> 2, 3.14.3 -> 0). The old
|
|
45
|
+
# >=3.9.0 floor permitted a resolution with 76 known advisories, and CI
|
|
46
|
+
# could never see it because it always resolves the newest in range.
|
|
47
|
+
management = ["aiohttp>=3.14.3,<4.0.0"]
|
|
42
48
|
otel = ["opentelemetry-api>=1.20,<2"]
|
|
43
49
|
settings = ["pydantic-settings>=2.0.0,<3.0.0"]
|
|
44
50
|
cli = ["typer>=0.12.0,<1.0.0"]
|
|
45
|
-
|
|
51
|
+
# starlette 1.4.0 is the first clean release (1.0 -> 10 advisories,
|
|
52
|
+
# 1.3 -> 2, 1.4 -> 0). The upper bound already allowed 1.x and CI has
|
|
53
|
+
# been testing 1.6, so this only removes resolutions nothing tested.
|
|
54
|
+
dashboard = ["starlette>=1.4.0,<2.0.0", "uvicorn>=0.29.0,<1.0.0"]
|
|
46
55
|
reload = ["watchfiles>=0.21.0,<2.0.0"]
|
|
47
56
|
all = ["rabbitkit[all-brokers,otel,redis,pydantic,msgspec,fastapi,compression,settings,cli,dashboard,reload,management]"]
|
|
48
57
|
dev = [
|
|
@@ -285,6 +285,7 @@ class AsyncBroker:
|
|
|
285
285
|
prefetch_count: int | None = None,
|
|
286
286
|
filter_fn: Callable[[RabbitMessage], bool] | None = None,
|
|
287
287
|
reject_without_dlx: str | None = None,
|
|
288
|
+
reply_to_allow: tuple[str, ...] | None = None,
|
|
288
289
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
289
290
|
"""Register a subscriber handler."""
|
|
290
291
|
return self._registry.subscriber(
|
|
@@ -301,6 +302,7 @@ class AsyncBroker:
|
|
|
301
302
|
prefetch_count=prefetch_count,
|
|
302
303
|
filter_fn=filter_fn,
|
|
303
304
|
reject_without_dlx=reject_without_dlx,
|
|
305
|
+
reply_to_allow=reply_to_allow,
|
|
304
306
|
)
|
|
305
307
|
|
|
306
308
|
def publisher(
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import urllib.parse
|
|
6
7
|
from typing import Any, cast
|
|
7
8
|
|
|
8
9
|
import typer
|
|
@@ -15,6 +16,24 @@ topology_app = typer.Typer(help="Topology inspection and management commands.")
|
|
|
15
16
|
topology_app.command("migrate")(migrate_command)
|
|
16
17
|
|
|
17
18
|
|
|
19
|
+
def _safe_url(management_url: str) -> str:
|
|
20
|
+
"""The URL with any embedded credentials removed, for display.
|
|
21
|
+
|
|
22
|
+
``--url`` accepts ``http://user:pass@host:15672`` (the form ``migrate``
|
|
23
|
+
documents), and these commands echo the URL back on failure. Printing it
|
|
24
|
+
verbatim puts the password into terminal scrollback and, in CI, into the
|
|
25
|
+
build log.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
parsed = urllib.parse.urlparse(management_url)
|
|
29
|
+
if not parsed.hostname:
|
|
30
|
+
return "<redacted url>"
|
|
31
|
+
netloc = parsed.hostname if parsed.port is None else f"{parsed.hostname}:{parsed.port}"
|
|
32
|
+
return f"{parsed.scheme}://{netloc}{parsed.path.rstrip('/')}"
|
|
33
|
+
except Exception:
|
|
34
|
+
return "<redacted url>"
|
|
35
|
+
|
|
36
|
+
|
|
18
37
|
@topology_app.command("list")
|
|
19
38
|
def topology_list(
|
|
20
39
|
app_path: str = typer.Argument(..., help="Broker path, e.g. 'myapp.main:broker'"),
|
|
@@ -143,7 +162,7 @@ def topology_validate(
|
|
|
143
162
|
try:
|
|
144
163
|
live = _live_resources(management_url, vhost)
|
|
145
164
|
except Exception as exc:
|
|
146
|
-
typer.echo(f"ERROR: could not reach management API at {management_url}: {exc}", err=True)
|
|
165
|
+
typer.echo(f"ERROR: could not reach management API at {_safe_url(management_url)}: {exc}", err=True)
|
|
147
166
|
raise typer.Exit(1) from None
|
|
148
167
|
|
|
149
168
|
issues: list[str] = []
|
|
@@ -203,7 +222,7 @@ def topology_diff(
|
|
|
203
222
|
try:
|
|
204
223
|
live = _live_resources(management_url, vhost)
|
|
205
224
|
except Exception as exc:
|
|
206
|
-
typer.echo(f"ERROR: could not reach management API at {management_url}: {exc}", err=True)
|
|
225
|
+
typer.echo(f"ERROR: could not reach management API at {_safe_url(management_url)}: {exc}", err=True)
|
|
207
226
|
raise typer.Exit(1) from None
|
|
208
227
|
|
|
209
228
|
diff: dict[str, Any] = {
|
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import asyncio
|
|
15
15
|
import concurrent.futures
|
|
16
|
+
import contextvars
|
|
16
17
|
import logging
|
|
17
18
|
import queue
|
|
18
19
|
import threading
|
|
@@ -112,7 +113,18 @@ class _DaemonWorkerPool:
|
|
|
112
113
|
fut, fn, args, kwargs = item
|
|
113
114
|
if fut.set_running_or_notify_cancel():
|
|
114
115
|
try:
|
|
115
|
-
|
|
116
|
+
# Run in a FRESH context per message.
|
|
117
|
+
#
|
|
118
|
+
# A pooled thread is long-lived and carries ONE
|
|
119
|
+
# contextvars.Context for its entire life, so calling the
|
|
120
|
+
# handler directly let message A's ContextVar writes leak
|
|
121
|
+
# into message B on the same thread. With DI's
|
|
122
|
+
# `set_local`, tenant B could read tenant A's scope --
|
|
123
|
+
# which contradicts the isolation the DI context class
|
|
124
|
+
# advertises. The async path is safe because each message
|
|
125
|
+
# is its own Task (and a Task already copies the context);
|
|
126
|
+
# this gives the sync pool the same guarantee.
|
|
127
|
+
fut.set_result(contextvars.copy_context().run(fn, *args, **kwargs))
|
|
116
128
|
except BaseException as exc:
|
|
117
129
|
fut.set_exception(exc)
|
|
118
130
|
# loop back: re-mark idle at the top of the next iteration
|
|
@@ -272,8 +284,12 @@ class SyncWorkerPool:
|
|
|
272
284
|
Otherwise, submits to the thread pool.
|
|
273
285
|
"""
|
|
274
286
|
if self._executor is None:
|
|
275
|
-
# Single worker mode -- run directly
|
|
276
|
-
|
|
287
|
+
# Single worker mode -- run directly, but still in a FRESH
|
|
288
|
+
# context. This runs on the transport's owner thread, which lives
|
|
289
|
+
# for the whole process, so without the copy every message's
|
|
290
|
+
# ContextVar writes would accumulate on it and leak into the next
|
|
291
|
+
# message exactly as they did on the pooled path.
|
|
292
|
+
contextvars.copy_context().run(callback, message)
|
|
277
293
|
return
|
|
278
294
|
|
|
279
295
|
future = self._executor.submit(callback, message)
|
|
@@ -902,6 +902,15 @@ class DeduplicationConfig:
|
|
|
902
902
|
store_results: bool = False
|
|
903
903
|
max_result_bytes: int = 65536
|
|
904
904
|
on_in_flight: str = "nack_requeue" # claim only: "nack_requeue" (retry-safe) | "ack_skip"
|
|
905
|
+
#: Seconds to wait before nack-requeueing a duplicate of an IN-FLIGHT
|
|
906
|
+
#: message. Without it the requeue is immediate, the broker redelivers at
|
|
907
|
+
#: once, and the message spins until the claim resolves — measured at
|
|
908
|
+
#: ~3,160 redeliveries/second from ONE duplicate, for up to
|
|
909
|
+
#: ``processing_timeout`` (default 300s). That burns a prefetch slot and
|
|
910
|
+
#: broker bandwidth, and an attacker who can publish a colliding id plus a
|
|
911
|
+
#: slow payload gets the amplification for free. 0 restores the old
|
|
912
|
+
#: behaviour.
|
|
913
|
+
in_flight_requeue_delay: float = 0.5
|
|
905
914
|
|
|
906
915
|
def __post_init__(self) -> None:
|
|
907
916
|
if self.store_results and self.mark_policy != "claim":
|
|
@@ -79,9 +79,12 @@ of (or in addition to) the defaults.
|
|
|
79
79
|
|
|
80
80
|
from __future__ import annotations
|
|
81
81
|
|
|
82
|
+
import logging
|
|
82
83
|
from dataclasses import dataclass
|
|
83
84
|
from typing import TYPE_CHECKING, Any
|
|
84
85
|
|
|
86
|
+
from rabbitkit.core.sanitizer import ErrorSanitizer
|
|
87
|
+
|
|
85
88
|
if TYPE_CHECKING:
|
|
86
89
|
import structlog
|
|
87
90
|
|
|
@@ -206,6 +209,119 @@ def _redact_processor(keys: frozenset[str]) -> Any:
|
|
|
206
209
|
return processor
|
|
207
210
|
|
|
208
211
|
|
|
212
|
+
|
|
213
|
+
# ── Value-level redaction for STDLIB logging (0.16) ───────────────────────
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class SecretRedactingFilter(logging.Filter):
|
|
217
|
+
"""Redact credential-shaped VALUES from rendered log records.
|
|
218
|
+
|
|
219
|
+
``_redact_processor`` above scrubs by key NAME and only under structlog.
|
|
220
|
+
Two gaps followed from that, both of which leaked plaintext:
|
|
221
|
+
|
|
222
|
+
1. Only four rabbitkit modules use structlog; **thirty-four use
|
|
223
|
+
:mod:`logging` directly** and bypassed the processor entirely.
|
|
224
|
+
2. Key-name matching cannot catch a secret in the VALUE of an innocently
|
|
225
|
+
named field. ``logger.warning("connection lost", error=str(exc))`` has
|
|
226
|
+
the key ``error``, so an exception carrying
|
|
227
|
+
``amqp://svc:pw@host`` sailed through.
|
|
228
|
+
|
|
229
|
+
``ErrorSanitizer`` already knew how to redact these, but it had exactly
|
|
230
|
+
one call site in the whole package — the dead-letter header — so
|
|
231
|
+
``error_detail="sanitized"`` protected the header while the log line,
|
|
232
|
+
right next to it, printed the password.
|
|
233
|
+
|
|
234
|
+
This filter closes both by redacting the *formatted* message. Attach it
|
|
235
|
+
to the ``rabbitkit`` logger and it covers every module regardless of
|
|
236
|
+
which logging API they use.
|
|
237
|
+
|
|
238
|
+
It does NOT redact tracebacks: those are rendered by the Formatter, not
|
|
239
|
+
the record. Use :class:`SecretRedactingFormatter` for that.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
def __init__(self, sanitizer: ErrorSanitizer | None = None, name: str = "") -> None:
|
|
243
|
+
super().__init__(name)
|
|
244
|
+
self._sanitizer = sanitizer or ErrorSanitizer()
|
|
245
|
+
|
|
246
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
247
|
+
try:
|
|
248
|
+
rendered = record.getMessage()
|
|
249
|
+
except Exception: # pragma: no cover - a broken record must still log
|
|
250
|
+
return True
|
|
251
|
+
redacted = self._sanitizer.redact(rendered)
|
|
252
|
+
if redacted != rendered:
|
|
253
|
+
# Replace msg wholesale and drop args: the substitution already
|
|
254
|
+
# happened, and re-applying args would raise or re-introduce the
|
|
255
|
+
# secret.
|
|
256
|
+
record.msg = redacted
|
|
257
|
+
record.args = ()
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class SecretRedactingFormatter(logging.Formatter):
|
|
262
|
+
"""A Formatter that also redacts the rendered TRACEBACK.
|
|
263
|
+
|
|
264
|
+
The last line of a traceback is ``str(exc)``, so any handler raising
|
|
265
|
+
``SQLAlchemyError("could not connect: postgres://svc:pw@db/app")`` put
|
|
266
|
+
the password into every ``logger.exception(...)`` and every
|
|
267
|
+
``exc_info=True`` call — of which rabbitkit has roughly forty.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
def __init__(self, *args: Any, sanitizer: ErrorSanitizer | None = None, **kwargs: Any) -> None:
|
|
271
|
+
super().__init__(*args, **kwargs)
|
|
272
|
+
self._sanitizer = sanitizer or ErrorSanitizer()
|
|
273
|
+
|
|
274
|
+
def formatException(self, ei: Any) -> str: # noqa: N802 — stdlib signature
|
|
275
|
+
return self._sanitizer.redact(super().formatException(ei))
|
|
276
|
+
|
|
277
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
278
|
+
return self._sanitizer.redact(super().format(record))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def install_log_redaction(
|
|
282
|
+
logger_name: str = "rabbitkit",
|
|
283
|
+
*,
|
|
284
|
+
sanitizer: ErrorSanitizer | None = None,
|
|
285
|
+
) -> SecretRedactingFilter:
|
|
286
|
+
"""Attach :class:`SecretRedactingFilter` to *logger_name*, idempotently.
|
|
287
|
+
|
|
288
|
+
Called automatically by :func:`configure_structlog`. Call it directly if
|
|
289
|
+
you do not use rabbitkit's logging setup but still want its log lines
|
|
290
|
+
scrubbed.
|
|
291
|
+
"""
|
|
292
|
+
root = logging.getLogger(logger_name)
|
|
293
|
+
for existing in root.filters:
|
|
294
|
+
if isinstance(existing, SecretRedactingFilter):
|
|
295
|
+
return existing
|
|
296
|
+
filt = SecretRedactingFilter(sanitizer)
|
|
297
|
+
|
|
298
|
+
# A Filter on a Logger runs only for records logged DIRECTLY on it.
|
|
299
|
+
# Records from child loggers propagate to ancestor HANDLERS and skip
|
|
300
|
+
# ancestor filters entirely, so attaching to "rabbitkit" alone would
|
|
301
|
+
# miss every `logging.getLogger(__name__)` in the package — which is 34
|
|
302
|
+
# of the 38 modules. Attach to each logger in the namespace instead.
|
|
303
|
+
root.addFilter(filt)
|
|
304
|
+
prefix = f"{logger_name}."
|
|
305
|
+
for name, logger in list(logging.Logger.manager.loggerDict.items()):
|
|
306
|
+
if not name.startswith(prefix) or not isinstance(logger, logging.Logger):
|
|
307
|
+
continue
|
|
308
|
+
if not any(isinstance(f, SecretRedactingFilter) for f in logger.filters):
|
|
309
|
+
logger.addFilter(filt)
|
|
310
|
+
|
|
311
|
+
# Any handler already attached under this namespace gets it too, which
|
|
312
|
+
# also covers loggers created AFTER this call whose records reach one of
|
|
313
|
+
# those handlers.
|
|
314
|
+
for logger in (root, *(
|
|
315
|
+
lg
|
|
316
|
+
for name, lg in logging.Logger.manager.loggerDict.items()
|
|
317
|
+
if name.startswith(prefix) and isinstance(lg, logging.Logger)
|
|
318
|
+
)):
|
|
319
|
+
for handler in logger.handlers:
|
|
320
|
+
if not any(isinstance(f, SecretRedactingFilter) for f in handler.filters):
|
|
321
|
+
handler.addFilter(filt)
|
|
322
|
+
return filt
|
|
323
|
+
|
|
324
|
+
|
|
209
325
|
def configure_structlog(config: LoggingConfig | None = None) -> None:
|
|
210
326
|
"""One-time structlog configuration.
|
|
211
327
|
|
|
@@ -232,6 +348,10 @@ def configure_structlog(config: LoggingConfig | None = None) -> None:
|
|
|
232
348
|
|
|
233
349
|
if config.redact_keys:
|
|
234
350
|
processors.append(_redact_processor(config.redact_keys))
|
|
351
|
+
# Key-name redaction only reaches structlog callers (4 of 38 modules)
|
|
352
|
+
# and cannot see a secret in the VALUE of an innocently named field.
|
|
353
|
+
# This covers every rabbitkit logger regardless of logging API.
|
|
354
|
+
install_log_redaction()
|
|
235
355
|
|
|
236
356
|
if config.add_log_level:
|
|
237
357
|
processors.append(structlog.stdlib.add_log_level)
|
|
@@ -380,6 +380,28 @@ _ACK_STRATEGIES_ASYNC: dict[AckPolicy, _AsyncAckStrategy] = {
|
|
|
380
380
|
}
|
|
381
381
|
|
|
382
382
|
|
|
383
|
+
#: RabbitMQ's private direct-reply-to pseudo-queue. A reply here can only
|
|
384
|
+
#: reach the channel that issued the request, so it is safe by construction.
|
|
385
|
+
_DIRECT_REPLY_PREFIX = "amq.rabbitmq.reply-to"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _reply_to_permitted(reply_to: str, allow: tuple[str, ...] | None) -> bool:
|
|
389
|
+
"""Is *reply_to* an acceptable destination for a handler result?
|
|
390
|
+
|
|
391
|
+
``allow=None`` permits everything (the historical behaviour). Entries
|
|
392
|
+
ending in ``*`` match as prefixes.
|
|
393
|
+
"""
|
|
394
|
+
if allow is None:
|
|
395
|
+
return True
|
|
396
|
+
for pattern in allow:
|
|
397
|
+
if pattern.endswith("*"):
|
|
398
|
+
if reply_to.startswith(pattern[:-1]):
|
|
399
|
+
return True
|
|
400
|
+
elif reply_to == pattern:
|
|
401
|
+
return True
|
|
402
|
+
return reply_to.startswith(_DIRECT_REPLY_PREFIX)
|
|
403
|
+
|
|
404
|
+
|
|
383
405
|
class HandlerPipeline:
|
|
384
406
|
"""Executes the full message processing pipeline.
|
|
385
407
|
|
|
@@ -1267,6 +1289,18 @@ class HandlerPipeline:
|
|
|
1267
1289
|
|
|
1268
1290
|
# Determine destination (Contract 5)
|
|
1269
1291
|
if message.reply_to:
|
|
1292
|
+
# `reply_to` comes from the PUBLISHER. Publishing a handler result
|
|
1293
|
+
# to it unchecked is a one-hop write into any queue in the vhost.
|
|
1294
|
+
if not _reply_to_permitted(message.reply_to, route.reply_to_allow):
|
|
1295
|
+
logger.warning(
|
|
1296
|
+
"Route %r refused to publish its result to reply_to=%r: not in reply_to_allow. "
|
|
1297
|
+
"Result dropped.",
|
|
1298
|
+
route.name,
|
|
1299
|
+
message.reply_to,
|
|
1300
|
+
)
|
|
1301
|
+
return None
|
|
1302
|
+
if route.reply_to_allow is None and not message.reply_to.startswith(_DIRECT_REPLY_PREFIX):
|
|
1303
|
+
route.runtime_state.warn_unbounded_reply_to(route.name, message.reply_to)
|
|
1270
1304
|
# RPC reply takes precedence
|
|
1271
1305
|
if user_envelope is not None:
|
|
1272
1306
|
# Preserve user-provided fields (headers, message_id,
|
|
@@ -66,6 +66,7 @@ class SubscriberRegistry:
|
|
|
66
66
|
prefetch_count: int | None = None,
|
|
67
67
|
filter_fn: Callable[[RabbitMessage], bool] | None = None,
|
|
68
68
|
reject_without_dlx: str | None = None,
|
|
69
|
+
reply_to_allow: tuple[str, ...] | None = None,
|
|
69
70
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
70
71
|
"""Decorator to register a message handler for a queue.
|
|
71
72
|
|
|
@@ -81,6 +82,12 @@ class SubscriberRegistry:
|
|
|
81
82
|
description: Human-readable route description.
|
|
82
83
|
name: Explicit route name (auto-generated if None).
|
|
83
84
|
prefetch_count: Per-route prefetch override (None=use global).
|
|
85
|
+
reply_to_allow: Destinations this route may publish a handler
|
|
86
|
+
result to via the incoming message's ``reply_to``. That field
|
|
87
|
+
is set by the PUBLISHER, so leaving it unrestricted makes the
|
|
88
|
+
route a one-hop write into any queue in the vhost. ``None``
|
|
89
|
+
(default) allows anything and warns once per route. Entries
|
|
90
|
+
ending in ``*`` match as prefixes.
|
|
84
91
|
reject_without_dlx: Per-route override of
|
|
85
92
|
``SafetyConfig.reject_without_dlx`` — 'auto_provision',
|
|
86
93
|
'error', or 'discard' (None=inherit broker default).
|
|
@@ -144,6 +151,7 @@ class SubscriberRegistry:
|
|
|
144
151
|
description=description,
|
|
145
152
|
filter_fn=filter_fn,
|
|
146
153
|
reject_without_dlx=reject_without_dlx,
|
|
154
|
+
reply_to_allow=reply_to_allow,
|
|
147
155
|
)
|
|
148
156
|
|
|
149
157
|
# Validate at registration time (fail fast)
|
|
@@ -67,6 +67,26 @@ class RouteRuntimeState:
|
|
|
67
67
|
"""
|
|
68
68
|
|
|
69
69
|
consumer_tag: str | None = None
|
|
70
|
+
#: One-shot latch for the unbounded-reply_to warning (see
|
|
71
|
+
#: RouteDefinition.reply_to_allow). Per route, so a busy queue does not
|
|
72
|
+
#: flood the log.
|
|
73
|
+
_warned_unbounded_reply_to: bool = False
|
|
74
|
+
|
|
75
|
+
def warn_unbounded_reply_to(self, route_name: str, reply_to: str) -> None:
|
|
76
|
+
"""Warn ONCE that this route publishes results to an unrestricted
|
|
77
|
+
publisher-supplied destination."""
|
|
78
|
+
if self._warned_unbounded_reply_to:
|
|
79
|
+
return
|
|
80
|
+
self._warned_unbounded_reply_to = True
|
|
81
|
+
import logging
|
|
82
|
+
|
|
83
|
+
logging.getLogger(__name__).warning(
|
|
84
|
+
"Route %r published a handler result to reply_to=%r, which the PUBLISHER chose. "
|
|
85
|
+
"Without reply_to_allow this is a one-hop write into any queue in the vhost. "
|
|
86
|
+
"Set reply_to_allow=(...) on the subscriber to restrict it. Warned once per route.",
|
|
87
|
+
route_name,
|
|
88
|
+
reply_to,
|
|
89
|
+
)
|
|
70
90
|
|
|
71
91
|
|
|
72
92
|
# ── Route definition ─────────────────────────────────────────────────────
|
|
@@ -124,6 +144,22 @@ class RouteDefinition:
|
|
|
124
144
|
# Per-route override of SafetyConfig.reject_without_dlx (None = inherit)
|
|
125
145
|
reject_without_dlx: str | None = None
|
|
126
146
|
|
|
147
|
+
# Destinations this route may publish a handler RESULT to via the
|
|
148
|
+
# incoming message's `reply_to`.
|
|
149
|
+
#
|
|
150
|
+
# `reply_to` is set by the PUBLISHER and was used verbatim as a routing
|
|
151
|
+
# key on the default exchange, which makes it a one-hop write primitive:
|
|
152
|
+
# a publisher who can reach one queue could have this route's handler
|
|
153
|
+
# output delivered into any queue in the vhost, including queues they
|
|
154
|
+
# have no publish rights to. Combined with a dedup result store, it also
|
|
155
|
+
# let a replayed message return someone else's stored result.
|
|
156
|
+
#
|
|
157
|
+
# None (default) keeps the previous behaviour and warns ONCE per route
|
|
158
|
+
# when a reply goes somewhere other than the broker's private
|
|
159
|
+
# direct-reply-to pseudo-queue. Set a tuple to enforce; entries ending in
|
|
160
|
+
# "*" are treated as prefixes.
|
|
161
|
+
reply_to_allow: tuple[str, ...] | None = None
|
|
162
|
+
|
|
127
163
|
# ── Runtime state (mutable sub-object; populated by broker) ──
|
|
128
164
|
runtime_state: RouteRuntimeState = field(default_factory=RouteRuntimeState)
|
|
129
165
|
|
|
@@ -81,6 +81,7 @@ class RabbitRouter:
|
|
|
81
81
|
prefetch_count: int | None = None,
|
|
82
82
|
filter_fn: Callable[[RabbitMessage], bool] | None = None,
|
|
83
83
|
reject_without_dlx: str | None = None,
|
|
84
|
+
reply_to_allow: tuple[str, ...] | None = None,
|
|
84
85
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
85
86
|
"""Register a subscriber on this router.
|
|
86
87
|
|
|
@@ -125,6 +126,7 @@ class RabbitRouter:
|
|
|
125
126
|
prefetch_count=prefetch_count,
|
|
126
127
|
filter_fn=filter_fn,
|
|
127
128
|
reject_without_dlx=reject_without_dlx,
|
|
129
|
+
reply_to_allow=reply_to_allow,
|
|
128
130
|
)
|
|
129
131
|
|
|
130
132
|
def publisher(
|
|
@@ -35,7 +35,13 @@ _DEFAULT_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
|
35
35
|
# scheme://user:password@host → scheme://***:***@host
|
|
36
36
|
# password is greedy up to the LAST "@" before the host so an unescaped
|
|
37
37
|
# "@" inside the secret cannot leak its tail
|
|
38
|
-
|
|
38
|
+
# The scheme run is bounded ({0,31}) on purpose. With an unbounded `*`
|
|
39
|
+
# this pattern is QUADRATIC: a long run of scheme-legal characters that
|
|
40
|
+
# never reaches "://" makes the engine retry from every offset. Measured
|
|
41
|
+
# before the bound: 8 KiB 0.06s, 16 KiB 0.25s, 32 KiB 0.96s, 64 KiB 3.8s
|
|
42
|
+
# — 4x per doubling, i.e. ~16 minutes for 1 MiB, on the event loop.
|
|
43
|
+
# Real URI schemes are a handful of characters (RFC 3986).
|
|
44
|
+
re.compile(r"(?i)\b([a-z][a-z0-9+.-]{0,31}://)([^/\s:@]{1,256}):([^/\s]{1,512})@"),
|
|
39
45
|
# Authorization: Bearer <token> / Basic <blob>
|
|
40
46
|
re.compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9\-._~+/]+=*"),
|
|
41
47
|
# key=value / key: value / "key": "value" for secret-bearing key names
|
|
@@ -53,6 +59,9 @@ _DEFAULT_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
|
53
59
|
|
|
54
60
|
_CODE_SAFE = re.compile(r"[^A-Za-z0-9_.]")
|
|
55
61
|
|
|
62
|
+
#: How much more than the kept summary to redact over. See summary_for().
|
|
63
|
+
_REDACT_WINDOW_FACTOR = 8
|
|
64
|
+
|
|
56
65
|
|
|
57
66
|
@dataclass(frozen=True, slots=True)
|
|
58
67
|
class SanitizedError:
|
|
@@ -142,7 +151,20 @@ class ErrorSanitizer:
|
|
|
142
151
|
# Collapse whitespace/newlines: a header is one line; a stack-trace-
|
|
143
152
|
# shaped message must not smuggle formatting into log pipelines.
|
|
144
153
|
text = " ".join(text.split())
|
|
145
|
-
|
|
154
|
+
# Bound the input BEFORE redacting. redact() runs six regexes over
|
|
155
|
+
# whatever it is given, and `str(exc)` is attacker-influenced whenever
|
|
156
|
+
# a handler echoes the body (json.JSONDecodeError, pydantic's
|
|
157
|
+
# ValidationError, ValueError(f"bad: {body}")). Redacting a 16 MiB
|
|
158
|
+
# message to keep 256 characters is pure waste, and on the async path
|
|
159
|
+
# it blocks the event loop, which costs heartbeats and wedges the
|
|
160
|
+
# consumer into a redelivery loop.
|
|
161
|
+
#
|
|
162
|
+
# The window is a MULTIPLE of the kept length, not the kept length:
|
|
163
|
+
# truncating to exactly _max_summary could slice a secret that starts
|
|
164
|
+
# just inside the kept region, leaving its prefix unredacted. With 8x
|
|
165
|
+
# slack, any secret beginning within the kept 256 characters is still
|
|
166
|
+
# seen whole by the patterns.
|
|
167
|
+
return self.redact(text[: self._max_summary * _REDACT_WINDOW_FACTOR])[: self._max_summary]
|
|
146
168
|
|
|
147
169
|
def sanitize(self, exc: BaseException, severity: ErrorSeverity | None = None) -> SanitizedError:
|
|
148
170
|
category = severity.value if severity is not None else "unknown"
|
|
@@ -130,27 +130,44 @@ class CompressionMiddleware(BaseMiddleware):
|
|
|
130
130
|
``unconsumed_tail`` each iteration; the running total is checked every
|
|
131
131
|
chunk — a zip bomb raises before a huge allocation is materialised.
|
|
132
132
|
"""
|
|
133
|
-
decomp = zlib.decompressobj(16 + zlib.MAX_WBITS)
|
|
134
133
|
out = bytearray()
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
remaining: bytes = data
|
|
135
|
+
# gzip permits CONCATENATED members and the stdlib joins them. Decode
|
|
136
|
+
# each member in turn; stopping after the first silently discarded
|
|
137
|
+
# everything after it, which is a body-smuggling primitive against
|
|
138
|
+
# anything that inspects the decompressed body separately.
|
|
139
|
+
while remaining:
|
|
140
|
+
decomp = zlib.decompressobj(16 + zlib.MAX_WBITS)
|
|
141
|
+
tail: bytes = remaining
|
|
142
|
+
while True:
|
|
143
|
+
chunk = decomp.decompress(tail, _CHUNK)
|
|
144
|
+
out += chunk
|
|
145
|
+
if len(out) > self._max_decompressed_size:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"Decompressed size ({len(out)}) exceeds "
|
|
148
|
+
f"max_decompressed_size ({self._max_decompressed_size})"
|
|
149
|
+
)
|
|
150
|
+
tail = decomp.unconsumed_tail
|
|
151
|
+
if decomp.eof:
|
|
152
|
+
break
|
|
153
|
+
if not chunk and not tail:
|
|
154
|
+
# Input exhausted without reaching the end of the member.
|
|
155
|
+
# The gzip trailer (CRC32 + ISIZE) is the payload's only
|
|
156
|
+
# integrity check, so accepting here would hand the
|
|
157
|
+
# handler a truncated or spliced body as if it were
|
|
158
|
+
# complete. The stdlib raises EOFError; so do we.
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"Truncated gzip stream: input exhausted before the end of a member "
|
|
161
|
+
"(CRC/length trailer never verified)"
|
|
162
|
+
)
|
|
163
|
+
out += decomp.flush()
|
|
139
164
|
if len(out) > self._max_decompressed_size:
|
|
140
165
|
raise ValueError(
|
|
141
|
-
f"Decompressed size ({len(out)}) exceeds
|
|
166
|
+
f"Decompressed size ({len(out)}) exceeds "
|
|
167
|
+
f"max_decompressed_size ({self._max_decompressed_size})"
|
|
142
168
|
)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
break
|
|
146
|
-
if not chunk and not tail:
|
|
147
|
-
# No output and no input left to feed — avoid spinning.
|
|
148
|
-
break
|
|
149
|
-
out += decomp.flush()
|
|
150
|
-
if len(out) > self._max_decompressed_size:
|
|
151
|
-
raise ValueError(
|
|
152
|
-
f"Decompressed size ({len(out)}) exceeds max_decompressed_size ({self._max_decompressed_size})"
|
|
153
|
-
)
|
|
169
|
+
# unused_data is whatever followed this member's trailer.
|
|
170
|
+
remaining = decomp.unused_data
|
|
154
171
|
return bytes(out)
|
|
155
172
|
|
|
156
173
|
def _decompress_zstd_streaming(self, data: bytes) -> bytes:
|
|
@@ -198,7 +215,12 @@ class CompressionMiddleware(BaseMiddleware):
|
|
|
198
215
|
elif content_encoding == "zstd":
|
|
199
216
|
return self._decompress_zstd_streaming(data)
|
|
200
217
|
else:
|
|
201
|
-
|
|
218
|
+
# content_encoding is an AMQP property the PUBLISHER sets, so it
|
|
219
|
+
# is attacker-controlled, unbounded, and may contain newlines.
|
|
220
|
+
# Logging it verbatim lets an attacker forge whole log lines
|
|
221
|
+
# attributed to a real logger. Collapse whitespace and bound it.
|
|
222
|
+
safe_encoding = " ".join(str(content_encoding).split())[:64]
|
|
223
|
+
logger.warning("Unknown content_encoding: %r, returning raw data", safe_encoding)
|
|
202
224
|
return data
|
|
203
225
|
|
|
204
226
|
def on_receive(self, message: RabbitMessage) -> None:
|