Skip to content

Commit 68c1e01

Browse files
committed
fix(supervisor-network): check the canonical target for encoded slashes
1 parent baf9780 commit 68c1e01

1 file changed

Lines changed: 78 additions & 1 deletion

File tree

  • crates/openshell-supervisor-network/src/l7

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

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,8 +569,12 @@ 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
{
575579
crate::l7::rest::RestProvider::default()
576580
.deny_with_redacted_target(
@@ -6236,6 +6240,79 @@ network_policies:
62366240
.unwrap();
62376241
}
62386242

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

0 commit comments

Comments
 (0)