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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sam-image-hook",
"version": "1.1.0",
"version": "1.2.0",
"private": true,
"type": "module",
"engines": {
+17 -1
View File
@@ -1,4 +1,4 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
export function hmacHex(secret, body) {
return createHmac('sha256', secret).update(body).digest('hex');
@@ -37,3 +37,19 @@ export function verifyAdminSignature({ secret, timestamp, requestId, body, suppl
body,
]), supplied);
}
export function uploadSignature(secret, { expires, requestId, pathname, contentType, body }) {
const digest = createHash('sha256').update(body).digest('hex');
return hmacHex(secret, `${expires}.${requestId}.${pathname}.${contentType}.${digest}`);
}
export function verifyUploadSignature({ secret, expires, requestId, pathname, contentType, body, supplied, now = Date.now() }) {
if (!/^\d{10}$/.test(String(expires ?? '')) || !/^[A-Za-z0-9._:-]{8,128}$/.test(requestId ?? '')) {
return false;
}
const expiresAt = Number(expires) * 1000;
if (!Number.isFinite(expiresAt) || expiresAt < now || expiresAt > now + 5 * 60 * 1000) {
return false;
}
return verifyHexHmac(secret, `${expires}.${requestId}.${pathname}.${contentType}.${createHash('sha256').update(body).digest('hex')}`, supplied);
}
+11 -7
View File
@@ -18,13 +18,13 @@ function secret(name, fileName) {
return readFileSync(path, 'utf8').trim();
}
function syncClientSecrets() {
const entries = text('IMAGE_SYNC_CLIENT_SECRET_FILES', '')
function clientSecrets(variableName) {
const entries = text(variableName, '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
if (entries.length === 0) {
throw new Error('IMAGE_SYNC_CLIENT_SECRET_FILES is required');
throw new Error(`${variableName} is required`);
}
const result = Object.create(null);
@@ -33,14 +33,14 @@ function syncClientSecrets() {
const client = entry.slice(0, separator);
const path = entry.slice(separator + 1);
if (separator < 1 || !/^[a-z0-9][a-z0-9_-]{1,31}$/.test(client) || !path) {
throw new Error(`Invalid image sync client entry: ${entry}`);
throw new Error(`Invalid ${variableName} client entry: ${entry}`);
}
const value = readFileSync(path, 'utf8').trim();
if (value.length < 32) {
throw new Error(`Image sync secret for ${client} must be at least 32 characters`);
throw new Error(`${variableName} secret for ${client} must be at least 32 characters`);
}
if (result[client]) {
throw new Error(`Duplicate image sync client: ${client}`);
throw new Error(`Duplicate ${variableName} client: ${client}`);
}
result[client] = value;
}
@@ -73,7 +73,11 @@ export function loadConfig() {
.filter(Boolean),
webhookSecret,
adminSecret,
syncClientSecrets: syncClientSecrets(),
syncClientSecrets: clientSecrets('IMAGE_SYNC_CLIENT_SECRET_FILES'),
uploadClientSecrets: clientSecrets('IMAGE_UPLOAD_CLIENT_SECRET_FILES'),
maxBodyBytes: Number(text('MAX_BODY_BYTES', '1048576')),
maxUploadBytes: Number(text('MAX_UPLOAD_BYTES', '51200')),
uploadRoot: text('IMAGE_UPLOAD_ROOT', '/var/lib/image-hook/uploads'),
uploadStatePath: text('IMAGE_UPLOAD_STATE_PATH', '/var/lib/image-hook/upload-state.json'),
};
}
+57 -2
View File
@@ -1,8 +1,9 @@
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { loadConfig } from './config.mjs';
import { verifyAdminSignature, verifyHexHmac } from './auth.mjs';
import { verifyAdminSignature, verifyHexHmac, verifyUploadSignature } from './auth.mjs';
import { DeploymentError, GitService } from './git-service.mjs';
import { UploadStore } from './upload-store.mjs';
function json(response, status, value) {
const body = JSON.stringify(value);
@@ -35,9 +36,22 @@ function parseJson(body) {
}
}
function hasImageSignature(body, extension) {
if (extension === 'png') return body.length >= 8 && body.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'));
if (extension === 'jpg') return body.length >= 3 && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff;
if (extension === 'gif') return body.length >= 6 && ['GIF87a', 'GIF89a'].includes(body.subarray(0, 6).toString('ascii'));
if (extension === 'webp') return body.length >= 12 && body.subarray(0, 4).toString('ascii') === 'RIFF'
&& body.subarray(8, 12).toString('ascii') === 'WEBP';
if (extension === 'avif') return body.length >= 16 && body.subarray(4, 8).toString('ascii') === 'ftyp'
&& (body.subarray(8, 64).includes(Buffer.from('avif')) || body.subarray(8, 64).includes(Buffer.from('avis')));
return false;
}
export async function createApp(config = loadConfig(), dependencies = {}) {
const service = dependencies.service ?? new GitService(config);
const uploadStore = dependencies.uploadStore ?? new UploadStore(config);
await service.initialize();
await uploadStore.initialize();
const server = createServer(async (request, response) => {
const url = new URL(request.url, 'http://image-hook');
@@ -135,6 +149,47 @@ export async function createApp(config = loadConfig(), dependencies = {}) {
});
return json(response, 200, { ok: true, ...result });
}
if (request.method === 'PUT' && url.pathname.startsWith('/v1/uploads/user-icons/')) {
const client = request.headers['x-image-client'];
const expires = request.headers['x-image-expires'];
const requestId = request.headers['x-image-request-id'];
const contentType = request.headers['content-type']?.toLowerCase() ?? '';
const knownClient = typeof client === 'string' && Object.hasOwn(config.uploadClientSecrets, client);
const match = url.pathname.match(/^\/v1\/uploads\/user-icons\/([a-z0-9][a-z0-9_-]{1,31})\/([a-f0-9]{32})\.(avif|webp|jpg|png|gif)$/);
if (!match || match[1] !== client) {
throw new DeploymentError('Invalid upload path', 400);
}
const mimeByExtension = {
avif: 'image/avif', webp: 'image/webp', jpg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
};
if (contentType !== mimeByExtension[match[3]]) {
throw new DeploymentError('Content-Type does not match upload path', 415);
}
const body = await readBody(request, config.maxUploadBytes);
const signatureValid = verifyUploadSignature({
secret: knownClient ? config.uploadClientSecrets[client] : 'invalid-client-secret'.padEnd(32, '!'),
expires,
requestId,
pathname: url.pathname,
contentType,
body,
supplied: request.headers['x-image-signature'],
});
if (!knownClient || !signatureValid) {
return json(response, 401, { ok: false, reason: 'invalid or expired upload grant' });
}
if (!hasImageSignature(body, match[3])) {
throw new DeploymentError('Body is not the declared image format', 400);
}
const result = await uploadStore.store({
requestKey: `${client}:${requestId}`,
client,
filename: `${match[2]}.${match[3]}`,
body,
});
const urls = config.publicBases.map((base) => `${base}/${result.path}`);
return json(response, 201, { ok: true, ...result, urls });
}
return json(response, 404, { ok: false, reason: 'not found' });
} catch (error) {
await service.recordError(error).catch(() => undefined);
@@ -146,7 +201,7 @@ export async function createApp(config = loadConfig(), dependencies = {}) {
}
});
return { server, service };
return { server, service, uploadStore };
}
if (process.argv[1] === new URL(import.meta.url).pathname) {
+63
View File
@@ -0,0 +1,63 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { DeploymentError } from './git-service.mjs';
export class UploadStore {
constructor(config) {
this.root = config.uploadRoot;
this.statePath = config.uploadStatePath;
this.queue = Promise.resolve();
this.uploads = [];
}
async initialize() {
await mkdir(this.root, { recursive: true });
await mkdir(dirname(this.statePath), { recursive: true });
try {
const saved = JSON.parse(await readFile(this.statePath, 'utf8'));
this.uploads = Array.isArray(saved.uploads) ? saved.uploads : [];
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
store({ requestKey, client, filename, body }) {
const operation = this.queue.then(async () => {
if (!/^[a-z0-9][a-z0-9_-]{1,31}$/.test(client)
|| !/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(filename)) {
throw new DeploymentError('Invalid upload path', 400);
}
const relativePath = `${client}/${filename}`;
const path = `icons/users/${relativePath}`;
const digest = createHash('sha256').update(body).digest('hex');
const previous = this.uploads.find((upload) => upload.key === requestKey);
if (previous) {
if (previous.path !== path || previous.digest !== digest) {
throw new DeploymentError('Upload request ID was already used', 409);
}
return { duplicate: true, path: previous.path };
}
const destination = join(this.root, relativePath);
await mkdir(dirname(destination), { recursive: true });
try {
await writeFile(destination, body, { flag: 'wx', mode: 0o644 });
} catch (error) {
if (error.code !== 'EEXIST' || !(await readFile(destination)).equals(body)) {
throw new DeploymentError('Upload path already exists', 409);
}
}
this.uploads = [...this.uploads.slice(-999), { key: requestKey, path, digest }];
await this.#save();
return { duplicate: false, path };
});
this.queue = operation.catch(() => undefined);
return operation;
}
async #save() {
const temporary = `${this.statePath}.tmp-${process.pid}`;
await writeFile(temporary, JSON.stringify({ uploads: this.uploads }, null, 2), { mode: 0o600 });
await rename(temporary, this.statePath);
}
}
+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());
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import test from 'node:test';
import { UploadStore } from '../src/upload-store.mjs';
test('stores uploads only in the bind directory and persists replay state', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'image-upload-store-'));
t.after(() => rm(root, { recursive: true, force: true }));
const config = { uploadRoot: join(root, 'uploads'), uploadStatePath: join(root, 'upload-state.json') };
const body = Buffer.from('immutable image bytes');
const filename = `${'a'.repeat(32)}.png`;
const store = new UploadStore(config);
await store.initialize();
const first = await store.store({ requestKey: 'core2026:request-1', client: 'core2026', filename, body });
assert.deepEqual(first, { duplicate: false, path: `icons/users/core2026/${filename}` });
assert.equal(await readFile(join(root, 'uploads', 'core2026', filename), 'utf8'), 'immutable image bytes');
const restarted = new UploadStore(config);
await restarted.initialize();
assert.deepEqual(
await restarted.store({ requestKey: 'core2026:request-1', client: 'core2026', filename, body }),
{ duplicate: true, path: `icons/users/core2026/${filename}` },
);
await assert.rejects(
restarted.store({
requestKey: 'core2026:request-1',
client: 'core2026',
filename: `${'b'.repeat(32)}.png`,
body,
}),
/already used/,
);
await assert.rejects(
restarted.store({ requestKey: 'core2026:request-2', client: '../escape', filename, body }),
/Invalid upload path/,
);
});