You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(sandbox): extend L7 credential injection to query params, Basic auth, and URL paths
Closes#689
Extend SecretResolver to resolve openshell:resolve:env:* placeholders in
URL query parameters, Basic auth tokens, and URL path segments. Absorbs
working code from PR #631 for query param and Basic auth support. Adds
path rewriting for Telegram-style APIs (/bot{TOKEN}/method).
Changes all placeholder rewriting to fail-closed: unresolved placeholders
cause HTTP 500 instead of forwarding raw placeholder strings. Validates
resolved secret values for CRLF/null injection (CWE-113). Validates path
credentials for traversal sequences (CWE-22).
Rewrites request targets before OPA L7 policy evaluation. OPA receives a
redacted path with [CREDENTIAL] markers. Real secrets appear only in the
upstream connection. All log statements use redacted targets.
|`UpstreamUnavailable(_)`|`503`|`inference service unavailable`|
832
+
|`UpstreamProtocol(_)` / `Internal(_)`|`502`|`inference service error`|
833
+
834
+
Response messages are generic — internal details (upstream URLs, hostnames, TLS errors, route hints) are never exposed to the sandboxed process. Full error context is logged server-side at `warn` level.
832
835
833
836
### Inference routing context
834
837
@@ -1027,20 +1030,131 @@ TLS termination is automatic. The proxy peeks the first bytes of every CONNECT t
1027
1030
1028
1031
System CA bundles are searched at well-known paths: `/etc/ssl/certs/ca-certificates.crt` (Debian/Ubuntu), `/etc/pki/tls/certs/ca-bundle.crt` (RHEL), `/etc/ssl/ca-bundle.pem` (openSUSE), `/etc/ssl/cert.pem` (Alpine/macOS).
The sandbox proxy resolves `openshell:resolve:env:*` credential placeholders in outbound HTTP requests. The `SecretResolver` holds a supervisor-only map from placeholder strings to real secret values, constructed at startup from the provider environment. Child processes only see placeholder values in their environment; the proxy rewrites them to real secrets immediately before forwarding upstream.
1038
+
1039
+
#### `SecretResolver`
1040
+
1041
+
```rust
1042
+
pub(crate) structSecretResolver {
1043
+
by_placeholder:HashMap<String, String>,
1044
+
}
1045
+
```
1046
+
1047
+
`SecretResolver::from_provider_env()` splits the provider environment into two maps: a child-visible map with placeholder values (`openshell:resolve:env:ANTHROPIC_API_KEY`) and a supervisor-only resolver map (`{"openshell:resolve:env:ANTHROPIC_API_KEY": "sk-real-key"}`). The placeholder grammar is `openshell:resolve:env:[A-Za-z_][A-Za-z0-9_]*`.
1048
+
1049
+
#### Credential placement locations
1050
+
1051
+
The resolver rewrites placeholders in four locations within HTTP requests:
1052
+
1053
+
| Location | Example | Encoding | Implementation |
**Header values**: Direct match replaces the entire value. Prefixed match (e.g., `Bearer <placeholder>`) splits on whitespace, resolves the placeholder portion, and reassembles. Basic auth match detects `Authorization: Basic <base64>`, decodes the Base64 content, resolves any placeholders in the decoded `user:password` string, and re-encodes.
1062
+
1063
+
**Query parameters**: Each `key=value` pair is checked. Values are percent-decoded before resolution and percent-encoded after (RFC 3986 Section 2.3 unreserved characters preserved: `ALPHA / DIGIT / "-" / "." / "_" / "~"`).
1064
+
1065
+
**Path segments**: Handles substring matching for APIs that embed tokens within path segments (e.g., Telegram's `/bot{TOKEN}/sendMessage`). Each segment is percent-decoded, scanned for placeholder boundaries using the env var key grammar (`[A-Za-z_][A-Za-z0-9_]*`), resolved, validated for path safety, and percent-encoded per RFC 3986 Section 3.3 pchar rules (`unreserved / sub-delims / ":" / "@"`).
1066
+
1067
+
#### Path credential validation (CWE-22)
1068
+
1069
+
Resolved credential values destined for URL path segments are validated by `validate_credential_for_path()` before insertion. The following values are rejected:
1070
+
1071
+
| Pattern | Rejection reason |
1072
+
|---------|-----------------|
1073
+
|`../`, `..\\`, `..`| Path traversal sequence |
1074
+
|`/`, `\`| Path separator |
1075
+
|`\0`, `\r`, `\n`| Control character |
1076
+
|`?`, `#`| URI delimiter |
1077
+
1078
+
Rejection causes the request to fail closed (HTTP 500).
1079
+
1080
+
#### Secret value validation (CWE-113)
1081
+
1082
+
All resolved credential values are validated at the `resolve_placeholder()` level for prohibited control characters: CR (`\r`), LF (`\n`), and null byte (`\0`). This prevents HTTP header injection via malicious credential values. The validation applies to all placement locations automatically — header values, query parameters, and path segments all pass through `resolve_placeholder()`.
1083
+
1084
+
#### Fail-closed behavior
1085
+
1086
+
All placeholder rewriting fails closed. If any `openshell:resolve:env:*` placeholder is detected in the request but cannot be resolved, the proxy rejects the request with HTTP 500 instead of forwarding the raw placeholder to the upstream. The fail-closed mechanism operates at two levels:
1087
+
1088
+
1.**Per-location**: Each rewrite function (`rewrite_uri_query_params`, `rewrite_path_segment`, `rewrite_header_line`) returns an `UnresolvedPlaceholderError` when a placeholder is detected but the resolver has no mapping for it.
1089
+
1090
+
2.**Final scan**: After all rewriting completes, `rewrite_http_header_block()` scans the output for any remaining `openshell:resolve:env:` tokens. It also checks the percent-decoded form of the request line to catch encoded placeholder bypass attempts (e.g., `openshell%3Aresolve%3Aenv%3AUNKNOWN`).
When L7 inspection is active, credential placeholders in the request target (path + query) are resolved BEFORE OPA L7 policy evaluation. This is implemented in `relay_with_inspection()` and `relay_passthrough_with_credentials()` in `l7/relay.rs`:
1101
+
1102
+
1.`rewrite_target_for_eval()` resolves the request target, producing two strings:
1103
+
-**Resolved**: real secrets inserted — used only for the upstream connection
1104
+
-**Redacted**: `[CREDENTIAL]` markers in place of secrets — used for OPA input and logs
1105
+
1106
+
2. OPA `evaluate_l7_request()` receives the redacted path in `request.path`, so policy rules never see real credential values.
1107
+
1108
+
3. All log statements (`L7_REQUEST`, `HTTP_REQUEST`) use the redacted target. Real credential values never appear in logs.
1109
+
1110
+
4. The resolved path (with real secrets) goes only to the upstream via `relay_http_request_with_resolver()`.
1111
+
1112
+
```rust
1113
+
pub(crate) structRewriteTargetResult {
1114
+
pubresolved:String, // for upstream forwarding only
1115
+
pubredacted:String, // for OPA + logs
1116
+
}
1117
+
```
1118
+
1119
+
If credential resolution fails on the request target, the relay returns HTTP 500 and closes the connection.
When TLS is auto-terminated but no L7 policy (`protocol` + `access`/`rules`) is configured on the endpoint, the proxy enters a passthrough mode that still provides value: it parses HTTP requests minimally to rewrite credential placeholders (via `SecretResolver`) and logs each request for observability. This relay:
1125
+
When TLS is auto-terminated but no L7 policy (`protocol` + `access`/`rules`) is configured on the endpoint, the proxy enters a passthrough mode that still provides credential injection and observability. This relay:
1035
1126
1036
1127
1. Reads each HTTP request from the client via `RestProvider::parse_request()`
1037
-
2. Logs the request method, path, host, and port at `info!()` level (tagged `"HTTP relay (credential injection)"`)
1038
-
3. Forwards the request to upstream via `relay_http_request_with_resolver()`, which rewrites headers containing `openshell:resolve:env:*` placeholders with actual provider credential values
1039
-
4. Relays the upstream response back to the client
1040
-
5. Loops for HTTP keep-alive; exits on client close or non-reusable response
1128
+
2. Resolves and redacts the request target via `rewrite_target_for_eval()` (for log safety)
1129
+
3. Logs the request method, redacted path, host, and port at `info!()` level (tagged `HTTP_REQUEST`)
1130
+
4. Forwards the request to upstream via `relay_http_request_with_resolver()`, which rewrites all credential placeholders in headers, query parameters, path segments, and Basic auth tokens
1131
+
5. Relays the upstream response back to the client
1132
+
6. Loops for HTTP keep-alive; exits on client close or non-reusable response
1041
1133
1042
1134
This enables credential injection on all HTTPS endpoints automatically, without requiring the policy author to add `protocol: rest` and `access: full` just to get credentials injected.
1043
1135
1136
+
#### Known limitation: host-binding
1137
+
1138
+
The resolver resolves all placeholders regardless of destination host. If an agent has OPA-allowed access to an attacker-controlled host, it could construct a URL containing a placeholder and exfiltrate the resolved credential value to that host. OPA host restrictions are the defense — only endpoints explicitly allowed by policy receive traffic. Per-credential host binding (restricting which credentials resolve for which destination hosts) is not implemented.
1139
+
1140
+
#### Data flow
1141
+
1142
+
```mermaid
1143
+
sequenceDiagram
1144
+
participant A as Agent Process
1145
+
participant P as Proxy (SecretResolver)
1146
+
participant O as OPA Engine
1147
+
participant U as Upstream API
1148
+
1149
+
A->>P: GET /bot<placeholder>/send?key=<placeholder> HTTP/1.1<br/>Authorization: Bearer <placeholder>
@@ -1060,11 +1174,12 @@ Implements `L7Provider` for HTTP/1.1:
1060
1174
`relay_with_inspection()` in `crates/openshell-sandbox/src/l7/relay.rs` is the main relay loop:
1061
1175
1062
1176
1. Parse one HTTP request from client via the provider
1063
-
2. Build L7 input JSON with `request.method`, `request.path`, `request.query_params`, plus the CONNECT-level context (host, port, binary, ancestors, cmdline)
1064
-
3. Evaluate `data.openshell.sandbox.allow_request` and `data.openshell.sandbox.request_deny_reason`
1065
-
4. Log the L7 decision (tagged `L7_REQUEST`)
1066
-
5. If allowed (or audit mode): relay request to upstream and response back to client, then loop
1067
-
6. If denied in enforce mode: send 403 and close the connection
1177
+
2. Resolve credential placeholders in the request target via `rewrite_target_for_eval()`. OPA receives the redacted path (`[CREDENTIAL]` markers); the resolved path goes only to upstream. If resolution fails, return HTTP 500 and close the connection.
1178
+
3. Build L7 input JSON with `request.method`, the **redacted**`request.path`, `request.query_params`, plus the CONNECT-level context (host, port, binary, ancestors, cmdline)
1179
+
4. Evaluate `data.openshell.sandbox.allow_request` and `data.openshell.sandbox.request_deny_reason`
1180
+
5. Log the L7 decision (tagged `L7_REQUEST`) using the redacted target — real credential values never appear in logs
1181
+
6. If allowed (or audit mode): relay request to upstream via `relay_http_request_with_resolver()` (which rewrites all remaining credential placeholders in headers, query parameters, path segments, and Basic auth tokens) and relay the response back to client, then loop
1182
+
7. If denied in enforce mode: send 403 (using redacted target in the response body) and close the connection
1068
1183
1069
1184
## Process Identity
1070
1185
@@ -1317,6 +1432,10 @@ The sandbox uses `miette` for error reporting and `thiserror` for typed errors.
0 commit comments