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
4 changes: 3 additions & 1 deletion crates/openshell-sandbox/src/l7/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,10 @@ pub struct L7Decision {
pub struct L7RequestInfo {
/// Protocol action: HTTP method (GET, POST, ...) or SQL command (SELECT, INSERT, ...).
pub action: String,
/// Target: URL path for REST, or empty for SQL.
/// Target: URL path for REST (without query string), or empty for SQL.
pub target: String,
/// Query string from the request URI (after `?`), empty if absent.
pub query: String,
}

/// Parse an L7 endpoint config from a regorus Value (returned by Rego query).
Expand Down
6 changes: 5 additions & 1 deletion crates/openshell-sandbox/src/l7/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,12 @@ pub enum BodyLength {
pub struct L7Request {
/// Protocol action: HTTP method or SQL command.
pub action: String,
/// Target: URL path for REST, empty for SQL.
/// Target: URL path for REST (without query string), empty for SQL.
pub target: String,
/// Query string from the request URI (after `?`), empty if absent.
/// Kept separate from `target` so path-based policy matching is not
/// affected by the presence or content of query parameters.
pub query: String,
/// Raw request header bytes (request line + headers for HTTP, message for SQL).
/// May include overflow body bytes read during header parsing.
pub raw_header: Vec<u8>,
Expand Down
3 changes: 3 additions & 0 deletions crates/openshell-sandbox/src/l7/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ where
let request_info = L7RequestInfo {
action: req.action.clone(),
target: req.target.clone(),
query: req.query.clone(),
};

// Evaluate L7 policy via Rego
Expand All @@ -127,6 +128,7 @@ where
l7_protocol = "rest",
l7_action = %request_info.action,
l7_target = %request_info.target,
l7_query = %request_info.query,
l7_decision = decision_str,
l7_deny_reason = %reason,
"L7_REQUEST",
Expand Down Expand Up @@ -198,6 +200,7 @@ fn evaluate_l7_request(
"request": {
"method": request.action,
"path": request.target,
"query": request.query,
}
});

Expand Down
82 changes: 77 additions & 5 deletions crates/openshell-sandbox/src/l7/rest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,16 @@ async fn parse_http_request<C: AsyncRead + Unpin>(client: &mut C) -> Result<Opti
.next()
.ok_or_else(|| miette!("Missing HTTP method"))?
.to_string();
let path = parts
.next()
.ok_or_else(|| miette!("Missing HTTP path"))?
.to_string();
let raw_target = parts.next().ok_or_else(|| miette!("Missing HTTP path"))?;

// Split the request target into path and query string so that L7 policy
// path matching is not affected by query parameters. The raw header
// bytes (forwarded to upstream) are left untouched — only the policy
// evaluation fields are split.
let (path, query) = raw_target
.split_once('?')
.map_or((raw_target, ""), |(p, q)| (p, q));

let version = parts
.next()
.ok_or_else(|| miette!("Missing HTTP version"))?;
Expand All @@ -132,7 +138,8 @@ async fn parse_http_request<C: AsyncRead + Unpin>(client: &mut C) -> Result<Opti

Ok(Some(L7Request {
action: method,
target: path,
target: path.to_string(),
query: query.to_string(),
raw_header: buf, // exact header bytes up to and including \r\n\r\n
body_length,
}))
Expand Down Expand Up @@ -783,6 +790,69 @@ mod tests {
assert_eq!(second.target, "/blocked");
}

/// Query string is split from path for policy matching.
#[tokio::test]
async fn parse_http_request_splits_query_string() {
let (mut client, mut writer) = tokio::io::duplex(4096);
tokio::spawn(async move {
writer
.write_all(
b"GET /api/v1/download?slug=my-skill&version=1.0 HTTP/1.1\r\nHost: x\r\n\r\n",
)
.await
.unwrap();
});
let req = parse_http_request(&mut client)
.await
.expect("should parse")
.expect("expected request");
assert_eq!(req.action, "GET");
assert_eq!(
req.target, "/api/v1/download",
"path should not include query string"
);
assert_eq!(
req.query, "slug=my-skill&version=1.0",
"query should be captured"
);
}

/// Path without query string has empty query field.
#[tokio::test]
async fn parse_http_request_no_query_string() {
let (mut client, mut writer) = tokio::io::duplex(4096);
tokio::spawn(async move {
writer
.write_all(b"GET /api/v1/download HTTP/1.1\r\nHost: x\r\n\r\n")
.await
.unwrap();
});
let req = parse_http_request(&mut client)
.await
.expect("should parse")
.expect("expected request");
assert_eq!(req.target, "/api/v1/download");
assert_eq!(req.query, "", "query should be empty when no ? present");
}

/// Empty query string (path ends with ?) results in empty query field.
#[tokio::test]
async fn parse_http_request_empty_query_string() {
let (mut client, mut writer) = tokio::io::duplex(4096);
tokio::spawn(async move {
writer
.write_all(b"GET /api/v1/download? HTTP/1.1\r\nHost: x\r\n\r\n")
.await
.unwrap();
});
let req = parse_http_request(&mut client)
.await
.expect("should parse")
.expect("expected request");
assert_eq!(req.target, "/api/v1/download");
assert_eq!(req.query, "", "empty query after ? should be empty string");
}

#[test]
fn http_method_detection() {
assert!(looks_like_http(b"GET / HTTP/1.1\r\n"));
Expand Down Expand Up @@ -1149,6 +1219,7 @@ mod tests {
let req = L7Request {
action: "POST".to_string(),
target: "/v1/chat/completions".to_string(),
query: String::new(),
raw_header: format!(
"POST /v1/chat/completions HTTP/1.1\r\n\
Host: integrate.api.nvidia.com\r\n\
Expand Down Expand Up @@ -1232,6 +1303,7 @@ mod tests {
let req = L7Request {
action: "POST".to_string(),
target: "/v1/chat/completions".to_string(),
query: String::new(),
raw_header: format!(
"POST /v1/chat/completions HTTP/1.1\r\n\
Host: integrate.api.nvidia.com\r\n\
Expand Down
63 changes: 62 additions & 1 deletion crates/openshell-sandbox/src/opa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,16 @@ process:
}

fn l7_input(host: &str, port: u16, method: &str, path: &str) -> serde_json::Value {
l7_input_with_query(host, port, method, path, "")
}

fn l7_input_with_query(
host: &str,
port: u16,
method: &str,
path: &str,
query: &str,
) -> serde_json::Value {
serde_json::json!({
"network": { "host": host, "port": port },
"exec": {
Expand All @@ -1368,7 +1378,8 @@ process:
},
"request": {
"method": method,
"path": path
"path": path,
"query": query
}
})
}
Expand Down Expand Up @@ -1472,6 +1483,56 @@ process:
assert!(eval_l7(&engine, &input));
}

/// Query strings are now stripped from the path before policy evaluation.
/// This test verifies that an exact path rule matches requests with query params.
#[test]
fn l7_path_with_query_string_matches_exact_path() {
let engine = l7_engine();
// Rule: path "/repos/*/issues" should match even with query params
let input = l7_input_with_query(
"api.example.com",
8080,
"POST",
"/repos/org/issues",
"page=2&per_page=50",
);
assert!(
eval_l7(&engine, &input),
"exact path should match regardless of query string"
);
}

/// Query strings don't affect glob path matching.
#[test]
fn l7_path_glob_with_query_string() {
let engine = l7_engine();
// Rule: path "/repos/**" should match with query params
let input = l7_input_with_query(
"api.example.com",
8080,
"GET",
"/repos/org/repo/pulls",
"state=open",
);
assert!(
eval_l7(&engine, &input),
"glob path should match regardless of query string"
);
}

/// Path that doesn't match the rule should still be denied even with query string.
#[test]
fn l7_wrong_path_with_query_string_denied() {
let engine = l7_engine();
// Rule only allows /repos/**, not /admin/**
let input =
l7_input_with_query("api.example.com", 8080, "GET", "/admin/settings", "foo=bar");
assert!(
!eval_l7(&engine, &input),
"non-matching path should still be denied"
);
}

#[test]
fn l7_no_request_on_l4_only_endpoint() {
// L4-only endpoint should not match L7 allow_request
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/policy-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@ Used when `access` is not set. Each rule explicitly allows a method and path com
| `allow.method` | string | Yes | HTTP method to allow (for example, `GET`, `POST`). |
| `allow.path` | string | Yes | URL path pattern. Supports `*` and `**` glob syntax. |

**Path matching behavior:**

- Path rules match only the path component of the request URI (everything before `?`).
- Query strings are not evaluated by path rules. A rule with `path: /api/v1/download` matches both `/api/v1/download` and `/api/v1/download?slug=my-skill&version=1.0`.
- Glob patterns use `/` as the segment delimiter. `*` matches within a single segment, `**` matches across segments.

Example with rules:

```yaml
Expand Down
Loading