Skip to content

Commit fa5c600

Browse files
committed
fix(supervisor-network): check the canonical target for encoded slashes
Signed-off-by: Adrien Langou <alangou@nvidia.com>
1 parent baf9780 commit fa5c600

2 files changed

Lines changed: 178 additions & 13 deletions

File tree

crates/openshell-supervisor-network/src/l7/relay.rs

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -569,14 +569,20 @@ where
569569
// on this host:port. Re-check it against the config that actually
570570
// matched: the opt-in is per-endpoint, and one endpoint enabling it
571571
// must not loosen parsing for the others.
572+
// Check `req.target`, not `route_target`: redaction percent-decodes any
573+
// segment holding a credential placeholder and re-inserts the redacted
574+
// form without re-encoding, so a `%2F` sharing that segment becomes a
575+
// literal `/` and would escape this check.
572576
if !config.allow_encoded_slash
573-
&& crate::l7::path::canonical_path_has_encoded_slash(&route_target)
577+
&& crate::l7::path::canonical_path_has_encoded_slash(&req.target)
574578
{
579+
let detail = "request-target contains an encoded '/' (%2F) which is not allowed on this endpoint";
580+
emit_parse_rejection(ctx, detail, engine_type_for_protocol(config.protocol));
575581
crate::l7::rest::RestProvider::default()
576582
.deny_with_redacted_target(
577583
&req,
578584
&ctx.policy_name,
579-
"request-target contains an encoded '/' (%2F) which is not allowed on this endpoint",
585+
detail,
580586
client,
581587
None,
582588
Some(crate::l7::rest::DenyResponseContext::from_l7_context(ctx)),
@@ -6184,7 +6190,11 @@ network_policies:
61846190
.clone_engine_for_tunnel(engine.current_generation())
61856191
.unwrap();
61866192
let configs = encoded_slash_scoping_configs();
6187-
let ctx = encoded_slash_scoping_ctx();
6193+
let (activity_tx, mut activity_rx) = tokio::sync::mpsc::channel(1);
6194+
let ctx = L7EvalContext {
6195+
activity_tx: Some(activity_tx),
6196+
..encoded_slash_scoping_ctx()
6197+
};
61886198

61896199
let (mut app, mut relay_client) = tokio::io::duplex(8192);
61906200
let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192);
@@ -6217,6 +6227,85 @@ network_policies:
62176227
"denial must name the encoded-slash reason: {response}"
62186228
);
62196229

