Skip to content

Commit bf1d4f8

Browse files
authored
Merge pull request #91 from barbacane-dev/security/plugin-bypass-fixes
Security: fix plugin auth/access bypasses + fail-open controls
2 parents 68736e0 + 8b9385e commit bf1d4f8

20 files changed

Lines changed: 827 additions & 374 deletions

File tree

crates/barbacane-plugin-sdk/src/types.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,44 @@ pub fn streamed_response() -> Response {
143143
}
144144
}
145145

146+
/// Resolve the effective client IP, honoring trusted proxies.
147+
///
148+
/// `req.client_ip` is the peer the gateway actually observed. The forwarded
149+
/// headers (`X-Forwarded-For`, `X-Real-IP`) are attacker-controlled, so they are
150+
/// consulted **only** when the immediate peer is one of `trusted_proxies`; then
151+
/// the right-most forwarded address that is not itself a trusted proxy is
152+
/// returned. With no trusted proxies configured (the default), forwarded headers
153+
/// are ignored entirely and the observed peer is used.
154+
///
155+
/// This is the single source of truth for client-IP resolution so security
156+
/// middlewares (ip-restriction, rate-limit, ai-token-limit) cannot drift into
157+
/// independently spoofable implementations.
158+
pub fn resolve_client_ip(req: &Request, trusted_proxies: &[String]) -> String {
159+
let peer = req.client_ip.trim();
160+
let is_trusted = |ip: &str| trusted_proxies.iter().any(|p| p.trim() == ip);
161+
162+
if !is_trusted(peer) {
163+
return peer.to_string();
164+
}
165+
166+
if let Some(xff) = req.headers.get("x-forwarded-for") {
167+
for hop in xff.split(',').map(str::trim).rev() {
168+
if !hop.is_empty() && !is_trusted(hop) {
169+
return hop.to_string();
170+
}
171+
}
172+
}
173+
174+
if let Some(real) = req.headers.get("x-real-ip") {
175+
let real = real.trim();
176+
if !real.is_empty() {
177+
return real.to_string();
178+
}
179+
}
180+
181+
peer.to_string()
182+
}
183+
146184
#[cfg(test)]
147185
mod tests {
148186
use super::*;
@@ -471,4 +509,38 @@ mod tests {
471509
.unwrap();
472510
assert_eq!(bytes, original);
473511
}
512+
513+
fn req_with(peer: &str, xff: Option<&str>) -> Request {
514+
let mut headers = BTreeMap::new();
515+
if let Some(v) = xff {
516+
headers.insert("x-forwarded-for".to_string(), v.to_string());
517+
}
518+
Request {
519+
method: "GET".into(),
520+
path: "/".into(),
521+
query: None,
522+
headers,
523+
body: None,
524+
client_ip: peer.to_string(),
525+
path_params: BTreeMap::new(),
526+
}
527+
}
528+
529+
#[test]
530+
fn resolve_client_ip_ignores_xff_from_untrusted_peer() {
531+
// No trusted proxies: a spoofed XFF must be ignored.
532+
let req = req_with("203.0.113.9", Some("10.0.0.5"));
533+
assert_eq!(resolve_client_ip(&req, &[]), "203.0.113.9");
534+
}
535+
536+
#[test]
537+
fn resolve_client_ip_uses_xff_only_behind_trusted_proxy() {
538+
let trusted = vec!["203.0.113.9".to_string()];
539+
// Peer is the trusted proxy: take the right-most non-trusted hop.
540+
let req = req_with("203.0.113.9", Some("198.51.100.7, 203.0.113.9"));
541+
assert_eq!(resolve_client_ip(&req, &trusted), "198.51.100.7");
542+
// Peer is NOT trusted: XFF ignored even though one is configured.
543+
let req2 = req_with("198.51.100.200", Some("10.0.0.5"));
544+
assert_eq!(resolve_client_ip(&req2, &trusted), "198.51.100.200");
545+
}
474546
}

plugins/ai-prompt-guard/config-schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@
6161
"description": "Named policy profiles.",
6262
"additionalProperties": { "$ref": "#/$defs/PromptProfile" },
6363
"minProperties": 1
64+
},
65+
"fail_open": {
66+
"type": "boolean",
67+
"default": false,
68+
"description": "When the request body can't be inspected (unparseable JSON or a non-array 'messages'), allow it through instead of rejecting. Defaults to false (fail closed) so the guard can't be bypassed with a malformed body."
6469
}
6570
}
6671
}

