Pithos 0.3.3: fix SignatureDoesNotMatch when opening a bucket

The SigV4 encoder kept '/' unescaped in object paths by swapping in a
"never matches" regex (/$^/) for query values, but that regex matches the
empty string, so every empty query value went out as "/": prefix= became
prefix=/ and ?policy became ?policy=/. The request then no longer matched
what was signed, and listing any bucket or reading its policy failed
against s3d. Bucket listing and creation carry no query, so they worked.

Verified end to end against s3d (list, folders, upload, download, public
policy, folder delete) and against the aws cli; two regression tests.
This commit is contained in:
Local Dev 2026-10-03 22:12:44 +02:00
parent c09872122a
commit 735482d3b6
4 changed files with 24 additions and 5 deletions

View file

@ -14,9 +14,11 @@ const hmac = (k, s) => crypto.createHmac('sha256', k).update(s).digest();
// RFC 3986 encoding as SigV4 wants it; '/' kept for object keys in paths.
function uriEncode(str, keepSlash) {
return encodeURIComponent(str)
.replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
.replace(keepSlash ? /%2F/g : /$^/, '/');
const out = encodeURIComponent(str)
.replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
// (A "never matches" regex like /$^/ is not one: it matches the empty
// string, which turned every empty query value into "/".)
return keepSlash ? out.replace(/%2F/g, '/') : out;
}
function canonicalQuery(query) {

View file

@ -1,6 +1,6 @@
{
"name": "pithos",
"version": "0.3.2",
"version": "0.3.3",
"description": "A control panel for s3d, the Sia S3 gateway: daemon, users and keys, buckets, upload pipeline.",
"license": "MPL-2.0",
"type": "module",

View file

@ -77,3 +77,20 @@ test('presigned URL matches aws cli', { skip: !hasAws && 'aws cli not installed'
assert.equal(ours.pathname, t.pathname);
assert.equal(ours.searchParams.get('X-Amz-Signature'), t.searchParams.get('X-Amz-Signature'));
});
// Regression: empty query values (prefix=, policy) were sent as "/" and
// every bucket listing failed with SignatureDoesNotMatch against s3d.
test('empty query values stay empty and match the aws cli', { skip: !hasAws && 'aws cli not installed' }, async () => {
let port;
const req = await capture((p) => { port = p; return runAws(['s3api', 'list-objects-v2', '--bucket', 'my-bucket', '--prefix', '', '--delimiter', '/', '--endpoint-url', `http://127.0.0.1:${p}`]); });
const { theirs, ours } = resign(port, req, 'my-bucket');
assert.equal(ours, theirs);
});
test('client sends empty values as empty', async () => {
const seen = await new Promise((resolve) => {
const server = http.createServer((req, res) => { res.end('<ListBucketResult><IsTruncated>false</IsTruncated></ListBucketResult>'); server.close(); resolve(req.url); });
server.listen(0, '127.0.0.1', () => new S3Client({ endpoint: `http://127.0.0.1:${server.address().port}`, accessKeyId: AK, secretKey: SK }).listObjects('b').catch(() => {}));
});
assert.match(seen, /[?&]prefix=(&|$)/);
});

View file

@ -1,7 +1,7 @@
{
"id": "pithos",
"name": "Pithos",
"version": "0.3.2",
"version": "0.3.3",
"description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",