Security Notes¶
Found a vulnerability in rabbitkit itself (not just a hardening question)? See SECURITY.md for how to report it privately.
Safe defaults at a glance¶
A scannable summary — each row links to the section with the full story.
| Feature | Safe by default? | What you must configure for production |
|---|---|---|
TLS (SSLConfig) |
Yes, when enabled: CERT_REQUIRED, hostname verification, TLS ≥ 1.2 |
You must still explicitly enable it (SSLConfig(enabled=True)) and point it at real certs — see TLS for Broker Connections. |
| Default credentials | No — rabbitkit warns if you use guest/guest against a non-local host, but does not block it |
Use dedicated, least-privilege credentials per service; never ship the default in production — see Credentials. |
Config repr() / .url |
Yes — passwords are masked in repr() and in .safe_url |
Use .safe_url (not .url) anywhere you might log a config object; .url still contains the plaintext password by necessity (it's what actually connects). |
Message signing (SigningMiddleware) |
Partially — HMAC comparison is constant-time and the signature covers routing metadata, not just the body | The default nonce cache is per-process — real replay protection across multiple workers/pods requires wiring RedisNonceCache yourself. You'll get a RuntimeWarning if you don't — don't ignore it. See Shared nonce store. |
Deduplication (DeduplicationMiddleware) |
Fails open by default (fallback_on_redis_error=True) — availability over strict dedup |
For payments or anything non-idempotent at the business layer, set fallback_on_redis_error=False to fail closed. Either way, see the idempotency contract — dedup is a mitigation, not a substitute for idempotent handlers. |
Management API client (RabbitManagementClient) |
Yes — rejects non-http(s) schemes, doesn't follow redirects, caps response size |
Never construct ManagementConfig.url from user-controllable input, even though the client defends against SSRF-class abuse itself. |
Distributed locking (RedisLock) |
Release is atomic (compare-and-delete) | ttl has no auto-renewal — a slow handler can lose the lock mid-work. Size ttl well above worst-case handler time; use fencing_token() for downstream writes that must not double-apply. |
Monitoring dashboard (create_dashboard_app) |
No — unauthenticated by default | Always pass auth_token=, bind to loopback only, and put it behind a reverse proxy for anything beyond local debugging. This is the single riskiest default in the toolkit if ignored — see docs/stability-policy.md's Experimental section. |
Result backends (ResultBackend) |
N/A — no auth built in, it's a Redis key-value store under the hood | Treat correlation IDs as sensitive if the stored result is; the TTL you set is the only cleanup mechanism. |
Message Bodies¶
Do not log message bodies by default. Bodies may contain PII, credentials, or sensitive business data. Enable body logging only in development environments with explicit opt-in.
Message Size Limits¶
Set a maximum incoming message size to prevent memory exhaustion from oversized payloads. RabbitMQ enforces max-frame-size at the connection level; apply additional validation in your handler.
When using CompressionMiddleware, configure a maximum decompressed size to prevent decompression bombs:
from rabbitkit import CompressionConfig
from rabbitkit.middleware.compression import CompressionMiddleware
mw = CompressionMiddleware(config=CompressionConfig(max_decompressed_size=10 * 1024 * 1024)) # 10 MB
TLS for Broker Connections¶
Always use TLS in production. Use SSLConfig to configure mutual TLS:
from rabbitkit import RabbitConfig, ConnectionConfig, SSLConfig
config = RabbitConfig(
connection=ConnectionConfig(host="rabbitmq.internal", port=5671),
ssl=SSLConfig(
ca_certs="/etc/ssl/certs/ca.crt",
certfile="/etc/ssl/certs/service.crt",
keyfile="/etc/ssl/private/service.key",
),
)
Credentials¶
Use separate RabbitMQ credentials per service. Do not share a single admin account across services. Apply the principle of least privilege: each service should only have permission to publish/consume on its own exchanges and queues.
Credential rotation¶
For short-lived / rotated secrets (Vault, IAM), pass a credentials_provider
instead of static username/password. It is called at every (re)connect,
so a rotated secret is picked up on the next reconnect with no redeploy:
from rabbitkit import RabbitConfig, ConnectionConfig
config = RabbitConfig(
connection=ConnectionConfig(
host="rabbit",
credentials_provider=lambda: vault.read("rabbitmq/creds"), # -> (user, password)
)
)
The provider must be quick and non-blocking (it runs on the connect path). Cache the secret and refresh it out-of-band; the reconnect just reads the current value.
Least-privilege consumers with TopologyMode.PASSIVE_ONLY¶
The default TopologyMode.AUTO_DECLARE issues queue.declare/
exchange.declare/queue.bind for every route at startup — which requires
the connecting user to hold RabbitMQ's configure permission on those
resources, in addition to write (publish) and read (consume).
Granting configure to every consumer service means any of them could
redeclare, rebind, or delete topology it doesn't own.
TopologyMode.PASSIVE_ONLY only ever checks that a queue/exchange already
exists (passive=True declares, which RabbitMQ permits with the read/write
permissions alone) — it never creates or mutates topology. A dedicated
topology-owning process/deploy step declares the real topology once (with a
configure-scoped credential), and every consumer/producer service instead
connects with a credential scoped to just write/read on its own
queues/exchanges (via RabbitMQ's per-vhost set_permissions <user> <conf>
<write> <read> regex patterns — an empty or narrowly-scoped configure
pattern):
from rabbitkit import RabbitConfig, ConnectionConfig
from rabbitkit.core.types import TopologyMode
config = RabbitConfig(
connection=ConnectionConfig(host="rabbit", username="orders-consumer"),
topology_mode=TopologyMode.PASSIVE_ONLY,
)
If the expected topology doesn't exist, PASSIVE_ONLY fails fast at startup
with a clear error instead of silently trying (and being denied permission)
to create it. TopologyMode.MANUAL goes further — it skips topology
declaration/checking entirely, for callers that manage topology completely
out-of-band (e.g. via rabbitmqadmin/Terraform).
Header Validation¶
Headers are untrusted input. Validate and sanitize all routing headers before acting on them. Do not use header values as file paths, SQL queries, or shell arguments.
Routing Key Trust¶
Do not let external callers control routing keys directly. A caller that controls the routing key can route messages to unintended queues. Always validate or override the routing key server-side.
HMAC Signing¶
SigningMiddleware signs and verifies messages using HMAC-SHA256 (or SHA-512). It uses hmac.compare_digest for constant-time comparison to prevent timing attacks. Configure it with a secret that is at least 32 bytes:
from rabbitkit.experimental import SigningConfig
from rabbitkit.middleware.signing import SigningMiddleware
mw = SigningMiddleware(config=SigningConfig(secret_key="a-long-random-secret-at-least-32-bytes"))
The default (require_freshness=True) signature covers timestamp, nonce,
exchange, routing_key, content_encoding, and reply_to, in addition to
the body — not just the body. Without this, an attacker who cannot forge the
signature could still capture a validly-signed message and re-publish it
under a different routing key, redirect an RPC reply via reply_to, or flip
content_encoding to hit a different decompression path, all while the
signature still verified. Headers other than the signature/timestamp/nonce
triplet itself are not covered — do not use freeform headers for
security-critical routing decisions. The legacy body-only signature path
(only reachable with require_freshness=False, kept for interop with
producers that predate the freshness headers) has none of these protections;
avoid it for security-sensitive traffic.
Rotate the signing secret periodically. Store it in a secrets manager, not in environment variables that may be logged.
Shared nonce store for multi-process/multi-pod deployments¶
SigningMiddleware's default nonce cache (TTLSetNonceCache) is a
per-process, in-memory dict. In any deployment with more than one consumer
process — pods behind a Deployment, multiple worker processes, or a restart —
a nonce recorded by one process is invisible to every other one, and a
replayed message that lands on a different process passes the nonce check.
SigningMiddleware emits a RuntimeWarning at construction time for exactly
this reason whenever require_freshness=True and no explicit nonce_cache
is supplied.
Use RedisNonceCache to share the seen-set across every process:
import redis
from rabbitkit.middleware.signing import RedisNonceCache, SigningConfig
cache = RedisNonceCache(redis.Redis(host="redis", port=6379))
config = SigningConfig(secret_key="shared-secret", nonce_cache=cache)
It records each nonce with an atomic SET NX EX, so two processes racing on
the same nonce can never both pass. For payments or other high-value traffic,
also use a tight max_skew (default 60s) to shrink the replay window, and
always pair it with a shared nonce_cache — the in-memory default is not
sufficient once there is more than one consumer process.
Replay Attack Prevention¶
Use DeduplicationMiddleware with a TTL that covers your maximum message delay. This prevents replay attacks where an attacker retransmits a previously captured message:
from rabbitkit import DeduplicationConfig
from rabbitkit.middleware.deduplication import DeduplicationMiddleware
import redis
mw = DeduplicationMiddleware(
redis_client=redis.Redis(),
config=DeduplicationConfig(key_source="message_id", ttl=3600),
)
Fail-open by default (M9): DeduplicationConfig.fallback_on_redis_error
defaults to True — if Redis is unreachable, messages are processed
without idempotency enforcement rather than blocking the consumer (an
ERROR-level log and, if you wire a MetricsCollector, a
dedup_fallback_total counter are emitted every time this happens, so it's
observable, not silent). This is the right default for most workloads
(availability over strict dedup), but for traffic where a duplicate is
unacceptable — payments, anything non-idempotent at the business layer — set
fallback_on_redis_error=False to fail closed (re-raise the Redis error)
instead.
If you need to prevent concurrent duplicate processing, use
mark_policy="claim": it takes an atomic in-flight claim (expiring after
processing_timeout) before the handler and flips it to completed on
success, so concurrent copies are requeued rather than double-executed and a
crash mid-handler never loses the message — set processing_timeout
comfortably above your worst-case handler duration. Avoid
mark_policy="on_start" unless you explicitly accept that a crash after the
mark but before the handler finishes loses the message (the redelivery is
skipped as a duplicate).