Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ All paths are relative to `crates/openshell-sandbox/src/`.
| `l7/tls.rs` | Ephemeral CA generation (`SandboxCa`), per-hostname leaf cert cache (`CertCache`), TLS termination/connection helpers, `looks_like_tls()` auto-detection |
| `l7/relay.rs` | Protocol-aware bidirectional relay with per-request OPA evaluation, credential-injection-only passthrough relay |
| `l7/rest.rs` | HTTP/1.1 request/response parsing, body framing (Content-Length, chunked), deny response generation |
| `l7/path.rs` | Request-target canonicalization: percent-decoding, dot-segment resolution, `;params` stripping, encoded-slash policy (opt-in per endpoint via `allow_encoded_slash: true` for upstreams like GitLab that embed `%2F` in paths). Single source of truth for the path both OPA evaluates and the upstream receives. |
| `l7/provider.rs` | `L7Provider` trait and `L7Request`/`BodyLength` types |
| `secrets.rs` | `SecretResolver` credential placeholder system — placeholder generation, multi-location rewriting (headers, query params, path segments, Basic auth), fail-closed scanning, secret validation, percent-encoding |

Expand Down
8 changes: 8 additions & 0 deletions crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ struct NetworkEndpointDef {
allowed_ips: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
deny_rules: Vec<L7DenyRuleDef>,
/// When true, percent-encoded `/` (`%2F`) is preserved in path segments
/// rather than rejected by the L7 path canonicalizer. Required for
/// upstreams like GitLab that embed `%2F` in namespaced resource paths.
/// Defaults to false (strict).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
allow_encoded_slash: bool,
}

fn is_zero(v: &u16) -> bool {
Expand Down Expand Up @@ -254,6 +260,7 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
.collect(),
})
.collect(),
allow_encoded_slash: e.allow_encoded_slash,
}
})
.collect(),
Expand Down Expand Up @@ -393,6 +400,7 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
.collect(),
})
.collect(),
allow_encoded_slash: e.allow_encoded_slash,
}
})
.collect(),
Expand Down
11 changes: 9 additions & 2 deletions crates/openshell-sandbox/data/sandbox-policy.rego
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,13 @@ method_matches(actual, expected) if {
}

# Path matching: "**" matches everything; otherwise glob.match with "/" delimiter.
#
# INVARIANT: `input.request.path` is canonicalized by the sandbox before
# policy evaluation — percent-decoded, dot-segments resolved, doubled
# slashes collapsed, `;params` stripped, `%2F` rejected (unless an
# endpoint opts in). Patterns here must therefore match canonical paths;
# do not attempt defensive matching against `..` or `%2e%2e` — those
# inputs are rejected at the L7 parser boundary before this rule runs.
path_matches(_, "**") if true

path_matches(actual, pattern) if {
Expand Down Expand Up @@ -394,8 +401,8 @@ command_matches(actual, expected) if {

# --- Matched endpoint config (for L7 and allowed_ips extraction) ---
# Returns the raw endpoint object for the matched policy + host:port.
# Used by Rust to extract L7 config (protocol, tls, enforcement) and/or
# allowed_ips for SSRF allowlist validation.
# Used by Rust to extract L7 config (protocol, tls, enforcement,
# allow_encoded_slash) and/or allowed_ips for SSRF allowlist validation.

# Per-policy helper: returns matching endpoint configs for a single policy.
_policy_endpoint_configs(policy) := [ep |
Expand Down
41 changes: 41 additions & 0 deletions crates/openshell-sandbox/src/l7/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
//! evaluated against OPA policy, and either forwarded or denied.

pub mod inference;
pub mod path;
pub mod provider;
pub mod relay;
pub mod rest;
Expand Down Expand Up @@ -59,6 +60,10 @@ pub struct L7EndpointConfig {
pub protocol: L7Protocol,
pub tls: TlsMode,
pub enforcement: EnforcementMode,
/// When true, percent-encoded `/` (`%2F`) is preserved in path segments
/// rather than rejected at the parser. Needed by upstreams like GitLab
/// that embed `%2F` in namespaced project paths. Defaults to false.
pub allow_encoded_slash: bool,
}

/// Result of an L7 policy decision for a single request.
Expand Down Expand Up @@ -122,10 +127,13 @@ pub fn parse_l7_config(val: &regorus::Value) -> Option<L7EndpointConfig> {
_ => EnforcementMode::Audit,
};

let allow_encoded_slash = get_object_bool(val, "allow_encoded_slash").unwrap_or(false);

Some(L7EndpointConfig {
protocol,
tls,
enforcement,
allow_encoded_slash,
})
}

Expand All @@ -141,6 +149,19 @@ pub fn parse_tls_mode(val: &regorus::Value) -> TlsMode {
}
}

/// Extract a bool value from a regorus object. Returns `None` when the key
/// is absent or not a boolean.
fn get_object_bool(val: &regorus::Value, key: &str) -> Option<bool> {
let key_val = regorus::Value::String(key.into());
match val {
regorus::Value::Object(map) => match map.get(&key_val) {
Some(regorus::Value::Bool(b)) => Some(*b),
_ => None,
},
_ => None,
}
}

/// Extract a string value from a regorus object.
fn get_object_str(val: &regorus::Value, key: &str) -> Option<String> {
let key_val = regorus::Value::String(key.into());
Expand Down Expand Up @@ -746,6 +767,26 @@ mod tests {
assert!(parse_l7_config(&val).is_none());
}

#[test]
fn parse_l7_config_allow_encoded_slash_defaults_false() {
let val = regorus::Value::from_json_str(
r#"{"protocol": "rest", "host": "api.example.com", "port": 443}"#,
)
.unwrap();
let config = parse_l7_config(&val).unwrap();
assert!(!config.allow_encoded_slash);
}

#[test]
fn parse_l7_config_allow_encoded_slash_opt_in() {
let val = regorus::Value::from_json_str(
r#"{"protocol": "rest", "host": "gitlab.example.com", "port": 443, "allow_encoded_slash": true}"#,
)
.unwrap();
let config = parse_l7_config(&val).unwrap();
assert!(config.allow_encoded_slash);
}

#[test]
fn validate_rules_and_access_mutual_exclusion() {
let data = serde_json::json!({
Expand Down
Loading
Loading