pithos/test/sigv4.test.js
Local Dev 735482d3b6 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.
2026-10-03 22:12:44 +02:00

96 lines
5 KiB
JavaScript

// Cross-checks the SigV4 signer against the real AWS CLI: a capture server
// records what `aws` sends, and we re-sign the same request ourselves. Skips
// when the aws CLI is not installed.
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { execFileSync, execFile } from 'node:child_process';
import { S3Client, objectPath } from '../core/s3.js';
const AK = 'AKIDEXAMPLE12345678';
const SK = 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY';
let hasAws = true;
try { execFileSync('aws', ['--version'], { stdio: 'ignore' }); } catch { hasAws = false; }
function capture(fn) {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
res.writeHead(200, { 'content-type': 'application/xml' });
res.end('<?xml version="1.0"?><ListBucketResult><IsTruncated>false</IsTruncated></ListBucketResult>');
server.close();
resolve({ method: req.method, url: req.url, headers: req.headers });
});
});
server.listen(0, '127.0.0.1', () => fn(server.address().port).catch(reject));
});
}
function runAws(args) {
return new Promise((resolve) => execFile('aws', args, {
env: { ...process.env, AWS_ACCESS_KEY_ID: AK, AWS_SECRET_ACCESS_KEY: SK, AWS_REGION: 'us-east-1', AWS_CONFIG_FILE: 'nul', AWS_SHARED_CREDENTIALS_FILE: 'nul' },
}, () => resolve()));
}
function resign(port, req, bucket, key) {
const client = new S3Client({ endpoint: `http://127.0.0.1:${port}`, accessKeyId: AK, secretKey: SK });
const auth = req.headers.authorization;
const signedNames = auth.match(/SignedHeaders=([^,]+)/)[1].split(';');
const headers = {};
for (const n of signedNames) if (!['host', 'x-amz-date', 'x-amz-content-sha256'].includes(n)) headers[n] = req.headers[n];
const u = new URL(req.url, 'http://x');
const query = {};
for (const [k, v] of u.searchParams) query[k] = v;
const ours = client.sign(req.method, objectPath(bucket, key), query, headers, {
now: req.headers['x-amz-date'], payloadHash: req.headers['x-amz-content-sha256'],
});
return { theirs: auth.match(/Signature=(\w+)/)[1], ours: ours.authorization.match(/Signature=(\w+)/)[1] };
}
test('list with awkward prefix matches 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', "a b/ü+x=(1)*'~", '--endpoint-url', `http://127.0.0.1:${p}`]); });
const { theirs, ours } = resign(port, req, 'my-bucket');
assert.equal(ours, theirs);
});
test('object key path encoding matches aws cli', { skip: !hasAws && 'aws cli not installed' }, async () => {
let port;
const key = "dir/a b+c(1)!ü*'~.txt";
const req = await capture((p) => { port = p; return runAws(['s3api', 'delete-object', '--bucket', 'my-bucket', '--key', key, '--endpoint-url', `http://127.0.0.1:${p}`]); });
const { theirs, ours } = resign(port, req, 'my-bucket', key);
assert.equal(ours, theirs);
});
test('presigned URL matches aws cli', { skip: !hasAws && 'aws cli not installed' }, () => {
const key = 'dir/report 2026 (final).pdf';
const theirs = execFileSync('aws', ['s3', 'presign', `s3://my-bucket/${key}`, '--expires-in', '3600', '--endpoint-url', 'http://127.0.0.1:18000'], {
env: { ...process.env, AWS_ACCESS_KEY_ID: AK, AWS_SECRET_ACCESS_KEY: SK, AWS_REGION: 'us-east-1', AWS_CONFIG_FILE: 'nul', AWS_SHARED_CREDENTIALS_FILE: 'nul' },
}).toString().trim();
const t = new URL(theirs);
const client = new S3Client({ endpoint: 'http://127.0.0.1:18000', accessKeyId: AK, secretKey: SK });
const ours = new URL(client.presign('my-bucket', key, 3600, undefined, t.searchParams.get('X-Amz-Date')));
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=(&|$)/);
});