6230+
let mut upstream_bytes = [0u8; 16];
6231+
let result = tokio::time::timeout(
6232+
std::time::Duration::from_millis(100),
6233+
upstream.read(&mut upstream_bytes),
6234+
)
6235+
.await;
6236+
assert!(
6237+
matches!(result, Err(_) | Ok(Ok(0))),
6238+
"request must not reach upstream"
6239+
);
6240+
let activity = tokio::time::timeout(std::time::Duration::from_secs(1), activity_rx.recv())
6241+
.await
6242+
.expect("parse rejection activity should be emitted")
6243+
.expect("activity channel should remain open");
6244+
assert!(activity.denied);
6245+
assert_eq!(activity.deny_group, "l7_parse_rejection");
6246+
6247+
drop(app);
6248+
tokio::time::timeout(std::time::Duration::from_secs(1), relay)
6249+
.await
6250+
.expect("relay should finish")
6251+
.unwrap()
6252+
.unwrap();
6253+
}
6254+
6255+
/// Credential redaction percent-decodes any segment holding a placeholder
6256+
/// and re-inserts the redacted form without re-encoding it. A `%2F` sharing
6257+
/// that segment therefore becomes a literal `/` in the redacted target, so
6258+
/// the scoping check must read the canonical target rather than the
6259+
/// redacted one — otherwise a placeholder is enough to smuggle an encoded
6260+
/// slash past an endpoint that never opted in.
6261+
#[tokio::test]
6262+
async fn route_selected_encoded_slash_check_survives_credential_redaction() {
6263+
let engine = OpaEngine::from_strings(TEST_POLICY, ENCODED_SLASH_SCOPING_POLICY).unwrap();
6264+
let tunnel_engine = engine
6265+
.clone_engine_for_tunnel(engine.current_generation())
6266+
.unwrap();
6267+
let configs = encoded_slash_scoping_configs();
6268+
let (child_env, resolver) = SecretResolver::from_provider_env(
6269+
std::iter::once(("TOKEN".to_string(), "real-token".to_string())).collect(),
6270+
);
6271+
let placeholder = child_env.get("TOKEN").expect("placeholder env").clone();
6272+
let ctx = L7EvalContext {
6273+
secret_resolver: resolver.map(Arc::new),
6274+
..encoded_slash_scoping_ctx()
6275+
};
6276+
6277+
let (mut app, mut relay_client) = tokio::io::duplex(8192);
6278+
let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192);
6279+
let relay = tokio::spawn(async move {
6280+
relay_with_route_selection(
6281+
&configs,
6282+
tunnel_engine,
6283+
&mut relay_client,
6284+
&mut relay_upstream,
6285+
&ctx,
6286+
)
6287+
.await
6288+
});
6289+
6290+
// Placeholder and encoded slash in the same segment, on the endpoint
6291+
// that did NOT opt into encoded slashes.
6292+
let request = format!(
6293+
"GET /admin/{placeholder}%2Fx HTTP/1.1\r\nHost: gateway.example.test\r\nConnection: close\r\n\r\n"
6294+
);
6295+
app.write_all(request.as_bytes()).await.unwrap();
6296+
6297+
let mut response = [0u8; 1024];
6298+
let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response))
6299+
.await
6300+
.expect("denial should reach client")
6301+
.unwrap();
6302+
let response = String::from_utf8_lossy(&response[..n]);
6303+
assert!(response.contains("403 Forbidden"), "{response}");
6304+
assert!(
6305+
response.contains("not allowed on this endpoint"),
6306+
"redaction must not hide the encoded slash: {response}"
6307+
);
6308+
62206309
let mut upstream_bytes = [0u8; 16];
62216310
let result = tokio::time::timeout(
62226311
std::time::Duration::from_millis(100),

crates/openshell-supervisor-network/src/proxy.rs

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ const TUNNEL_PROTOCOL_PEEK_POLL: std::time::Duration = std::time::Duration::from
5959
const TUNNEL_PROTOCOL_PEEK_POLL: std::time::Duration = std::time::Duration::from_millis(1);
6060
const INFERENCE_LOCAL_HOST: &str = "inference.local";
6161
const INFERENCE_LOCAL_PORT: u16 = 443;
62+
const FORWARD_ENCODED_SLASH_REJECTION_DETAIL: &str =
63+
"request-target contains an encoded '/' (%2F) which is not allowed on this endpoint";
6264
#[cfg(target_os = "linux")]
6365
const SIDECAR_SUPERVISOR_TOPOLOGY: &str = "sidecar";
6466

@@ -908,6 +910,41 @@ fn build_forward_parse_error_ocsf_event(path: &str) -> openshell_ocsf::OcsfEvent
908910
.build()
909911
}
910912

