104 lines
5.1 KiB
JavaScript
104 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);
|
||
|
|
});
|