Add timeout handling across the SSH connection stack to prevent
indefinite hangs during connection establishment:
- Add SSH_PROXY_ACCEPT_TIMEOUT (5s) and SSH_PROXY_CONNECT_TIMEOUT (10s)
- Add UPSTREAM_HANDSHAKE_TIMEOUT (10s) for WebSocket upstream connections
- Extract establish_upstream for cleaner connection setup
- Refactor SshHandler to maintain per-channel state instead of global
state, properly isolating pty_master, input_sender, and pty_request
per ChannelId
This prevents clients from hanging indefinitely when connection
establishment fails or times out.
* 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.
Disables GPU Feature Discovery and Node Feature Discovery DaemonSets and
overrides the device plugin's default nodeAffinity to null so it schedules
unconditionally on the single-node gateway without requiring NFD/GFD labels.
Setting affinity to an empty map ({}) does not override the chart defaults
because Helm deep-merges user values with chart defaults. Using null explicitly
removes the key, causing the chart template to skip the affinity block entirely.
Docker COPY preserves build-host file permissions. On hosts with a
restrictive umask (e.g. 0027), the migrations directory is copied as
750 root:root, making it unreadable by the openshell user at runtime
and causing the server to crash with a permission denied error on
startup.
Using --chmod=755 ensures the directory is always world-readable
regardless of the build host umask.
Signed-off-by: Evan Lezar <elezar@nvidia.com>
The secrets context is not available in step-level if expressions in
GitHub Actions — only in with and env blocks. This caused the workflow
file to be invalid, failing every run with:
Unrecognized named-value: 'secrets'
Fix by exporting the secret to a job-level env var and checking that
in the step condition instead.
Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
The ORG_READ_TOKEN (read:org PAT) was being used for all API calls,
including closing PRs and posting comments, which it lacks permissions
for. Split into two steps:
1. Org membership check — uses ORG_READ_TOKEN exclusively
2. VOUCHED.td check + close — uses default GITHUB_TOKEN (has repo write)
Step 2 is skipped entirely if step 1 confirms org membership.
Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
The GITHUB_TOKEN cannot determine org membership — it lacks read:org
scope and this is not configurable via the permissions block. Both
author_association and orgs.checkMembershipForUser return NONE/404 for
org members (even public ones) when called with the repo-scoped token.
Use an ORG_READ_TOKEN secret (fine-grained PAT with read:org) when
available, falling back to GITHUB_TOKEN. Also restores the
checkCollaborator fallback since the PAT can resolve that too.
Setup required: create a fine-grained PAT with Organization > Members >
Read permission, then add it as a repo secret named ORG_READ_TOKEN.
Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
The trigger-wheel-publish job was downloading the wheel artifact onto a
persistent self-hosted runner just to glob the filenames. Since
download-artifact does not clean the destination directory, stale .whl
files from every previous run accumulated in release/ and were all sent
to GitLab as WHEEL_FILENAMES.
Confirmed on the runner: /home/ubuntu/actions-runner/_work/OpenShell/
OpenShell/release/ contained 120+ wheels spanning versions 0.0.5
through 0.0.10.
Fix: capture wheel filenames as a job output in build-python-wheels and
pass them directly to trigger-wheel-publish. The trigger job no longer
downloads the artifact at all — it only needs the filenames to construct
GitHub release asset URLs for GitLab/Kitmaker.
The webhook payload field context.payload.pull_request.author_association
is unreliable under pull_request_target events — it was absent or not
populated, causing the previous fix (#442) to still fail.
Switch to fetching author_association via pulls.get REST API, which only
needs pull-requests permission (already granted) and reliably returns
MEMBER for org members regardless of membership visibility.
Also removes the redundant orgs.checkMembershipForUser and
repos.checkCollaborator fallbacks, which suffered from the same
GITHUB_TOKEN permission limitation that started this bug.
Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
The vouch-check workflow was closing PRs from NVIDIA org members because
the GITHUB_TOKEN lacks read:org scope, causing orgs.checkMembershipForUser
to return 404 for non-public members. The catch block silently swallowed
these as expected 'not found' responses.
Add an author_association check from the webhook payload as the primary
bypass. GitHub sets this field server-side (MEMBER, OWNER, COLLABORATOR)
regardless of membership visibility, with no extra token permissions
needed. The existing API calls are kept as fallbacks.
Fixes the false positive that closed#430.
Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
The console lexer cannot tokenize single-quoted JSON arguments in
multi-line shell commands. Switch the Step 5 code blocks to the bash
lexer which handles quoting correctly. Also standardize earlier blocks
to use the console lexer with $ prompts for simple commands, and
replace -H/-d curl flags with --json for consistency.
The OPENSHELL_CARGO_VERSION build arg contains a git commit hash that
changes on every build (e.g. 0.0.7-dev.11+g085b131ae). Declaring this
ARG near the top of each Dockerfile invalidated every layer below it --
including expensive dependency installs, toolchain setup, and the Rust
dependency pre-build step -- on every single commit.
Move the ARG declaration to just before the RUN that actually uses it so
upstream layers stay cached. This recovers ~5-6 minutes per build on the
gateway image (from ~9m back toward ~2-3m) and similarly improves cluster,
CLI, and Python wheel builds.
Also removes unused OPENSHELL_IMAGE_TAG ARG from cli-macos, python-wheels,
and python-wheels-macos Dockerfiles.