Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/real-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ jobs:
execd_image = "opensandbox/execd:local"
[egress]
image = "opensandbox/egress:local"
mode = "dns"
mode = "dns+nft"
[docker]
network_mode = "bridge"
[storage]
Expand Down Expand Up @@ -308,6 +308,7 @@ jobs:
execd_image = "opensandbox/execd:local"
[egress]
image = "opensandbox/egress:local"
mode = "dns+nft"
[docker]
network_mode = "bridge"
[storage]
Expand Down Expand Up @@ -398,6 +399,7 @@ jobs:
execd_image = "opensandbox/execd:local"
[egress]
image = "opensandbox/egress:local"
mode = "dns+nft"
[docker]
network_mode = "bridge"
[storage]
Expand Down Expand Up @@ -490,6 +492,7 @@ jobs:
execd_image = "opensandbox/execd:local"
[egress]
image = "opensandbox/egress:local"
mode = "dns+nft"
[docker]
network_mode = "bridge"
[storage]
Expand Down
22 changes: 22 additions & 0 deletions components/egress/credential_vault_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ func TestCredentialVaultDeleteRequiresReady(t *testing.T) {

func TestCredentialVaultWriteRequiresTLSOrLoopback(t *testing.T) {
t.Setenv(constants.EnvMitmproxyTransparent, "true")
t.Setenv(constants.EnvEgressMode, constants.PolicyDnsNft)
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
srv := &policyServer{
proxy: &stubProxy{updated: initial},
Expand Down Expand Up @@ -234,6 +235,7 @@ func TestCredentialVaultWriteRequiresTLSOrLoopback(t *testing.T) {

func TestCredentialVaultWriteAllowsForwardedProto(t *testing.T) {
t.Setenv(constants.EnvMitmproxyTransparent, "true")
t.Setenv(constants.EnvEgressMode, constants.PolicyDnsNft)
t.Setenv(constants.EnvCredentialVaultTrustedProxyCIDRs, "198.51.100.0/24")
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
srv := &policyServer{
Expand All @@ -253,6 +255,7 @@ func TestCredentialVaultWriteAllowsForwardedProto(t *testing.T) {

func TestCredentialVaultWriteRejectsForwardedProtoFromUntrustedPeer(t *testing.T) {
t.Setenv(constants.EnvMitmproxyTransparent, "true")
t.Setenv(constants.EnvEgressMode, constants.PolicyDnsNft)
t.Setenv(constants.EnvCredentialVaultTrustedProxyCIDRs, "203.0.113.0/24")
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
srv := &policyServer{
Expand All @@ -272,6 +275,7 @@ func TestCredentialVaultWriteRejectsForwardedProtoFromUntrustedPeer(t *testing.T

func TestCredentialVaultWriteSkipsTLSCheckByDefault(t *testing.T) {
t.Setenv(constants.EnvMitmproxyTransparent, "true")
t.Setenv(constants.EnvEgressMode, constants.PolicyDnsNft)
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
srv := &policyServer{
proxy: &stubProxy{updated: initial},
Expand All @@ -285,3 +289,21 @@ func TestCredentialVaultWriteSkipsTLSCheckByDefault(t *testing.T) {

require.Equal(t, http.StatusCreated, w.Result().StatusCode)
}

func TestCredentialVaultWriteRejectsDNSOnlyEnforcement(t *testing.T) {
t.Setenv(constants.EnvMitmproxyTransparent, "true")
t.Setenv(constants.EnvEgressMode, constants.PolicyDnsOnly)
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
srv := &policyServer{
proxy: &stubProxy{updated: initial},
credentialVault: credentialvault.NewStore(nil, func() bool { return true }),
}

req := httptest.NewRequest(http.MethodPost, "/credential-vault", strings.NewReader(`{"credentials":[],"bindings":[]}`))
req.RemoteAddr = "127.0.0.1:4321"
w := httptest.NewRecorder()
srv.handleCredentialVault(w, req)

require.Equal(t, http.StatusPreconditionFailed, w.Result().StatusCode)
require.Contains(t, w.Body.String(), "dns+nft")
}
10 changes: 10 additions & 0 deletions components/egress/pkg/credentialvault/vault.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"sync"

"github.com/alibaba/opensandbox/egress/pkg/constants"
"github.com/alibaba/opensandbox/egress/pkg/log"
"github.com/alibaba/opensandbox/egress/pkg/mitmproxy"
"github.com/alibaba/opensandbox/egress/pkg/policy"
)
Expand Down Expand Up @@ -377,13 +378,22 @@ func (v *Store) Ready(ctx context.Context) error {
if constants.IsTruthy(os.Getenv(constants.EnvMitmproxySslInsecure)) {
return fmt.Errorf("credential vault rejects insecure upstream TLS mode")
}
if !constants.ModeUsesNft(os.Getenv(constants.EnvEgressMode)) {
return fmt.Errorf("credential vault requires dns+nft egress enforcement")
Comment thread
hellomypastor marked this conversation as resolved.
}
if v.mitmGate != nil && !v.mitmGate.WaitReady(ctx) {
return fmt.Errorf("credential proxy is not ready")
}
return nil
}

func (v *Store) validateCandidate(credentials map[string]record, bindings map[string]Binding, pol *policy.NetworkPolicy) error {
if len(bindings) > 0 && pol == nil {
return fmt.Errorf("credential vault bindings require an egress policy")
}
if len(bindings) > 0 && pol.DefaultAction != policy.ActionDeny {
log.Warnf("credential vault: default-allow egress policy is deprecated and may allow credential destination bypass; use defaultAction=deny")
}
for _, b := range bindings {
if err := validateBindingCredentialRefs(b, credentials); err != nil {
return err
Expand Down
7 changes: 4 additions & 3 deletions components/egress/pkg/credentialvault/vault_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,13 @@ func TestCredentialVaultCreateSanitizesAndRendersActiveSnapshot(t *testing.T) {
require.Contains(t, payload.Redactions, "secret-token")
}

func TestCredentialVaultAllowsDefaultAllowWithoutExplicitRules(t *testing.T) {
func TestCredentialVaultAllowsDefaultAllowPolicyForCompatibility(t *testing.T) {
store := NewStore(nil, func() bool { return true })
pol := testCredentialPolicy(t, `{"defaultAction":"allow","egress":[]}`)

_, err := store.Create(testCredentialVaultRequest(), pol)
require.NoError(t, err, "defaultAction allow should not require explicit egress rules")
state, err := store.Create(testCredentialVaultRequest(), pol)
require.NoError(t, err)
require.Len(t, state.Bindings, 1)
}

func TestCredentialVaultDefaultAllowRespectsExplicitDenyRule(t *testing.T) {
Expand Down
20 changes: 17 additions & 3 deletions docs/guides/credential-vault.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,25 @@ Credential Vault is OpenSandbox's outbound credential broker for sandboxed agent
- Go SDK >= 1.0.3
- C# SDK >= 0.1.3
- Server config sets `[egress].image`.
- Server config sets `[egress].mode = "dns+nft"`. Credential Vault refuses to
activate in DNS-only mode because direct-IP connections can bypass DNS policy.
- Sandbox create request includes an outbound network policy.
- The outbound network policy should use `defaultAction="deny"` and explicitly
allow every host referenced by a credential binding. Default-allow remains
temporarily supported for backward compatibility but emits a security warning.
- Sandbox create request enables Credential Proxy.
- Sandbox pods are not running with an additional transparent service-mesh sidecar (for example Istio/Envoy injection) in the same network namespace. Credential Vault currently assumes the OpenSandbox egress sidecar is the only transparent outbound interception layer in the pod.
- The sandbox image has the tools you want to run. For Claude Code, use an image
with Node.js and npm, such as the OpenSandbox code-interpreter image.

::: warning Migration notice
Credential Proxy still requires server `[egress].mode = "dns+nft"`; deployments
that cannot provide nft enforcement cannot safely enable credential injection.
Default-allow policies remain accepted during the compatibility period, but emit
a security warning and should migrate to `defaultAction="deny"` before enforcement
is tightened in a future release.
:::

## How It Works

![Credential Vault request flow](../public/images/credential-vault.png)
Expand Down Expand Up @@ -59,8 +72,8 @@ Use one of these operator patterns instead:

For the underlying egress-sidecar limitation, see [Egress](/components/egress#service-mesh-compatibility).

Credential bindings are intentionally precise. Prefer a default-deny egress
policy and a narrow path match, for example `/v1/*` for Anthropic API calls.
Credential bindings are intentionally precise. A default-deny egress policy is
required. Use a narrow path match, for example `/v1/*` for Anthropic API calls.

## Auth Types

Expand Down Expand Up @@ -300,7 +313,8 @@ curl -fsS https://api.example.com/v1/projects/123/variables
## Binding Guidance

- Use `defaultAction="deny"` and only allow the service hosts required by the
tool.
tool. Default-allow policies are deprecated because they may allow credential
destination bypass and will emit a security warning.
- Scope bindings by path whenever possible, for example `/v1/*`.
- Avoid overlapping bindings at the same precedence; ambiguous matches are
rejected.
Expand Down
1 change: 1 addition & 0 deletions scripts/common/kubernetes-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ configToml: |

[egress]
image = "${EGRESS_IMG}"
mode = "dns+nft"

[kubernetes]
namespace = "${E2E_NAMESPACE}"
Expand Down
5 changes: 3 additions & 2 deletions server/opensandbox_server/api/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,8 +507,9 @@ def validate_source_and_entrypoint(self) -> "CreateSandboxRequest":
self.snapshot_id = None
return self

if self.credential_proxy and self.credential_proxy.enabled and self.network_policy is None:
raise ValueError("credentialProxy.enabled requires networkPolicy.")
if self.credential_proxy and self.credential_proxy.enabled:
if self.network_policy is None:
raise ValueError("credentialProxy.enabled requires networkPolicy.")

has_image = self.image is not None and bool(self.image.uri.strip())
has_snapshot = bool((self.snapshot_id or "").strip())
Expand Down
9 changes: 8 additions & 1 deletion server/opensandbox_server/services/docker/networking.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@
build_egress_auth_headers,
merge_endpoint_headers,
)
from opensandbox_server.services.validators import ensure_egress_configured, ensure_egress_runtime_compatible
from opensandbox_server.services.validators import (
ensure_credential_proxy_configured,
ensure_egress_configured,
ensure_egress_runtime_compatible,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -139,6 +143,9 @@ def _ensure_network_policy_support(self, request) -> None:

# Common validation: egress.image must be configured
ensure_egress_configured(request.network_policy, self.app_config.egress)
ensure_credential_proxy_configured(
request.credential_proxy, request.network_policy, self.app_config.egress
)
ensure_egress_runtime_compatible(request.network_policy, self.app_config.secure_runtime)

def _ensure_secure_access_support(self, request) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
)
from opensandbox_server.services.sandbox_service import SandboxService
from opensandbox_server.services.validators import (
ensure_credential_proxy_configured,
ensure_entrypoint,
ensure_egress_configured,
ensure_egress_runtime_compatible,
Expand Down Expand Up @@ -262,6 +263,9 @@ def _ensure_network_policy_support(self, request: CreateSandboxRequest) -> None:
and that the secure runtime supports the iptables nat table needed by the sidecar.
"""
ensure_egress_configured(request.network_policy, self.app_config.egress)
ensure_credential_proxy_configured(
request.credential_proxy, request.network_policy, self.app_config.egress
)
ensure_egress_runtime_compatible(request.network_policy, self.app_config.secure_runtime)

def _ensure_image_auth_support(self, request: CreateSandboxRequest) -> None:
Expand Down
41 changes: 39 additions & 2 deletions server/opensandbox_server/services/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,21 @@

from __future__ import annotations

import logging
import re
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Dict, List, Optional, Sequence

from fastapi import HTTPException, status
import re

from opensandbox_server.services.constants import RESERVED_LABEL_PREFIX, SandboxErrorCodes

if TYPE_CHECKING:
from opensandbox_server.api.schema import NetworkPolicy, OSSFS, PlatformSpec, Volume
from opensandbox_server.api.schema import CredentialProxyConfig, NetworkPolicy, OSSFS, PlatformSpec, Volume
from opensandbox_server.config import EgressConfig, SecureRuntimeConfig

logger = logging.getLogger(__name__)


def ensure_entrypoint(entrypoint: Sequence[str]) -> None:
"""
Expand Down Expand Up @@ -600,6 +603,39 @@ def ensure_egress_configured(
)


def ensure_credential_proxy_configured(
credential_proxy: Optional["CredentialProxyConfig"],
network_policy: Optional["NetworkPolicy"],
egress_config: Optional["EgressConfig"],
) -> None:
"""Require network-layer enforcement when Credential Proxy is enabled."""
if not credential_proxy or not credential_proxy.enabled:
return

egress_mode = egress_config.mode if egress_config else None
if egress_mode != "dns+nft":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": 'credentialProxy.enabled requires server [egress].mode = "dns+nft".',
},
)

default_action = (
(network_policy.default_action or "deny").strip().lower()
if network_policy
else "deny"
)
if default_action != "deny":
logger.warning(
"credentialProxy.enabled with networkPolicy.defaultAction=%s is allowed for backward "
"compatibility but is deprecated and may allow credential destination bypass; "
"use defaultAction=deny",
default_action,
)


_GVISOR_NAT_INCOMPATIBLE_RUNTIMES = frozenset({"gvisor"})


Expand Down Expand Up @@ -732,6 +768,7 @@ def ensure_volumes_valid(
"ensure_platform_valid",
"ensure_metadata_labels",
"ensure_egress_configured",
"ensure_credential_proxy_configured",
"ensure_valid_volume_name",
"ensure_valid_mount_path",
"ensure_valid_sub_path",
Expand Down
26 changes: 24 additions & 2 deletions server/tests/k8s/test_kubernetes_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@
SANDBOX_MANUAL_CLEANUP_LABEL,
SandboxErrorCodes,
)
from opensandbox_server.api.schema import ImageAuth, ListSandboxesRequest, NetworkPolicy, PlatformSpec
from opensandbox_server.api.schema import (
CredentialProxyConfig,
ImageAuth,
ListSandboxesRequest,
NetworkPolicy,
PlatformSpec,
)
from opensandbox_server.config import (
EGRESS_MODE_DNS,
EGRESS_MODE_DNS_NFT,
Expand Down Expand Up @@ -82,7 +88,23 @@ def test_init_with_k8s_client_failure_raises_http_exception(self, k8s_app_config
assert exc_info.value.detail["code"] == SandboxErrorCodes.K8S_INITIALIZATION_ERROR

class TestKubernetesSandboxServiceCreate:


def test_credential_proxy_requires_dns_nft_mode(
self, k8s_service, create_sandbox_request
):
create_sandbox_request.network_policy = NetworkPolicy(default_action="deny", egress=[])
create_sandbox_request.credential_proxy = CredentialProxyConfig(enabled=True)
k8s_service.app_config.egress = EgressConfig(
image="opensandbox/egress:v1.1.2", mode=EGRESS_MODE_DNS
)

with pytest.raises(HTTPException) as exc_info:
k8s_service._ensure_network_policy_support(create_sandbox_request)

assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER
assert "dns+nft" in exc_info.value.detail["message"]

@pytest.mark.asyncio
async def test_create_sandbox_with_valid_request_succeeds(
self, k8s_service, create_sandbox_request, mock_workload
Expand Down
Loading
Loading