pithos/core/s3.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

223 lines
9.1 KiB
JavaScript

// A small S3 client for s3d: AWS SigV4 (path-style, us-east-1, UNSIGNED-PAYLOAD
// for bodies so uploads stream straight through) plus the handful of bucket
// and object calls the console needs.
import crypto from 'node:crypto';
import http from 'node:http';
import { Readable } from 'node:stream';
const REGION = 'us-east-1';
const SERVICE = 's3';
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
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) {
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) {
return Object.keys(query).sort()
.map((k) => `${uriEncode(k)}=${uriEncode(String(query[k] ?? ''))}`)
.join('&');
}
function amzDate(d = new Date()) {
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
function signingKey(secret, date) {
return hmac(hmac(hmac(hmac('AWS4' + secret, date), REGION), SERVICE), 'aws4_request');
}
export function objectPath(bucket, key) {
return '/' + uriEncode(bucket) + (key != null ? '/' + uriEncode(key, true) : '');
}
export class S3Client {
// endpoint: 'http://127.0.0.1:8000'
constructor({ endpoint, accessKeyId, secretKey }) {
this.endpoint = new URL(endpoint);
this.accessKeyId = accessKeyId;
this.secretKey = secretKey;
}
sign(method, path, query, headers, { now = amzDate(), payloadHash = 'UNSIGNED-PAYLOAD' } = {}) {
const date = now.slice(0, 8);
const h = { ...headers, host: this.endpoint.host, 'x-amz-date': now, 'x-amz-content-sha256': payloadHash };
const names = Object.keys(h).map((k) => k.toLowerCase()).sort();
const lower = Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), String(v).trim()]));
const signed = names.join(';');
const canonical = [method, path, canonicalQuery(query), names.map((n) => `${n}:${lower[n]}\n`).join(''), signed, payloadHash].join('\n');
const scope = `${date}/${REGION}/${SERVICE}/aws4_request`;
const toSign = ['AWS4-HMAC-SHA256', now, scope, sha256(canonical)].join('\n');
const sig = crypto.createHmac('sha256', signingKey(this.secretKey, date)).update(toSign).digest('hex');
lower.authorization = `AWS4-HMAC-SHA256 Credential=${this.accessKeyId}/${scope}, SignedHeaders=${signed}, Signature=${sig}`;
delete lower.host;
return lower;
}
// Presigned GET for share links. publicEndpoint lets the link name the
// address readers will use, which may differ from the loopback the GUI uses.
presign(bucket, key, expiresSec = 3600, publicEndpoint, now = amzDate()) {
const base = publicEndpoint ? new URL(publicEndpoint) : this.endpoint;
const date = now.slice(0, 8);
const scope = `${date}/${REGION}/${SERVICE}/aws4_request`;
const path = objectPath(bucket, key);
const query = {
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${this.accessKeyId}/${scope}`,
'X-Amz-Date': now,
'X-Amz-Expires': String(Math.min(Math.max(1, expiresSec | 0), 604800)),
'X-Amz-SignedHeaders': 'host',
};
const canonical = ['GET', path, canonicalQuery(query), `host:${base.host}\n`, 'host', 'UNSIGNED-PAYLOAD'].join('\n');
const toSign = ['AWS4-HMAC-SHA256', now, scope, sha256(canonical)].join('\n');
query['X-Amz-Signature'] = crypto.createHmac('sha256', signingKey(this.secretKey, date)).update(toSign).digest('hex');
return `${base.origin}${path}?${canonicalQuery(query)}`;
}
// Low-level request. body: Buffer | string | Readable | undefined.
// Resolves the raw http.IncomingMessage so callers can stream it.
request(method, path, { query = {}, headers = {}, body } = {}) {
const signedHeaders = this.sign(method, path, query, headers);
const qs = canonicalQuery(query);
return new Promise((resolve, reject) => {
const req = http.request({
protocol: this.endpoint.protocol,
hostname: this.endpoint.hostname,
port: this.endpoint.port,
method,
path: path + (qs ? '?' + qs : ''),
headers: signedHeaders,
}, resolve);
req.on('error', reject);
if (body instanceof Readable) body.pipe(req);
else req.end(body);
});
}
async call(method, path, opts) {
const res = await this.request(method, path, opts);
const text = await readBody(res);
if (res.statusCode >= 300) throw s3Error(res.statusCode, text);
return { status: res.statusCode, headers: res.headers, text };
}
async listBuckets() {
const { text } = await this.call('GET', '/');
return blocks(text, 'Bucket').map((b) => ({ name: tag(b, 'Name'), created: tag(b, 'CreationDate') }));
}
createBucket(bucket) { return this.call('PUT', objectPath(bucket)); }
deleteBucket(bucket) { return this.call('DELETE', objectPath(bucket)); }
async listObjects(bucket, { prefix = '', delimiter = '/', token, max = 1000 } = {}) {
const query = { 'list-type': '2', prefix, delimiter, 'max-keys': String(max) };
if (token) query['continuation-token'] = token;
const { text } = await this.call('GET', objectPath(bucket), { query });
return {
folders: blocks(text, 'CommonPrefixes').map((b) => tag(b, 'Prefix')),
objects: blocks(text, 'Contents').map((b) => ({
key: tag(b, 'Key'), size: Number(tag(b, 'Size')), modified: tag(b, 'LastModified'), etag: tag(b, 'ETag'),
})),
truncated: tag(text, 'IsTruncated') === 'true',
nextToken: tag(text, 'NextContinuationToken'),
};
}
async deleteObject(bucket, key) { return this.call('DELETE', objectPath(bucket, key)); }
// Deletes every key under prefix. Returns the number deleted.
async deletePrefix(bucket, prefix) {
let n = 0;
let token;
do {
const page = await this.listObjects(bucket, { prefix, delimiter: '', token });
for (const o of page.objects) { await this.deleteObject(bucket, o.key); n++; }
token = page.truncated ? page.nextToken : null;
} while (token);
return n;
}
putObject(bucket, key, body, { contentType, contentLength } = {}) {
const headers = {};
if (contentType) headers['content-type'] = contentType;
if (contentLength != null) headers['content-length'] = String(contentLength);
return this.call('PUT', objectPath(bucket, key), { headers, body });
}
getObject(bucket, key, { range } = {}) {
return this.request('GET', objectPath(bucket, key), { headers: range ? { range } : {} });
}
async getPolicy(bucket) {
const res = await this.request('GET', objectPath(bucket), { query: { policy: '' } });
const text = await readBody(res);
if (res.statusCode === 404) return null;
if (res.statusCode >= 300) throw s3Error(res.statusCode, text);
return JSON.parse(text);
}
putPolicy(bucket, policy) {
const body = JSON.stringify(policy);
return this.call('PUT', objectPath(bucket), { query: { policy: '' }, body, headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } });
}
deletePolicy(bucket) { return this.call('DELETE', objectPath(bucket), { query: { policy: '' } }); }
}
// The only policy shape s3d accepts: anonymous read of objects and listing.
export function publicReadPolicy(bucket, { list = false } = {}) {
const statements = [{
Effect: 'Allow', Principal: '*', Action: ['s3:GetObject'], Resource: [`arn:aws:s3:::${bucket}/*`],
}];
if (list) statements.push({ Effect: 'Allow', Principal: '*', Action: ['s3:ListBucket'], Resource: [`arn:aws:s3:::${bucket}`] });
return { Version: '2012-10-17', Statement: statements };
}
export function readBody(res) {
return new Promise((resolve, reject) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
res.on('error', reject);
});
}
export class S3Error extends Error {
constructor(status, code, message) { super(message || code); this.status = status; this.code = code; }
}
function s3Error(status, text) {
return new S3Error(status, tag(text, 'Code') || `HTTP ${status}`, tag(text, 'Message') || text.slice(0, 200) || `HTTP ${status}`);
}
// S3 responses are flat enough that tag extraction is all we need.
function blocks(xml, name) {
const re = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, 'g');
const out = [];
let m;
while ((m = re.exec(xml))) out.push(m[1]);
return out;
}
function tag(xml, name) {
const m = xml.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`));
return m ? unescapeXml(m[1]) : null;
}
function unescapeXml(s) {
return s.replace(/&(lt|gt|amp|quot|apos|#\d+|#x[0-9a-f]+);/gi, (_, e) => {
switch (e.toLowerCase()) {
case 'lt': return '<'; case 'gt': return '>'; case 'amp': return '&';
case 'quot': return '"'; case 'apos': return "'";
default: return String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10));
}
});
}