Security model¶
Use this page to choose resource bounds. The tables cover audited paths, not a proof that every growable path has been found. Client and server limits have separate owners.
Scope and trust boundary¶
Every byte from a peer is untrusted until dispatch. Request lines, headers, bodies, WebSocket frames, HTTP/2 frames and MQTT packets are all parsed by BlackBull's own code. Each path listed on this page is bounded before it can allocate on a peer's behalf, and each bound has a test named under Evidence. What establishes the list is a code audit, and Non-claims says what that method can and cannot prove.
Named non-goals — real limits, not oversights:
| Out of scope | Why |
|---|---|
| Your application code | A handler that reads an unbounded file or shells out is beyond any server's reach. |
| Authentication and authorisation | BlackBull ships neither. Use middleware or a gateway. |
| TLS trust decisions | BlackBull terminates TLS when configured to, but certificate issuance, pinning and revocation are yours. |
| OS and deployment hardening | File-descriptor limits, cgroups, network ACLs. BlackBull reads the fd limit (see below) but does not set it. |
| Volumetric denial of service | A flood that saturates your uplink is answered upstream, not in a Python process. |
What a peer can make the server spend¶
| Resource | How a peer reaches it | Bounded by |
|---|---|---|
| Memory | large or accumulating request bodies, WebSocket messages, MQTT packets and broker state | size and total caps on each path in the table below |
| Event-loop time | floods of cheap control frames that each oblige a small piece of work | per-type frame-rate meters |
| Connection slots | opening connections and holding them | BB_MAX_CONNECTIONS (accept-time: see the startup window), plus idle and header deadlines and, on HTTP/2 and WebSocket, a liveness probe |
| Task slots | opening HTTP/2 streams | BB_H2_MAX_CONCURRENT_STREAMS, per-connection handler semaphore |
| Write path | requesting a response and refusing to read it | BB_WRITE_TIMEOUT, on the socket drain and on the HTTP/2 flow-control wait |
| Broker state | subscribing without acknowledging; subscribing to endless filters; retaining messages; leaving sessions behind | BB_MQTT_MAX_QUEUED_MESSAGES, BB_MQTT_MAX_SUBSCRIPTIONS, BB_MQTT_MAX_RETAINED, BB_MQTT_MAX_SESSIONS |
The invariant¶
A path a peer can grow gets a bound on one unit, on the total, and on how long it may take.
Apply the rule to each growable path, including write-only state. Unit, aggregate, count and time limits are independent; one cannot replace another.
| Path | One unit | Total | Time |
|---|---|---|---|
| HTTP/1.1 request head | BB_HEADER_MAX_LINE 8 KiB |
BB_HEADER_MAX_TOTAL 64 KiB |
BB_HEADER_TIMEOUT 10 s |
| HTTP/1.1 request body | BB_BODY_CHUNK_MAX 512 KiB |
BB_MAX_BODY_SIZE 30 MiB |
BB_BODY_TIMEOUT 30 s + BB_MIN_BODY_RATE 240 B/s |
| HTTP/2 header block | SETTINGS_MAX_FRAME_SIZE |
BB_HEADER_MAX_TOTAL 64 KiB |
BB_HEADER_TIMEOUT 10 s |
| HTTP/2 request body | SETTINGS_MAX_FRAME_SIZE |
BB_MAX_BODY_SIZE 30 MiB |
BB_MIN_BODY_RATE 240 B/s |
| HTTP/2 connection | — | stream and window budgets | BB_H2_IDLE_TIMEOUT 300 s + BB_H2_PING_TIMEOUT 30 s |
| HTTP/2 response write | SETTINGS_MAX_FRAME_SIZE |
— | BB_WRITE_TIMEOUT 30 s |
| Control frames (HTTP/2 + WebSocket) | — | BB_FRAME_RATE_LIMIT 20 per type |
BB_FRAME_RATE_WINDOW 1 s |
| HTTP/2 priority signals | 5-byte PRIORITY payload | no state recorded (PRIORITY); SETTINGS_MAX_CONCURRENT_STREAMS (PRIORITY_UPDATE hints) |
— |
| WebSocket frame | BB_WS_MAX_FRAME_PAYLOAD 64 MiB |
— | — |
| WebSocket message | per frame | BB_WS_MAX_MESSAGE_SIZE 16 MiB |
— |
| WebSocket connection | — | BB_MAX_CONNECTIONS (derived) |
BB_WS_IDLE_TIMEOUT 300 s + BB_WS_PONG_TIMEOUT 30 s |
| MQTT packet | BB_MQTT_MAX_PACKET_SIZE 1 MiB |
same, before buffering | keep-alive × 1.5 |
| MQTT session state | BB_MQTT_MAX_SUBSCRIPTIONS 1000, BB_MQTT_MAX_QUEUED_MESSAGES 1000, 16-bit packet-identifier space |
BB_MQTT_MAX_SESSIONS 10000 |
Session Expiry Interval, swept (see qualification 3) |
| MQTT retained store | — | BB_MQTT_MAX_RETAINED 10000 |
— |
| Connections | — | BB_MAX_CONNECTIONS (derived) |
detect deadline, keep-alive |
| Client response head (HTTP/1.1) | BB_CLIENT_HEAD_MAX_LINE 8 KiB |
BB_CLIENT_HEAD_MAX_TOTAL 64 KiB |
BB_CLIENT_HEAD_TIMEOUT 30 s |
| Client response head (HTTP/2) | BB_CLIENT_H2_MAX_FRAME_SIZE 16 KiB |
BB_CLIENT_HEAD_MAX_TOTAL 64 KiB, spent twice — encoded, per field block reassembled across CONTINUATION; and decoded, across every section on the stream — plus BB_CLIENT_H2_MAX_HEADER_LIST_SIZE 64 KiB decoded, per section, inside the decoder |
BB_CLIENT_HEAD_TIMEOUT 30 s |
| Client response body | 64 KiB read (HTTP/1.1); BB_CLIENT_H2_MAX_FRAME_SIZE (HTTP/2) |
BB_CLIENT_BODY_MAX_TOTAL off |
BB_CLIENT_BODY_TIMEOUT 30 s + BB_CLIENT_MIN_BODY_RATE off (payload bytes; H2 per stream) |
| Client raw HTTP/2 stream | BB_CLIENT_H2_MAX_FRAME_SIZE 16 KiB |
BB_CLIENT_RAW_QUEUE_DEPTH 1024 frames |
— |
| Client WebSocket frame | BB_CLIENT_WS_MAX_FRAME_PAYLOAD 64 MiB |
— | — |
| Client WebSocket message | per frame | BB_CLIENT_WS_MAX_MESSAGE_SIZE 16 MiB |
— |
| Client send path | HTTP/2 peer SETTINGS_MAX_FRAME_SIZE |
— deliberately no upload aggregate; the flow-control window is only in-flight credit | BB_CLIENT_WRITE_TIMEOUT 30 s per socket-drain/flow-control wait, not a whole-upload total; one-byte credit can make an upload indefinite |
One client bound is not in that grid, because it is not one of the three.
BB_CLIENT_MAX_INTERIM_RESPONSES (8) bounds how many 1xx heads may precede
the final one, on either transport: a count, and what owns the aggregate of
the two per-head bounds above it, each of which is spent afresh on every
interim. It is also the only bound an HTTP/2 interim section with no fields
has — one of those charges nothing to BB_CLIENT_HEAD_MAX_TOTAL, which is
what owns the aggregate once fields are present.
Full descriptions: Environment variables.
Four limits that are less obvious than they look¶
Bound messages after reassembly and decompression. A small frame can
inflate to a large message. BB_WS_MAX_MESSAGE_SIZE bounds inflation before
the oversized result is materialized; a frame cap alone does not.
A size cap is judged from what the peer declares, before its payload is
read. An over-cap Content-Length, an over-cap MQTT Remaining Length, and an
over-cap HTTP/2 frame are all refused at the header. MQTT 5 permits a peer to
declare 268,435,455 bytes; nothing waits for them to arrive.
An advertised limit is not always an enforced one, and this page says
which is which. BB_MQTT_RECEIVE_MAXIMUM is announced in CONNACK so a
conforming client paces itself; it is a promise, and nothing counts a
non-conforming client's in-flight publishes against it. What bounds that
direction is the 16-bit packet-identifier space and the packet size cap. The
client's own Receive Maximum, in the outbound direction, is enforced. Every
other limit in the table above is enforced.
A rate floor is what a per-read timeout cannot be. An arrival-paced read
completes before any per-read deadline no matter how few bytes it carries, so a
one-byte-per-second drip satisfies BB_BODY_TIMEOUT indefinitely.
BB_MIN_BODY_RATE bounds the average, which a drip cannot reset by sending
one more byte.
Posture by protocol¶
Three rungs, and the difference between them is what an operator has to do:
bounded-by-default— every path listed above is bounded with nothing configured. It is a statement about this page's rows, not about paths nobody has thought of yet.bounded-when-configured— the bounds exist but one or more default open.bounded-unit-only— individual units are bounded; totals are not.
The rungs are read against the rows above, so the table needs to say whose path each row was. A protocol is not a role: the same HTTP/2 parser bounds a request we were sent and a response we asked for, and only one of those came from a peer who chose us.
| Role | Protocol | Posture | Notes |
|---|---|---|---|
| Server | HTTP/1.1 | bounded-by-default |
see qualifications 1 and 2 below |
| Server | HTTP/2 | bounded-by-default |
includes control-frame rate metering and PING-based liveness |
| Server | gRPC | bounded-by-default |
own message bounds, plus everything HTTP/2 provides |
| Server | WebSocket | bounded-by-default |
message bounded post-reassembly and post-inflation; a silent peer is probed with a PING and closed if it does not answer |
| Server | MQTT | bounded-by-default |
limits are also advertised in CONNACK, so conforming clients stay inside them; see qualification 3 |
| Client | HTTP/1.1 | bounded-when-configured |
two of its bounds ship off; see qualification 4 |
| Client | HTTP/2 | bounded-when-configured |
the same two default-open bounds; see qualification 4 |
Four qualifications, stated here rather than in a footnote, because they are the difference between the label and the whole truth:
-
There is no total request-duration bound by default.
BB_REQUEST_TIMEOUTis0. A peer delivering at exactly the minimum body rate, up to the body cap, legally holds a connection for 31,457,280 ÷ 240 = 131,072 seconds ≈ 36.4 hours. Every bound above is doing its job the whole time — that is the composition of a size cap and a rate floor, not a hole in either. nginx and Kestrel have the same property. SetBB_REQUEST_TIMEOUTif your application has no long-lived requests. -
An auto connection cap is a descriptor budget, not a memory budget.
autosubtracts 64 reserved descriptors and those already open fromRLIMIT_NOFILE. Set an explicit cap for the worker's memory and CPU budget, allowing for descriptors the application opens after acceptance starts. TLS handshakes count from accept. Excess connections wait in the accept queue; HTTP/1.1 and prior-knowledge h2c get best-effort 503, while ALPN-h2 and raw connections close silently. -
An MQTT session that never expires is bounded by the total, not by the clock. §3.1.2.11.2 defines a Session Expiry Interval of
0xFFFFFFFFas does not expire, and BlackBull honours it. Such a session is collected by nothing; what bounds it isBB_MQTT_MAX_SESSIONS, at which a CONNECT for an unknown Client Identifier is refused with0x97 (Quota Exceeded). Sessions with a finite interval are swept by a timer armed at the earliest pending deadline, so they cost nothing while none is pending. Sessions live in memory only and do not survive a restart. -
Client buffered-body and rate bounds default off. Set
BB_CLIENT_BODY_MAX_TOTALwhen response size is predictable, andBB_CLIENT_MIN_BODY_RATEonly for transfers expected to make sustained progress. Both apply to HTTP/1.1 and HTTP/2. Client send and WebSocket limits use client-owned settings, separate from server tuning.
Defaults and deployment checklist¶
On out of the box:
- every limit in the invariant table except
BB_REQUEST_TIMEOUTand the client's two,BB_CLIENT_BODY_MAX_TOTALandBB_CLIENT_MIN_BODY_RATE; - cap-hit logging on
blackbull.caps— every refusal above emits a structuredWARNINGnaming the limit, the requested value and the configured one, the client's included, on each protocol that enforces the bound (see Logging); - MQTT limits advertised to clients in CONNACK, so a conforming client never reaches the enforcement path.
Worth setting for an internet-facing deployment:
| Setting | Why |
|---|---|
BB_REQUEST_TIMEOUT |
the only default-open bound; see qualification 1 |
BB_MAX_CONNECTIONS |
an explicit number bounds event-loop health, which the derived value does not. Set it to the concurrency the deployment actually expects, particularly when serving WebSocket, where connections are long-lived by design |
BB_WS_MAX_MESSAGE_SIZE |
the default admits 16 MiB so the WebSocket conformance suite passes unconfigured; lower it if you do not serve huge messages |
BB_MAX_BODY_SIZE |
lower it if you accept no uploads |
BB_TCP_USER_TIMEOUT_MS |
evicts dead peers behind NATs without waiting for keepalives — BlackBull's own listeners only (scope) |
The client's two turn on a different question — not is this deployment
internet-facing but do I know what this peer should return. Set
BB_CLIENT_BODY_MAX_TOTAL when you do, at half of what one response may
occupy, because the buffered body costs about twice the cap in peak
memory. BB_CLIENT_MIN_BODY_RATE asserts the peer is a transfer rather than a
stream, which an event stream or a long poll is not.
Evidence¶
Every claim on this page is backed by a test or an external conformance suite.
| Claim | Evidence |
|---|---|
| Body size and rate bounds | tests/conformance/http1/test_rfc9110_body_cap.py, tests/conformance/http2/test_rfc9113_body_cap.py, tests/unit/test_body_rate_limit.py |
| A slow drip is closed at the shipped default | tests/conformance/http1/test_rfc9112_slowloris.py::TestBodyTrickle |
| WebSocket message bounds, including that a bomb is never materialised | tests/unit/test_ws_message_bounds.py |
| MQTT packet, backlog and retained-store bounds | tests/unit/test_mqtt_resource_bounds.py |
| MQTT session count, subscription count and expiry sweep | tests/unit/test_mqtt_session_bounds.py |
| HTTP/2 time bounds, including that a responsive peer is never closed | tests/unit/test_h2_time_bounds.py |
| WebSocket liveness probe, including that an answering peer is never closed | tests/unit/test_ws_liveness_probe.py |
| Frame-rate metering | tests/unit/test_rate_window.py, tests/unit/test_frame_rate_metering.py |
| Derived connection cap | tests/unit/test_max_connections_default.py |
| Protocol conformance | h2spec (HTTP/2 + HPACK), Autobahn (WebSocket), http11probe — see Conformance |
| Client response bounds, and that a peer which stops is abandoned rather than waited on | tests/unit/client/test_http1_client_head_bounds.py, tests/unit/client/test_http1_client_desync_and_deadline.py, tests/unit/client/test_http2_client_response_bounds.py |
| Client rate floor, including HTTP/2 stream isolation and DATA payload accounting | tests/unit/client/test_http1_client_rate_floor.py, tests/unit/client/test_http2_client_rate_floor.py |
| Client-owned transport and WebSocket bounds | tests/unit/client/test_client_owned_bounds.py, tests/caps/ |
Every bound names its refusing subsystem on blackbull.caps |
tests/caps/test_every_cap_has_a_row.py audits source (cap, protocol) pairs; the caps tables provoke each refusal and check its record |
| A buffered client response body costs about twice the cap | tests/unit/client/test_client_body_buffer_cost.py |
Non-claims¶
The limits do not cover volumetric floods or arbitrary application resource use. No third-party audit or red-team exercise establishes completeness. Treat the tables as audited paths, not proof of absence of other paths.
- The server sends
100 (Continue)only to HTTP/1.1. An HTTP/1.0 client gets no interim response at all and itsExpectis ignored (COMP-NO-1XX-HTTP10), so a client that waits for the signal stalls until its own timeout. The server also never originates a103 (Early Hints): it sends what the application sends and infers nothing before then.
On the async HTTP client specifically, and after the server ones so the contrast is visible:
- A peer that sends content where RFC 9112 §6.3 rule 1 says there is none —
a 204, a 304 or a HEAD response carrying a body — is not refused over
HTTP/1.1, unless it also declares a boundary the rule forbids it to
have. Those octets are left
unread and are parsed as the start of the next response: a
ProtocolErrorin the usual case, and a response taken for another when the leftover happens to look like one. The reader trusts the rule rather than the peer here; a 205 is consumed to its declared length precisely because the rule does not cover one. HTTP/2 refuses such frames instead, because frames are self-delimiting and no boundary is at stake. The one exception is the next bullet: a message that names the boundary the rule forbids it to have is refused, because there the leftover and the next response cannot be told apart at all. -
A 204 or a 1xx that declares a boundary is the one shape that is refused. RFC 9112 §6.1 forbids
Content-LengthandTransfer-Encodingin one, so what follows cannot be told apart from the next response; the connection is retired and the reader refuses it. A 204 with no boundary, or withContent-Length: 0, still leaves its octets to the next response: nothing is declared, so nothing is refused. -
It does not follow redirects and does not pool connections. Neither exists in
blackbull/client/— so neither is bounded or unbounded, and a report that one of them is unsafe is a feature request. - A buffered response body costs about twice the cap in peak memory.
Reaching ~1× means
stream(), which exposes no status, no headers and sits outside the cap — so today a caller can have ~1× or all three, never both. Found something? Please open an issue at github.com/TOKUJI/BlackBull.