Skip to content

Commit d793e33

Browse files
committed
Harden direct VM request handling
1 parent 92acf13 commit d793e33

4 files changed

Lines changed: 347 additions & 27 deletions

File tree

src/kernel/_client.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@
4141
prepare_direct_vm_request,
4242
rewrite_direct_vm_options,
4343
browser_routing_config_from_env,
44+
install_direct_vm_auth_stripping,
4445
is_stale_direct_vm_auth_response,
4546
should_retry_stale_direct_vm_auth,
4647
install_stale_direct_vm_auth_eviction,
48+
install_async_direct_vm_auth_stripping,
4749
maybe_evict_browser_route_from_response,
4850
should_retry_direct_vm_connection_error,
4951
install_async_stale_direct_vm_auth_eviction,
@@ -210,6 +212,7 @@ def __init__(
210212
)
211213
self.browser_route_cache = _browser_route_cache or BrowserRouteCache()
212214
self._browser_routing = browser_routing_config_from_env()
215+
install_direct_vm_auth_stripping(self._client)
213216
install_stale_direct_vm_auth_eviction(self._client, cache=self.browser_route_cache)
214217

215218
@cached_property
@@ -375,7 +378,7 @@ def _prepare_options(self, options: Any) -> Any:
375378

376379
@override
377380
def _prepare_request(self, request: httpx.Request) -> None:
378-
prepare_direct_vm_request(request, cache=self.browser_route_cache)
381+
prepare_direct_vm_request(request)
379382

380383
@override
381384
def _should_retry_on_connection_error(self, request: httpx.Request) -> bool:
@@ -608,6 +611,7 @@ def __init__(
608611
)
609612
self.browser_route_cache = _browser_route_cache or BrowserRouteCache()
610613
self._browser_routing = browser_routing_config_from_env()
614+
install_async_direct_vm_auth_stripping(self._client)
611615
install_async_stale_direct_vm_auth_eviction(self._client, cache=self.browser_route_cache)
612616

613617
@cached_property
@@ -773,7 +777,7 @@ async def _prepare_options(self, options: Any) -> Any:
773777

774778
@override
775779
async def _prepare_request(self, request: httpx.Request) -> None:
776-
prepare_direct_vm_request(request, cache=self.browser_route_cache)
780+
prepare_direct_vm_request(request)
777781

778782
@override
779783
def _should_retry_on_connection_error(self, request: httpx.Request) -> bool:

src/kernel/lib/browser_routing/routing.py

Lines changed: 72 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
cdp_ws_url_from_browser_like,
1616
session_id_from_browser_like,
1717
)
18+
from ..._utils import is_given
1819
from ..._compat import model_copy
1920
from ..._models import FinalRequestOptions
2021
from ..._constants import RAW_RESPONSE_HEADER
@@ -33,6 +34,8 @@ class BrowserRoutingConfig:
3334

3435

3536
_EVICTION_HOOK_CACHE_ATTR = "_kernel_browser_route_cache"
37+
_DIRECT_VM_AUTH_HOOK_ATTR = "_kernel_direct_vm_auth_hook"
38+
_DIRECT_VM_REQUEST_MARKER_HEADER = "x-kernel-direct-vm-request"
3639
_STALE_DIRECT_VM_AUTH_REQUEST_EXTENSION = "kernel_stale_direct_vm_auth"
3740
_DIRECT_VM_BODY_REPLAYABLE_REQUEST_EXTENSION = "kernel_direct_vm_body_replayable"
3841

