pithos/test/sigv4.test.js

97 lines
5 KiB
JavaScript
Raw Normal View History

// 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=(&|$)/);
});