Skip to content

Commit 94b4a3f

Browse files
authored
Merge pull request #94 from barbacane-dev/security/data-plane-dos
security(data-plane): ingress DoS hardening + chunked body-size cap
2 parents d19349f + fae0701 commit 94b4a3f

4 files changed

Lines changed: 209 additions & 80 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- **security**: `jwt-auth` performs real signature verification via the host `verify_signature` capability (inline JWK).
1717
- **security**: a security testing framework — adversarial integration suite (`crates/barbacane-test/tests/security/`) and `cargo-fuzz` targets (`fuzz/`).
1818
- **security**: WASM sandbox resource limits — buffered plugin HTTP responses are capped (`BARBACANE_MAX_UPSTREAM_RESPONSE_BYTES`, default 16 MiB); the host cache and rate limiter bound their entry/partition counts and clamp plugin-supplied TTL/window/quota; a wall-clock epoch deadline backstops fuel-based CPU limiting.
19+
- **security**: ingress DoS hardening — a per-request header-read deadline (slowloris defense, doubling as the HTTP keep-alive idle timeout, wiring the previously-ignored `--keepalive-timeout`), a TLS handshake timeout, an HTTP/2 concurrent-stream cap, and a concurrent-connection ceiling (`BARBACANE_MAX_CONNECTIONS`, default 10000).
1920
- **docs**: [Configuration & environment variables](reference/configuration.md) reference.
2021

2122
### Changed (breaking, secure-by-default)
@@ -30,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3031
- **security**: fail-open middleware short-circuit downgrade in the WASM chain.
3132
- **security**: panic on hostile `x-request-id` / `traceparent`; unbounded Prometheus path-label cardinality on unmatched routes.
3233
- **security**: panic vectors in WASM host functions — guest-controlled pointer/length slice reads now use saturating arithmetic, and cache/rate-limiter time arithmetic is overflow/underflow-safe.
34+
- **security**: chunked request bodies with no `Content-Length` are now size-capped while streaming (`http_body_util::Limited`) instead of being fully buffered before the limit check.
3335
- **deps**: bump `anyhow` to 1.0.103 (RUSTSEC-2026-0190).
3436

3537
## [0.7.0] - 2026-05-05

crates/barbacane-test/tests/security/dos.rs

Lines changed: 63 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
//! request path on the `RouteMatch::NotFound` arm
1818
//! (`crates/barbacane/src/main.rs`), so an attacker can blow up metric
1919
//! cardinality (memory DoS) and exfiltrate scanned paths. The fix replaces
20-
//! the label with a sentinel such as `<not_found>`.
20+
//! the label with an `<unmatched>` sentinel.
2121
2222
use barbacane_test::TestGateway;
2323

@@ -27,21 +27,24 @@ use crate::{fixture, security_fixture};
2727
// 1. Oversized chunked body -> 413
2828
// ---------------------------------------------------------------------------
2929

