Files
OpenShell/e2e/rust/tests/websocket_conformance.rs
John T. MyersandJohn Myers 0120535efc feat(proxy): bind static credentials to provider endpoints (#2510)
* feat(proxy): bind static credentials to provider endpoints

Signed-off-by: John Myers <johntmyers@users.noreply.github.com>

* test(e2e): verify static credential endpoint isolation

Signed-off-by: John Myers <johntmyers@users.noreply.github.com>

* docs(provider): explain static credential endpoint binding

Signed-off-by: John Myers <johntmyers@users.noreply.github.com>

* fix(e2e): use valid endpoint isolation fixtures

Signed-off-by: John Myers <johntmyers@users.noreply.github.com>

* docs(provider): explain static credential endpoint binding

Signed-off-by: John Myers <johntmyers@users.noreply.github.com>

* fix(credentials): preserve binding identity across rotations

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(proxy): enforce bindings across request lifecycle

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(proxy): close credential relay gaps

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* docs(credentials): clarify binding failure behavior

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(credentials): hash selected provider profile scope

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(proxy): resolve credentials after request admission

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* docs(credentials): clarify binding failure diagnostics

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(proxy): align single-route credential denials

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(credentials): harden endpoint-bound rotation

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(credentials): enforce identity and authority binding

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(credentials): snapshot provider environment atomically

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* test(e2e): include authority port in query proxy requests

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(credentials): close credential revocation gaps

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* docs(proxy): explain authority mismatch diagnostics

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(credentials): enforce binding lifecycle invariants

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(provider): reject credential config collisions

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(network): capture credential scope atomically

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(network): distinguish origin and absolute targets

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(provider): isolate endpointless profile credentials

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(network): normalize IPv6 request authorities

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* docs(credentials): clarify endpointless profile isolation

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* feat(policy): bind endpointless provider credentials

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(credentials): use current GCP placeholder revision

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* docs(providers): explain policy credential bindings

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* test(credentials): cover endpointless fail-closed invariant

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* test(policy): expect ambiguity rejection at creation

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* test(server): authenticate rebased policy requests

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* refactor(proxy): share credential mismatch finding builder

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* test(credentials): cover malformed binding metadata

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* test(credentials): verify multi-key endpoint isolation

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* test(e2e): cover same-host credential path denial

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* docs(credentials): document serialized refresh contract

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* refactor(proxy): consolidate L7 log formatting

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* perf(credentials): precompile endpoint binding patterns

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* perf(credentials): share identity epoch revisions

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* test(proxy): require explicit request default ports

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(policy): validate SigV4 credential sources

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* fix(credentials): preserve endpoint bindings for credential handles

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

* feat(go-sdk): expose network credential bindings

Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>

---------

Signed-off-by: John Myers <johntmyers@users.noreply.github.com>
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
2026-08-10 19:19:45 +00:00

547 lines
17 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
#![cfg(feature = "e2e")]
//! E2E regression: WebSocket credential placeholders are resolved on the
//! sandbox path after an RFC 6455 upgrade.
//!
//! The sandbox process sends its provider-managed placeholder in a masked text
//! frame. The local upstream only reports whether it saw the real secret and
//! whether any placeholder survived; it never echoes payload bytes, placeholder
//! text, or secret material back into test output.
use std::io::{self, Error, ErrorKind, Write};
use std::process::Stdio;
use std::sync::Mutex;
use base64::Engine as _;
use openshell_e2e::harness::binary::openshell_cmd;
use openshell_e2e::harness::sandbox::SandboxGuard;
use sha1::{Digest, Sha1};
use tempfile::{Builder as TempFileBuilder, NamedTempFile};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::task::JoinHandle;
const WEBSOCKET_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const PROVIDER_NAME: &str = "e2e-websocket-conformance";
const PROVIDER_PROFILE_ID: &str = "e2e-websocket-conformance";
const TEST_SERVER_HOST: &str = "host.openshell.internal";
const TEST_SECRET: &str = "sk-e2e-websocket-conformance-secret";
const TOKEN_ENV: &str = "WS_E2E_TOKEN";
const PLACEHOLDER_PREFIX: &str = "openshell:resolve:env:";
static PROVIDER_LOCK: Mutex<()> = Mutex::new(());
async fn run_cli(args: &[&str]) -> Result<String, String> {
let mut cmd = openshell_cmd();
cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
let output = cmd
.output()
.await
.map_err(|e| format!("failed to spawn openshell {}: {e}", args.join(" ")))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = format!("{stdout}{stderr}");
if !output.status.success() {
return Err(format!(
"openshell {} failed (exit {:?}):\n{combined}",
args.join(" "),
output.status.code()
));
}
Ok(combined)
}
async fn delete_provider(name: &str) {
let mut cmd = openshell_cmd();
cmd.arg("provider")
.arg("delete")
.arg(name)
.stdout(Stdio::null())
.stderr(Stdio::null());
let _ = cmd.status().await;
}
async fn delete_provider_profile(id: &str) {
let mut cmd = openshell_cmd();
cmd.args(["provider", "profile", "delete", id])
.stdout(Stdio::null())
.stderr(Stdio::null());
let _ = cmd.status().await;
}
async fn create_bound_provider(name: &str) -> Result<String, String> {
let credential = format!("{TOKEN_ENV}={TEST_SECRET}");
run_cli(&[
"provider",
"create",
"--name",
name,
"--type",
PROVIDER_PROFILE_ID,
"--credential",
&credential,
])
.await
}
fn write_credential_profile(port: u16) -> Result<NamedTempFile, String> {
let mut file = TempFileBuilder::new()
.suffix(".yaml")
.tempfile()
.map_err(|error| format!("create provider profile: {error}"))?;
let profile = format!(
r#"id: {PROVIDER_PROFILE_ID}
display_name: E2E WebSocket conformance credentials
category: other
credentials:
- name: websocket_token
env_vars: [{TOKEN_ENV}]
required: true
auth_style: bearer
header_name: authorization
endpoints:
- host: {TEST_SERVER_HOST}
port: {port}
path: /ws
protocol: websocket
enforcement: enforce
access: read-write
websocket_credential_rewrite: true
allowed_ips:
- "10.0.0.0/8"
- "172.0.0.0/8"
- "192.168.0.0/16"
- "fc00::/7"
binaries:
- path: /usr/bin/python*
- path: /usr/local/bin/python*
- path: /sandbox/.uv/python/*/bin/python*
"#
);
file.write_all(profile.as_bytes())
.map_err(|error| format!("write provider profile: {error}"))?;
file.flush()
.map_err(|error| format!("flush provider profile: {error}"))?;
Ok(file)
}
struct WebSocketProbeServer {
port: u16,
task: JoinHandle<()>,
}
impl WebSocketProbeServer {
async fn start() -> Result<Self, String> {
let listener = TcpListener::bind(("0.0.0.0", 0))
.await
.map_err(|e| format!("bind websocket probe server: {e}"))?;
let port = listener
.local_addr()
.map_err(|e| format!("read websocket probe server address: {e}"))?
.port();
let task = tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let _ = handle_websocket_probe_connection(stream).await;
});
}
});
Ok(Self { port, task })
}
}
impl Drop for WebSocketProbeServer {
fn drop(&mut self) {
self.task.abort();
}
}
async fn recv_until(stream: &mut TcpStream, marker: &[u8]) -> io::Result<Vec<u8>> {
let mut data = Vec::new();
let mut buf = [0_u8; 1024];
loop {
let read = stream.read(&mut buf).await?;
if read == 0 {
return Ok(data);
}
data.extend_from_slice(&buf[..read]);
if data.windows(marker.len()).any(|window| window == marker) {
return Ok(data);
}
}
}
async fn read_websocket_text(stream: &mut TcpStream) -> io::Result<String> {
let mut header = [0_u8; 2];
stream.read_exact(&mut header).await?;
let length = match header[1] & 0x7F {
len @ 0..=125 => usize::from(len),
126 => {
let mut bytes = [0_u8; 2];
stream.read_exact(&mut bytes).await?;
usize::from(u16::from_be_bytes(bytes))
}
127 => {
let mut bytes = [0_u8; 8];
stream.read_exact(&mut bytes).await?;
usize::try_from(u64::from_be_bytes(bytes))
.map_err(|_| Error::new(ErrorKind::InvalidData, "websocket frame too large"))?
}
_ => unreachable!(),
};
let mut mask = [0_u8; 4];
if header[1] & 0x80 != 0 {
stream.read_exact(&mut mask).await?;
} else {
mask = [0, 0, 0, 0];
}
let mut payload = vec![0_u8; length];
stream.read_exact(&mut payload).await?;
if header[1] & 0x80 != 0 {
for (index, byte) in payload.iter_mut().enumerate() {
*byte ^= mask[index % mask.len()];
}
}
String::from_utf8(payload).map_err(|e| {
Error::new(
ErrorKind::InvalidData,
format!("invalid websocket text: {e}"),
)
})
}
async fn send_websocket_text(stream: &mut TcpStream, payload: &str) -> io::Result<()> {
let data = payload.as_bytes();
let mut frame = Vec::with_capacity(data.len() + 10);
frame.push(0x81);
if data.len() < 126 {
frame.push(
u8::try_from(data.len())
.map_err(|_| Error::new(ErrorKind::InvalidData, "websocket frame too large"))?,
);
} else if data.len() <= usize::from(u16::MAX) {
frame.push(126);
frame.extend_from_slice(
&u16::try_from(data.len())
.map_err(|_| Error::new(ErrorKind::InvalidData, "websocket frame too large"))?
.to_be_bytes(),
);
} else {
frame.push(127);
frame.extend_from_slice(
&u64::try_from(data.len())
.map_err(|_| Error::new(ErrorKind::InvalidData, "websocket frame too large"))?
.to_be_bytes(),
);
}
frame.extend_from_slice(data);
stream.write_all(&frame).await
}
fn header_value(request: &str, name: &str) -> Option<String> {
request.lines().find_map(|line| {
let (header, value) = line.split_once(':')?;
if header.trim().eq_ignore_ascii_case(name) {
Some(value.trim().to_string())
} else {
None
}
})
}
fn websocket_accept_for_key(key: &str) -> String {
let mut hasher = Sha1::new();
hasher.update(key.as_bytes());
hasher.update(WEBSOCKET_GUID.as_bytes());
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
}
async fn handle_websocket_probe_connection(mut stream: TcpStream) -> io::Result<()> {
let request_bytes = recv_until(&mut stream, b"\r\n\r\n").await?;
let request = String::from_utf8_lossy(&request_bytes);
if !request.to_ascii_lowercase().contains("upgrade: websocket") {
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
.await?;
return Ok(());
}
let accept = header_value(&request, "Sec-WebSocket-Key")
.map(|key| websocket_accept_for_key(&key))
.ok_or_else(|| Error::new(ErrorKind::InvalidData, "missing Sec-WebSocket-Key"))?;
let response = format!(
"HTTP/1.1 101 Switching Protocols\r\n\
Upgrade: websocket\r\n\
Connection: Upgrade\r\n\
Sec-WebSocket-Accept: {accept}\r\n\
\r\n"
);
stream.write_all(response.as_bytes()).await?;
let text = read_websocket_text(&mut stream).await?;
let response = format!(
r#"{{"saw_placeholder": {}, "saw_secret": {}}}"#,
text.contains(PLACEHOLDER_PREFIX),
text.contains(TEST_SECRET)
);
send_websocket_text(&mut stream, &response).await
}
fn write_websocket_policy(host: &str, port: u16) -> Result<NamedTempFile, String> {
let mut file = NamedTempFile::new().map_err(|e| format!("create temp policy file: {e}"))?;
let policy = format!(
r#"version: 1
filesystem_policy:
include_workdir: true
read_only:
- /usr
- /lib
- /proc
- /dev/urandom
- /app
- /etc
- /var/log
read_write:
- /sandbox
- /tmp
- /dev/null
landlock:
compatibility: best_effort
process:
run_as_user: sandbox
run_as_group: sandbox
network_policies:
websocket_conformance:
name: websocket_conformance
endpoints:
- host: {host}
port: {port}
protocol: websocket
enforcement: enforce
access: read-write
websocket_credential_rewrite: true
allowed_ips:
- "10.0.0.0/8"
- "172.0.0.0/8"
- "192.168.0.0/16"
- "fc00::/7"
binaries:
- path: /usr/bin/python*
- path: /usr/local/bin/python*
- path: /sandbox/.uv/python/*/bin/python*
"#
);
file.write_all(policy.as_bytes())
.map_err(|e| format!("write temp policy file: {e}"))?;
file.flush()
.map_err(|e| format!("flush temp policy file: {e}"))?;
Ok(file)
}
fn websocket_client_script(host: &str, port: u16) -> String {
format!(
r#"
import base64
import json
import os
import socket
import struct
import time
import urllib.parse
HOST = {host:?}
PORT = {port}
TOKEN_ENV = {token_env:?}
def recv_until(sock, marker):
data = b""
while marker not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
return data
def read_exact(sock, size):
data = b""
while len(data) < size:
chunk = sock.recv(size - len(data))
if not chunk:
raise EOFError("unexpected end of websocket frame")
data += chunk
return data
def masked_text_frame(payload):
data = payload.encode("utf-8")
mask = os.urandom(4)
if len(data) < 126:
header = bytes([0x81, 0x80 | len(data)])
elif len(data) <= 0xFFFF:
header = bytes([0x81, 0x80 | 126]) + struct.pack("!H", len(data))
else:
header = bytes([0x81, 0x80 | 127]) + struct.pack("!Q", len(data))
masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(data))
return header + mask + masked
def read_frame(sock):
first, second = read_exact(sock, 2)
length = second & 0x7F
if length == 126:
length = struct.unpack("!H", read_exact(sock, 2))[0]
elif length == 127:
length = struct.unpack("!Q", read_exact(sock, 8))[0]
mask = read_exact(sock, 4) if second & 0x80 else b""
payload = read_exact(sock, length)
if mask:
payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
return first, payload
def proxy_parts():
names = ("HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy")
proxy_url = next((os.environ.get(name) for name in names if os.environ.get(name)), None)
if not proxy_url:
raise RuntimeError("proxy environment is not configured")
parsed = urllib.parse.urlparse(proxy_url)
if not parsed.hostname:
raise RuntimeError(f"invalid proxy URL: {{proxy_url!r}}")
return parsed.hostname, parsed.port or 80
def proxy_socket_with_retry(host, port, mode, timeout_seconds=20):
proxy_host, proxy_port = proxy_parts()
target = f"{{host}}:{{port}}"
deadline = time.monotonic() + timeout_seconds
last_error = None
while time.monotonic() < deadline:
sock = None
try:
sock = socket.create_connection((proxy_host, proxy_port), timeout=5)
if mode == "connect":
request = f"CONNECT {{target}} HTTP/1.1\r\nHost: {{target}}\r\n\r\n"
sock.sendall(request.encode("ascii"))
response = recv_until(sock, b"\r\n\r\n").decode("iso-8859-1", "replace")
if not (response.startswith("HTTP/1.1 200") or response.startswith("HTTP/1.0 200")):
first_line = response.splitlines()[0] if response else "<empty response>"
raise RuntimeError(f"proxy CONNECT failed: {{first_line}}")
return sock
except (OSError, RuntimeError) as error:
if sock is not None:
sock.close()
last_error = error
time.sleep(0.25)
raise last_error
token = os.environ[TOKEN_ENV]
payload = json.dumps({{"authorization": "Bearer " + token}}, sort_keys=True)
results = {{}}
for mode in ("connect", "forward"):
key = base64.b64encode(os.urandom(16)).decode("ascii")
with proxy_socket_with_retry(HOST, PORT, mode) as sock:
request_target = "/ws" if mode == "connect" else f"http://{{HOST}}:{{PORT}}/ws"
request = (
f"GET {{request_target}} HTTP/1.1\r\n"
f"Host: {{HOST}}:{{PORT}}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {{key}}\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n"
)
sock.sendall(request.encode("ascii"))
response = recv_until(sock, b"\r\n\r\n").decode("iso-8859-1", "replace")
if not response.startswith("HTTP/1.1 101"):
raise RuntimeError(f"{{mode}} websocket upgrade failed: {{response!r}}")
sock.sendall(masked_text_frame(payload))
_, response_payload = read_frame(sock)
results[mode] = json.loads(response_payload.decode("utf-8"))
print(json.dumps(results, sort_keys=True))
"#,
host = host,
port = port,
token_env = TOKEN_ENV,
)
}
#[tokio::test]
async fn websocket_text_placeholder_is_rewritten_through_both_adapters() {
let _provider_lock = PROVIDER_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
delete_provider(PROVIDER_NAME).await;
delete_provider_profile(PROVIDER_PROFILE_ID).await;
let result = async {
let server = WebSocketProbeServer::start().await?;
let profile = write_credential_profile(server.port)?;
let profile_path = profile.path().to_string_lossy().into_owned();
run_cli(&["provider", "profile", "import", "--file", &profile_path]).await?;
create_bound_provider(PROVIDER_NAME).await?;
let policy = write_websocket_policy(TEST_SERVER_HOST, server.port)?;
let policy_path = policy
.path()
.to_str()
.ok_or_else(|| "temp policy path should be utf-8".to_string())?
.to_string();
let script = websocket_client_script(TEST_SERVER_HOST, server.port);
SandboxGuard::create(&[
"--policy",
&policy_path,
"--provider",
PROVIDER_NAME,
"--",
"python3",
"-c",
&script,
])
.await
}
.await;
delete_provider(PROVIDER_NAME).await;
delete_provider_profile(PROVIDER_PROFILE_ID).await;
let guard = result.expect("sandbox create");
assert!(
guard
.create_output
.contains(r#""connect": {"saw_placeholder": false, "saw_secret": true}"#),
"expected CONNECT upstream to see only the resolved secret marker:\n{}",
guard.create_output
);
assert!(
guard
.create_output
.contains(r#""forward": {"saw_placeholder": false, "saw_secret": true}"#),
"expected upstream to see only the resolved secret marker:\n{}",
guard.create_output
);
assert!(
!guard.create_output.contains(TEST_SECRET),
"test output should not expose the raw WebSocket credential:\n{}",
guard.create_output
);
assert!(
!guard.create_output.contains(PLACEHOLDER_PREFIX),
"test output should not expose unresolved credential placeholders:\n{}",
guard.create_output
);
}