Skip to content

Commit baf9780

Browse files
committed
fix(supervisor-network): scope allow_encoded_slash to the matched L7 endpoint
Signed-off-by: Adrien Langou <alangou@nvidia.com>
1 parent c840a69 commit baf9780

3 files changed

Lines changed: 276 additions & 0 deletions

File tree

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,20 @@ pub fn canonicalize_request_target(
218218
))
219219
}
220220

221+
/// Report whether a canonical path carries a `%2F` that survived
222+
/// canonicalization.
223+
///
224+
/// Callers that canonicalize before they know which endpoint config applies
225+
/// use this to re-check the result against the config that actually matched.
226+
///
227+
/// The test is exact: [`build_canonical_path`] emits the literal `%2F` only
228+
/// for the encoded-slash sentinel, and percent-encodes any other `%` byte as
229+
/// `%25`, so no `%2F` substring can reach the output by another route.
230+
#[must_use]
231+
pub fn canonical_path_has_encoded_slash(canonical_path: &str) -> bool {
232+
canonical_path.contains("%2F")
233+
}
234+
221235
// ---------------------------------------------------------------------------
222236
// Internals
223237
// ---------------------------------------------------------------------------
@@ -684,6 +698,35 @@ mod tests {
684698
);
685699
}
686700

701+
#[test]
702+
fn encoded_slash_detection_on_canonical_paths_is_exact() {
703+
let opts = CanonicalizeOptions {
704+
allow_encoded_slash: true,
705+
..CanonicalizeOptions::default()
706+
};
707+
708+
// A surviving sentinel is detected.
709+
let slug = canon_with("/repos/group%2fproject/issues", opts).unwrap();
710+
assert_eq!(slug, "/repos/group%2Fproject/issues");
711+
assert!(canonical_path_has_encoded_slash(&slug));
712+
713+
// Ordinary paths are not.
714+
assert!(!canonical_path_has_encoded_slash(
715+
&canon("/public/secret").unwrap()
716+
));
717+
718+
// A literal `%` in the input is re-emitted as `%25`, so it cannot
719+
// fabricate a `%2F` substring — including when the input spells out
720+
// `%252F`, which decodes to the three characters `%`, `2`, `F`.
721+
let escaped = canon("/a/%252F/b").unwrap();
722+
assert_eq!(escaped, "/a/%252F/b");
723+
assert!(!canonical_path_has_encoded_slash(&escaped));
724+
725+
let percent = canon("/a/100%25/b").unwrap();
726+
assert_eq!(percent, "/a/100%25/b");
727+
assert!(!canonical_path_has_encoded_slash(&percent));
728+
}
729+
687730
#[test]
688731
fn canonical_output_never_contains_dot_segments() {
689732
// The contract the policy engine relies on: whatever comes back is

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

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,26 @@ where
564564
.await?;
565565
return Ok(());
566566
};
567+
// The request was canonicalized before the matching config was known,
568+
// so `allow_encoded_slash` was taken permissively across every config
569+
// on this host:port. Re-check it against the config that actually
570+
// matched: the opt-in is per-endpoint, and one endpoint enabling it
571+
// must not loosen parsing for the others.
572+
if !config.allow_encoded_slash
573+
&& crate::l7::path::canonical_path_has_encoded_slash(&route_target)
574+
{
575+
crate::l7::rest::RestProvider::default()
576+
.deny_with_redacted_target(
577+
&req,
578+
&ctx.policy_name,
579+
"request-target contains an encoded '/' (%2F) which is not allowed on this endpoint",
580+
client,
581+
None,
582+
Some(crate::l7::rest::DenyResponseContext::from_l7_context(ctx)),
583+
)
584+
.await?;
585+
return Ok(());
586+
}
567587
if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? {
568588
return Ok(());
569589
}
@@ -6086,6 +6106,186 @@ network_policies:
60866106
.unwrap();
60876107
}
60886108

