mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-17 05:31:16 +08:00
* fix(sandbox): canonicalize HTTP request-targets before L7 policy evaluation
The L7 REST proxy evaluated OPA path rules against the raw request-target
and forwarded the raw header block byte-for-byte to the upstream. The
upstream normalized `..`, `%2e%2e`, and `//` before dispatching, so a
compromised agent inside the sandbox could bypass any path-based allow
rule by prefixing the blocked path with an allowed one (e.g.
`GET /public/../secret` matches `/public/**` at the proxy but the
upstream serves `/secret`). The inference-routing detection had the same
normalization gap via `normalize_inference_path`, which only stripped
scheme+authority and preserved dot-segments verbatim.
- Add `crates/openshell-sandbox/src/l7/path.rs` with
`canonicalize_request_target`. Percent-decodes (with uppercase hex
re-emission), resolves `.` / `..` segments per RFC 3986 5.2.4,
collapses doubled slashes, strips trailing `;params`, rejects
fragments / raw non-ASCII / control bytes / encoded slashes (unless
explicitly enabled per-endpoint), and returns a `rewritten` flag so
callers know when to rewrite the outbound request line.
- Wire the canonicalizer into `rest.rs::parse_http_request`. The
canonical path is the `target` on `L7Request` that OPA sees, and
when the canonical form differs from the raw input the request line
is rebuilt in `raw_header` so the upstream dispatches on the exact
bytes the policy evaluated.
- Canonicalize the forward (plain HTTP proxy) path at
`proxy.rs`'s second L7 evaluation site. A non-canonical request-target
is rejected with 400 Bad Request and an OCSF `NetworkActivity` Fail
event rather than silently passed through.
- Replace the old `normalize_inference_path` body with a call to the
canonicalizer. On canonicalization error the raw path is returned
(so inference detection simply misses and the normal forward path
handles and rejects).
- Document the invariant in `sandbox-policy.rego`: `input.request.path`
is pre-canonicalized so rules must not attempt defensive matching
against `..` or `%2e%2e`.
- Architecture doc (`architecture/sandbox.md`) lists the new module.
- 24 new unit tests cover dot segments, percent-encoded dot segments,
double-slash collapse, encoded-slash reject/opt-in, null/control byte
rejection, legitimate percent-encoded bytes round-tripping, mixed-case
percent normalization, fragment rejection, absolute-form stripping,
empty / length-bounded / non-ASCII handling, and regression payloads
for the specific bypasses above.
Tracks OS-99.
* fix(sandbox): close forward-proxy parser-differential and wire allow_encoded_slash
Addresses two review findings on the L7 path canonicalization change.
1. `handle_forward_proxy` previously evaluated OPA on the canonical path
but wrote the raw path to the upstream via `rewrite_forward_request`,
leaving the parser-differential open for plain-HTTP forward-proxy
traffic even though it was closed for the L7 tunnel path. `path` is
now reassigned to the canonical form inside the L7 block before the
outbound write, so the bytes dispatched to the upstream match what
OPA evaluated. Covered by a new regression test against
`rewrite_forward_request`.
2. `allow_encoded_slash` is now wired through the YAML policy schema,
the proto `NetworkEndpoint` message, the Rego endpoint config, and
the `L7EndpointConfig` → `RestProvider` canonicalization options.
Operators can opt in per-endpoint for upstreams like GitLab that
embed `%2F` in namespaced resource paths; the default remains
strict. The `RestProvider` gains a constructor
`RestProvider::with_options` so `relay_rest` constructs a provider
with per-endpoint options while `relay_passthrough_with_credentials`
retains strict defaults.
Integration tests:
- `parse_http_request_canonicalizes_target_and_rewrites_raw_header`
drives a non-canonical request through the parser and asserts both
`L7Request.target` (what OPA evaluates) and `raw_header` (what the
upstream receives) are canonical.
- `parse_http_request_canonicalization_preserves_query_string`,
`parse_http_request_leaves_canonical_input_byte_for_byte`,
`parse_http_request_rejects_traversal_above_root`,
`parse_http_request_preserves_http_10_version_on_rewrite`.
- `parse_http_request_accepts_encoded_slash_when_endpoint_opts_in` /
`_rejects_encoded_slash_by_default` verify the
`allow_encoded_slash` gate.
- `test_rewrite_forward_request_uses_canonical_path_on_the_wire`
asserts the fix to the forward-proxy flow.
- `parse_l7_config_allow_encoded_slash_{defaults_false,opt_in}` verify
the YAML/Rego wiring.
223 lines
7.8 KiB
Protocol Buffer
223 lines
7.8 KiB
Protocol Buffer
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
syntax = "proto3";
|
|
|
|
package openshell.sandbox.v1;
|
|
|
|
// Sandbox-supervisor configuration and policy messages.
|
|
//
|
|
// Conventions:
|
|
// - This file owns messages exchanged between the gateway and the sandbox
|
|
// supervisor/runtime.
|
|
// - Public sandbox resource types live in `openshell.proto`.
|
|
// - Internal compute-driver sandbox observation types live in `compute_driver.proto`.
|
|
|
|
// Sandbox security policy configuration.
|
|
message SandboxPolicy {
|
|
// Policy version.
|
|
uint32 version = 1;
|
|
// Filesystem access policy.
|
|
FilesystemPolicy filesystem = 2;
|
|
// Landlock configuration.
|
|
LandlockPolicy landlock = 3;
|
|
// Process execution policy.
|
|
ProcessPolicy process = 4;
|
|
// Network access policies keyed by name (e.g. "claude_code", "gitlab").
|
|
map<string, NetworkPolicyRule> network_policies = 5;
|
|
}
|
|
|
|
// Filesystem access policy.
|
|
message FilesystemPolicy {
|
|
// Automatically include the workdir as read-write.
|
|
bool include_workdir = 1;
|
|
// Read-only directory allow list.
|
|
repeated string read_only = 2;
|
|
// Read-write directory allow list.
|
|
repeated string read_write = 3;
|
|
}
|
|
|
|
// Landlock policy configuration.
|
|
message LandlockPolicy {
|
|
// Compatibility mode (e.g. "best_effort", "hard_requirement").
|
|
string compatibility = 1;
|
|
}
|
|
|
|
// Process execution policy.
|
|
message ProcessPolicy {
|
|
// User name to run the sandboxed process as.
|
|
string run_as_user = 1;
|
|
// Group name to run the sandboxed process as.
|
|
string run_as_group = 2;
|
|
}
|
|
|
|
// A named network access policy rule.
|
|
message NetworkPolicyRule {
|
|
// Human-readable name for this policy rule.
|
|
string name = 1;
|
|
// Allowed endpoint (host:port) pairs.
|
|
repeated NetworkEndpoint endpoints = 2;
|
|
// Allowed binary identities.
|
|
repeated NetworkBinary binaries = 3;
|
|
}
|
|
|
|
// A network endpoint (host + port) with optional L7 inspection config.
|
|
message NetworkEndpoint {
|
|
// Hostname or host glob pattern. Exact match is case-insensitive.
|
|
// Glob patterns use "." as delimiter: "*.example.com" matches a single
|
|
// subdomain label, "**.example.com" matches across labels.
|
|
string host = 1;
|
|
// Single port (backwards compat). Use `ports` for multiple ports.
|
|
// Mutually exclusive with `ports` — if both are set, `ports` takes precedence.
|
|
uint32 port = 2;
|
|
// Application protocol for L7 inspection: "rest", "sql", or "" (L4-only).
|
|
string protocol = 3;
|
|
// TLS handling: "terminate" or "passthrough" (default).
|
|
string tls = 4;
|
|
// Enforcement mode: "enforce" or "audit" (default).
|
|
string enforcement = 5;
|
|
// Access preset shorthand: "read-only", "read-write", "full".
|
|
// Mutually exclusive with rules.
|
|
string access = 6;
|
|
// Explicit L7 rules (mutually exclusive with access).
|
|
repeated L7Rule rules = 7;
|
|
// Allowed resolved IP addresses or CIDR ranges for this endpoint.
|
|
// When non-empty, the SSRF internal-IP check is replaced by an allowlist check:
|
|
// - If host is also set: domain must resolve to an IP in this list.
|
|
// - If host is empty: any domain is allowed as long as it resolves to an IP in this list.
|
|
// Supports exact IPs ("10.0.5.20") and CIDR notation ("10.0.5.0/24").
|
|
// Loopback (127.0.0.0/8) and link-local (169.254.0.0/16) are always blocked
|
|
// regardless of this field.
|
|
repeated string allowed_ips = 8;
|
|
// Multiple ports. When non-empty, this endpoint covers all listed ports.
|
|
// If `port` is set and `ports` is empty, `port` is normalized to `ports: [port]`.
|
|
// If both are set, `ports` takes precedence.
|
|
repeated uint32 ports = 9;
|
|
// Explicit L7 deny rules. When present, requests matching any deny rule
|
|
// are blocked even if they match an allow rule or access preset.
|
|
// Deny rules take precedence over allow rules.
|
|
repeated L7DenyRule deny_rules = 10;
|
|
// 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).
|
|
bool allow_encoded_slash = 11;
|
|
}
|
|
|
|
// An L7 deny rule that blocks specific requests.
|
|
// Mirrors L7Allow — same fields, same matching semantics, inverted effect.
|
|
// Deny rules are evaluated after allow rules and take precedence.
|
|
message L7DenyRule {
|
|
// HTTP method (REST): GET, POST, etc. or "*" for any.
|
|
string method = 1;
|
|
// URL path glob pattern (REST): "/repos/*/pulls/*/reviews", "**" for any.
|
|
string path = 2;
|
|
// SQL command (SQL): SELECT, INSERT, etc. or "*" for any.
|
|
string command = 3;
|
|
// Query parameter matcher map (REST).
|
|
// Same semantics as L7Allow.query.
|
|
map<string, L7QueryMatcher> query = 4;
|
|
}
|
|
|
|
// An L7 policy rule (allow-only).
|
|
message L7Rule {
|
|
L7Allow allow = 1;
|
|
}
|
|
|
|
// Allowed action definition for L7 rules.
|
|
message L7Allow {
|
|
// HTTP method (REST): GET, POST, etc. or "*" for any.
|
|
string method = 1;
|
|
// URL path glob pattern (REST): "/repos/**", "**" for any.
|
|
string path = 2;
|
|
// SQL command (SQL): SELECT, INSERT, etc. or "*" for any.
|
|
string command = 3;
|
|
// Query parameter matcher map (REST).
|
|
// Key is the decoded query parameter name (case-sensitive).
|
|
// Value supports either a single glob (`glob`) or a list (`any`).
|
|
map<string, L7QueryMatcher> query = 4;
|
|
}
|
|
|
|
// Query value matcher for one query parameter key.
|
|
message L7QueryMatcher {
|
|
// Single glob pattern.
|
|
string glob = 1;
|
|
// Any-of glob patterns.
|
|
repeated string any = 2;
|
|
}
|
|
|
|
// A binary identity for network policy matching.
|
|
message NetworkBinary {
|
|
string path = 1;
|
|
// Deprecated: the harness concept has been removed. This field is ignored.
|
|
bool harness = 2 [deprecated = true];
|
|
}
|
|
|
|
// Request to get sandbox settings by sandbox ID.
|
|
message GetSandboxConfigRequest {
|
|
// The sandbox ID.
|
|
string sandbox_id = 1;
|
|
}
|
|
|
|
// Request to get gateway-global settings.
|
|
message GetGatewayConfigRequest {}
|
|
|
|
// Response containing gateway-global settings.
|
|
message GetGatewayConfigResponse {
|
|
// Gateway-global settings map excluding the reserved policy key.
|
|
// Registered keys without a configured value are returned with an empty SettingValue.
|
|
map<string, SettingValue> settings = 1;
|
|
// Monotonically increasing revision for gateway-global settings.
|
|
uint64 settings_revision = 2;
|
|
}
|
|
|
|
// Scope that currently controls a setting.
|
|
enum SettingScope {
|
|
SETTING_SCOPE_UNSPECIFIED = 0;
|
|
SETTING_SCOPE_SANDBOX = 1;
|
|
SETTING_SCOPE_GLOBAL = 2;
|
|
}
|
|
|
|
// Type-aware setting value for sandbox/gateway settings.
|
|
message SettingValue {
|
|
oneof value {
|
|
string string_value = 1;
|
|
bool bool_value = 2;
|
|
int64 int_value = 3;
|
|
bytes bytes_value = 4;
|
|
}
|
|
}
|
|
|
|
// Effective setting value and the scope it was resolved from.
|
|
message EffectiveSetting {
|
|
SettingValue value = 1;
|
|
SettingScope scope = 2;
|
|
}
|
|
|
|
// Source used for the policy payload in GetSandboxConfigResponse.
|
|
enum PolicySource {
|
|
POLICY_SOURCE_UNSPECIFIED = 0;
|
|
POLICY_SOURCE_SANDBOX = 1;
|
|
POLICY_SOURCE_GLOBAL = 2;
|
|
}
|
|
|
|
// Response containing effective sandbox settings and policy.
|
|
message GetSandboxConfigResponse {
|
|
// The sandbox policy configuration.
|
|
SandboxPolicy policy = 1;
|
|
// Current policy version (monotonically increasing per sandbox).
|
|
uint32 version = 2;
|
|
// SHA-256 hash of the serialized policy payload.
|
|
string policy_hash = 3;
|
|
// Effective settings resolved for this sandbox, excluding the reserved policy key.
|
|
// Registered keys without a configured value are returned with an empty EffectiveSetting.value.
|
|
map<string, EffectiveSetting> settings = 4;
|
|
// Fingerprint for effective config (policy + settings). Changes when any effective input changes.
|
|
uint64 config_revision = 5;
|
|
// Source of the policy payload for this response.
|
|
PolicySource policy_source = 6;
|
|
// When policy_source is GLOBAL, the version of the global policy revision.
|
|
// Zero when no global policy is active or when policy_source is SANDBOX.
|
|
uint32 global_policy_version = 7;
|
|
}
|