plugins/ai-prompt-guard/src/lib.rs

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ pub struct AiPromptGuard {
9191
/// Named profiles the operator can select between.
9292
profiles: BTreeMap<String, PromptProfile>,
9393

94+
/// When the request body can't be inspected (unparseable JSON, or a
95+
/// `messages` field that isn't an array), allow it through (fail open)
96+
/// instead of rejecting. Defaults to false (fail closed), so a client can't
97+
/// bypass the guard by sending a body the parser rejects.
98+
#[serde(default)]
99+
fail_open: bool,
100+
94101
/// Compiled regex cache, keyed by profile name. Populated lazily.
95102
#[serde(skip)]
96103
compiled: BTreeMap<String, Vec<Regex>>,
@@ -129,17 +136,37 @@ impl AiPromptGuard {
129136
return Action::ShortCircuit(regex_compile_error_response(&profile_name, &err));
130137
}
131138

139+
// No body: nothing to inspect (and nothing to inject into).
132140
let Some(body_bytes) = req.body.as_deref() else {
133141
return Action::Continue(req);
134142
};
135143

144+
// Unparseable body is the classic guard-bypass vector. Fail closed by
145+
// default so a client can't skip filtering by sending malformed JSON.
136146
let mut root: serde_json::Value = match serde_json::from_slice(body_bytes) {
137147
Ok(v) => v,
138-
Err(_) => return Action::Continue(req),
148+
Err(_) => {
149+
if self.fail_open {
150+
return Action::Continue(req);
151+
}
152+
return Action::ShortCircuit(reject(&profile, "request body is not valid JSON"));
153+
}
139154
};
140155

141-
let Some(messages) = root.get("messages").and_then(|v| v.as_array()).cloned() else {
142-
return Action::Continue(req);
156+
let messages = match root.get("messages") {
157+
// No `messages` field at all: a different request shape with no chat
158+
// messages to guard — pass through.
159+
None => return Action::Continue(req),
160+
// Present but not an array: malformed; fail closed unless opted out.
161+
Some(v) => match v.as_array() {
162+
Some(arr) => arr.clone(),
163+
None => {
164+
if self.fail_open {
165+
return Action::Continue(req);
166+
}
167+
return Action::ShortCircuit(reject(&profile, "'messages' is not an array"));
168+
}
169+
},
143170
};
144171

145172
// --- Message count limit ---
@@ -493,6 +520,7 @@ mod tests {
493520
.into_iter()
494521
.map(|(k, v)| (k.to_string(), v))
495522
.collect(),
523+
fail_open: false,
496524
compiled: BTreeMap::new(),
497525
compile_errors: BTreeMap::new(),
498526
}
@@ -855,13 +883,36 @@ mod tests {
855883
assert!(matches!(p.on_request(r), Action::Continue(_)));
856884
}
857885

886+
// PL-6: an unparseable body must NOT bypass the guard by default.
858887
#[test]
859-
fn non_json_body_continues() {
888+
fn non_json_body_fails_closed() {
860889
mock_host::reset();
861890
let mut p = single_profile_plugin(profile_with(Some(5), None, vec![]));
891+
match p.on_request(req("not json")) {
892+
Action::ShortCircuit(resp) => assert_eq!(resp.status, 400),
893+
Action::Continue(_) => panic!("unparseable body must fail closed"),
894+
}
895+
}
896+
897+
#[test]
898+
fn non_json_body_continues_when_fail_open() {
899+
mock_host::reset();
900+
let mut p = single_profile_plugin(profile_with(Some(5), None, vec![]));
901+
p.fail_open = true;
862902
assert!(matches!(p.on_request(req("not json")), Action::Continue(_)));
863903
}
864904

905+
// PL-6: a `messages` field that isn't an array fails closed by default.
906+
#[test]
907+
fn non_array_messages_fails_closed() {
908+
mock_host::reset();
909+
let mut p = single_profile_plugin(profile_with(Some(5), None, vec![]));
910+
match p.on_request(req(r#"{"messages":"oops"}"#)) {
911+
Action::ShortCircuit(resp) => assert_eq!(resp.status, 400),
912+
Action::Continue(_) => panic!("non-array messages must fail closed"),
913+
}
914+
}
915+
865916
#[test]
866917
fn body_without_messages_continues() {
867918
mock_host::reset();

plugins/ai-proxy/config-schema.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
},
2424
"api_key": {
2525
"type": "string",
26-
"description": "Provider API key. Supports `env://VAR` substitution. Omit for Ollama (unauthenticated local endpoint)."
26+
"description": "Provider API key. Use a secret reference (`env://VAR` or `file://...`) rather than a literal value, so the key is resolved at runtime and never baked into the compiled artifact. Omit for Ollama (unauthenticated local endpoint)."
2727
},
2828
"base_url": {
2929
"type": "string",
@@ -58,7 +58,7 @@
5858
},
5959
"api_key": {
6060
"type": "string",
61-
"description": "Provider API key. Supports `env://VAR` substitution."
61+
"description": "Provider API key. Use a secret reference (`env://VAR` or `file://...`) rather than a literal value, so the key is resolved at runtime and never baked into the compiled artifact."
6262
},
6363
"base_url": {
6464
"type": "string",
@@ -84,7 +84,7 @@
8484
},
8585
"api_key": {
8686
"type": "string",
87-
"description": "API key for the flat config. Supports `env://VAR` substitution."
87+
"description": "API key for the flat config. Use a secret reference (`env://VAR` or `file://...`) rather than a literal value, so the key is resolved at runtime and never baked into the compiled artifact."
8888
},
8989
"base_url": {
9090
"type": "string",

plugins/ai-token-limit/config-schema.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@
5151
"description": "Source of the per-consumer partition key. Accepted forms: `client_ip`, `header:<name>`, `context:<key>`, or a literal string (shared budget across all requests). Matches the `rate-limit` plugin's semantics.",
5252
"default": "client_ip"
5353
},
54+
"trusted_proxies": {
55+
"type": "array",
56+
"items": { "type": "string" },
57+
"default": [],
58+
"description": "Exact IPs of trusted reverse proxies. With partition_key 'client_ip', X-Forwarded-For / X-Real-IP are honored only behind these; otherwise the observed peer IP is used so clients cannot rotate XFF to evade their budget."
59+
},
60+
"fail_open": {
61+
"type": "boolean",
62+
"default": false,
63+
"description": "When the host rate limiter is unavailable, allow the request (fail open) instead of rejecting with 503. Defaults to false (fail closed)."
64+
},
5465
"count": {
5566
"type": "string",
5667
"description": "Which token counts charge against the budget. `prompt` counts input tokens only, `completion` counts output tokens only, `total` counts both.",

0 commit comments

Comments
 (0)