feat: add signed bind-backed user icon uploads
This commit is contained in:
+17
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user