Skip to content

Commit 5a8ef8d

Browse files
AHarmlessPyroclaude
andcommitted
Check origins and secret env names as master does
Allowed origins are trimmed the way JavaScript's trim() does, so a leading byte-order mark is accepted and \x1c-\x1f or \x85 are not, and an IPv6 zone is checked as Node's isIPv6 does, which Python 3.8 refused outright. A `secrets` variable name longer than 256 characters is now refused locally, as the server refuses it. The README now says a frame fill skips the allowed-origin tab step, that SECRET_ONEPASSWORD_AUTH_FAILED also covers a malformed token or one for another sign-in host, and the 503 that CREDENTIAL_FILL_FAILED and SECRET_ONEPASSWORD_FAILED can carry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v
1 parent 4225fdf commit 5a8ef8d

4 files changed

Lines changed: 52 additions & 23 deletions

File tree

‎README.md‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,9 @@ Things to know:
225225
it.
226226
Without `page_id`, fill considers the tabs whose URL starts with `page_url`
227227
(all tabs when it's not given). If one is left, that's the target. If several
228-
are left, it prefers the tabs on an origin every secret allows, then the
229-
visible one among them; if more than one still qualifies, it fails with
228+
are left, it prefers the tabs on an origin every secret allows (skipped with
229+
`frame_url`, since a login frame's tab is usually on another origin), then
230+
the visible one among them; if more than one still qualifies, it fails with
230231
`CREDENTIAL_PAGE_AMBIGUOUS`. The chosen tab's origin is then checked: on any
231232
other origin nothing is typed and the fill fails with
232233
`CREDENTIAL_ORIGIN_NOT_ALLOWED`. Under Playwright every tab reports as visible, so pass
@@ -266,15 +267,17 @@ Things to know:
266267
`SECRET_NOT_FOUND`, `SECRET_VERSION_NOT_FOUND`, `VAULT_NOT_FOUND`,
267268
`VAULT_ENTRY_NOT_FOUND` (404), `SESSION_NOT_ACTIVE` (409).
268269
- `CREDENTIAL_FILL_TIMEOUT` (503/504, retryable) and
269-
`CREDENTIAL_FILL_FAILED` (500/502, retryable as flagged).
270+
`CREDENTIAL_FILL_FAILED` (500/502/503, retryable as flagged).
270271
- `CREDENTIALS_UNSUPPORTED_RUNTIME` (400), `CREDENTIAL_FILL_UNSUPPORTED`
271272
(501) and `CREDENTIAL_VALUE_UNSUPPORTED` (501): the session's browser
272273
can't fill this, such as 1Password fields on an older host.
273274
- `SECRET_ONEPASSWORD_REFERENCE_FAILED` (422): a 1Password item or field
274275
couldn't be read, so nothing was filled. `details` has the failing
275276
`selectors` and `reasons` by selector. `SECRET_ONEPASSWORD_AUTH_FAILED`
276-
(422): the token can't sign in. `SECRET_ONEPASSWORD_RATE_LIMITED` (429)
277-
and `SECRET_ONEPASSWORD_FAILED` (502) are retryable. Each has the
277+
(422): 1Password rejected the token, or the token is malformed or doesn't
278+
sign in to `1password.com`, `.ca` or `.eu` (or a subdomain), so it wasn't
279+
sent to 1Password. `SECRET_ONEPASSWORD_RATE_LIMITED` (429) and
280+
`SECRET_ONEPASSWORD_FAILED` (502/503) are retryable. Each has the
278281
token's `tokenSecretId` and `tokenVersion` in `details`.
279282
- Locally, invalid fill requests raise `invalid_request` and invalid values
280283
raise `invalid_secret_value`, before any request.
@@ -816,8 +819,10 @@ Things to know:
816819
sandbox's host predates TOTP or 1Password, so nothing started. Retryable
817820
on sandbox create, which may land on a newer host; not on a process start.
818821
- `SECRET_ONEPASSWORD_REFERENCE_FAILED` (422, `details` names the
819-
variables) and `SECRET_ONEPASSWORD_AUTH_FAILED` (422): fix the fields or
820-
the token. `SECRET_ONEPASSWORD_RATE_LIMITED` (429) and
822+
variables) and `SECRET_ONEPASSWORD_AUTH_FAILED` (422, also a malformed
823+
token, or one that doesn't sign in to `1password.com`, `.ca` or `.eu`,
824+
which is never sent to 1Password): fix the fields or the token.
825+
`SECRET_ONEPASSWORD_RATE_LIMITED` (429) and
821826
`SECRET_ONEPASSWORD_FAILED` (502/503) are retryable.
822827
- `retryable` is the API's flag when it sends one.
823828

‎hyperbrowser/models/secret.py‎

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,7 @@ def normalize_secret_references(value):
7777
f"At most {MAX_SECRET_BINDINGS} secrets can be delivered at once"
7878
)
7979
for env in value:
80-
if (
81-
not isinstance(env, str)
82-
or not SECRET_ENV_NAME_PATTERN.fullmatch(env)
83-
or env == "__proto__"
84-
):
85-
raise ValueError(
86-
f"Invalid environment variable name {env!r} for a secret: "
87-
"expected letters, digits and '_', not starting with a digit"
88-
)
80+
validate_secret_env_name(env)
8981
return {
9082
env: {"name": reference} if isinstance(reference, str) else reference
9183
for env, reference in value.items()
@@ -289,6 +281,30 @@ def onepassword_clashes(
289281
)
290282
_ORIGIN_HOST_LABEL = re.compile(r"(?!-)[a-z0-9-]{1,63}(?<!-)", re.ASCII)
291283
_LOCAL_ORIGIN_HOSTS = frozenset({"localhost", "127.0.0.1", "[::1]"})
284+
# What JavaScript's trim() removes, which the server trims origins with. Unlike
285+
# Python's strip(), it removes a byte-order mark and keeps \x1c-\x1f and \x85.
286+
_JS_WHITESPACE = "".join(
287+
chr(code)
288+
for code in [0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0xA0, 0x1680]
289+
+ list(range(0x2000, 0x200B))
290+
+ [0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF]
291+
)
292+
# The zone ID Node's isIPv6 accepts after an address, as in fe80::1%eth0.
293+
_IPV6_ZONE = re.compile(r"[0-9a-zA-Z.:-]+", re.ASCII)
294+
295+
296+
def _is_ipv6(address: str) -> bool:
297+
"""Whether Node's isIPv6 accepts ``address``. Python 3.8 knows no zone
298+
IDs, and later versions accept zones Node refuses, so the zone is
299+
checked here."""
300+
address, mark, zone = address.partition("%")
301+
if mark and not _IPV6_ZONE.fullmatch(zone):
302+
return False
303+
try:
304+
ipaddress.IPv6Address(address)
305+
except ValueError:
306+
return False
307+
return True
292308

293309

294310
def validate_allowed_origin(entry) -> str:
@@ -309,7 +325,7 @@ def validate_allowed_origin(entry) -> str:
309325
def reject(reason: str) -> ValueError:
310326
return ValueError(f"Allowed origin {entry!r} {reason}")
311327

312-
match = _ALLOWED_ORIGIN_PATTERN.fullmatch(entry.strip())
328+
match = _ALLOWED_ORIGIN_PATTERN.fullmatch(entry.strip(_JS_WHITESPACE))
313329
if not match:
314330
raise reject("must be scheme://host[:port] with no path")
315331
scheme, wildcard, host, port = match.groups()
@@ -333,11 +349,8 @@ def reject(reason: str) -> ValueError:
333349
raise reject("cannot use a wildcard with an IP address")
334350
elif wildcard:
335351
raise reject("cannot use a wildcard with an IP address")
336-
else:
337-
try:
338-
ipaddress.IPv6Address(host[1:-1])
339-
except ValueError:
340-
raise reject("has an invalid host") from None
352+
elif not _is_ipv6(host[1:-1]):
353+
raise reject("has an invalid host")
341354
return entry
342355

343356

‎tests/test_credentials.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,12 @@ def test_set_allowed_origins_replaces_or_clears_the_browser_metadata():
405405
"https://[::g]",
406406
"https://" + "a" * 505 + ".com",
407407
"",
408+
# The server trims as JavaScript does, which keeps these.
409+
"https://example.com\x1c",
410+
"https://example.com\x85",
411+
# Node's isIPv6 takes only these characters in a zone.
412+
"https://[fe80::1%e_th]",
413+
"https://[fe80::1%]",
408414
]
409415