@@ -211,22 +214,24 @@ def install_stale_direct_vm_auth_eviction(client: httpx.Client, *, cache: Browse
211214
whose body read fails — the read error surfaces from `send()` instead and the
212215
dead route would stay cached, wedging every later call for that session. A
213216
response event hook runs after the status is known and before any body is
214-
read, which keeps eviction independent of the body. For a caller-supplied
217+
read, which keeps eviction independent of the body. It also rejects redirects
218+
before httpx can replay an unreplayable direct request body. For a caller-supplied
215219
`http_client`, the hook is installed into that client's `event_hooks` and
216-
prepended so an existing hook cannot pre-empt eviction by reading a failing
217-
body or raising.
220+
prepended so an existing hook cannot pre-empt these safeguards by reading a
221+
failing body or raising.
218222
"""
219223
hooks = client.event_hooks.setdefault("response", [])
220224
if _has_eviction_hook(hooks, cache):
221225
return
222226

223-
def evict(response: httpx.Response) -> None:
227+
def handle_response(response: httpx.Response) -> None:
228+
_reject_unreplayable_direct_vm_redirect(response, cache=cache)
224229
if is_stale_direct_vm_auth_response(response):
225230
response.request.extensions[_STALE_DIRECT_VM_AUTH_REQUEST_EXTENSION] = True
226231
maybe_evict_browser_route_from_response(response, cache=cache)
227232

228-
setattr(evict, _EVICTION_HOOK_CACHE_ATTR, cache)
229-
hooks.insert(0, evict)
233+
setattr(handle_response, _EVICTION_HOOK_CACHE_ATTR, cache)
234+
hooks.insert(0, handle_response)
230235

231236

232237
def install_async_stale_direct_vm_auth_eviction(client: httpx.AsyncClient, *, cache: BrowserRouteCache) -> None:
@@ -235,13 +240,42 @@ def install_async_stale_direct_vm_auth_eviction(client: httpx.AsyncClient, *, ca
235240
if _has_eviction_hook(hooks, cache):
236241
return
237242

238-
async def evict(response: httpx.Response) -> None:
243+
async def handle_response(response: httpx.Response) -> None:
244+
_reject_unreplayable_direct_vm_redirect(response, cache=cache)
239245
if is_stale_direct_vm_auth_response(response):
240246
response.request.extensions[_STALE_DIRECT_VM_AUTH_REQUEST_EXTENSION] = True
241247
maybe_evict_browser_route_from_response(response, cache=cache)
242248

243-
setattr(evict, _EVICTION_HOOK_CACHE_ATTR, cache)
244-
hooks.insert(0, evict)
249+
setattr(handle_response, _EVICTION_HOOK_CACHE_ATTR, cache)
250+
hooks.insert(0, handle_response)
251+
252+
253+
def install_direct_vm_auth_stripping(client: httpx.Client) -> None:
254+
"""Remove Authorization after httpx auth and existing request hooks run."""
255+
hooks = client.event_hooks.setdefault("request", [])
256+
if any(getattr(hook, _DIRECT_VM_AUTH_HOOK_ATTR, False) for hook in hooks):
257+
return
258+
259+
def strip_auth(request: httpx.Request) -> None:
260+
if _is_direct_vm_request(request):
261+
request.headers.pop("Authorization", None)
262+
263+
setattr(strip_auth, _DIRECT_VM_AUTH_HOOK_ATTR, True)
264+
hooks.append(strip_auth)
265+
266+
267+
def install_async_direct_vm_auth_stripping(client: httpx.AsyncClient) -> None:
268+
"""Async counterpart of `install_direct_vm_auth_stripping`."""
269+
hooks = client.event_hooks.setdefault("request", [])
270+
if any(getattr(hook, _DIRECT_VM_AUTH_HOOK_ATTR, False) for hook in hooks):
271+
return
272+
273+
async def strip_auth(request: httpx.Request) -> None:
274+
if _is_direct_vm_request(request):
275+
request.headers.pop("Authorization", None)
276+
277+
setattr(strip_auth, _DIRECT_VM_AUTH_HOOK_ATTR, True)
278+
hooks.append(strip_auth)
245279

246280

247281
def _has_eviction_hook(hooks: list[Any], cache: BrowserRouteCache) -> bool:
@@ -250,6 +284,17 @@ def _has_eviction_hook(hooks: list[Any], cache: BrowserRouteCache) -> bool:
250284
return any(getattr(hook, _EVICTION_HOOK_CACHE_ATTR, None) is cache for hook in hooks)
251285

252286

287+
def _reject_unreplayable_direct_vm_redirect(response: httpx.Response, *, cache: BrowserRouteCache) -> None:
288+
if not response.has_redirect_location or not direct_vm_request_body_is_known_unreplayable(response.request):
289+
return
290+
291+
jwt = str(response.request.url.params.get("jwt") or "").strip()
292+
session_id = _session_id_from_direct_vm_response(response, cache=cache)
293+
if session_id and jwt:
294+
cache.delete_if_jwt(session_id, jwt)
295+
raise httpx.RequestError("Cannot safely redirect an unreplayable direct VM request", request=response.request)
296+
297+
253298
def should_retry_direct_vm_connection_error(request: httpx.Request) -> bool:
254299
"""Prevent connection retries from replaying an unreplayable VM request body."""
255300
if direct_vm_request_body_is_known_unreplayable(request):
@@ -414,20 +459,27 @@ def rewrite_direct_vm_options(
414459
params.update(options.params)
415460
params["jwt"] = route.jwt
416461
rewritten.params = params or options.params
462+
463+
headers = dict(options.headers) if is_given(options.headers) else {}
464+
headers[_DIRECT_VM_REQUEST_MARKER_HEADER] = "true"
465+
rewritten.headers = headers
417466
return rewritten
418467

419468

420-
def prepare_direct_vm_request(request: httpx.Request, *, cache: BrowserRouteCache) -> None:
421-
raw = str(request.url)
422-
for route in cache.values():
423-
if raw.startswith(route.base_url.rstrip("/") + "/"):
424-
# Request hooks and custom auth can buffer this request while consuming
425-
# the original body that the SDK would use to build a retry.
426-
request.extensions[_DIRECT_VM_BODY_REPLAYABLE_REQUEST_EXTENSION] = (
427-
_classify_direct_vm_request_body_replayability(request)
428-
)
429-
request.headers.pop("Authorization", None)
430-
return
469+
def prepare_direct_vm_request(request: httpx.Request) -> None:
470+
if request.headers.pop(_DIRECT_VM_REQUEST_MARKER_HEADER, None) is None:
471+
return
472+
473+
# Request hooks and custom auth can buffer this request while consuming
474+
# the original body that the SDK would use to build a retry.
475+
request.extensions[_DIRECT_VM_BODY_REPLAYABLE_REQUEST_EXTENSION] = _classify_direct_vm_request_body_replayability(
476+
request
477+
)
478+
request.headers.pop("Authorization", None)
479+
480+
481+
def _is_direct_vm_request(request: httpx.Request) -> bool:
482+
return isinstance(request.extensions.get(_DIRECT_VM_BODY_REPLAYABLE_REQUEST_EXTENSION), bool)
431483

432484

433485
def match_direct_vm_path(path: str) -> tuple[str, str, str] | None:

src/kernel/resources/browsers/fs/fs.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ def upload(
510510
if not id_or_name:
511511
raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}")
512512
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
513-
body = deepcopy_with_paths({"files": files}, [["files", "<array>", "file"]])
513+
body = deepcopy_with_paths({"files": list(files)}, [["files", "<array>", "file"]])
514514
# The remote filesystem pairs each file part with the sibling fields of the
515515
# same array entry, so both halves of the form use indexed names
516516
# (`files[0][file]`, `files[0][dest_path]`).
@@ -1078,7 +1078,7 @@ async def upload(
10781078
if not id_or_name:
10791079
raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}")
10801080
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
1081-
body = deepcopy_with_paths({"files": files}, [["files", "<array>", "file"]])
1081+
body = deepcopy_with_paths({"files": list(files)}, [["files", "<array>", "file"]])
10821082
# The remote filesystem pairs each file part with the sibling fields of the
10831083
# same array entry, so both halves of the form use indexed names
10841084
# (`files[0][file]`, `files[0][dest_path]`).

0 commit comments

Comments
 (0)