6109+
/// Policy allowing GET on both `/repos/**` and `/admin/**` for the same
6110+
/// host:port, so an encoded-slash denial can only come from the
6111+
/// per-endpoint `allow_encoded_slash` scoping.
6112+
const ENCODED_SLASH_SCOPING_POLICY: &str = r#"
6113+
network_policies:
6114+
route_api:
6115+
name: route_api
6116+
endpoints:
6117+
- host: gateway.example.test
6118+
port: 443
6119+
path: /repos/**
6120+
protocol: rest
6121+
enforcement: enforce
6122+
allow_encoded_slash: true
6123+
rules:
6124+
- allow:
6125+
method: GET
6126+
path: "/repos/**"
6127+
- host: gateway.example.test
6128+
port: 443
6129+
path: /admin/**
6130+
protocol: rest
6131+
enforcement: enforce
6132+
rules:
6133+
- allow:
6134+
method: GET
6135+
path: "/admin/**"
6136+
binaries:
6137+
- { path: /usr/bin/node }
6138+
"#;
6139+
6140+
fn encoded_slash_scoping_configs() -> Vec<L7EndpointConfig> {
6141+
let rest = |path: &str, allow_encoded_slash: bool| L7EndpointConfig {
6142+
protocol: L7Protocol::Rest,
6143+
path: path.into(),
6144+
tls: crate::l7::TlsMode::Auto,
6145+
enforcement: EnforcementMode::Enforce,
6146+
graphql_max_body_bytes: 0,
6147+
json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES,
6148+
mcp_strict_tool_names: true,
6149+
allow_encoded_slash,
6150+
websocket_credential_rewrite: false,
6151+
request_body_credential_rewrite: false,
6152+
websocket_graphql_policy: false,
6153+
credential_signing: crate::l7::CredentialSigning::None,
6154+
signing_service: String::new(),
6155+
signing_region: String::new(),
6156+
};
6157+
// One endpoint opts in, the other does not.
6158+
vec![rest("/repos/**", true), rest("/admin/**", false)]
6159+
}
6160+
6161+
fn encoded_slash_scoping_ctx() -> L7EvalContext {
6162+
L7EvalContext {
6163+
host: "gateway.example.test".into(),
6164+
port: 443,
6165+
request_default_port: Some(443),
6166+
policy_name: "route_api".into(),
6167+
binary_path: "/usr/bin/node".into(),
6168+
ancestors: vec![],
6169+
cmdline_paths: vec![],
6170+
secret_resolver: None,
6171+
..Default::default()
6172+
}
6173+
}
6174+
6175+
/// Canonicalization runs before the matching config is known, so
6176+
/// `allow_encoded_slash` is taken permissively across the whole
6177+
/// host:port. The endpoint that did *not* opt in must still reject a
6178+
/// `%2F`, otherwise one endpoint's opt-in silently loosens every other
6179+
/// endpoint sharing that host:port.
6180+
#[tokio::test]
6181+
async fn route_selected_encoded_slash_optin_does_not_leak_to_other_endpoints() {
6182+
let engine = OpaEngine::from_strings(TEST_POLICY, ENCODED_SLASH_SCOPING_POLICY).unwrap();
6183+
let tunnel_engine = engine
6184+
.clone_engine_for_tunnel(engine.current_generation())
6185+
.unwrap();
6186+
let configs = encoded_slash_scoping_configs();
6187+
let ctx = encoded_slash_scoping_ctx();
6188+
6189+
let (mut app, mut relay_client) = tokio::io::duplex(8192);
6190+
let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192);
6191+
let relay = tokio::spawn(async move {
6192+
relay_with_route_selection(
6193+
&configs,
6194+
tunnel_engine,
6195+
&mut relay_client,
6196+
&mut relay_upstream,
6197+
&ctx,
6198+
)
6199+
.await
6200+
});
6201+
6202+
app.write_all(
6203+
b"GET /admin/x%2Fy HTTP/1.1\r\nHost: gateway.example.test\r\nConnection: close\r\n\r\n",
6204+
)
6205+
.await
6206+
.unwrap();
6207+
6208+
let mut response = [0u8; 1024];
6209+
let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response))
6210+
.await
6211+
.expect("denial should reach client")
6212+
.unwrap();
6213+
let response = String::from_utf8_lossy(&response[..n]);
6214+
assert!(response.contains("403 Forbidden"), "{response}");
6215+
assert!(
6216+
response.contains("not allowed on this endpoint"),
6217+
"denial must name the encoded-slash reason: {response}"
6218+
);
6219+
6220+
let mut upstream_bytes = [0u8; 16];
6221+
let result = tokio::time::timeout(
6222+
std::time::Duration::from_millis(100),
6223+
upstream.read(&mut upstream_bytes),
6224+
)
6225+
.await;
6226+
assert!(
6227+
matches!(result, Err(_) | Ok(Ok(0))),
6228+
"request must not reach upstream"
6229+
);
6230+
6231+
drop(app);
6232+
tokio::time::timeout(std::time::Duration::from_secs(1), relay)
6233+
.await
6234+
.expect("relay should finish")
6235+
.unwrap()
6236+
.unwrap();
6237+
}
6238+
6239+
/// The converse: tightening the scope must not break the endpoint that
6240+
/// legitimately opted in. A GitLab-style encoded slug still reaches the
6241+
/// upstream verbatim.
6242+
#[tokio::test]
6243+
async fn route_selected_encoded_slash_still_allowed_on_opted_in_endpoint() {
6244+
let engine = OpaEngine::from_strings(TEST_POLICY, ENCODED_SLASH_SCOPING_POLICY).unwrap();
6245+
let tunnel_engine = engine
6246+
.clone_engine_for_tunnel(engine.current_generation())
6247+
.unwrap();
6248+
let configs = encoded_slash_scoping_configs();
6249+
let ctx = encoded_slash_scoping_ctx();
6250+
6251+
let (mut app, mut relay_client) = tokio::io::duplex(8192);
6252+
let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192);
6253+
let relay = tokio::spawn(async move {
6254+
relay_with_route_selection(
6255+
&configs,
6256+
tunnel_engine,
6257+
&mut relay_client,
6258+
&mut relay_upstream,
6259+
&ctx,
6260+
)
6261+
.await
6262+
});
6263+
6264+
app.write_all(
6265+
b"GET /repos/group%2Fproject HTTP/1.1\r\nHost: gateway.example.test\r\nConnection: close\r\n\r\n",
6266+
)
6267+
.await
6268+
.unwrap();
6269+
6270+
let mut upstream_bytes = [0u8; 512];
6271+
let n = tokio::time::timeout(
6272+
std::time::Duration::from_secs(1),
6273+
upstream.read(&mut upstream_bytes),
6274+
)
6275+
.await
6276+
.expect("opted-in request should reach upstream")
6277+
.unwrap();
6278+
let forwarded = String::from_utf8_lossy(&upstream_bytes[..n]);
6279+
assert!(
6280+
forwarded.contains("GET /repos/group%2Fproject "),
6281+
"encoded slug must be forwarded verbatim: {forwarded}"
6282+
);
6283+
6284+
drop(app);
6285+
drop(upstream);
6286+
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), relay).await;
6287+
}
6288+
60896289
#[tokio::test]
60906290
async fn route_selected_websocket_upgrade_rejects_invalid_accept_without_forwarding_101() {
60916291
let data = r#"

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4448,6 +4448,39 @@ async fn handle_forward_proxy(
44484448
.await?;
44494449
return Ok(());
44504450
};
4451+
// `canonicalize_options` was built before the matching config was
4452+
// known, so `allow_encoded_slash` was taken permissively across every
4453+
// config on this route. Re-check it against the config that actually
4454+
// matched: the opt-in is per-endpoint, and one endpoint enabling it
4455+
// must not loosen parsing for the others. Rejecting here yields the
4456+
// same response the parser would have produced had the option been
4457+
// scoped correctly from the start.
4458+
if !l7_config.config.allow_encoded_slash
4459+
&& crate::l7::path::canonical_path_has_encoded_slash(&path)
4460+
{
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);
4471+
emit_activity_simple(activity_tx, true, "forward_parse_rejection");
4472+
respond(
4473+
client,
4474+
&build_json_error_response(
4475+
400,
4476+
"Bad Request",
4477+
"invalid_request_target",
4478+
"request-target must be canonical",
4479+
),
4480+
)
4481+
.await?;
4482+
return Ok(());
4483+
}
44514484
if crate::l7::rest::request_is_h2c_upgrade(&forward_request_bytes) {
44524485
let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx())
44534486
.activity(ActivityId::Other)

0 commit comments

Comments
 (0)