410416

@@ -451,6 +457,10 @@ def test_secrets_take_origins_the_server_accepts(private_key):
451457
"http://[::1]:8080",
452458
"https://[2001:db8::1]",
453459
"https://10.0.0.1:8443",
460+
# JavaScript's trim() removes a byte-order mark; Python's strip() doesn't.
461+
"\ufeffhttps://github.com",
462+
# Zones Node's isIPv6 accepts, which Python 3.8's ipaddress refuses.
463+
"https://[fe80::1%eth0]",
454464
]
455465
http = ScriptedHTTPClient(_create_routes(private_key, _typed("secret")))
456466
SecretManager(FakeClient(http)).create(

‎tests/test_secrets.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1532,11 +1532,12 @@ def test_secret_names_env_names_and_versions_are_checked_locally():
15321532
CreateSecretParams(name=name)
15331533
assert CreateSecretParams(name="a" * 128).name == "a" * 128
15341534

1535-
for env in ["1ABC", "API-KEY", "", "__proto__", "KEY\n"]:
1535+
for env in ["1ABC", "API-KEY", "", "__proto__", "KEY\n", "A" * 257]:
15361536
with pytest.raises(ValueError, match="environment variable name"):
15371537
SandboxExecParams(command="x", secrets={env: "provider-key"})
15381538
with pytest.raises(ValueError, match="environment variable name"):
15391539
CreateSandboxParams(image_name="node", secrets={env: "provider-key"})
1540+
assert SandboxExecParams(command="x", secrets={"A" * 256: "provider-key"}).secrets
15401541
with pytest.raises(ValueError, match="Invalid secret name"):
15411542
SandboxExecParams(command="x", secrets={"API_KEY": "bad name"})
15421543

0 commit comments

Comments
 (0)