From 7bf25148774a2d38753c5836fb5f6a2d612c067e Mon Sep 17 00:00:00 2001 From: Zihan Dai <99155080+PDGGK@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:33:17 +1000 Subject: [PATCH] fix(services/b2): percent-encode the object path in presigned requests The three presign arms interpolate the path raw, while the ordinary read and write paths in the same crate encode it: // backend.rs, presign Stat and Read let url = format!( "{}/file/{}/{}?Authorization={}", auth_info.download_url, self.core.bucket, path, resp.authorization_token ); // backend.rs, presign Write req = req.header("X-Bz-File-Name", build_abs_path(&self.core.root, path)); // core.rs:144-149, download_file_by_name -- the reference let url = format!("{}/file/{}/{}", auth_info.download_url, self.bucket, percent_encode_path(&path)); // core.rs:262, upload_file -- the reference req = req.header(X_BZ_FILE_NAME, percent_encode_path(&p)); Measured by handing the formatted strings to http::Request::get: dir/file.txt path=/file/bkt/dir/file.txt query=Authorization=TOK a#b.txt path=/file/bkt/a query=None a?b.txt path=/file/bkt/a query=b.txt?Authorization=TOK a b.txt BUILD ERROR: invalid uri character a%20b.txt sends a%20b.txt, which B2 decodes to "a b.txt" The `#` case is the worst: the URL is truncated to a different object *and* the whole ?Authorization= is swallowed as a fragment, so the presigned URL carries no token at all. `a b.txt` cannot be presigned even though op.read("a b.txt") on the same object works, because that goes through the encoding path. And the write header sends a%20b.txt where op.write("a%20b.txt") sends a%2520b.txt, so a presigned upload lands on a different key than an ordinary write for the same OpenDAL path. The first line above is the one that matters for blast radius: a path with no reserved characters encodes to itself, because percent_encode_path leaves `/` alone. Presigned URLs for ordinary object names are byte-identical to what they are today. No new tests: these are format! arguments inside an async method whose only seam is a live B2 authorization call, and asserting on them would mean restructuring the presign arms. The three existing unit tests pass, fmt and clippy are clean. Note for rebasing: #7801 renames build_abs_path at these same three sites. If it lands first the X-Bz-File-Name line needs a one-word rebase. --- core/services/b2/src/backend.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/core/services/b2/src/backend.rs b/core/services/b2/src/backend.rs index 27e1cb6cf427..7675a411e06b 100644 --- a/core/services/b2/src/backend.rs +++ b/core/services/b2/src/backend.rs @@ -374,7 +374,10 @@ impl Service for B2Backend { let url = format!( "{}/file/{}/{}?Authorization={}", - auth_info.download_url, self.core.bucket, path, resp.authorization_token + auth_info.download_url, + self.core.bucket, + percent_encode_path(&path), + resp.authorization_token ); let req = Request::get(url); @@ -401,7 +404,10 @@ impl Service for B2Backend { let url = format!( "{}/file/{}/{}?Authorization={}", - auth_info.download_url, self.core.bucket, path, resp.authorization_token + auth_info.download_url, + self.core.bucket, + percent_encode_path(&path), + resp.authorization_token ); let req = Request::get(url); @@ -423,7 +429,10 @@ impl Service for B2Backend { let mut req = Request::post(&resp.upload_url); req = req.header(http::header::AUTHORIZATION, resp.authorization_token); - req = req.header("X-Bz-File-Name", build_abs_path(&self.core.root, path)); + req = req.header( + "X-Bz-File-Name", + percent_encode_path(&build_abs_path(&self.core.root, path)), + ); req = req.header(http::header::CONTENT_TYPE, "b2/x-auto"); req = req.header(constants::X_BZ_CONTENT_SHA1, "do_not_verify");