Skip to content

Commit 7cda19e

Browse files
authored
Merge pull request #97 from barbacane-dev/security/data-plane-misc
security(data-plane): canonical percent-decode, raw header limits, admin exposure warning
2 parents c4c833e + 1e4dcd0 commit 7cda19e

6 files changed

Lines changed: 155 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3535
- **plugin**: `jwt-auth` and `oidc-auth` now declare the `log` capability they use, so they load under WASM capability enforcement (they emit a one-time warning when no `audience` is configured).
3636
- **ci**: the adversarial security suite (`crates/barbacane-test/tests/security/`) now runs in CI.
3737
- **security (control plane)**: request-body size limits — 1 MiB default for JSON endpoints, 32 MiB for spec/plugin uploads — bound in-memory buffering; database errors are routed through the generic error mapper (no schema disclosure); upload filenames are sanitized to a safe basename (defeating path traversal into the compile temp dir and CRLF/quote injection in `Content-Disposition`); and concurrent WebSocket sessions are capped so unauthenticated sockets can't pile up during the registration window.
38+
- **security (data plane)**: a single canonical, UTF-8-correct percent-decoder is applied to query and path parameters before validation and dispatch (the previous per-byte decode corrupted multi-byte sequences); request header limits are enforced on the raw header map so duplicate header names can't undercount the header limit or skip per-value size checks; and the unauthenticated admin port (`/health`, `/metrics`, `/provenance`) logs a startup warning when bound to a non-loopback address.
3839
- **deps**: bump `anyhow` to 1.0.103 (RUSTSEC-2026-0190).
3940

4041
## [0.7.0] - 2026-05-05

crates/barbacane-test/tests/validation.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,14 +236,14 @@ async fn test_limits_body_size_exceeds_limit() {
236236
);
237237

