pithos/test/hosted.test.js
Local Dev cdbc8ad93b Pithos hosting: fixes from an independent review
A server-sent file list could name paths outside the s3d folder (or the key
file in it), and a failed move back deleted every listed path. Transfers now
accept only s3d data paths, check all of them before writing, never
overwrite, and clean up only what they wrote.

A lost reply could lose or fork the drives: move-up and move-down now ask the
server who holds them before touching the local copy, and once the server is
told to release, the local copy is never removed (the release is retried).

The gateway no longer lets one account route another's access key id, refuses
. and .. in shared-key paths, strips presign parameters before re-signing,
counts upload size on disk, stops s3d before it can be restarted mid-move, and
claims sign-up proofs before checking them. The key file is never replaced
when unreadable, a local edit removes the older encrypted copy, listing pages
no longer repeat, and data hashes stream instead of loading whole files.
2026-10-04 03:15:42 +02:00

103 lines
5.1 KiB
JavaScript

// The client half of hosting, the parts that can go wrong without a server:
// what a server-sent file list may touch, the key file, listing pages.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { isTransferPath, transferTarget, downloadData, dataFiles, fileSecrets, EncryptingS3Client } from '../core/hosted.js';
import { driveKeys, encryptPath } from '../core/crypt.js';
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'pithos-hosted-'));
test('transfer paths: only s3d data, never ours, never outside', () => {
for (const ok of ['s3d.db', 's3d.db-wal', 'uploads/02e9.obj']) assert.equal(isTransferPath(ok), true, ok);
const bad = ['', '/etc/passwd', '../x', 'a/../b', './a', 'a//b', 'C:/x', 'a' + String.fromCharCode(92) + 'b',
's3d.yml', 'pithos-prefs.json', 'pithos-hosted-secret.json', 'pithos-hosted-secret.replaced-1.json',
'previous-account-20261004/s3d.db', 'tmp/x', 's3d.log'];
for (const b of bad) assert.equal(isTransferPath(b), false, b);
assert.throws(() => transferTarget('/data', '../outside'), /unsafe/);
});
test('a hostile file list writes nothing and removes nothing', async () => {
const dir = tmp();
const secret = path.join(dir, 'pithos-hosted-secret.json');
fs.writeFileSync(secret, 'precious');
const outside = path.join(path.dirname(dir), `victim-${crypto.randomBytes(4).toString('hex')}.txt`);
fs.writeFileSync(outside, 'keep me');
const sha = 'a'.repeat(64);
const written = [];
const g = { base: 'http://127.0.0.1:9', auth: 'x' };
for (const files of [
[{ path: 's3d.db', size: 1, sha256: sha }, { path: `../${path.basename(outside)}`, size: 1, sha256: sha }],
[{ path: 's3d.db', size: 1, sha256: sha }, { path: 'pithos-hosted-secret.json', size: 1, sha256: sha }],
[{ path: 'uploads/x.obj', size: 1, sha256: sha }],
]) {
await assert.rejects(downloadData(g, dir, files, () => {}, written));
}
assert.equal(written.length, 0, 'nothing was started');
assert.equal(fs.readFileSync(secret, 'utf8'), 'precious');
assert.equal(fs.readFileSync(outside, 'utf8'), 'keep me');
fs.rmSync(outside);
});
test('data files are listed with streamed hashes, skipping local files', async () => {
const dir = tmp();
fs.writeFileSync(path.join(dir, 's3d.db'), 'db');
fs.mkdirSync(path.join(dir, 'uploads'));
fs.writeFileSync(path.join(dir, 'uploads', 'a.obj'), 'obj');
for (const n of ['s3d.yml', 'pithos-prefs.json', 'pithos-hosted-secret.json', 's3d.log']) fs.writeFileSync(path.join(dir, n), 'x');
fs.mkdirSync(path.join(dir, 'previous-account-1'));
fs.writeFileSync(path.join(dir, 'previous-account-1', 's3d.db'), 'old');
const files = await dataFiles(dir);
assert.deepEqual(files.map((f) => f.path).sort(), ['s3d.db', 'uploads/a.obj']);
assert.equal(files.find((f) => f.path === 's3d.db').sha256, crypto.createHash('sha256').update('db').digest('hex'));
});
test('key file: made once, never silently replaced', async () => {
const dir = tmp();
const cfg = path.join(dir, 's3d.yml');
const a = fileSecrets(cfg);
const k1 = await a.encryptionKey();
assert.deepEqual(await fileSecrets(cfg).encryptionKey(), k1, 'same key on the next start');
fs.writeFileSync(a.file, '{"v":1,"secret":"trunc');
await assert.rejects(fileSecrets(cfg).encryptionKey(), /damaged/);
assert.match(fs.readFileSync(a.file, 'utf8'), /trunc/, 'the damaged file is left for the user to restore');
const b = fileSecrets(cfg);
b.importSecret({ secret: crypto.randomBytes(32).toString('base64') });
assert.ok(fs.readdirSync(dir).some((n) => n.startsWith('pithos-hosted-secret.replaced-')), 'the replaced key is kept');
});
// A fake s3d that pages listings, to check the merge of plain and encrypted.
function fakeInner(keys, pageSize) {
const sorted = [...keys].sort();
return {
async listObjects(bucket, { prefix = '', delimiter = '/', token }) {
const all = sorted.filter((k) => k.startsWith(prefix));
const start = token ? Number(token) : 0;
const page = all.slice(start, start + pageSize);
void delimiter;
return { folders: [], objects: page.map((k) => ({ key: k, size: 100, modified: '', etag: '' })), truncated: start + pageSize < all.length, nextToken: String(start + pageSize) };
},
};
}
test('listing pages never repeat a source that ran out', async () => {
const master = crypto.randomBytes(32);
const dk = driveKeys(master, 'd');
const plain = Array.from({ length: 7 }, (_, i) => `f/plain${i}`);
const enc = [encryptPath(dk, 'f/secret1')];
const c = new EncryptingS3Client({ endpoint: 'http://127.0.0.1:1', accessKeyId: 'a', secretKey: 'b', master: async () => master });
c.inner = fakeInner([...plain, ...enc], 2);
const seen = [];
let token;
do {
const page = await c.listObjects('d', { prefix: 'f/', delimiter: '', token });
seen.push(...page.objects.map((o) => o.key));
token = page.truncated ? page.nextToken : null;
} while (token);
assert.equal(seen.filter((k) => k === 'f/secret1').length, 1);
assert.equal(seen.length, 8);
});