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
+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);
}
}