238238
// Server may either:
239-
// 1. Return 400 before the client finishes sending
239+
// 1. Return 413 Payload Too Large before the client finishes sending
240240
// 2. Close the connection early (connection reset, broken pipe, etc.)
241241
// Both are valid behaviors for rejecting oversized bodies
242242
match gateway.post("/users", &large_body).await {
243243
Ok(resp) => {
244-
assert_eq!(resp.status(), 400);
244+
assert_eq!(resp.status(), 413);
245245
let body: serde_json::Value = resp.json().await.unwrap();
246-
assert_eq!(body["type"], "urn:barbacane:error:validation-failed");
246+
assert_eq!(body["type"], "urn:barbacane:error:payload-too-large");
247247
}
248248
Err(_) => {
249249
// Any connection error is acceptable when the server rejects a large body

crates/barbacane/src/admin.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ pub async fn start_admin_server(
4242
.await
4343
.map_err(|e| format!("admin: failed to bind to {}: {}", addr, e))?;
4444

45+
// The admin endpoints (/health, /metrics, /provenance) are unauthenticated
46+
// by design (metrics scraping). /provenance and /metrics expose build and
47+
// operational metadata, so warn when the port is reachable off-host.
48+
if !addr.ip().is_loopback() {
49+
tracing::warn!(
50+
%addr,
51+
"admin API (incl. /provenance, /metrics) is bound to a non-loopback \
52+
address and is UNAUTHENTICATED; restrict it to loopback or place it \
53+
behind a trusted network boundary"
54+
);
55+
}
56+
4557
loop {
4658
tokio::select! {
4759
_ = shutdown_rx.changed() => {

crates/barbacane/src/main.rs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,15 +1099,34 @@ impl Gateway {
10991099
)));
11001100
}
11011101

1102-
// Extract headers for validation
1102+
// Enforce header limits on the RAW header map first: `HeaderMap::len()`
1103+
// counts every header line (including repeated names), so a flood of
1104+
// duplicate-named headers can't slip under the count limit, and each
1105+
// value is size-checked (not just the last one for a given name).
1106+
let raw_headers = req.headers();
1107+
let header_limit_error = self
1108+
.limits
1109+
.validate_header_count(raw_headers.len())
1110+
.err()
1111+
.or_else(|| {
1112+
raw_headers.iter().find_map(|(name, value)| {
1113+
self.limits
1114+
.validate_header_size(name.as_str(), name.as_str().len() + value.len())
1115+
.err()
1116+
})
1117+
});
1118+
1119+
// Extract headers for downstream validation and plugin forwarding. This
1120+
// collapses duplicate names (last value wins), which is fine now that the
1121+
// limit checks above ran against the raw map.
11031122
let headers: HashMap<String, String> = req
11041123
.headers()
11051124
.iter()
11061125
.filter_map(|(k, v)| Some((k.as_str().to_string(), v.to_str().ok()?.to_string())))
11071126
.collect();
11081127

11091128
// Check header limits
1110-
if let Err(e) = self.limits.validate_headers(&headers) {
1129+
if let Some(e) = header_limit_error {
11111130
let response = self.validation_error_response(&[e]);
11121131
self.record_request_metrics(
11131132
&method_str,
@@ -1148,7 +1167,14 @@ impl Gateway {
11481167

11491168
// Route lookup
11501169
match self.router.lookup(&path, &method_str) {
1151-
RouteMatch::Found { entry, params } => {
1170+
RouteMatch::Found { entry, mut params } => {
1171+
// Canonically percent-decode captured path-parameter values once,
1172+
// so routing, validation, and the dispatcher all see the same
1173+
// decoded value (segments were matched raw; `%2F` stays a literal
1174+
// slash within its segment rather than acting as a separator).
1175+
for (_, value) in params.iter_mut() {
1176+
*value = barbacane_lib::validator::percent_decode(value);
1177+
}
11521178
let operation = &self.operations[entry.operation_index];
11531179
let validator = &self.validators[entry.operation_index];
11541180
let route_path = operation.path.clone();

crates/barbacane/src/validator.rs

Lines changed: 100 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,32 @@ impl RequestLimits {
243243
Ok(())
244244
}
245245

246+
/// Validate the raw header count. Unlike [`validate_headers`], which operates
247+
/// on a name→value map that collapses duplicate names, this counts the total
248+
/// number of header lines so repeated names cannot undercount the limit.
249+
pub fn validate_header_count(&self, count: usize) -> Result<(), ValidationError2> {
250+
if count > self.max_headers {
251+
return Err(ValidationError2::TooManyHeaders {
252+
count,
253+
limit: self.max_headers,
254+
});
255+
}
256+
Ok(())
257+
}
258+
259+
/// Validate a single header line's size (name bytes + value bytes). Applied
260+
/// per raw header so a duplicate name's value isn't skipped.
261+
pub fn validate_header_size(&self, name: &str, size: usize) -> Result<(), ValidationError2> {
262+
if size > self.max_header_size {
263+
return Err(ValidationError2::HeaderTooLarge {
264+
name: name.to_string(),
265+
size,
266+
limit: self.max_header_size,
267+
});
268+
}
269+
Ok(())
270+
}
271+
246272
/// Validate body size.
247273
pub fn validate_body_size(&self, body_len: usize) -> Result<(), ValidationError2> {
248274
if body_len > self.max_body_size {
@@ -437,7 +463,7 @@ impl OperationValidator {
437463
let mut parts = pair.splitn(2, '=');
438464
let key = parts.next()?;
439465
let value = parts.next().unwrap_or("");
440-
Some((urlencoding_decode(key), urlencoding_decode(value)))
466+
Some((percent_decode(key), percent_decode(value)))
441467
})
442468
.collect();
443469

@@ -582,28 +608,49 @@ impl OperationValidator {
582608
}
583609
}
584610

585-
/// Simple URL decoding (handles %XX escapes).
586-
fn urlencoding_decode(input: &str) -> String {
587-
let mut result = String::with_capacity(input.len());
588-
let mut chars = input.chars().peekable();
589-
590-
while let Some(c) = chars.next() {
591-
if c == '%' {
592-
let hex: String = chars.by_ref().take(2).collect();
593-
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
594-
result.push(byte as char);
595-
} else {
596-
result.push('%');
597-
result.push_str(&hex);
611+
/// Canonical percent-decoding of a URL component (handles `%XX` escapes and `+`).
612+
///
613+
/// Decodes into a byte buffer and interprets the result as UTF-8, so multi-byte
614+
/// sequences like `%C3%A9` decode to `é` rather than being corrupted by a naive
615+
/// per-byte `as char` cast. This is the single decoder used for both query and
616+
/// path parameters so routing and validation agree on the decoded value.
617+
pub fn percent_decode(input: &str) -> String {
618+
let bytes = input.as_bytes();
619+
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
620+
let mut i = 0;
621+
while i < bytes.len() {
622+
match bytes[i] {
623+
b'%' if i + 2 < bytes.len() => match (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
624+
(Some(h), Some(l)) => {
625+
out.push((h << 4) | l);
626+
i += 3;
627+
}
628+
_ => {
629+
out.push(b'%');
630+
i += 1;
631+
}
632+
},
633+
b'+' => {
634+
out.push(b' ');
635+
i += 1;
636+
}
637+
b => {
638+
out.push(b);
639+
i += 1;
598640
}
599-
} else if c == '+' {
600-
result.push(' ');
601-
} else {
602-
result.push(c);
603641
}
604642
}
643+
String::from_utf8_lossy(&out).into_owned()
644+
}
605645

606-
result
646+
/// Parse a single ASCII hex digit into its 0..=15 value.
647+
fn hex_val(b: u8) -> Option<u8> {
648+
match b {
649+
b'0'..=b'9' => Some(b - b'0'),
650+
b'a'..=b'f' => Some(b - b'a' + 10),
651+
b'A'..=b'F' => Some(b - b'A' + 10),
652+
_ => None,
653+
}
607654
}
608655

609656
#[cfg(test)]
@@ -1313,4 +1360,38 @@ mod tests {
13131360
let result = validator.validate_querystring(Some("anything"));
13141361
assert!(result.is_ok());
13151362
}
1363+
1364+
#[test]
1365+
fn percent_decode_handles_multibyte_utf8() {
1366+
// %C3%A9 is UTF-8 for é; the old per-byte `as char` cast corrupted this.
1367+
assert_eq!(percent_decode("caf%C3%A9"), "café");
1368+
// %E2%82%AC is the euro sign €.
1369+
assert_eq!(percent_decode("%E2%82%AC"), "€");
1370+
// ASCII, plus-as-space, and passthrough.
1371+
assert_eq!(percent_decode("a%20b+c"), "a b c");
1372+
// A malformed escape is left intact rather than dropped.
1373+
assert_eq!(percent_decode("100%"), "100%");
1374+
assert_eq!(percent_decode("%zz"), "%zz");
1375+
}
1376+
1377+
#[test]
1378+
fn header_count_counts_duplicates() {
1379+
let limits = RequestLimits {
1380+
max_headers: 2,
1381+
..Default::default()
1382+
};
1383+
// A map collapses duplicate names, but the raw count is what matters.
1384+
assert!(limits.validate_header_count(2).is_ok());
1385+
assert!(limits.validate_header_count(3).is_err());
1386+
}
1387+
1388+
#[test]
1389+
fn header_size_is_checked_per_entry() {
1390+
let limits = RequestLimits {
1391+
max_header_size: 10,
1392+
..Default::default()
1393+
};
1394+
assert!(limits.validate_header_size("x", 10).is_ok());
1395+
assert!(limits.validate_header_size("x-big", 11).is_err());
1396+
}
13161397
}

docs/reference/configuration.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ These changes are intentional secure defaults. Adopt them as follows:
4747
capabilities; custom plugins must list theirs under
4848
`[capabilities] host_functions = [...]`.
4949

50+
## Admin endpoints (loopback by default)
51+
52+
The data plane serves `/health`, `/metrics`, and `/provenance` on a dedicated
53+
admin port (`--admin-bind`). These endpoints are **unauthenticated** so metrics
54+
scrapers can reach them; `/provenance` and `/metrics` expose build and
55+
operational metadata. Keep the admin port bound to loopback (the default) or
56+
behind a trusted network boundary. Binding it to a non-loopback address (e.g.
57+
`--admin-bind 0.0.0.0:...`) logs a startup warning because it exposes that
58+
metadata off-host.
59+
5060
## Artifact signing quickstart
5161

5262
```bash

0 commit comments

Comments
 (0)