feat: add signed bind-backed user icon uploads

This commit is contained in:
2026-08-06 15:40:55 +00:00
parent a0e1271c88
commit 9046a131f2
12 changed files with 331 additions and 17 deletions
+75 -3
View File
@@ -1,9 +1,11 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import test from 'node:test';
import { adminSignature } from '../src/auth.mjs';
import { adminSignature, uploadSignature } from '../src/auth.mjs';
import { createApp } from '../src/server.mjs';
const noUploadStore = { async initialize() {}, async store() { throw new Error('must not upload'); } };
test('sync endpoint authenticates a scoped caller and passes only an optional commit', async (t) => {
const calls = [];
const service = {
@@ -18,7 +20,7 @@ test('sync endpoint authenticates a scoped caller and passes only an optional co
const { server } = await createApp({
maxBodyBytes: 4096,
syncClientSecrets: { core: secret },
}, { service });
}, { service, uploadStore: noUploadStore });
server.listen(0, '127.0.0.1');
await once(server, 'listening');
t.after(() => server.close());
@@ -42,6 +44,73 @@ test('sync endpoint authenticates a scoped caller and passes only an optional co
assert.deepEqual(calls, [{ requestKey: `core:${requestId}`, expectedCommit: 'a'.repeat(40) }]);
});
test('upload endpoint accepts a short-lived body-bound grant and rejects replay or tampering', async (t) => {
const service = {
async initialize() {},
async recordError() {},
};
const calls = [];
const uploadStore = {
async initialize() {},
async store(value) {
calls.push(value);
return { duplicate: false, path: `icons/users/${value.client}/${value.filename}` };
},
};
const secret = 'u'.repeat(32);
const config = {
maxBodyBytes: 4096,
maxUploadBytes: 51200,
syncClientSecrets: { core: 's'.repeat(32) },
uploadClientSecrets: { core2026: secret },
publicBases: ['https://sam-image.hided.net', 'https://sam.hided.net/image'],
};
const { server } = await createApp(config, { service, uploadStore });
server.listen(0, '127.0.0.1');
await once(server, 'listening');
t.after(() => server.close());
const address = server.address();
const pathname = `/v1/uploads/user-icons/core2026/${'a'.repeat(32)}.png`;
const body = Buffer.from('89504e470d0a1a0a00000000', 'hex');
const expires = String(Math.floor(Date.now() / 1000) + 60);
const requestId = 'upload-request-1234';
const signature = uploadSignature(secret, { expires, requestId, pathname, contentType: 'image/png', body });
const headers = {
'content-type': 'image/png',
'x-image-client': 'core2026',
'x-image-expires': expires,
'x-image-request-id': requestId,
'x-image-signature': signature,
};
const accepted = await fetch(`http://127.0.0.1:${address.port}${pathname}`, { method: 'PUT', headers, body });
assert.equal(accepted.status, 201);
assert.deepEqual(calls[0], {
requestKey: `core2026:${requestId}`,
client: 'core2026',
filename: `${'a'.repeat(32)}.png`,
body,
});
assert.deepEqual((await accepted.json()).urls, [
`https://sam-image.hided.net/icons/users/core2026/${'a'.repeat(32)}.png`,
`https://sam.hided.net/image/icons/users/core2026/${'a'.repeat(32)}.png`,
]);
const tampered = await fetch(`http://127.0.0.1:${address.port}${pathname}`, {
method: 'PUT', headers, body: Buffer.from('89504e470d0a1a0affffffff', 'hex'),
});
assert.equal(tampered.status, 401);
const expired = '1000000000';
const expiredHeaders = {
...headers,
'x-image-expires': expired,
'x-image-signature': uploadSignature(secret, { expires: expired, requestId, pathname, contentType: 'image/png', body }),
};
const expiredResponse = await fetch(`http://127.0.0.1:${address.port}${pathname}`, {
method: 'PUT', headers: expiredHeaders, body,
});
assert.equal(expiredResponse.status, 401);
});
test('sync endpoint rejects unknown callers and body fields outside the sync contract', async (t) => {
const service = {
async initialize() {},
@@ -49,7 +118,10 @@ test('sync endpoint rejects unknown callers and body fields outside the sync con
async recordError() {},
};
const secret = 's'.repeat(32);
const { server } = await createApp({ maxBodyBytes: 4096, syncClientSecrets: { core: secret } }, { service });
const { server } = await createApp(
{ maxBodyBytes: 4096, syncClientSecrets: { core: secret } },
{ service, uploadStore: noUploadStore },
);
server.listen(0, '127.0.0.1');
await once(server, 'listening');
t.after(() => server.close());