feat: add signed bind-backed user icon uploads
This commit is contained in:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user