diff --git a/src/blaxel/core/client/models/__init__.py b/src/blaxel/core/client/models/__init__.py index c86b8df..69a2411 100644 --- a/src/blaxel/core/client/models/__init__.py +++ b/src/blaxel/core/client/models/__init__.py @@ -21,16 +21,24 @@ from .create_job_execution_request_env import CreateJobExecutionRequestEnv from .create_job_execution_request_tasks_item import CreateJobExecutionRequestTasksItem from .create_workspace_service_account_body import CreateWorkspaceServiceAccountBody -from .create_workspace_service_account_response_200 import CreateWorkspaceServiceAccountResponse200 +from .create_workspace_service_account_response_200 import ( + CreateWorkspaceServiceAccountResponse200, +) from .custom_domain import CustomDomain from .custom_domain_metadata import CustomDomainMetadata from .custom_domain_spec import CustomDomainSpec from .custom_domain_spec_status import CustomDomainSpecStatus from .custom_domain_spec_txt_records import CustomDomainSpecTxtRecords from .delete_drive_response_200 import DeleteDriveResponse200 -from .delete_sandbox_preview_token_response_200 import DeleteSandboxPreviewTokenResponse200 -from .delete_volume_template_version_response_200 import DeleteVolumeTemplateVersionResponse200 -from .delete_workspace_service_account_response_200 import DeleteWorkspaceServiceAccountResponse200 +from .delete_sandbox_preview_token_response_200 import ( + DeleteSandboxPreviewTokenResponse200, +) +from .delete_volume_template_version_response_200 import ( + DeleteVolumeTemplateVersionResponse200, +) +from .delete_workspace_service_account_response_200 import ( + DeleteWorkspaceServiceAccountResponse200, +) from .drive import Drive from .drive_spec import DriveSpec from .drive_state import DriveState @@ -52,6 +60,7 @@ from .expiration_policy import ExpirationPolicy from .expiration_policy_action import ExpirationPolicyAction from .expiration_policy_type import ExpirationPolicyType +from .firewall_config import FirewallConfig from .flavor import Flavor from .flavor_type import FlavorType from .form import Form @@ -65,7 +74,9 @@ from .get_drive_jwks_response_200 import GetDriveJWKSResponse200 from .get_drive_jwks_response_200_keys_item import GetDriveJWKSResponse200KeysItem from .get_workspace_features_response_200 import GetWorkspaceFeaturesResponse200 -from .get_workspace_features_response_200_features import GetWorkspaceFeaturesResponse200Features +from .get_workspace_features_response_200_features import ( + GetWorkspaceFeaturesResponse200Features, +) from .get_workspace_service_accounts_response_200_item import ( GetWorkspaceServiceAccountsResponse200Item, ) @@ -194,7 +205,9 @@ from .trigger_configuration_task import TriggerConfigurationTask from .trigger_type import TriggerType from .update_workspace_service_account_body import UpdateWorkspaceServiceAccountBody -from .update_workspace_service_account_response_200 import UpdateWorkspaceServiceAccountResponse200 +from .update_workspace_service_account_response_200 import ( + UpdateWorkspaceServiceAccountResponse200, +) from .update_workspace_user_role_body import UpdateWorkspaceUserRoleBody from .volume import Volume from .volume_attachment import VolumeAttachment @@ -269,6 +282,7 @@ "ExpirationPolicy", "ExpirationPolicyAction", "ExpirationPolicyType", + "FirewallConfig", "Flavor", "FlavorType", "Form", diff --git a/src/blaxel/core/client/models/firewall_config.py b/src/blaxel/core/client/models/firewall_config.py new file mode 100644 index 0000000..8064e89 --- /dev/null +++ b/src/blaxel/core/client/models/firewall_config.py @@ -0,0 +1,65 @@ +from typing import Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="FirewallConfig") + + +@_attrs_define +class FirewallConfig: + """Firewall configuration specifying which network lockdown rulesets to apply. Valid rulesets are "default" (no-op), + "proxy" (restrict egress to proxy), and "dedicated-ip" (restrict egress to dedicated IP gateway). + + Attributes: + rulesets (Union[Unset, list[str]]): List of firewall rulesets to apply. Valid values: "default" (no-op), "proxy" + (restrict egress to proxy), "dedicated-ip" (restrict egress to dedicated IP gateway). Example: ["proxy"]. + """ + + rulesets: Union[Unset, list[str]] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + rulesets: Union[Unset, list[str]] = UNSET + if not isinstance(self.rulesets, Unset): + rulesets = self.rulesets + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if rulesets is not UNSET: + field_dict["rulesets"] = rulesets + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: + if not src_dict: + return None + d = src_dict.copy() + rulesets = cast(list[str], d.pop("rulesets", UNSET)) + + firewall_config = cls( + rulesets=rulesets, + ) + + firewall_config.additional_properties = d + return firewall_config + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/blaxel/core/client/models/proxy_config.py b/src/blaxel/core/client/models/proxy_config.py index 6da195d..8ae1b93 100644 --- a/src/blaxel/core/client/models/proxy_config.py +++ b/src/blaxel/core/client/models/proxy_config.py @@ -18,24 +18,40 @@ class ProxyConfig: destination header/body injection Attributes: + allowed_domains (Union[Unset, list[str]]): List of allowed external domains (allowlist). When set, only these + domains are reachable through the proxy. Supports wildcards (e.g. *.s3.amazonaws.com). Example: ["api.stripe.com", + "*.s3.amazonaws.com"]. bypass (Union[Unset, list[str]]): Domains that bypass the proxy entirely via the NO_PROXY directive. Traffic to these destinations goes direct, not through the CONNECT tunnel. Supports wildcards. Note that localhost, private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), 169.254.169.254, .local and .internal are always bypassed by default. Example: ["*.s3.amazonaws.com"]. + forbidden_domains (Union[Unset, list[str]]): List of forbidden external domains (denylist). When set, all + domains except these are reachable through the proxy. Supports wildcards (e.g. *.malware.com). If both + allowedDomains and forbiddenDomains are set, allowedDomains takes precedence. Example: ["*.malware.com", + "evil.example.org"]. routing (Union[Unset, list['ProxyTarget']]): Per-destination routing rules with header/body injection and secrets. Use destinations ["*"] for global rules that apply to all destinations. """ + allowed_domains: Union[Unset, list[str]] = UNSET bypass: Union[Unset, list[str]] = UNSET + forbidden_domains: Union[Unset, list[str]] = UNSET routing: Union[Unset, list["ProxyTarget"]] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: + allowed_domains: Union[Unset, list[str]] = UNSET + if not isinstance(self.allowed_domains, Unset): + allowed_domains = self.allowed_domains bypass: Union[Unset, list[str]] = UNSET if not isinstance(self.bypass, Unset): bypass = self.bypass + forbidden_domains: Union[Unset, list[str]] = UNSET + if not isinstance(self.forbidden_domains, Unset): + forbidden_domains = self.forbidden_domains + routing: Union[Unset, list[dict[str, Any]]] = UNSET if not isinstance(self.routing, Unset): routing = [] @@ -49,8 +65,12 @@ def to_dict(self) -> dict[str, Any]: field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update({}) + if allowed_domains is not UNSET: + field_dict["allowedDomains"] = allowed_domains if bypass is not UNSET: field_dict["bypass"] = bypass + if forbidden_domains is not UNSET: + field_dict["forbiddenDomains"] = forbidden_domains if routing is not UNSET: field_dict["routing"] = routing @@ -63,8 +83,14 @@ def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: if not src_dict: return None d = src_dict.copy() + allowed_domains = cast(list[str], d.pop("allowedDomains", d.pop("allowed_domains", UNSET))) + bypass = cast(list[str], d.pop("bypass", UNSET)) + forbidden_domains = cast( + list[str], d.pop("forbiddenDomains", d.pop("forbidden_domains", UNSET)) + ) + routing = [] _routing = d.pop("routing", UNSET) for routing_item_data in _routing or []: @@ -73,7 +99,9 @@ def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: routing.append(routing_item) proxy_config = cls( + allowed_domains=allowed_domains, bypass=bypass, + forbidden_domains=forbidden_domains, routing=routing, ) diff --git a/src/blaxel/core/client/models/sandbox_network.py b/src/blaxel/core/client/models/sandbox_network.py index 19e337e..bfef58c 100644 --- a/src/blaxel/core/client/models/sandbox_network.py +++ b/src/blaxel/core/client/models/sandbox_network.py @@ -7,6 +7,7 @@ if TYPE_CHECKING: from ..models.egress_config import EgressConfig + from ..models.firewall_config import FirewallConfig from ..models.proxy_config import ProxyConfig @@ -15,29 +16,36 @@ @_attrs_define class SandboxNetwork: - """Network configuration for a sandbox including domain filtering, egress IP binding, and proxy settings - - Attributes: - allowed_domains (Union[Unset, list[str]]): List of allowed external domains (allowlist). When set, only these - domains are reachable. Supports wildcards (e.g. *.s3.amazonaws.com). Example: ["api.stripe.com", - "api.openai.com", "*.s3.amazonaws.com"]. - egress (Union[Unset, EgressConfig]): Egress configuration for routing sandbox outbound traffic through a - dedicated IP gateway - forbidden_domains (Union[Unset, list[str]]): List of forbidden external domains (denylist). When set, all - domains except these are reachable. Supports wildcards (e.g. *.malware.com). If both allowedDomains and - forbiddenDomains are set, allowedDomains takes precedence. Example: ["*.malware.com", "evil.example.org"]. - proxy (Union[Unset, ProxyConfig]): Proxy configuration for routing sandbox HTTP traffic through the platform - proxy with MITM inspection and per-destination header/body injection + """Network configuration for a sandbox including subnet, firewall rulesets, domain filtering, egress IP binding, and + proxy settings + + Attributes: + allowed_domains (Union[Unset, list[str]]): Deprecated: use proxy.allowedDomains or firewall rulesets instead. + List of allowed external domains (allowlist). When set, only these domains are reachable. Supports wildcards (e.g. + *.s3.amazonaws.com). Example: ["api.stripe.com", "api.openai.com", "*.s3.amazonaws.com"]. + egress (Union[Unset, EgressConfig]): Egress configuration for routing sandbox outbound traffic through a + dedicated IP gateway + firewall (Union[Unset, FirewallConfig]): Firewall configuration specifying which network lockdown rulesets to + apply. Valid rulesets are "default" (no-op), "proxy" (restrict egress to proxy), and "dedicated-ip" (restrict + egress to dedicated IP gateway). + forbidden_domains (Union[Unset, list[str]]): Deprecated: use proxy.forbiddenDomains or firewall rulesets + instead. List of forbidden external domains (denylist). When set, all domains except these are reachable. Supports + wildcards (e.g. *.malware.com). If both allowedDomains and forbiddenDomains are set, allowedDomains takes + precedence. Example: ["*.malware.com", "evil.example.org"]. + proxy (Union[Unset, ProxyConfig]): Proxy configuration for routing sandbox HTTP traffic through the platform + proxy with MITM inspection and per-destination header/body injection + subnet (Union[Unset, str]): Name of the subnet to attach the sandbox to. """ allowed_domains: Union[Unset, list[str]] = UNSET egress: Union[Unset, "EgressConfig"] = UNSET + firewall: Union[Unset, "FirewallConfig"] = UNSET forbidden_domains: Union[Unset, list[str]] = UNSET proxy: Union[Unset, "ProxyConfig"] = UNSET + subnet: Union[Unset, str] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: - allowed_domains: Union[Unset, list[str]] = UNSET if not isinstance(self.allowed_domains, Unset): allowed_domains = self.allowed_domains @@ -48,6 +56,16 @@ def to_dict(self) -> dict[str, Any]: elif self.egress and isinstance(self.egress, dict): egress = self.egress + firewall: Union[Unset, dict[str, Any]] = UNSET + if ( + self.firewall + and not isinstance(self.firewall, Unset) + and not isinstance(self.firewall, dict) + ): + firewall = self.firewall.to_dict() + elif self.firewall and isinstance(self.firewall, dict): + firewall = self.firewall + forbidden_domains: Union[Unset, list[str]] = UNSET if not isinstance(self.forbidden_domains, Unset): forbidden_domains = self.forbidden_domains @@ -58,6 +76,8 @@ def to_dict(self) -> dict[str, Any]: elif self.proxy and isinstance(self.proxy, dict): proxy = self.proxy + subnet = self.subnet + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update({}) @@ -65,16 +85,21 @@ def to_dict(self) -> dict[str, Any]: field_dict["allowedDomains"] = allowed_domains if egress is not UNSET: field_dict["egress"] = egress + if firewall is not UNSET: + field_dict["firewall"] = firewall if forbidden_domains is not UNSET: field_dict["forbiddenDomains"] = forbidden_domains if proxy is not UNSET: field_dict["proxy"] = proxy + if subnet is not UNSET: + field_dict["subnet"] = subnet return field_dict @classmethod def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: from ..models.egress_config import EgressConfig + from ..models.firewall_config import FirewallConfig from ..models.proxy_config import ProxyConfig if not src_dict: @@ -89,6 +114,13 @@ def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: else: egress = EgressConfig.from_dict(_egress) + _firewall = d.pop("firewall", UNSET) + firewall: Union[Unset, FirewallConfig] + if isinstance(_firewall, Unset): + firewall = UNSET + else: + firewall = FirewallConfig.from_dict(_firewall) + forbidden_domains = cast( list[str], d.pop("forbiddenDomains", d.pop("forbidden_domains", UNSET)) ) @@ -100,11 +132,15 @@ def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: else: proxy = ProxyConfig.from_dict(_proxy) + subnet = d.pop("subnet", UNSET) + sandbox_network = cls( allowed_domains=allowed_domains, egress=egress, + firewall=firewall, forbidden_domains=forbidden_domains, proxy=proxy, + subnet=subnet, ) sandbox_network.additional_properties = d diff --git a/tests/core/test_sandbox_network.py b/tests/core/test_sandbox_network.py new file mode 100644 index 0000000..1806b81 --- /dev/null +++ b/tests/core/test_sandbox_network.py @@ -0,0 +1,69 @@ +"""Tests for the sandbox network config models (firewall + proxy body).""" + +from blaxel.core.client.models import FirewallConfig, ProxyConfig, SandboxNetwork + + +def test_firewall_config_round_trip(): + """FirewallConfig serializes rulesets and parses them back.""" + fw = FirewallConfig(rulesets=["proxy", "dedicated-ip"]) + assert fw.to_dict() == {"rulesets": ["proxy", "dedicated-ip"]} + + parsed = FirewallConfig.from_dict({"rulesets": ["default"]}) + assert parsed.rulesets == ["default"] + + +def test_firewall_config_empty(): + """An empty FirewallConfig omits rulesets and from_dict of empty is None.""" + assert FirewallConfig().to_dict() == {} + assert FirewallConfig.from_dict({}) is None + + +def test_proxy_config_allowed_and_forbidden_domains(): + """ProxyConfig exposes allowed/forbidden domains with camelCase keys.""" + proxy = ProxyConfig( + allowed_domains=["api.stripe.com", "*.s3.amazonaws.com"], + forbidden_domains=["*.malware.com"], + bypass=["*.internal"], + ) + + serialized = proxy.to_dict() + assert serialized["allowedDomains"] == ["api.stripe.com", "*.s3.amazonaws.com"] + assert serialized["forbiddenDomains"] == ["*.malware.com"] + assert serialized["bypass"] == ["*.internal"] + + parsed = ProxyConfig.from_dict(serialized) + assert parsed.allowed_domains == ["api.stripe.com", "*.s3.amazonaws.com"] + assert parsed.forbidden_domains == ["*.malware.com"] + assert parsed.bypass == ["*.internal"] + + +def test_sandbox_network_firewall_and_subnet(): + """SandboxNetwork carries firewall + subnet and round-trips them.""" + payload = { + "firewall": {"rulesets": ["proxy"]}, + "subnet": "sn-1", + "proxy": { + "allowedDomains": ["api.stripe.com"], + "forbiddenDomains": ["*.evil.com"], + }, + } + + network = SandboxNetwork.from_dict(payload) + assert isinstance(network.firewall, FirewallConfig) + assert network.firewall.rulesets == ["proxy"] + assert network.subnet == "sn-1" + assert isinstance(network.proxy, ProxyConfig) + assert network.proxy.allowed_domains == ["api.stripe.com"] + assert network.proxy.forbidden_domains == ["*.evil.com"] + + serialized = network.to_dict() + assert serialized["firewall"] == {"rulesets": ["proxy"]} + assert serialized["subnet"] == "sn-1" + assert serialized["proxy"]["allowedDomains"] == ["api.stripe.com"] + assert serialized["proxy"]["forbiddenDomains"] == ["*.evil.com"] + + +def test_sandbox_network_firewall_enables_proxy_ruleset(): + """The common firewall: {rulesets: ["proxy"]} shape is constructible from objects.""" + network = SandboxNetwork(firewall=FirewallConfig(rulesets=["proxy"])) + assert network.to_dict() == {"firewall": {"rulesets": ["proxy"]}} diff --git a/tests/integration/core/sandbox/proxy/test_firewall.py b/tests/integration/core/sandbox/proxy/test_firewall.py index 2795984..d4471e9 100644 --- a/tests/integration/core/sandbox/proxy/test_firewall.py +++ b/tests/integration/core/sandbox/proxy/test_firewall.py @@ -196,3 +196,54 @@ async def test_blocks_non_allowlisted_domain_even_without_routing(self): "wait_for_completion": True, }) assert result.exit_code != 0 + + +@pytest.mark.asyncio(loop_scope="class") +class TestFirewallNoProxyBypass: + """firewall ruleset "proxy": egress is enforced at the network level, so the + proxy cannot be bypassed by unsetting proxy env vars.""" + + sandbox: SandboxInstance + sandbox_name: str + + @pytest_asyncio.fixture(autouse=True, scope="class", loop_scope="class") + async def setup_sandbox(self, request): + request.cls.sandbox_name = unique_name("fw-bypass") + request.cls.sandbox = await SandboxInstance.create({ + "name": request.cls.sandbox_name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "firewall": {"rulesets": ["proxy"]}, + "allowedDomains": ["httpbin.org"], + "proxy": {"routing": []}, + }, + }) + await request.cls.sandbox.fs.write("/tmp/proxy-test.js", PROXY_HELPER_SCRIPT) + # Warm up the proxy path so the first real assertion isn't racing setup. + await request.cls.sandbox.process.exec({ + "command": "node /tmp/proxy-test.js GET https://httpbin.org/get", + "wait_for_completion": True, + }) + yield + try: + await SandboxInstance.delete(request.cls.sandbox_name) + except Exception: + pass + + async def test_blocks_requests_even_when_proxy_env_vars_are_unset(self): + # Strip every proxy hint so the helper attempts a direct connection, + # bypassing the proxy entirely. With the "proxy" firewall ruleset egress is + # enforced at the network level by dropping packets, so a direct connection + # won't be refused -- it just hangs. `timeout` turns that hang into a + # non-zero exit (124), proving the bypass is blocked rather than silently + # succeeding. + result = await self.sandbox.process.exec({ + "command": ( + "timeout 10 env -u HTTP_PROXY -u http_proxy -u HTTPS_PROXY " + "-u https_proxy node /tmp/proxy-test.js GET https://httpbin.org/get" + ), + "wait_for_completion": True, + }) + assert result.exit_code != 0, result.logs