Skip to content

Commit 64cfebf

Browse files
committed
Harden secret validation and ambiguous sandbox outcomes
1 parent c74a652 commit 64cfebf

6 files changed

Lines changed: 105 additions & 13 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,8 @@ Things to know:
334334
The sync client checks the limit between requests and caps each request's
335335
HTTP timeouts at the time left, so a slow response or a runtime token
336336
refresh can overrun it; use the async client for a strict limit.
337+
Secret process output comes from the receiver's retained output, so
338+
`max_output_bytes` does not apply; incomplete retained output raises an error.
337339
- A process start with secrets that times out, or whose result the API can't
338340
confirm, raises a non-retryable error with code
339341
`secret_process_outcome_unknown`: the command may be running, so check

‎hyperbrowser/client/managers/async_manager/sandbox.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1180,7 +1180,10 @@ async def _create_detail(
11801180
data = dump_request(normalized, CreateSandboxParams)
11811181
try:
11821182
payload = await self._request("POST", "/sandbox", data=data)
1183-
return SandboxDetail(**payload)
1183+
detail = SandboxDetail(**payload)
1184+
if normalized.secrets and not detail.id.strip():
1185+
raise ValueError("The API accepted the create but returned no sandbox ID")
1186+
return detail
11841187
except Exception as error:
11851188
# A create that may have reached the API may be running its
11861189
# startup command with the secrets, so it must not look retryable.

‎hyperbrowser/client/managers/sync_manager/sandbox.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1158,7 +1158,10 @@ def _create_detail(
11581158
data = dump_request(normalized, CreateSandboxParams)
11591159
try:
11601160
payload = self._request("POST", "/sandbox", data=data)
1161-
return SandboxDetail(**payload)
1161+
detail = SandboxDetail(**payload)
1162+
if normalized.secrets and not detail.id.strip():
1163+
raise ValueError("The API accepted the create but returned no sandbox ID")
1164+
return detail
11621165
except Exception as error:
11631166
# A create that may have reached the API may be running its
11641167
# startup command with the secrets, so it must not look retryable.

‎hyperbrowser/models/secret.py‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
SECRET_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$")
1010
SECRET_ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
1111
MAX_SECRET_VERSION = 2**31 - 1
12+
MAX_SECRET_BINDINGS = 32
1213

1314

1415
def validate_secret_name(name: str) -> str:
15-
if not SECRET_NAME_PATTERN.match(name):
16+
if not SECRET_NAME_PATTERN.fullmatch(name):
1617
raise ValueError(
1718
f"Invalid secret name {name!r}: names are 1-128 letters, digits, "
1819
"'_' or '-', starting with a letter or digit"
@@ -21,7 +22,11 @@ def validate_secret_name(name: str) -> str:
2122

2223

2324
def validate_secret_version(version: int) -> int:
24-
if isinstance(version, bool) or not 1 <= version <= MAX_SECRET_VERSION:
25+
if (
26+
not isinstance(version, int)
27+
or isinstance(version, bool)
28+
or not 1 <= version <= MAX_SECRET_VERSION
29+
):
2530
raise ValueError(
2631
f"Invalid secret version {version!r}: expected 1 to {MAX_SECRET_VERSION}"
2732
)
@@ -46,10 +51,10 @@ class SecretReference(SecretBaseModel):
4651
def validate_name(cls, value):
4752
return validate_secret_name(value)
4853

49-
@field_validator("version")
54+
@field_validator("version", mode="before")
5055
@classmethod
5156
def validate_version(cls, value):
52-
if isinstance(value, int):
57+
if value is not None and value != "latest":
5358
validate_secret_version(value)
5459
return value
5560

@@ -62,8 +67,18 @@ def normalize_secret_references(value):
6267
check the environment variable names."""
6368
if not isinstance(value, dict):
6469
return value
70+
if not value:
71+
return None
72+
if len(value) > MAX_SECRET_BINDINGS:
73+
raise ValueError(
74+
f"At most {MAX_SECRET_BINDINGS} secrets can be delivered at once"
75+
)
6576
for env in value:
66-
if not isinstance(env, str) or not SECRET_ENV_NAME_PATTERN.match(env):
77+
if (
78+
not isinstance(env, str)
79+
or not SECRET_ENV_NAME_PATTERN.fullmatch(env)
80+
or env == "__proto__"
81+
):
6782
raise ValueError(
6883
f"Invalid environment variable name {env!r} for a secret: "
6984
"expected letters, digits and '_', not starting with a digit"
@@ -75,10 +90,12 @@ def normalize_secret_references(value):
7590

7691

7792
class CreateSecretParams(SecretBaseModel):
93+
model_config = ConfigDict(populate_by_name=True, hide_input_in_errors=True)
94+
7895
name: str
7996
description: Optional[str] = None
8097
# Encrypted locally and uploaded as the first version; never sent as is.
81-
value: Optional[Union[str, bytes]] = Field(default=None, exclude=True)
98+
value: Optional[Union[str, bytes]] = Field(default=None, exclude=True, repr=False)
8299

83100
@field_validator("name")
84101
@classmethod

‎hyperbrowser/secrets_common.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,8 @@ def secret_process_id(process: Any) -> str:
290290
"""A started process's ID; the receiver may send it as ``processId``."""
291291
if not isinstance(process, dict):
292292
return ""
293-
return str(process.get("id") or process.get("processId") or "").strip()
293+
process_id = process.get("id") or process.get("processId")
294+
return process_id.strip() if isinstance(process_id, str) else ""
294295

295296

296297
def secret_process_failed_error(

‎tests/test_secrets.py‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,23 @@ def test_a_start_without_a_process_id_is_an_unknown_outcome():
626626
assert error.value.code == "secret_process_outcome_unknown"
627627

628628

629+
@pytest.mark.anyio
630+
@pytest.mark.parametrize("async_client", [False, True])
631+
@pytest.mark.parametrize("process_id", [42, True, " "])
632+
async def test_invalid_process_ids_are_unknown_outcomes(async_client, process_id):
633+
manager, http = _sandbox_manager(
634+
{("POST", "/sandbox/sbx_123/processes"): (200, {"process": {"id": process_id}})},
635+
async_client=async_client,
636+
)
637+
with pytest.raises(HyperbrowserError) as error:
638+
result = manager.start_secret_process("sbx_123", {"command": "x"})
639+
if async_client:
640+
await result
641+
assert error.value.code == "secret_process_outcome_unknown"
642+
assert error.value.retryable is False
643+
assert len(http.calls) == 1
644+
645+
629646
def test_a_timed_out_start_is_an_unknown_outcome_but_a_refused_one_is_not():
630647
timed_out = httpx.ReadTimeout("timed out")
631648
manager, _ = _sandbox_manager({("POST", "/sandbox/sbx_123/processes"): timed_out})
@@ -1238,6 +1255,23 @@ def test_a_name_with_hyphens_in_other_places_goes_straight_to_the_name_search():
12381255
CREATE_WITH_SECRETS = {"image_name": "node", "secrets": {"API_KEY": "provider-key"}}
12391256

12401257

1258+
@pytest.mark.anyio
1259+
@pytest.mark.parametrize("async_client", [False, True])
1260+
@pytest.mark.parametrize("sandbox_id", ["", " "])
1261+
async def test_empty_sandbox_ids_are_unknown_outcomes(async_client, sandbox_id):
1262+
manager, http = _sandbox_manager(
1263+
{("POST", "/sandbox"): (200, dict(SANDBOX_DETAIL_PAYLOAD, id=sandbox_id))},
1264+
async_client=async_client,
1265+
)
1266+
with pytest.raises(HyperbrowserError) as error:
1267+
result = manager.create(CREATE_WITH_SECRETS)
1268+
if async_client:
1269+
await result
1270+
assert error.value.code == "secret_process_outcome_unknown"
1271+
assert error.value.retryable is False
1272+
assert len(http.calls) == 1
1273+
1274+
12411275
@pytest.mark.parametrize(
12421276
"outcome",
12431277
[
@@ -1493,20 +1527,20 @@ async def test_use_shell_is_rejected_for_starts_with_secrets(api_type):
14931527

14941528

14951529
def test_secret_names_env_names_and_versions_are_checked_locally():
1496-
for name in ["", "-leading", "has space", "a" * 129]:
1530+
for name in ["", "-leading", "has space", "a" * 129, "name\n"]:
14971531
with pytest.raises(ValueError, match="Invalid secret name"):
14981532
CreateSecretParams(name=name)
14991533
assert CreateSecretParams(name="a" * 128).name == "a" * 128
15001534

1501-
for env in ["1ABC", "API-KEY", ""]:
1535+
for env in ["1ABC", "API-KEY", "", "__proto__", "KEY\n"]:
15021536
with pytest.raises(ValueError, match="environment variable name"):
15031537
SandboxExecParams(command="x", secrets={env: "provider-key"})
15041538
with pytest.raises(ValueError, match="environment variable name"):
15051539
CreateSandboxParams(image_name="node", secrets={env: "provider-key"})
15061540
with pytest.raises(ValueError, match="Invalid secret name"):
15071541
SandboxExecParams(command="x", secrets={"API_KEY": "bad name"})
15081542

1509-
for version in [0, -1, 2**31]:
1543+
for version in [0, -1, 2**31, True, 1.0, "1"]:
15101544
with pytest.raises(ValueError, match="Invalid secret version"):
15111545
SandboxExecParams(
15121546
command="x", secrets={"A": {"name": "k", "version": version}}
@@ -1524,7 +1558,7 @@ def test_list_pages_and_version_numbers_are_checked_before_any_request():
15241558
for params in [{"limit": 0}, {"limit": 101}, {"page": 0}]:
15251559
with pytest.raises(ValueError):
15261560
manager.list(params)
1527-
for version in [0, 2**31, "1"]:
1561+
for version in [0, 2**31, "1", True, 1.0, 1.5]:
15281562
with pytest.raises(HyperbrowserError) as error:
15291563
manager.delete_version("provider-key", version)
15301564
assert error.value.code == "invalid_secret_version"
@@ -1542,3 +1576,35 @@ async def test_async_version_numbers_are_checked_before_any_request():
15421576
with pytest.raises(ValueError):
15431577
await manager.list({"limit": 101})
15441578
assert http.calls == []
1579+
1580+
1581+
def test_secret_request_repr_does_not_expose_the_value():
1582+
params = CreateSecretParams(name="key", value="dummy-sensitive-value")
1583+
assert "dummy-sensitive-value" not in repr(params)
1584+
assert "dummy-sensitive-value" not in str(params)
1585+
assert "value" not in params.model_dump()
1586+
assert params.value == "dummy-sensitive-value"
1587+
1588+
1589+
def test_secret_request_validation_message_does_not_expose_the_value():
1590+
with pytest.raises(ValueError) as error:
1591+
CreateSecretParams.model_validate({"value": "dummy-sensitive-value"})
1592+
assert "dummy-sensitive-value" not in str(error.value)
1593+
1594+
1595+
@pytest.mark.parametrize(
1596+
"model,params",
1597+
[
1598+
(CreateSandboxParams, {"image_name": "node"}),
1599+
(SandboxExecParams, {"command": "x"}),
1600+
],
1601+
)
1602+
def test_secret_bindings_enforce_the_limit_and_omit_empty_maps(model, params):
1603+
from hyperbrowser.client._request import dump_request
1604+
1605+
assert "secrets" not in dump_request(dict(params, secrets={}), model)
1606+
assert (
1607+
len(model(**params, secrets={f"KEY_{i}": "k" for i in range(32)}).secrets) == 32
1608+
)
1609+
with pytest.raises(ValueError, match="32"):
1610+
model(**params, secrets={f"KEY_{i}": "k" for i in range(33)})

0 commit comments

Comments
 (0)