Files
OpenShell/e2e/python/test_inference_routing.py
John T. Myers 834f8aa184 fix: security hardening batch 1 (SEC-002 through SEC-010) (#548)
* fix(l7): reject ambiguous HTTP framing in REST proxy (SEC-009)

Harden the L7 REST proxy HTTP parser against request smuggling:

- Reject requests containing both Content-Length and Transfer-Encoding
  headers per RFC 7230 Section 3.3.3 (CL/TE ambiguity)
- Replace String::from_utf8_lossy with strict UTF-8 validation to
  prevent interpretation gaps with upstream servers
- Reject bare LF line endings (require CRLF per HTTP spec)
- Validate HTTP version string (HTTP/1.0 or HTTP/1.1 only)

* fix(server): harden shell_escape and command construction (SEC-002)

Harden the gRPC exec handler against command injection via the
structured-to-shell-string conversion:

- Reject null bytes and newlines/carriage returns in shell_escape()
- Add input validation in exec_sandbox: reject control characters in
  command args, env values, and workdir
- Enforce size limits: max 1024 args, 32 KiB per arg/value, 4 KiB
  workdir, 256 KiB total assembled command string
- Change shell_escape and build_remote_exec_command to return Result
  so callers must handle validation failures

* fix(server): add command validation at SSH transport boundary (SEC-003)

Add defense-in-depth validation in run_exec_with_russh before sending
the command to the sandbox SSH server:

- Reject null bytes in command string at transport boundary
- Enforce max command length (256 KiB) at transport boundary
- Enhance stream_exec_over_ssh logging with command length, stdin
  length, and truncated command preview for audit trail

* fix(sandbox): validate port range and extend loopback check in SSH (SEC-007)

Harden the SSH direct-tcpip channel handler:

- Validate port_to_connect <= 65535 before u32-to-u16 cast to prevent
  port truncation (e.g., 65537 becoming port 1)
- Replace string-literal loopback check with is_loopback_host() that
  covers the full 127.0.0.0/8 range, IPv4-mapped IPv6 (::ffff:127.x),
  bracketed IPv6, and case-insensitive localhost
- Remove #[allow(clippy::cast_possible_truncation)] since the cast is
  now proven safe by the preceding range check

* fix(sandbox): sanitize inference error messages returned to sandbox (SEC-008)

Replace verbatim internal error strings in router_error_to_http with
generic messages to prevent information leakage to sandboxed code.
Upstream URLs, internal hostnames, TLS details, and file paths are no
longer exposed. Full error context is still logged server-side at warn
level by the caller for debugging.

* fix(server): block internal IPs in SSH proxy target validation (SEC-006)

Add IP validation in start_single_use_ssh_proxy to prevent SSRF if a
sandbox status record were poisoned:

- Resolve DNS before connecting and validate the resolved IP
- Block loopback (127.0.0.0/8) and link-local (169.254.0.0/16, covers
  cloud metadata endpoint) addresses
- Block IPv4-mapped IPv6 variants of the same ranges
- Connect to the validated SocketAddr directly to prevent TOCTOU
- Add debug logging of resolved target IP for audit

* fix(sandbox): add resource limits to chunked body parser (SEC-010)

Harden parse_chunked_body in the inference interception path:

- Replace all unchecked +2 additions with checked_add for consistent
  overflow safety across all target architectures
- Add MAX_CHUNKED_BODY (10 MiB) to cap decoded body size
- Add MAX_CHUNK_COUNT (4096) to prevent CPU exhaustion via tiny chunks
- Early-reject chunk sizes larger than remaining buffer space

* fix(cli): double-escape command for SSH path, validate host and name (SEC-004)

Harden doctor_exec against command injection in the SSH remote path:

- Apply shell_escape to inner_cmd in the SSH path so it survives the
  double shell interpretation (SSH remote shell + sh -lc). This also
  fixes a correctness bug where multi-word commands were silently
  broken in the SSH path.
- Add validate_gateway_name to reject shell metacharacters in gateway
  names before use in container_name
- Add validate_ssh_host to reject metacharacters in remote_host loaded
  from metadata.json

* fix(sandbox): add CIDR breadth warning and control-plane port blocklist (SEC-005)

Defense-in-depth for the allowed_ips feature:

- Log a warning when a CIDR entry has a prefix length < /16, as overly
  broad ranges may unintentionally expose control-plane services
- Block K8s API (6443), etcd (2379/2380), and kubelet (10250/10255)
  ports unconditionally in resolve_and_check_allowed_ips, even when
  the resolved IP matches an allowed_ips entry

* test(e2e): update assertion for sanitized inference error message (SEC-008)

The SEC-008 fix changed the error message from 'no compatible route
for source protocol ...' to 'no compatible inference route available'.
Update the E2E assertion substring to match.
2026-03-23 14:41:03 -07:00

372 lines
12 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""E2E tests for explicit inference routing via `inference.local`.
In the new model, sandbox traffic is routed only when the request targets
`inference.local`. There is no implicit catch-all interception for arbitrary
hosts like `api.openai.com`.
"""
from __future__ import annotations
import fcntl
from contextlib import contextmanager
from typing import TYPE_CHECKING
import grpc
import pytest
from openshell._proto import datamodel_pb2, openshell_pb2, sandbox_pb2
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
from openshell import (
ClusterInferenceConfig,
InferenceRouteClient,
Sandbox,
SandboxClient,
)
_BASE_FILESYSTEM = sandbox_pb2.FilesystemPolicy(
include_workdir=True,
read_only=["/usr", "/lib", "/etc", "/app", "/var/log"],
read_write=["/sandbox", "/tmp"],
)
_BASE_LANDLOCK = sandbox_pb2.LandlockPolicy(compatibility="best_effort")
_BASE_PROCESS = sandbox_pb2.ProcessPolicy(run_as_user="sandbox", run_as_group="sandbox")
pytestmark = pytest.mark.xdist_group("inference-routing")
_MANAGED_OPENAI_MODEL_ID = "mock/e2e-openai-model"
_MANAGED_OPENAI_PROVIDER_NAME = "e2e-managed-openai"
_INFERENCE_CONFIG_LOCK = "/tmp/openshell-e2e-inference-config.lock"
def _baseline_policy() -> sandbox_pb2.SandboxPolicy:
return sandbox_pb2.SandboxPolicy(
version=1,
filesystem=_BASE_FILESYSTEM,
landlock=_BASE_LANDLOCK,
process=_BASE_PROCESS,
)
def _upsert_managed_inference(
inference_client: InferenceRouteClient,
sandbox_client: SandboxClient,
*,
provider_name: str,
provider_type: str,
credential_key: str,
base_url_key: str,
model_id: str,
base_url: str,
) -> None:
provider = datamodel_pb2.Provider(
name=provider_name,
type=provider_type,
credentials={credential_key: "mock"},
config={
base_url_key: base_url,
},
)
timeout = sandbox_client._timeout
for _ in range(5):
try:
sandbox_client._stub.UpdateProvider(
openshell_pb2.UpdateProviderRequest(provider=provider),
timeout=timeout,
)
break
except grpc.RpcError as exc:
if exc.code() != grpc.StatusCode.NOT_FOUND:
raise
try:
sandbox_client._stub.CreateProvider(
openshell_pb2.CreateProviderRequest(provider=provider),
timeout=timeout,
)
break
except grpc.RpcError as create_exc:
if create_exc.code() == grpc.StatusCode.ALREADY_EXISTS:
continue
if (
create_exc.code() == grpc.StatusCode.INTERNAL
and "UNIQUE constraint failed" in (create_exc.details() or "")
):
continue
raise
else:
raise RuntimeError("failed to upsert managed e2e provider after retries")
inference_client.set_cluster(
provider_name=provider_name,
model_id=model_id,
)
def _current_cluster_inference(
inference_client: InferenceRouteClient,
) -> ClusterInferenceConfig | None:
try:
return inference_client.get_cluster()
except grpc.RpcError as exc:
if exc.code() == grpc.StatusCode.NOT_FOUND:
return None
raise
def _restore_cluster_inference(
inference_client: InferenceRouteClient,
previous: ClusterInferenceConfig | None,
) -> None:
if previous is None:
return
inference_client.set_cluster(
provider_name=previous.provider_name,
model_id=previous.model_id,
)
@contextmanager
def _cluster_config_lock() -> Iterator[None]:
with open(_INFERENCE_CONFIG_LOCK, "a+", encoding="utf-8") as lock_file:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
@pytest.fixture
def managed_openai_route(
inference_client: InferenceRouteClient,
sandbox_client: SandboxClient,
) -> Iterator[str]:
with _cluster_config_lock():
previous = _current_cluster_inference(inference_client)
_upsert_managed_inference(
inference_client,
sandbox_client,
provider_name=_MANAGED_OPENAI_PROVIDER_NAME,
provider_type="openai",
credential_key="OPENAI_API_KEY",
base_url_key="OPENAI_BASE_URL",
model_id=_MANAGED_OPENAI_MODEL_ID,
base_url="mock://e2e-managed-openai",
)
try:
yield _MANAGED_OPENAI_MODEL_ID
finally:
_restore_cluster_inference(inference_client, previous)
def test_model_discovery_call_routed_to_backend(
sandbox: Callable[..., Sandbox],
managed_openai_route: str,
) -> None:
"""Model discovery endpoint is treated as an inference protocol."""
spec = datamodel_pb2.SandboxSpec(policy=_baseline_policy())
def call_models() -> str:
import ssl
import urllib.request
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request("https://inference.local/v1/models", method="GET")
resp = urllib.request.urlopen(req, timeout=30, context=ctx)
return resp.read().decode()
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(call_models, timeout_seconds=60)
assert result.exit_code == 0, f"stderr: {result.stderr}"
output = result.stdout.strip()
assert "Hello from openshell mock backend" in output
assert managed_openai_route in output
def test_inference_call_routed_to_backend(
sandbox: Callable[..., Sandbox],
managed_openai_route: str,
) -> None:
"""OpenAI chat request to `inference.local` is intercepted and routed."""
spec = datamodel_pb2.SandboxSpec(policy=_baseline_policy())
def call_chat_completions() -> str:
import json
import ssl
import urllib.request
body = json.dumps(
{
"model": "test-model",
"messages": [{"role": "user", "content": "hello"}],
}
).encode()
req = urllib.request.Request(
"https://inference.local/v1/chat/completions",
data=body,
headers={
"Content-Type": "application/json",
"Authorization": "Bearer dummy-key",
},
method="POST",
)
# The proxy will TLS-terminate, so we need to accept its cert.
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
resp = urllib.request.urlopen(req, timeout=30, context=ctx)
return resp.read().decode()
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(call_chat_completions, timeout_seconds=60)
assert result.exit_code == 0, f"stderr: {result.stderr}"
output = result.stdout.strip()
assert "Hello from openshell mock backend" in output
assert managed_openai_route in output
def test_non_inference_request_denied(
sandbox: Callable[..., Sandbox],
managed_openai_route: str,
) -> None:
"""Non-inference path on `inference.local` is denied with 403."""
_ = managed_openai_route
spec = datamodel_pb2.SandboxSpec(policy=_baseline_policy())
def make_non_inference_request() -> str:
import ssl
import urllib.error
import urllib.request
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
req = urllib.request.Request("https://inference.local/v1/not-inference")
urllib.request.urlopen(req, timeout=10, context=ctx)
return "unexpected_success"
except urllib.error.HTTPError as e:
return f"http_error_{e.code}"
except Exception as e:
return f"error: {e}"
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(make_non_inference_request, timeout_seconds=30)
assert result.exit_code == 0, f"stderr: {result.stderr}"
assert result.stdout.strip() == "http_error_403"
def test_unsupported_protocol_returns_400(
sandbox: Callable[..., Sandbox],
managed_openai_route: str,
) -> None:
"""Protocol mismatch returns 400 when no compatible route exists."""
_ = managed_openai_route
spec = datamodel_pb2.SandboxSpec(policy=_baseline_policy())
def call_anthropic_messages() -> str:
import json
import ssl
import urllib.error
import urllib.request
body = json.dumps(
{
"model": "claude-test",
"max_tokens": 64,
"messages": [{"role": "user", "content": "hello"}],
}
).encode()
req = urllib.request.Request(
"https://inference.local/v1/messages",
data=body,
headers={
"Content-Type": "application/json",
"x-api-key": "dummy-key",
"anthropic-version": "2023-06-01",
},
method="POST",
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
resp = urllib.request.urlopen(req, timeout=30, context=ctx)
return resp.read().decode()
except urllib.error.HTTPError as exc:
return (
f"http_error_{exc.code}:{exc.read().decode('utf-8', errors='replace')}"
)
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(call_anthropic_messages, timeout_seconds=60)
assert result.exit_code == 0, f"stderr: {result.stderr}"
output = result.stdout.strip()
assert output.startswith("http_error_400"), output
assert "no compatible inference route" in output
def test_non_inference_host_is_not_intercepted(
sandbox: Callable[..., Sandbox],
managed_openai_route: str,
) -> None:
"""Requests to non-`inference.local` hosts do not get inference routing."""
_ = managed_openai_route
spec = datamodel_pb2.SandboxSpec(policy=_baseline_policy())
def call_external_openai_endpoint() -> str:
import json
import ssl
import urllib.error
import urllib.request
body = json.dumps(
{
"model": "test-model",
"messages": [{"role": "user", "content": "hello"}],
}
).encode()
req = urllib.request.Request(
"https://api.openai.com/v1/chat/completions",
data=body,
headers={
"Content-Type": "application/json",
"Authorization": "Bearer dummy",
},
method="POST",
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
resp = urllib.request.urlopen(req, timeout=30, context=ctx)
return resp.read().decode()
except urllib.error.URLError as exc:
return f"url_error:{exc}"
except Exception as exc:
return f"error:{type(exc).__name__}:{exc}"
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(call_external_openai_endpoint, timeout_seconds=60)
assert result.exit_code == 0, f"stderr: {result.stderr}"
output = result.stdout.strip()
assert "Tunnel connection failed: 403 Forbidden" in output