Skip to content

Commit 72e0ed8

Browse files
authored
fix: preserve percent-encoding in presigned URLs to prevent signature invalidation (#662)
yarl.URL() normalizes %XX sequences (e.g. %40 → @), which breaks S3/CloudFront presigned URL signatures that are computed over the encoded form. This adds `_make_url(encoded=True)` to preserve encoding, manual redirect following to avoid aiohttp re-encoding Location headers, and an original_url passthrough so already-signed HTTP URLs bypass OpenDAL's presign (which strips query params). Signed-off-by: Benny Zlotnik <bzlotnik@redhat.com>
1 parent 288da40 commit 72e0ed8

8 files changed

Lines changed: 497 additions & 19 deletions

File tree

python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/adapter.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,19 @@ async def OpendalAdapter(
6464
path: str, # file path in storage backend relative to the storage root
6565
mode: Literal["rb", "wb"] = "rb", # binary read or binary write mode
6666
compression: Compression | None = None, # compression algorithm
67+
original_url: str | None = None, # original HTTP URL, bypasses OpenDAL presign to avoid path re-encoding
6768
):
69+
if original_url is not None and compression is not None:
70+
raise ValueError("compression is not supported with direct HTTP URLs (original_url)")
71+
if mode == "wb" and original_url is not None:
72+
raise ValueError("original_url is not supported in write mode")
73+
if mode == "rb" and compression is None and original_url is not None:
74+
yield PresignedRequestResource(
75+
headers={}, url=original_url, method="GET"
76+
).model_dump(mode="json")
77+
return
6878
# if the access mode is binary read, and the storage backend supports presigned read requests
69-
if mode == "rb" and operator.capability().presign_read and compression is None:
79+
elif mode == "rb" and operator.capability().presign_read and compression is None:
7080
# create presigned url for the specified file with a 60 second expiration
7181
presigned = await operator.to_async_operator().presign_read(path, expire_second=60)
7282
yield PresignedRequestResource(

python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/client.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def operator_for_path(path: PathBuf) -> tuple[PathBuf, Operator, str]:
5151
- the operator for the given path
5252
- the scheme of the operator.
5353
"""
54-
if type(path) is str and path.startswith(("http://", "https://")):
54+
if isinstance(path, str) and path.startswith(("http://", "https://")):
5555
parsed_url = urlparse(path)
5656
operator = Operator("http", root="/", endpoint=f"{parsed_url.scheme}://{parsed_url.netloc}")
5757
return Path(parsed_url.path), operator, "http"
@@ -79,10 +79,13 @@ def write_from_path(self, path: PathBuf, operator: Operator | None = None):
7979
"""
8080
Write into remote file with content from local file
8181
"""
82+
original_url = None
8283
if operator is None:
84+
if isinstance(path, str) and path.startswith(("http://", "https://")):
85+
original_url = path
8386
path, operator, _ = operator_for_path(path)
8487

85-
with OpendalAdapter(client=self.client, operator=operator, path=path) as handle:
88+
with OpendalAdapter(client=self.client, operator=operator, path=path, original_url=original_url) as handle:
8689
return self.__write(handle)
8790

8891
@validate_call(validate_return=True, config=ConfigDict(arbitrary_types_allowed=True))
@@ -630,10 +633,15 @@ def _flash_single(
630633
compression: Compression | None,
631634
):
632635
"""Flash image to DUT"""
636+
original_url = None
633637
if operator is None:
638+
if isinstance(image, str) and image.startswith(("http://", "https://")):
639+
original_url = image
634640
image, operator, _ = operator_for_path(image)
635641

636-
with OpendalAdapter(client=self, operator=operator, path=image, mode="rb", compression=compression) as handle:
642+
with OpendalAdapter(
643+
client=self, operator=operator, path=image, mode="rb", compression=compression, original_url=original_url
644+
) as handle:
637645
return self.call("flash", handle, target)
638646

639647
def flash(
@@ -763,10 +771,15 @@ def flash(
763771

764772
self.host()
765773

774+
original_url = None
766775
if operator is None:
776+
if isinstance(path, str) and path.startswith(("http://", "https://")):
777+
original_url = path
767778
path, operator, _ = operator_for_path(path)
768779

769-
with OpendalAdapter(client=self, operator=operator, path=path, mode="rb", compression=compression) as handle:
780+
with OpendalAdapter(
781+
client=self, operator=operator, path=path, mode="rb", compression=compression, original_url=original_url
782+
) as handle:
770783
try:
771784
return self.write(handle)
772785
finally:

python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/driver_test.py

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from .common import PresignedRequest
1414
from .driver import MockFlasher, MockStorageMux, MockStorageMuxFlasher, Opendal
15+
from jumpstarter.client.core import DriverError
1516
from jumpstarter.common.utils import serve
1617

1718

@@ -322,3 +323,290 @@ def test_copy_and_rename_tracking(tmp_path):
322323
assert "copied_dir" in created_paths
323324
assert "renamed_dir" in created_paths
324325
assert len(created_paths) == 4
326+
327+
328+
def test_flash_http_url_preserves_percent_encoding():
329+
"""Flashing from HTTP URL with percent-encoded path must preserve encoding.
330+
331+
Without _make_url(encoded=True), yarl.URL decodes %40 to @ in the path,
332+
so the HTTP request hits a different URL than intended. Presigned URL
333+
signatures (S3, CloudFront) are computed over the encoded form and would
334+
be rejected.
335+
"""
336+
received_paths = []
337+
338+
class EncodingCheckHandler(BaseHTTPRequestHandler):
339+
def do_HEAD(self):
340+
self.send_response(200)
341+
self.send_header("content-length", "4")
342+
self.end_headers()
343+
344+
def do_GET(self):
345+
received_paths.append(self.path)
346+
self.send_response(200)
347+
self.send_header("content-length", "4")
348+
self.end_headers()
349+
self.wfile.write(b"test")
350+
351+
def log_message(self, format, *args):
352+
pass
353+
354+
with serve(MockFlasher()) as flasher:
355+
server = HTTPServer(("127.0.0.1", 0), EncodingCheckHandler)
356+
port = server.server_address[1]
357+
server_thread = Thread(target=server.serve_forever)
358+
server_thread.daemon = True
359+
server_thread.start()
360+
361+
try:
362+
flasher.flash(f"http://127.0.0.1:{port}/path%40encoded/file.bin")
363+
364+
assert len(received_paths) >= 1
365+
assert "%40" in received_paths[-1], (
366+
f"Server received decoded path {received_paths[-1]!r} — "
367+
f"_make_url is not preserving percent-encoding"
368+
)
369+
finally:
370+
server.shutdown()
371+
server.server_close()
372+
server_thread.join(timeout=2)
373+
374+
375+
def test_flash_http_redirect_preserves_percent_encoding():
376+
"""When a presigned URL returns a redirect, encoding in Location must be preserved.
377+
378+
aiohttp's automatic redirect following re-parses Location through yarl.URL(),
379+
decoding %40 to @. The fix disables auto-redirect and manually follows with
380+
_make_url(encoded=True).
381+
"""
382+
received_paths = []
383+
384+
class RedirectHandler(BaseHTTPRequestHandler):
385+
def do_HEAD(self):
386+
self.send_response(200)
387+
self.send_header("content-length", "4")
388+
self.end_headers()
389+
390+
def do_GET(self):
391+
received_paths.append(self.path)
392+
if self.path.startswith("/start"):
393+
port = self.server.server_address[1]
394+
self.send_response(302)
395+
self.send_header(
396+
"Location",
397+
f"http://127.0.0.1:{port}/redirect%40target/file.bin",
398+
)
399+
self.end_headers()
400+
else:
401+
self.send_response(200)
402+
self.send_header("content-length", "4")
403+
self.end_headers()
404+
self.wfile.write(b"test")
405+
406+
def log_message(self, format, *args):
407+
pass
408+
409+
with serve(MockFlasher()) as flasher:
410+
server = HTTPServer(("127.0.0.1", 0), RedirectHandler)
411+
port = server.server_address[1]
412+
server_thread = Thread(target=server.serve_forever)
413+
server_thread.daemon = True
414+
server_thread.start()
415+
416+
try:
417+
flasher.flash(f"http://127.0.0.1:{port}/start")
418+
419+
assert len(received_paths) == 2, (
420+
f"Expected 2 requests (initial + redirect), got {len(received_paths)}"
421+
)
422+
assert received_paths[0] == "/start"
423+
assert "%40" in received_paths[1], (
424+
f"Redirect target received decoded path {received_paths[1]!r} — "
425+
f"redirect following is not preserving percent-encoding"
426+
)
427+
finally:
428+
server.shutdown()
429+
server.server_close()
430+
server_thread.join(timeout=2)
431+
432+
433+
def test_flash_http_chained_redirects_preserve_percent_encoding():
434+
"""Chained redirects (A->B->C) must all preserve percent-encoding."""
435+
received_paths = []
436+
437+
class ChainedRedirectHandler(BaseHTTPRequestHandler):
438+
def do_HEAD(self):
439+
self.send_response(200)
440+
self.send_header("content-length", "4")
441+
self.end_headers()
442+
443+
def do_GET(self):
444+
received_paths.append(self.path)
445+
port = self.server.server_address[1]
446+
if self.path.startswith("/hop1"):
447+
self.send_response(302)
448+
self.send_header(
449+
"Location",
450+
f"http://127.0.0.1:{port}/hop2%40middle/file.bin",
451+
)
452+
self.end_headers()
453+
elif self.path.startswith("/hop2"):
454+
self.send_response(302)
455+
self.send_header(
456+
"Location",
457+
f"http://127.0.0.1:{port}/hop3%40final/file.bin",
458+
)
459+
self.end_headers()
460+
else:
461+
self.send_response(200)
462+
self.send_header("content-length", "4")
463+
self.end_headers()
464+
self.wfile.write(b"test")
465+
466+
def log_message(self, format, *args):
467+
pass
468+
469+
with serve(MockFlasher()) as flasher:
470+
server = HTTPServer(("127.0.0.1", 0), ChainedRedirectHandler)
471+
port = server.server_address[1]
472+
server_thread = Thread(target=server.serve_forever)
473+
server_thread.daemon = True
474+
server_thread.start()
475+
476+
try:
477+
flasher.flash(f"http://127.0.0.1:{port}/hop1")
478+
479+
assert len(received_paths) == 3, (
480+
f"Expected 3 requests (hop1 + hop2 + hop3), got {len(received_paths)}"
481+
)
482+
assert received_paths[0] == "/hop1"
483+
assert "%40" in received_paths[1], (
484+
f"Hop 2 received decoded path {received_paths[1]!r}"
485+
)
486+
assert "%40" in received_paths[2], (
487+
f"Hop 3 received decoded path {received_paths[2]!r}"
488+
)
489+
finally:
490+
server.shutdown()
491+
server.server_close()
492+
server_thread.join(timeout=2)
493+
494+
495+
@pytest.mark.parametrize("status_code", [301, 302, 303, 307, 308])
496+
def test_flash_http_redirect_all_status_codes(status_code):
497+
"""All redirect status codes (301, 302, 303, 307, 308) must be followed with encoding preserved."""
498+
received_paths = []
499+
500+
class StatusCodeRedirectHandler(BaseHTTPRequestHandler):
501+
def do_HEAD(self):
502+
self.send_response(200)
503+
self.send_header("content-length", "4")
504+
self.end_headers()
505+
506+
def do_GET(self):
507+
received_paths.append(self.path)
508+
if self.path.startswith("/start"):
509+
port = self.server.server_address[1]
510+
self.send_response(status_code)
511+
self.send_header(
512+
"Location",
513+
f"http://127.0.0.1:{port}/target%40encoded/file.bin",
514+
)
515+
self.end_headers()
516+
else:
517+
self.send_response(200)
518+
self.send_header("content-length", "4")
519+
self.end_headers()
520+
self.wfile.write(b"test")
521+
522+
def log_message(self, format, *args):
523+
pass
524+
525+
with serve(MockFlasher()) as flasher:
526+
server = HTTPServer(("127.0.0.1", 0), StatusCodeRedirectHandler)
527+
port = server.server_address[1]
528+
server_thread = Thread(target=server.serve_forever)
529+
server_thread.daemon = True
530+
server_thread.start()
531+
532+
try:
533+
flasher.flash(f"http://127.0.0.1:{port}/start")
534+
535+
assert len(received_paths) == 2
536+
assert received_paths[0] == "/start"
537+
assert "%40" in received_paths[1], (
538+
f"Status {status_code}: redirect target received decoded path {received_paths[1]!r}"
539+
)
540+
finally:
541+
server.shutdown()
542+
server.server_close()
543+
server_thread.join(timeout=2)
544+
545+
546+
def test_flash_http_redirect_loop_raises():
547+
"""Redirect loop must raise RuntimeError after max_redirects."""
548+
549+
class LoopRedirectHandler(BaseHTTPRequestHandler):
550+
def do_HEAD(self):
551+
self.send_response(200)
552+
self.send_header("content-length", "4")
553+
self.end_headers()
554+
555+
def do_GET(self):
556+
port = self.server.server_address[1]
557+
self.send_response(302)
558+
self.send_header(
559+
"Location",
560+
f"http://127.0.0.1:{port}/loop",
561+
)
562+
self.end_headers()
563+
564+
def log_message(self, format, *args):
565+
pass
566+
567+
with serve(MockFlasher()) as flasher:
568+
server = HTTPServer(("127.0.0.1", 0), LoopRedirectHandler)
569+
port = server.server_address[1]
570+
server_thread = Thread(target=server.serve_forever)
571+
server_thread.daemon = True
572+
server_thread.start()
573+
574+
try:
575+
with pytest.raises(DriverError, match="Too many redirects"):
576+
flasher.flash(f"http://127.0.0.1:{port}/loop")
577+
finally:
578+
server.shutdown()
579+
server.server_close()
580+
server_thread.join(timeout=2)
581+
582+
583+
def test_flash_http_redirect_missing_location_raises():
584+
"""302 without Location header must raise RuntimeError."""
585+
586+
class NoLocationHandler(BaseHTTPRequestHandler):
587+
def do_HEAD(self):
588+
self.send_response(200)
589+
self.send_header("content-length", "4")
590+
self.end_headers()
591+
592+
def do_GET(self):
593+
self.send_response(302)
594+
self.end_headers()
595+
596+
def log_message(self, format, *args):
597+
pass
598+
599+
with serve(MockFlasher()) as flasher:
600+
server = HTTPServer(("127.0.0.1", 0), NoLocationHandler)
601+
port = server.server_address[1]
602+
server_thread = Thread(target=server.serve_forever)
603+
server_thread.daemon = True
604+
server_thread.start()
605+
606+
try:
607+
with pytest.raises(DriverError, match="missing Location header"):
608+
flasher.flash(f"http://127.0.0.1:{port}/start")
609+
finally:
610+
server.shutdown()
611+
server.server_close()
612+
server_thread.join(timeout=2)

python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def _upload_file_if_needed(self, file_path: str, operator: Operator | None = Non
4747
self.logger.info(f"Uploading {file_path} to storage as {filename}")
4848

4949
try:
50-
self.storage.write_from_path(filename, path_buf, operator=operator)
50+
self.storage.write_from_path(filename, file_path, operator=operator)
5151
except Exception as e:
5252
raise RideSXFlashError(
5353
f"Failed to transfer '{file_path}' to exporter storage as '{filename}' (scheme={operator_scheme})"

0 commit comments

Comments
 (0)