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
+2
View File
@@ -19,3 +19,5 @@ GITEA_WEBHOOK_SECRET_FILE=./secrets/gitea_webhook_secret
IMAGE_ADMIN_SECRET_FILE=./secrets/image_admin_secret
IMAGE_SYNC_CORE_SECRET_FILE=./secrets/image_sync_core_secret
IMAGE_SYNC_CORE2026_SECRET_FILE=./secrets/image_sync_core2026_secret
IMAGE_UPLOAD_CORE_SECRET_FILE=./secrets/image_upload_core_secret
IMAGE_UPLOAD_CORE2026_SECRET_FILE=./secrets/image_upload_core2026_secret
+26
View File
@@ -134,6 +134,32 @@ Never give either caller `image_admin_secret`, which also authorizes explicit
branch changes. This fallback handles webhook delivery outages; if the image
service itself is stopped, restore it and run the caller command again.
### Short-lived user-icon uploads
Core and Core2026 can store validated account icons in this repository through
`PUT /v1/uploads/user-icons/<client>/<random-32-hex>.<extension>`. Each game
server validates the authenticated user and image first, then sends the raw
image body with `X-Image-Client`, `X-Image-Expires`, `X-Image-Request-Id`, and
`X-Image-Signature` headers.
The signature is HMAC-SHA256 over
`expires.requestId.pathname.contentType.sha256(body)`. Expiry may be at most
five minutes in the future, so a grant cannot be reused for another path,
content type, body, or later upload. The service also checks the image magic,
caller scope, and request replay before it writes one immutable file below the
host bind directory `runtime-data/uploads`. User uploads are deliberately not
added to Git; Nginx exposes that bind read-only at `/icons/users/`.
Create separate upload secrets with `deploy/scripts/init-secrets.sh`. Mount only
the matching `image_upload_core_secret` or `image_upload_core2026_secret` on the
game server. The shared secrets stay server-side in Docker secrets; they are
not returned to browsers or forwarded to Cloudflare.
Run `deploy/scripts/init-secrets.sh` before the first Compose start so
`runtime-data/uploads` exists with permissions that allow the hook container to
write and the Nginx container to read. Back up this directory independently of
the Git repository when moving servers.
Legacy HTTP mutation is disabled by default. An emergency PHP rollback must
first stop `image-hook`, then create the ignored `hook/legacy-enabled` sentinel
in the legacy checkout before restoring its Caddy/Gitea route. Remove the
+16 -2
View File
@@ -4,7 +4,7 @@ services:
image-hook:
build:
context: ./node-hook
image: sam-image-hook:1.1.0
image: sam-image-hook:1.2.0
restart: unless-stopped
user: "${IMAGE_UID:-1000}:${IMAGE_GID:-1000}"
read_only: true
@@ -18,9 +18,13 @@ services:
IMAGE_ALLOWED_BRANCHES: ${IMAGE_ALLOWED_BRANCHES:-master}
IMAGE_PUBLIC_BASES: ${IMAGE_PUBLIC_BASES:-https://sam.hided.net/image,https://sam-image.hided.net}
IMAGE_STATE_PATH: /var/lib/image-hook/state.json
IMAGE_UPLOAD_ROOT: /var/lib/image-hook/uploads
IMAGE_UPLOAD_STATE_PATH: /var/lib/image-hook/upload-state.json
GITEA_WEBHOOK_SECRET_FILE: /run/secrets/gitea_webhook_secret
IMAGE_ADMIN_SECRET_FILE: /run/secrets/image_admin_secret
IMAGE_SYNC_CLIENT_SECRET_FILES: core=/run/secrets/image_sync_core_secret,core2026=/run/secrets/image_sync_core2026_secret
IMAGE_UPLOAD_CLIENT_SECRET_FILES: core=/run/secrets/image_upload_core_secret,core2026=/run/secrets/image_upload_core2026_secret
MAX_UPLOAD_BYTES: "51200"
volumes:
- type: bind
source: ${IMAGE_REPOSITORY_PATH:-.}
@@ -31,6 +35,8 @@ services:
- image_admin_secret
- image_sync_core_secret
- image_sync_core2026_secret
- image_upload_core_secret
- image_upload_core2026_secret
tmpfs:
- /tmp:size=16m,mode=1777
cap_drop: [ALL]
@@ -50,7 +56,7 @@ services:
image-web:
build:
context: ./deploy/nginx
image: sam-image-web:1.1.0
image: sam-image-web:1.2.0
restart: unless-stopped
depends_on:
image-hook:
@@ -65,6 +71,10 @@ services:
source: ${IMAGE_REPOSITORY_PATH:-.}
target: /srv/image
read_only: true
- type: bind
source: ./runtime-data/uploads
target: /srv/user-icons
read_only: true
tmpfs:
- /tmp:size=8m,mode=1777
cap_drop: [ALL]
@@ -95,3 +105,7 @@ secrets:
file: ${IMAGE_SYNC_CORE_SECRET_FILE:-./secrets/image_sync_core_secret}
image_sync_core2026_secret:
file: ${IMAGE_SYNC_CORE2026_SECRET_FILE:-./secrets/image_sync_core2026_secret}
image_upload_core_secret:
file: ${IMAGE_UPLOAD_CORE_SECRET_FILE:-./secrets/image_upload_core_secret}
image_upload_core2026_secret:
file: ${IMAGE_UPLOAD_CORE2026_SECRET_FILE:-./secrets/image_upload_core2026_secret}
@@ -69,6 +69,18 @@ http {
proxy_request_buffering on;
}
location ^~ /v1/uploads/user-icons/ {
limit_except PUT { deny all; }
client_max_body_size 50k;
proxy_pass http://image-hook:8081;
proxy_set_header Host $host;
proxy_set_header X-Image-Client $http_x_image_client;
proxy_set_header X-Image-Expires $http_x_image_expires;
proxy_set_header X-Image-Request-Id $http_x_image_request_id;
proxy_set_header X-Image-Signature $http_x_image_signature;
proxy_request_buffering on;
}
location ^~ /v1/admin/ { return 404; }
location = /image { return 404; }
location = /image/ { return 404; }
@@ -80,6 +92,15 @@ http {
add_header X-Content-Type-Options nosniff always;
}
location ^~ /icons/users/ {
alias /srv/user-icons/;
etag on;
expires 1y;
add_header Cache-Control "public, immutable" always;
add_header Access-Control-Allow-Origin "*" always;
add_header X-Content-Type-Options nosniff always;
}
location ^~ /icons/ {
try_files $uri =404;
add_header Access-Control-Allow-Origin "*" always;
+3 -1
View File
@@ -9,7 +9,8 @@ state_dir="$repository_dir/runtime-data"
umask 077
mkdir -p "$secret_dir"
mkdir -p "$state_dir"
for name in gitea_webhook_secret image_admin_secret image_sync_core_secret image_sync_core2026_secret; do
mkdir -p "$state_dir/uploads"
for name in gitea_webhook_secret image_admin_secret image_sync_core_secret image_sync_core2026_secret image_upload_core_secret image_upload_core2026_secret; do
path="$secret_dir/$name"
if [ ! -e "$path" ]; then
openssl rand -hex 32 > "$path"
@@ -17,5 +18,6 @@ for name in gitea_webhook_secret image_admin_secret image_sync_core_secret image
chmod 600 "$path"
done
chmod 700 "$state_dir"
chmod 755 "$state_dir/uploads"
echo "Secret files are ready in $secret_dir (values not printed)."
+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/,
);
});