이미지 업로드와 WebPush 응답의 자원 경계를 제한한다

This commit is contained in:
2026-10-09 07:09:23 +09:00
parent c5716cf144
commit 5771701007
18 changed files with 879 additions and 183 deletions
@@ -1,4 +1,4 @@
import { createHash, createHmac, randomUUID } from 'node:crypto';
import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload';
export interface UserIconUploadResult {
picture: string;
@@ -9,20 +9,6 @@ export interface UserIconUploadStore {
upload(input: { filename: string; contentType: string; body: Buffer }): Promise<UserIconUploadResult>;
}
const signature = (
secret: string,
expires: string,
requestId: string,
pathname: string,
contentType: string,
body: Buffer
): string => {
const digest = createHash('sha256').update(body).digest('hex');
return createHmac('sha256', secret)
.update(`${expires}.${requestId}.${pathname}.${contentType}.${digest}`)
.digest('hex');
};
export class RemoteUserIconStore implements UserIconUploadStore {
constructor(
private readonly baseUrl: string,
@@ -36,35 +22,17 @@ export class RemoteUserIconStore implements UserIconUploadStore {
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
throw new Error('Invalid user icon filename.');
}
const pathname = `/v1/uploads/user-icons/core2026/${input.filename}`;
const expires = String(Math.floor(this.now() / 1000) + 60);
const requestId = randomUUID();
const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, '')}${pathname}`, {
method: 'PUT',
headers: {
'content-type': input.contentType,
'x-image-client': 'core2026',
'x-image-expires': expires,
'x-image-request-id': requestId,
'x-image-signature': signature(
this.secret,
expires,
requestId,
pathname,
input.contentType,
input.body
),
await performSignedImageUpload(
{
...input,
kind: 'user-icons',
baseUrl: this.baseUrl,
secret: this.secret,
},
body: new Uint8Array(input.body),
});
if (!response.ok) {
throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
}
this.fetchImpl,
this.now
);
const picture = `users/core2026/${input.filename}`;
const payload: unknown = await response.json();
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== `icons/${picture}`) {
throw new Error('Image repository returned an unexpected upload path.');
}
return { picture, publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${picture}` };
}
}
+92 -44
View File
@@ -1,3 +1,9 @@
import {
ImageWorkBudget,
ImageWorkBusyError,
ImageUploadError,
isRasterImage,
} from '@sammo-ts/common/images/imageUpload';
import { randomBytes } from 'node:crypto';
import { TRPCError } from '@trpc/server';
@@ -13,6 +19,9 @@ import { isGatewaySessionCurrent } from '../auth/sessionValidity.js';
import { WEB_PUSH_EVENT_TYPES } from '@sammo-ts/common';
const zSessionToken = z.string().min(1);
const iconImageBudget = new ImageWorkBudget();
const MAX_IMAGE_PIXELS = 128 * 128 * 256;
const MAX_ICON_BYTES = 50 * 1024;
const MAX_ACTIVE_ICONS = 5;
const ICON_RETIRE_COOLDOWN_MS = 24 * 60 * 60 * 1000;
@@ -265,52 +274,91 @@ export const accountRouter = router({
)
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
const now = new Date();
const profiles = await listIconSyncProfiles(ctx, user.id);
const buffer = decodeImage(input.imageData);
const metadata = await sharp(buffer, { animated: true }).metadata();
const detectedFormat = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
if (!detectedFormat || !ALLOWED_ICON_FORMATS.has(detectedFormat)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
});
}
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
});
}
const extension = detectedFormat === 'jpeg' ? 'jpg' : detectedFormat;
const filename = `${randomBytes(16).toString('hex')}.${extension}`;
if (!ctx.userIconUpload) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
}
const uploaded = await ctx.userIconUpload.upload({
filename,
contentType: ICON_CONTENT_TYPES[extension]!,
body: buffer,
});
const stored = await ctx.users.addIconForWindow(user.id, uploaded.picture, 0, now, MAX_ACTIVE_ICONS);
if (!stored.ok) {
if (stored.reason === 'LIMIT') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.',
try {
return await iconImageBudget.run(async () => {
const now = new Date();
const profiles = await listIconSyncProfiles(ctx, user.id);
const buffer = decodeImage(input.imageData);
if (!isRasterImage(buffer))
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
const metadata = await sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
.timeout({ seconds: 10 })
.metadata()
.catch(() => {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
});
if ((metadata.pages ?? 1) > 256)
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 프레임 제한을 초과했습니다.' });
const detectedFormat = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
if (!detectedFormat || !ALLOWED_ICON_FORMATS.has(detectedFormat)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
});
}
if (
!metadata.width ||
metadata.width < 64 ||
metadata.width > 128 ||
metadata.height !== metadata.width
) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
});
}
const extension = detectedFormat === 'jpeg' ? 'jpg' : detectedFormat;
const filename = `${randomBytes(16).toString('hex')}.${extension}`;
if (!ctx.userIconUpload) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '이미지 저장소가 설정되지 않았습니다.',
});
}
const uploaded = await ctx.userIconUpload.upload({
filename,
contentType: ICON_CONTENT_TYPES[extension]!,
body: buffer,
});
}
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾을 수 없습니다.' });
const stored = await ctx.users.addIconForWindow(
user.id,
uploaded.picture,
0,
now,
MAX_ACTIVE_ICONS
);
if (!stored.ok) {
if (stored.reason === 'LIMIT') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.',
});
}
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾을 수 없습니다.' });
}
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
return {
ok: true,
iconUrl: uploaded.publicUrl,
revision: stored.revision,
icon: buildLibraryIcon(ctx, stored.icon),
profiles,
flushPublished,
};
});
} catch (error) {
if (error instanceof ImageWorkBusyError)
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: '이미지 처리 중입니다. 잠시 후 다시 시도해주세요.',
});
if (error instanceof ImageUploadError)
throw new TRPCError({
code: 'SERVICE_UNAVAILABLE',
message: '이미지 저장소에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.',
});
throw error;
}
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
return {
ok: true,
iconUrl: uploaded.publicUrl,
revision: stored.revision,
icon: buildLibraryIcon(ctx, stored.icon),
profiles,
flushPublished,
};
}),
setPreferredIcon: procedure
.input(z.object({ sessionToken: zSessionToken, iconId: z.string().uuid() }))
+4 -4
View File
@@ -1,3 +1,4 @@
import { sendBoundedNotification } from './sendNotification.js';
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
@@ -12,7 +13,7 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import webPush from 'web-push';
import { resolveGatewayProfileDisplayName } from '../profileOrder.js';
import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js';
import { parsePushEndpoint, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js';
export interface WebPushCoordinatorConfig {
enabled: boolean;
@@ -455,7 +456,7 @@ export class WebPushCoordinator {
try {
// 기존 DB 구독도 검사한다. DNS 검사는 실제 socket lookup에서 다시 수행한다.
parsePushEndpoint(delivery.subscription.endpoint);
await webPush.sendNotification(
await sendBoundedNotification(
{
endpoint: delivery.subscription.endpoint,
keys: { p256dh: delivery.subscription.p256dh, auth: delivery.subscription.auth },
@@ -465,8 +466,7 @@ export class WebPushCoordinator {
body: delivery.notification.body,
url: delivery.notification.url,
tag: delivery.notification.tag,
}),
{ TTL: 60 * 60, timeout: 10_000, agent: publicPushAgent }
})
);
await this.prisma.$executeRaw(GatewayPrisma.sql`
UPDATE web_push_delivery
@@ -0,0 +1,99 @@
import { request, type RequestOptions } from 'node:https';
import type { ClientRequest, IncomingMessage } from 'node:http';
import webPush from 'web-push';
import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError } from './endpointPolicy.js';
export class PushTransportError extends Error {
constructor(
message: string,
readonly statusCode = 0
) {
super(message);
}
}
export const MAX_PUSH_RESPONSE_BYTES = 16 * 1024;
type PushRequest = (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest;
/** Keep web-push encryption/VAPID, but bound the entire exchange and discard response content. */
export const sendPushRequest = (
details: webPush.RequestDetails,
requestImpl: PushRequest = request,
timeoutMs = 10_000
): Promise<void> =>
new Promise((resolve, reject) => {
const endpoint = parsePushEndpoint(details.endpoint);
let response: IncomingMessage | undefined;
let req: ClientRequest | undefined;
let settled = false;
const finish = (error?: PushTransportError | PushEndpointPolicyError) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (error) {
response?.destroy();
req?.destroy();
reject(error);
} else resolve();
};
// Socket inactivity timeouts can be bypassed by a response that drips bytes forever.
const timer = setTimeout(() => finish(new PushTransportError('Push service request timed out.')), timeoutMs);
try {
req = requestImpl(
{
protocol: 'https:',
hostname: endpoint.hostname,
port: 443,
path: `${endpoint.pathname}${endpoint.search}`,
method: details.method,
headers: details.headers,
agent: publicPushAgent,
},
(incoming) => {
response = incoming;
if (settled) {
incoming.destroy();
return;
}
if (Number(incoming.headers['content-length'] ?? 0) > MAX_PUSH_RESPONSE_BYTES) {
finish(new PushTransportError('Push service response exceeds its size limit.'));
return;
}
let bytes = 0;
incoming.on('data', (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > MAX_PUSH_RESPONSE_BYTES)
finish(new PushTransportError('Push service response exceeds its size limit.'));
});
incoming.on('error', () => finish(new PushTransportError('Push service response failed.')));
incoming.on('aborted', () => finish(new PushTransportError('Push service response failed.')));
incoming.on('end', () => {
const status = incoming.statusCode ?? 0;
finish(
status >= 200 && status < 300
? undefined
: new PushTransportError(`Push service returned HTTP ${status}.`, status)
);
});
}
);
req.on('error', (error) =>
finish(
error instanceof PushEndpointPolicyError
? error
: new PushTransportError('Push service request failed.')
)
);
req.end(details.body ?? undefined);
} catch {
finish(new PushTransportError('Push service request failed.'));
}
});
export const sendBoundedNotification = async (
subscription: webPush.PushSubscription,
payload: string
): Promise<void> => {
parsePushEndpoint(subscription.endpoint);
const details = webPush.generateRequestDetails(subscription, payload, { TTL: 60 * 60 });
await sendPushRequest(details);
};