913+
#[allow(clippy::too_many_arguments)]
914+
fn build_forward_l7_parse_rejection_ocsf_event(
915+
peer_addr: SocketAddr,
916+
method: &str,
917+
host: &str,
918+
port: u16,
919+
path: &str,
920+
binary: &str,
921+
pid: &str,
922+
ancestors: &str,
923+
cmdline: &str,
924+
policy: &str,
925+
detail: &str,
926+
) -> openshell_ocsf::OcsfEvent {
927+
HttpActivityBuilder::new(openshell_ocsf::ctx::ctx())
928+
.activity(ActivityId::Other)
929+
.action(ActionId::Denied)
930+
.disposition(DispositionId::Blocked)
931+
.severity(SeverityId::Medium)
932+
.status(StatusId::Failure)
933+
.http_request(HttpRequest::new(
934+
method,
935+
OcsfUrl::new("http", host, path, port),
936+
))
937+
.dst_endpoint(Endpoint::from_domain(host, port))
938+
.src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port()))
939+
.actor_process(Process::from_bypass(binary, pid, ancestors).with_cmd_line(cmdline))
940+
.firewall_rule(policy, "l7")
941+
.message(format!(
942+
"FORWARD_L7 denied non-canonical request-target for {method} {host}:{port}{path}"
943+
))
944+
.status_detail(detail)
945+
.build()
946+
}
947+
911948
#[allow(clippy::too_many_arguments)]
912949
fn build_forward_policy_deny_ocsf_event(
913950
peer_addr: SocketAddr,
@@ -4458,16 +4495,19 @@ async fn handle_forward_proxy(
44584495
if !l7_config.config.allow_encoded_slash
44594496
&& crate::l7::path::canonical_path_has_encoded_slash(&path)
44604497
{
4461-
let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx())
4462-
.activity(ActivityId::Fail)
4463-
.severity(SeverityId::Medium)
4464-
.status(StatusId::Failure)
4465-
.dst_endpoint(Endpoint::from_domain(&host_lc, port))
4466-
.message(
4467-
"FORWARD rejecting non-canonical request-target: request-target contains an encoded '/' (%2F) which is not allowed on this endpoint".to_string(),
4468-
)
4469-
.build();
4470-
ocsf_emit!(event);
4498+
ocsf_emit!(build_forward_l7_parse_rejection_ocsf_event(
4499+
workload_addr,
4500+
method,
4501+
&host_lc,
4502+
port,
4503+
&telemetry_path,
4504+
&binary_str,
4505+
&pid_str,
4506+
&ancestors_str,
4507+
&cmdline_str,
4508+
policy_str,
4509+
FORWARD_ENCODED_SLASH_REJECTION_DETAIL,
4510+
));
44714511
emit_activity_simple(activity_tx, true, "forward_parse_rejection");
44724512
respond(
44734513
client,
@@ -5645,6 +5685,42 @@ network_policies: {}
56455685
assert_eq!(json["disposition"], "Blocked");
56465686
}
56475687

5688+
#[test]
5689+
fn forward_l7_parse_rejection_ocsf_includes_denial_context() {
5690+
let event = build_forward_l7_parse_rejection_ocsf_event(
5691+
"127.0.0.1:45123".parse().unwrap(),
5692+
"GET",
5693+
"api.example.com",
5694+
80,
5695+
"/admin/x%2Fy",
5696+
"/usr/bin/curl",
5697+
"42",
5698+
"/usr/bin/bash",
5699+
"curl http://api.example.com/admin/x%2Fy",
5700+
"allow_api",
5701+
FORWARD_ENCODED_SLASH_REJECTION_DETAIL,
5702+
);
5703+
let json = event.to_json().unwrap();
5704+
5705+
assert_eq!(json["class_name"], "HTTP Activity");
5706+
assert_eq!(json["activity_name"], "Other");
5707+
assert_eq!(json["action"], "Denied");
5708+
assert_eq!(json["disposition"], "Blocked");
5709+
assert_eq!(json["severity"], "Medium");
5710+
assert_eq!(json["status"], "Failure");
5711+
assert_eq!(json["http_request"]["http_method"], "GET");
5712+
assert_eq!(json["http_request"]["url"]["path"], "/admin/x%2Fy");
5713+
assert_eq!(json["dst_endpoint"]["domain"], "api.example.com");
5714+
assert_eq!(json["dst_endpoint"]["port"], 80);
5715+
assert_eq!(json["actor"]["process"]["name"], "/usr/bin/curl");
5716+
assert_eq!(json["firewall_rule"]["name"], "allow_api");
5717+
assert_eq!(json["firewall_rule"]["type"], "l7");
5718+
assert_eq!(
5719+
json["status_detail"],
5720+
FORWARD_ENCODED_SLASH_REJECTION_DETAIL
5721+
);
5722+
}
5723+
56485724
#[test]
56495725
fn forward_ocsf_events_omit_queries_and_credential_key_names() {
56505726
let peer = "127.0.0.1:45123".parse().unwrap();

0 commit comments

Comments
 (0)