30-
/// A chunked request whose body exceeds the configured limit must get 413.
30+
/// A chunked request whose body exceeds the gateway's body-size limit must be
31+
/// rejected with 413 while streaming — not buffered in full and then checked.
3132
///
32-
/// `request-size-limit.yaml` caps `/limited` at 100 bytes. We send ~64 KiB with
33+
/// We boot the gateway with `--max-body-size 100` and POST ~64 KiB with
3334
/// `Transfer-Encoding: chunked` (reqwest streams a body of unknown length as
34-
/// chunked). The hardened gateway rejects it with 413.
35+
/// chunked, so there is no Content-Length). The hardened gateway caps the read
36+
/// (`http_body_util::Limited`) and returns 413 before the body is fully
37+
/// buffered or schema validation runs.
3538
#[tokio::test]
3639
async fn oversized_chunked_body_rejected_with_413() {
37-
// EXPECTED TO FAIL until BARB-SEC-003 is fixed (chunked bodies without a
38-
// Content-Length must be size-capped while streaming, not buffered then
39-
// checked / accepted).
40-
let gw = TestGateway::from_spec(&fixture("request-size-limit.yaml"))
41-
.await
42-
.expect("failed to start gateway");
40+
let gw = TestGateway::from_spec_with_args(
41+
&fixture("request-size-limit.yaml"),
42+
&["--max-body-size", "100"],
43+
)
44+
.await
45+
.expect("failed to start gateway");
4346

44-
// A 64 KiB payload (limit is 100 bytes). Using a streaming body forces
47+
// A 64 KiB payload (gateway limit is 100 bytes). A streaming body forces
4548
// chunked transfer-encoding (no Content-Length).
4649
let big = vec![b'a'; 64 * 1024];
4750
let body = reqwest::Body::wrap_stream(futures_util::stream::once(async move {
@@ -68,23 +71,54 @@ async fn oversized_chunked_body_rejected_with_413() {
6871
// ---------------------------------------------------------------------------
6972

7073
/// Slowloris: a client that opens a connection and dribbles bytes (or never
71-
/// finishes the body) must be timed out so it cannot pin a worker indefinitely.
74+
/// finishes the request headers) must be timed out so it cannot pin a worker
75+
/// indefinitely.
7276
///
73-
/// This is inherently timing-dependent and would require holding a socket open
74-
/// and measuring that the server closes it within a header/body read deadline.
75-
/// Asserting that deterministically (without sleeps that make CI flaky) needs a
76-
/// configurable, observable read timeout we can drive from the test. Parked
77-
/// until the gateway exposes a read/header timeout knob we can set low and a
78-
/// signal we can assert on.
77+
/// We boot the gateway with `--keepalive-timeout 2`, which drives the
78+
/// per-request header-read deadline. Then we open a raw TCP socket, send a
79+
/// partial request (request line + one header, but never the terminating blank
80+
/// line) and assert the server closes the connection well within a generous
81+
/// window rather than waiting forever.
7982
#[tokio::test]
80-
#[ignore = "BLOCKED: needs a configurable+observable read/header timeout to assert slowloris defence without flaky wall-clock sleeps (BARB-SEC-003)"]
8183
async fn slowloris_connection_is_timed_out() {
82-
// EXPECTED TO FAIL until BARB-SEC-003 (slowloris) is addressed.
83-
//
84-
// Intended shape: open a raw TCP socket to the gateway, send a partial
85-
// request ("GET /health HTTP/1.1\r\nHost: x\r\n") and then stop, and assert
86-
// the server closes the connection within N seconds rather than waiting
87-
// forever. Requires a deterministic timeout to assert against.
84+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
85+
use tokio::net::TcpStream;
86+
87+
let gw = TestGateway::from_spec_with_args(
88+
&fixture("request-size-limit.yaml"),
89+
&["--keepalive-timeout", "2"],
90+
)
91+
.await
92+
.expect("failed to start gateway");
93+
94+
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", gw.port()))
95+
.await
96+
.expect("connect");
97+
98+
// Partial request: never send the blank line that ends the header block.
99+
// A vulnerable server would wait on this read forever.
100+
stream
101+
.write_all(b"GET /limited HTTP/1.1\r\nHost: x\r\n")
102+
.await
103+
.expect("write");
104+
stream.flush().await.expect("flush");
105+
106+
// A hardened server hits the header-read deadline and closes the connection
107+
// (EOF / reset), optionally after a 408/400. The 15s window is far larger
108+
// than the 2s deadline, so this is not timing-sensitive.
109+
let mut buf = [0u8; 1024];
110+
match tokio::time::timeout(std::time::Duration::from_secs(15), stream.read(&mut buf)).await {
111+
Ok(Ok(0)) => { /* EOF: server closed the slow connection — good */ }
112+
Ok(Ok(n)) => {
113+
let resp = String::from_utf8_lossy(&buf[..n]);
114+
assert!(
115+
resp.contains("408") || resp.contains("400"),
116+
"expected the server to time out the slow connection, got: {resp}"
117+
);
118+
}
119+
Ok(Err(_)) => { /* connection reset by peer — server closed it, good */ }
120+
Err(_) => panic!("server did not time out the slowloris connection within 15s"),
121+
}
88122
}
89123

90124
// ---------------------------------------------------------------------------
@@ -93,13 +127,10 @@ async fn slowloris_connection_is_timed_out() {
93127

94128
/// Flooding unique unmatched paths must not create a distinct Prometheus series
95129
/// per raw path. We hit many random 404 paths, then scrape `/metrics` and
96-
/// assert (a) none of the raw flood paths appear as label values, and (b) a
97-
/// `<not_found>` sentinel label is present instead.
130+
/// assert (a) none of the raw flood paths appear as label values, and (b) an
131+
/// `<unmatched>` sentinel label is present instead.
98132
#[tokio::test]
99133
async fn not_found_flood_does_not_explode_metric_cardinality() {
100-
// EXPECTED TO FAIL until BARB-SEC-003 is fixed (RouteMatch::NotFound records
101-
// the raw request path as a metric label; should use a `<not_found>`
102-
// sentinel).
103134
let gw = TestGateway::from_spec(&security_fixture("metrics.yaml"))
104135
.await
105136
.expect("failed to start gateway");
@@ -133,7 +164,7 @@ async fn not_found_flood_does_not_explode_metric_cardinality() {
133164

134165
// (b) Unmatched requests should collapse to a sentinel label.
135166
assert!(
136-
metrics.contains("<not_found>"),
137-
"expected a `<not_found>` sentinel label for unmatched requests"
167+
metrics.contains("<unmatched>"),
168+
"expected an `<unmatched>` sentinel label for unmatched requests"
138169
);
139170
}

0 commit comments

Comments
 (0)