이미지 업로드와 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
+128 -63
View File
@@ -1,3 +1,9 @@
import {
ImageWorkBudget,
ImageWorkBusyError,
ImageUploadError,
isRasterImage,
} from '@sammo-ts/common/images/imageUpload';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
@@ -7,6 +13,9 @@ import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js'; import { resolveSecretPermission } from '../shared/secretPermission.js';
const boardImageBudget = new ImageWorkBudget();
const MAX_IMAGE_PIXELS = 16 * 1024 * 1024;
const MAX_UPLOAD_BYTES = 1024 * 1024; const MAX_UPLOAD_BYTES = 1024 * 1024;
const MAX_LONG_EDGE = 2048; const MAX_LONG_EDGE = 2048;
const WEBP_QUALITY = 80; const WEBP_QUALITY = 80;
@@ -57,7 +66,7 @@ const buildWebpBuffer = async (
buffer: Buffer, buffer: Buffer,
{ animated, resize }: { animated: boolean; resize: boolean } { animated, resize }: { animated: boolean; resize: boolean }
): Promise<Buffer> => { ): Promise<Buffer> => {
let pipeline = sharp(buffer, { animated: true }); let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 });
if (resize) { if (resize) {
pipeline = pipeline.resize({ pipeline = pipeline.resize({
width: MAX_LONG_EDGE, width: MAX_LONG_EDGE,
@@ -74,11 +83,16 @@ const buildWebpBuffer = async (
...(animated ? { animated: true } : {}), ...(animated ? { animated: true } : {}),
}; };
return pipeline.webp(webpOptions).toBuffer(); return pipeline
.webp(webpOptions)
.toBuffer()
.catch(() => {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지를 변환할 수 없습니다.' });
});
}; };
const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer> => { const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer> => {
let pipeline = sharp(buffer, { animated: true }); let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 });
if (resize) { if (resize) {
pipeline = pipeline.resize({ pipeline = pipeline.resize({
width: MAX_LONG_EDGE, width: MAX_LONG_EDGE,
@@ -87,7 +101,12 @@ const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer>
withoutEnlargement: true, withoutEnlargement: true,
}); });
} }
return pipeline.avif({ quality: 60, effort: 4 }).toBuffer(); return pipeline
.avif({ quality: 60, effort: 4 })
.toBuffer()
.catch(() => {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지를 변환할 수 없습니다.' });
});
}; };
export const boardRouter = router({ export const boardRouter = router({
@@ -213,70 +232,116 @@ export const boardRouter = router({
return { id: comment.id }; return { id: comment.id };
}), }),
uploadImage: authedProcedure.input(z.object({ dataUrl: z.string().min(1) })).mutation(async ({ ctx, input }) => { uploadImage: authedProcedure
const { permission } = await getBoardActor(ctx); .input(
assertBoardAccess(permission, true); z.object({
dataUrl: z
.string()
.min(1)
.max(Math.ceil(MAX_UPLOAD_BYTES / 3) * 4 + 128),
})
)
.mutation(async ({ ctx, input }) => {
const { permission } = await getBoardActor(ctx);
assertBoardAccess(permission, true);
const buffer = parseDataUrl(input.dataUrl); try {
if (buffer.length > MAX_UPLOAD_BYTES) { return await boardImageBudget.run(async () => {
throw new TRPCError({ code: 'PAYLOAD_TOO_LARGE', message: '이미지 용량 제한(1MB)을 초과했습니다.' }); const buffer = parseDataUrl(input.dataUrl);
} if (buffer.length > MAX_UPLOAD_BYTES) {
throw new TRPCError({
code: 'PAYLOAD_TOO_LARGE',
message: '이미지 용량 제한(1MB)을 초과했습니다.',
});
}
const metadata = await sharp(buffer, { animated: true }).metadata(); if (!isRasterImage(buffer))
if (!metadata.format || !metadata.width || !metadata.height) { throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
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: '이미지 프레임 제한을 초과했습니다.' });
if (!metadata.format || !metadata.width || !metadata.height) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
}
const format = metadata.format; const format = metadata.format;
const isAvif = metadata.mediaType === 'image/avif'; const isAvif = metadata.mediaType === 'image/avif';
const isAnimated = (metadata.pages ?? 1) > 1; const isAnimated = (metadata.pages ?? 1) > 1;
const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE; const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE;
const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']); const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']);
if (!allowed.has(format)) { if (!allowed.has(format)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
} }
let outputBuffer = buffer; let outputBuffer = buffer;
let outputFormat = isAvif ? 'avif' : 'webp'; let outputFormat = isAvif ? 'avif' : 'webp';
if (isAvif) { if (isAvif) {
if (needsResize) { if (needsResize) {
outputBuffer = await buildAvifBuffer(buffer, true); outputBuffer = await buildAvifBuffer(buffer, true);
}
} else {
const webpBuffer = await buildWebpBuffer(buffer, {
animated: isAnimated || format === 'gif',
resize: needsResize,
});
if (
format === 'webp' &&
!needsResize &&
webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO
) {
outputBuffer = buffer;
outputFormat = 'webp';
} else {
outputBuffer = webpBuffer;
outputFormat = 'webp';
}
}
if (!ctx.contentImageUpload) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '이미지 저장소가 설정되지 않았습니다.',
});
}
const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`;
const uploaded = await ctx.contentImageUpload.upload({
filename,
contentType: outputFormat === 'avif' ? 'image/avif' : 'image/webp',
body: outputBuffer,
});
const outputMeta = await sharp(outputBuffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
.timeout({ seconds: 10 })
.metadata();
return {
url: uploaded.publicUrl,
width: outputMeta.width ?? metadata.width,
height: outputMeta.height ?? metadata.height,
format: outputFormat,
animated: isAnimated,
size: outputBuffer.length,
};
});
} 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;
} }
} else { }),
const webpBuffer = await buildWebpBuffer(buffer, {
animated: isAnimated || format === 'gif',
resize: needsResize,
});
if (format === 'webp' && !needsResize && webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO) {
outputBuffer = buffer;
outputFormat = 'webp';
} else {
outputBuffer = webpBuffer;
outputFormat = 'webp';
}
}
if (!ctx.contentImageUpload) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
}
const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`;
const uploaded = await ctx.contentImageUpload.upload({
filename,
contentType: outputFormat === 'avif' ? 'image/avif' : 'image/webp',
body: outputBuffer,
});
const outputMeta = await sharp(outputBuffer, { animated: true }).metadata();
return {
url: uploaded.publicUrl,
width: outputMeta.width ?? metadata.width,
height: outputMeta.height ?? metadata.height,
format: outputFormat,
animated: isAnimated,
size: outputBuffer.length,
};
}),
}); });
@@ -1,4 +1,4 @@
import { createHash, createHmac, randomUUID } from 'node:crypto'; import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload';
export interface ContentImageUploadResult { export interface ContentImageUploadResult {
publicUrl: string; publicUrl: string;
@@ -21,32 +21,16 @@ export class RemoteContentImageStore implements ContentImageUploadStore {
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) { if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
throw new Error('Invalid content image filename.'); throw new Error('Invalid content image filename.');
} }
const pathname = `/v1/uploads/content/core2026/${input.filename}`; await performSignedImageUpload(
const expires = String(Math.floor(this.now() / 1000) + 60); {
const requestId = randomUUID(); ...input,
const digest = createHash('sha256').update(input.body).digest('hex'); kind: 'content',
const signature = createHmac('sha256', this.secret) baseUrl: this.baseUrl,
.update(`${expires}.${requestId}.${pathname}.${input.contentType}.${digest}`) secret: this.secret,
.digest('hex');
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,
}, },
body: new Uint8Array(input.body), this.fetchImpl,
}); this.now
if (!response.ok) { );
throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
}
const expectedPath = `uploads/core2026/${input.filename}`;
const payload: unknown = await response.json();
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== expectedPath) {
throw new Error('Image repository returned an unexpected content path.');
}
return { publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${input.filename}` }; return { publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${input.filename}` };
} }
} }
+75
View File
@@ -288,6 +288,81 @@ describe('board router actor, nation, and secret permissions', () => {
expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false }); expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false });
}); });
it('rejects highly compressed images over the decoded pixel budget before upload', async () => {
const upload = vi.fn(async () => ({ publicUrl: 'https://image.example/image.webp' }));
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
const png = await sharp({ create: { width: 4096, height: 4097, channels: 3, background: '#224466' } })
.png()
.toBuffer();
expect(png.length).toBeLessThan(1024 * 1024);
await expect(
appRouter.createCaller(fixture.context).board.uploadImage({
dataUrl: `data:image/png;base64,${png.toString('base64')}`,
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(upload).not.toHaveBeenCalled();
});
it('rejects excess concurrent image work and recovers when uploads settle', async () => {
const pending: Array<() => void> = [];
const upload = vi.fn(
() =>
new Promise<{ publicUrl: string }>((resolve) => {
pending.push(() => resolve({ publicUrl: 'https://image.example/image.webp' }));
})
);
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
const png = await sharp({ create: { width: 64, height: 48, channels: 3, background: '#224466' } })
.png()
.toBuffer();
const caller = appRouter.createCaller(fixture.context);
const input = { dataUrl: `data:image/png;base64,${png.toString('base64')}` };
const admitted = Array.from({ length: 4 }, () => caller.board.uploadImage(input));
try {
await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(4));
await expect(caller.board.uploadImage(input)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
} finally {
pending.forEach((done) => done());
await Promise.all(admitted);
}
upload.mockImplementation(async () => ({ publicUrl: 'https://image.example/image.webp' }));
await expect(caller.board.uploadImage(input)).resolves.toMatchObject({ format: 'webp' });
});
it('rejects SVG before a native image parser or upload is used', async () => {
const upload = vi.fn();
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
await expect(
appRouter.createCaller(fixture.context).board.uploadImage({
dataUrl: Buffer.from('<svg width="64" height="64"><script>alert(1)</script></svg>').toString('base64'),
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(upload).not.toHaveBeenCalled();
});
it('rejects excessive animation frames while preserving ordinary GIF animation', async () => {
const upload = vi.fn(async () => ({ publicUrl: 'https://image.example/image.webp' }));
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
const caller = appRouter.createCaller(fixture.context);
const makeGif = (pages: number) =>
sharp(Buffer.alloc(pages * 4, 255), {
raw: { width: 1, height: pages, channels: 4, pageHeight: 1 },
})
.gif({ keepDuplicateFrames: true, delay: Array(pages).fill(10) })
.toBuffer();
const excessive = await makeGif(257);
expect((await sharp(excessive, { animated: true }).metadata()).pages).toBe(257);
await expect(caller.board.uploadImage({ dataUrl: excessive.toString('base64') })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(upload).not.toHaveBeenCalled();
const ordinary = await makeGif(3);
await expect(caller.board.uploadImage({ dataUrl: ordinary.toString('base64') })).resolves.toMatchObject({
animated: true,
format: 'webp',
});
});
it('preserves an AVIF editor image when resizing is unnecessary', async () => { it('preserves an AVIF editor image when resizing is unnecessary', async () => {
const upload = vi.fn(async ({ filename }: { filename: string }) => ({ const upload = vi.fn(async ({ filename }: { filename: string }) => ({
publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`, publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`,
@@ -36,4 +36,15 @@ describe('remote content image store', () => {
expect(String(captured?.input)).toBe(`https://sam-image.hided.net${pathname}`); expect(String(captured?.input)).toBe(`https://sam-image.hided.net${pathname}`);
expect(Object.values(headers)).not.toContain(secret); expect(Object.values(headers)).not.toContain(secret);
}); });
it('rejects oversized upload response JSON before trusting its path', async () => {
const filename = `${'d'.repeat(32)}.png`;
const store = new RemoteContentImageStore(
'https://image.example',
'https://image.example/public',
'synthetic-secret',
async () =>
new Response(JSON.stringify({ path: `uploads/core2026/${filename}`, extra: 'x'.repeat(20_000) }))
);
await expect(store.upload({ filename, contentType: 'image/png', body: Buffer.from('x') })).rejects.toThrow();
});
}); });
@@ -1,3 +1,4 @@
import sharp from 'sharp';
import { buildGameEventChannel } from '@sammo-ts/common'; import { buildGameEventChannel } from '@sammo-ts/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { projectCurrentGeneral } from '../src/router/playAudit/projection.js'; import { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
@@ -821,6 +822,28 @@ integration('game API security over HTTP transport', () => {
} }
}); });
it('image upload enforces real HTTP authentication, officer authority and decoded pixel limits', async () => {
const input = { dataUrl: 'data:image/png;base64,AA==' };
const unauthenticated = await requestTrpc('board.uploadImage', { method: 'POST', input });
expect(unauthenticated.response.status).toBe(401);
const ordinary = await createAccessToken('image-ordinary', {}, ordinaryUserId);
const denied = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: ordinary });
expect(denied.response.status).toBe(403);
const officer = await createAccessToken('image-officer', {});
const malformed = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: officer });
expect(malformed.response.status).toBe(400);
const png = await sharp({ create: { width: 4096, height: 4097, channels: 3, background: '#224466' } })
.png()
.toBuffer();
const oversized = await requestTrpc('board.uploadImage', {
method: 'POST',
accessToken: officer,
input: { dataUrl: `data:image/png;base64,${png.toString('base64')}` },
});
expect(oversized.response.status).toBe(400);
expect(oversized.body).toMatchObject({ error: { data: { code: 'BAD_REQUEST' } } });
});
it('accepts an authenticated query from a POST JSON body', async () => { it('accepts an authenticated query from a POST JSON body', async () => {
const accessToken = await createAccessToken('json-query-body', {}); const accessToken = await createAccessToken('json-query-body', {});
const general = await requestTrpc('general.me', { const general = await requestTrpc('general.me', {
@@ -1,4 +1,4 @@
import { createHash, createHmac, randomUUID } from 'node:crypto'; import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload';
export interface UserIconUploadResult { export interface UserIconUploadResult {
picture: string; picture: string;
@@ -9,20 +9,6 @@ export interface UserIconUploadStore {
upload(input: { filename: string; contentType: string; body: Buffer }): Promise<UserIconUploadResult>; 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 { export class RemoteUserIconStore implements UserIconUploadStore {
constructor( constructor(
private readonly baseUrl: string, 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)) { if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
throw new Error('Invalid user icon filename.'); throw new Error('Invalid user icon filename.');
} }
const pathname = `/v1/uploads/user-icons/core2026/${input.filename}`; await performSignedImageUpload(
const expires = String(Math.floor(this.now() / 1000) + 60); {
const requestId = randomUUID(); ...input,
const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, '')}${pathname}`, { kind: 'user-icons',
method: 'PUT', baseUrl: this.baseUrl,
headers: { secret: this.secret,
'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
),
}, },
body: new Uint8Array(input.body), this.fetchImpl,
}); this.now
if (!response.ok) { );
throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
}
const picture = `users/core2026/${input.filename}`; 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}` }; 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 { randomBytes } from 'node:crypto';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
@@ -13,6 +19,9 @@ import { isGatewaySessionCurrent } from '../auth/sessionValidity.js';
import { WEB_PUSH_EVENT_TYPES } from '@sammo-ts/common'; import { WEB_PUSH_EVENT_TYPES } from '@sammo-ts/common';
const zSessionToken = z.string().min(1); 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_ICON_BYTES = 50 * 1024;
const MAX_ACTIVE_ICONS = 5; const MAX_ACTIVE_ICONS = 5;
const ICON_RETIRE_COOLDOWN_MS = 24 * 60 * 60 * 1000; const ICON_RETIRE_COOLDOWN_MS = 24 * 60 * 60 * 1000;
@@ -265,52 +274,91 @@ export const accountRouter = router({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken); const user = await requireSessionUser(ctx, input.sessionToken);
const now = new Date(); try {
const profiles = await listIconSyncProfiles(ctx, user.id); return await iconImageBudget.run(async () => {
const buffer = decodeImage(input.imageData); const now = new Date();
const metadata = await sharp(buffer, { animated: true }).metadata(); const profiles = await listIconSyncProfiles(ctx, user.id);
const detectedFormat = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format; const buffer = decodeImage(input.imageData);
if (!detectedFormat || !ALLOWED_ICON_FORMATS.has(detectedFormat)) { if (!isRasterImage(buffer))
throw new TRPCError({ throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
code: 'BAD_REQUEST', const metadata = await sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.', .timeout({ seconds: 10 })
}); .metadata()
} .catch(() => {
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) { throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
throw new TRPCError({ });
code: 'BAD_REQUEST', if ((metadata.pages ?? 1) > 256)
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.', throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 프레임 제한을 초과했습니다.' });
}); const detectedFormat = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
} if (!detectedFormat || !ALLOWED_ICON_FORMATS.has(detectedFormat)) {
const extension = detectedFormat === 'jpeg' ? 'jpg' : detectedFormat; throw new TRPCError({
const filename = `${randomBytes(16).toString('hex')}.${extension}`; code: 'BAD_REQUEST',
if (!ctx.userIconUpload) { message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' }); });
} }
const uploaded = await ctx.userIconUpload.upload({ if (
filename, !metadata.width ||
contentType: ICON_CONTENT_TYPES[extension]!, metadata.width < 64 ||
body: buffer, metadata.width > 128 ||
}); metadata.height !== metadata.width
const stored = await ctx.users.addIconForWindow(user.id, uploaded.picture, 0, now, MAX_ACTIVE_ICONS); ) {
if (!stored.ok) { throw new TRPCError({
if (stored.reason === 'LIMIT') { code: 'BAD_REQUEST',
throw new TRPCError({ message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
code: 'PRECONDITION_FAILED', });
message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.', }
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(
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾을 수 없습니다.' }); 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 setPreferredIcon: procedure
.input(z.object({ sessionToken: zSessionToken, iconId: z.string().uuid() })) .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 { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks'; import { performance } from 'node:perf_hooks';
@@ -12,7 +13,7 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import webPush from 'web-push'; import webPush from 'web-push';
import { resolveGatewayProfileDisplayName } from '../profileOrder.js'; import { resolveGatewayProfileDisplayName } from '../profileOrder.js';
import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js'; import { parsePushEndpoint, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js';
export interface WebPushCoordinatorConfig { export interface WebPushCoordinatorConfig {
enabled: boolean; enabled: boolean;
@@ -455,7 +456,7 @@ export class WebPushCoordinator {
try { try {
// 기존 DB 구독도 검사한다. DNS 검사는 실제 socket lookup에서 다시 수행한다. // 기존 DB 구독도 검사한다. DNS 검사는 실제 socket lookup에서 다시 수행한다.
parsePushEndpoint(delivery.subscription.endpoint); parsePushEndpoint(delivery.subscription.endpoint);
await webPush.sendNotification( await sendBoundedNotification(
{ {
endpoint: delivery.subscription.endpoint, endpoint: delivery.subscription.endpoint,
keys: { p256dh: delivery.subscription.p256dh, auth: delivery.subscription.auth }, keys: { p256dh: delivery.subscription.p256dh, auth: delivery.subscription.auth },
@@ -465,8 +466,7 @@ export class WebPushCoordinator {
body: delivery.notification.body, body: delivery.notification.body,
url: delivery.notification.url, url: delivery.notification.url,
tag: delivery.notification.tag, tag: delivery.notification.tag,
}), })
{ TTL: 60 * 60, timeout: 10_000, agent: publicPushAgent }
); );
await this.prisma.$executeRaw(GatewayPrisma.sql` await this.prisma.$executeRaw(GatewayPrisma.sql`
UPDATE web_push_delivery 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);
};
+40
View File
@@ -1527,6 +1527,46 @@ describe('account self service', () => {
} }
}); });
it('bounds pending icon uploads without changing the owned library limit', async () => {
const { caller, users, sessions, userIconUpload } = buildCaller();
const user = await users.createUser({ username: 'icon-budget', password: 'current-password' });
const session = await sessions.createSession(user);
const png = await sharp({ create: { width: 64, height: 64, channels: 3, background: '#224466' } })
.png()
.toBuffer();
const input = {
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
};
const pending: Array<() => void> = [];
userIconUpload.upload.mockImplementation(
({ filename }) =>
new Promise((resolve) => {
pending.push(() =>
resolve({
picture: `users/core2026/${filename}`,
publicUrl: `https://image.example/${filename}`,
})
);
})
);
const admitted = Array.from({ length: 4 }, () => caller.account.changeIcon(input));
try {
await vi.waitFor(() => expect(userIconUpload.upload).toHaveBeenCalledTimes(4));
await expect(caller.account.changeIcon(input)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
} finally {
pending.forEach((done) => done());
await Promise.all(admitted);
}
expect(await users.listIcons(user.id)).toHaveLength(4);
userIconUpload.upload.mockImplementation(async ({ filename }) => ({
picture: `users/core2026/${filename}`,
publicUrl: `https://image.example/${filename}`,
}));
await expect(caller.account.changeIcon(input)).resolves.toMatchObject({ ok: true });
expect(await users.listIcons(user.id)).toHaveLength(5);
});
it('accepts concurrent uploads until the five-icon limit is reached', async () => { it('accepts concurrent uploads until the five-icon limit is reached', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-')); const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-'));
try { try {
@@ -40,9 +40,7 @@ describe('remote user icon store', () => {
const pathname = `/v1/uploads/user-icons/core2026/${'a'.repeat(32)}.png`; const pathname = `/v1/uploads/user-icons/core2026/${'a'.repeat(32)}.png`;
const digest = createHash('sha256').update(body).digest('hex'); const digest = createHash('sha256').update(body).digest('hex');
const expected = createHmac('sha256', secret) const expected = createHmac('sha256', secret)
.update( .update(`${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/png.${digest}`)
`${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/png.${digest}`
)
.digest('hex'); .digest('hex');
expect(headers['x-image-signature']).toBe(expected); expect(headers['x-image-signature']).toBe(expected);
}); });
@@ -58,4 +56,15 @@ describe('remote user icon store', () => {
store.upload({ filename: `${'b'.repeat(32)}.png`, contentType: 'image/png', body: Buffer.from('x') }) store.upload({ filename: `${'b'.repeat(32)}.png`, contentType: 'image/png', body: Buffer.from('x') })
).rejects.toThrow('HTTP 401'); ).rejects.toThrow('HTTP 401');
}); });
it('rejects oversized upload response JSON before trusting its path', async () => {
const filename = `${'d'.repeat(32)}.png`;
const store = new RemoteUserIconStore(
'https://image.example',
'https://image.example/public',
'synthetic-secret',
async () =>
new Response(JSON.stringify({ path: `icons/users/core2026/${filename}`, extra: 'x'.repeat(20_000) }))
);
await expect(store.upload({ filename, contentType: 'image/png', body: Buffer.from('x') })).rejects.toThrow();
});
}); });
@@ -1,3 +1,4 @@
import * as pushSender from '../src/webPush/sendNotification.js';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import webPush from 'web-push'; import webPush from 'web-push';
@@ -214,7 +215,7 @@ integration('web push Gateway persistence boundary', () => {
notificationId: notification.id, notificationId: notification.id,
}, },
}); });
const sender = vi.spyOn(webPush, 'sendNotification').mockRejectedValue({ statusCode: 410 }); const sender = vi.spyOn(pushSender, 'sendBoundedNotification').mockRejectedValue({ statusCode: 410 });
try { try {
coordinator.start(); coordinator.start();
await vi.waitFor( await vi.waitFor(
@@ -0,0 +1,98 @@
import { Agent, request as httpsRequest } from 'node:https';
import { createPublicPushLookup, PushEndpointPolicyError } from '../src/webPush/endpointPolicy.js';
import { createServer, request, type Server } from 'node:http';
import { once } from 'node:events';
import { describe, expect, it } from 'vitest';
import { sendPushRequest } from '../src/webPush/sendNotification.js';
const details = {
endpoint: 'https://push.example/subscription',
method: 'POST' as const,
headers: {},
body: Buffer.from('encrypted'),
};
const listen = async (server: Server) => {
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return (server.address() as { port: number }).port;
};
const close = (server: Server) =>
new Promise<void>((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
});
const transport =
(port: number): Parameters<typeof sendPushRequest>[1] =>
(options, callback) =>
request({ ...options, protocol: 'http:', hostname: '127.0.0.1', port, agent: undefined }, callback);
describe('bounded WebPush response transport', () => {
it('accepts normal success and preserves terminal HTTP status without retaining sensitive response bodies', async () => {
let status = 201;
const server = createServer((_, res) => {
res.writeHead(status);
res.end('private upstream details');
});
const port = await listen(server);
try {
await expect(sendPushRequest(details, transport(port))).resolves.toBeUndefined();
status = 410;
await expect(sendPushRequest(details, transport(port))).rejects.toMatchObject({
statusCode: 410,
message: 'Push service returned HTTP 410.',
});
} finally {
await close(server);
}
});
it.each([true, false])('rejects oversized response with declared length %s', async (declared) => {
const server = createServer((_, res) => {
if (declared) res.setHeader('content-length', 20_000);
res.end(Buffer.alloc(20_000));
});
const port = await listen(server);
try {
await expect(sendPushRequest(details, transport(port))).rejects.toThrow('size limit');
} finally {
await close(server);
}
});
it('ends a continuously dripping response on the whole exchange deadline', async () => {
const server = createServer((_, res) => {
const timer = setInterval(() => res.write('x'), 10);
res.on('close', () => clearInterval(timer));
});
const port = await listen(server);
try {
await expect(sendPushRequest(details, transport(port), 100)).rejects.toThrow('timed out');
} finally {
await close(server);
}
});
it('preserves terminal public-address policy failures from actual socket lookup', async () => {
const agent = new Agent({ lookup: createPublicPushLookup(async () => [{ address: '127.0.0.1', family: 4 }]) });
try {
await expect(
sendPushRequest(details, (options, callback) => httpsRequest({ ...options, agent }, callback))
).rejects.toBeInstanceOf(PushEndpointPolicyError);
} finally {
agent.destroy();
}
});
it('does not follow redirects', async () => {
let calls = 0;
const server = createServer((_, res) => {
calls++;
res.writeHead(307, { location: 'https://elsewhere.example' });
res.end();
});
const port = await listen(server);
try {
await expect(sendPushRequest(details, transport(port))).rejects.toMatchObject({ statusCode: 307 });
expect(calls).toBe(1);
} finally {
await close(server);
}
});
});
+28
View File
@@ -744,3 +744,31 @@ NDJSON frame 512 KiB/stream 64 MiB, 느린 소비자의 write buffer 1 MiB를
실패합니다. 해당 host의 wrapper와 정확한 project를 사용하고 기존 volume·active 실패합니다. 해당 host의 wrapper와 정확한 project를 사용하고 기존 volume·active
release를 보존합니다. 외부 공개 stack과 임시 localhost stack의 token 파일은 release를 보존합니다. 외부 공개 stack과 임시 localhost stack의 token 파일은
공유하지 않습니다. 임시 host의 절차는 상위 local-runtime README를 따릅니다. 공유하지 않습니다. 임시 host의 절차는 상위 local-runtime README를 따릅니다.
### 이미지 업로드와 WebPush 전송 경계
게시판 이미지의 기존 1 MiB 입력·2048px 변환 정책, AVIF 원본 보존과 WebP 변환,
계정 아이콘의 50 KiB·64~128px 정사각형·활성 5개 정책은 유지한다.
서버 자원 보호를 위해 게시판은 총 16,777,216 입력 픽셀, 아이콘은 총 4,194,304
입력 픽셀, 양쪽은 최대 256 프레임을 허용한다. raster signature를 먼저 확인하고
Sharp의 `limitInputPixels`와 처리 timeout 10초를 적용한다. 각 API 프로세스의
이미지 작업은 최대 4개이며 대기열 없이 초과 요청을 `TOO_MANY_REQUESTS`로 거절한다.
실제 작업이 끝나기 전에는 슬롯을 반환하지 않는다. 이 제한은 일일 업로드 횟수나
아이콘 소유권 정책을 변경하지 않는다. 기존 Fastify HTTP body limit도 유지한다.
공통 server 전용 `@sammo-ts/common/images/imageUpload`가 두 이미지 저장 adapter의
서명 PUT 전송을 처리한다. 기존 60초 HMAC·path/body binding을 유지하며 redirect를
거부하고, 요청부터 응답 완료까지 10초, JSON 응답 16 KiB, 전송 body 16 MiB를
제한한다. 실제 streaming bytes를 세므로 Content-Length가 없거나 부정확해도 제한이
적용된다. 응답 path만 검증하고 공개 URL은 설정한 public base에서 생성한다.
abort를 무시하는 작업도 실제 종료까지 최대 4개 전송 슬롯을 점유한다.
저장소 실패는 API에서 `SERVICE_UNAVAILABLE`, 작업 포화는 `TOO_MANY_REQUESTS`이다.
WebPush는 `web-push.generateRequestDetails`의 암호화·VAPID·TTL을 유지하면서
`sendNotification.ts`에서 HTTPS 전송을 직접 수행한다. 기존 public DNS/socket lookup
정책을 유지하고 redirect를 따르지 않는다. 전체 요청 10초와 실제 응답 16 KiB를
제한하며 응답 body를 저장하지 않는다. HTTP status는 기존 404/410 subscription
비활성화와 재시도 판단에 전달된다. 일반 전송 오류는 일시 재시도 대상으로 유지한다.
이 값에는 추가 `.env`나 credential이 필요 없다. VAPID가 없는 설치의 WebPush는
기존대로 비활성 상태이며, 이 경우 실제 외부 push 성공 검증을 대신하지 않는다.
+4
View File
@@ -61,6 +61,10 @@
"./util/JosaUtil": { "./util/JosaUtil": {
"types": "./dist/util/JosaUtil.d.ts", "types": "./dist/util/JosaUtil.d.ts",
"default": "./dist/util/JosaUtil.js" "default": "./dist/util/JosaUtil.js"
},
"./images/imageUpload": {
"types": "./dist/images/imageUpload.d.ts",
"default": "./dist/images/imageUpload.js"
} }
}, },
"scripts": { "scripts": {
+143
View File
@@ -0,0 +1,143 @@
import { createHash, createHmac, randomUUID } from 'node:crypto';
export class ImageWorkBusyError extends Error {
constructor() {
super('Image service is busy.');
}
}
/** Admission lasts until actual work settles, including work that ignores abort. No queue. */
export class ImageWorkBudget {
private pending = 0;
constructor(private readonly limit = 4) {}
async run<T>(work: () => Promise<T>): Promise<T> {
if (this.pending >= this.limit) throw new ImageWorkBusyError();
this.pending++;
try {
return await work();
} finally {
this.pending--;
}
}
}
export class ImageUploadError extends Error {}
const uploadBudget = new ImageWorkBudget();
export const MAX_IMAGE_RESPONSE_BYTES = 16 * 1024;
export const IMAGE_UPLOAD_TIMEOUT_MS = 10_000;
export const isRasterImage = (body: Buffer): boolean =>
body.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
(body[0] === 255 && body[1] === 216 && body[2] === 255) ||
['GIF87a', 'GIF89a'].includes(body.toString('ascii', 0, 6)) ||
(body.toString('ascii', 0, 4) === 'RIFF' && body.toString('ascii', 8, 12) === 'WEBP') ||
body.toString('ascii', 0, 2) === 'BM' ||
['49492a00', '4d4d002a', '49492b00', '4d4d002b'].includes(body.subarray(0, 4).toString('hex')) ||
(body.length >= 12 && body.toString('ascii', 4, 8) === 'ftyp');
export const performSignedImageUpload = async (
input: {
kind: 'content' | 'user-icons';
baseUrl: string;
filename: string;
contentType: string;
body: Buffer;
secret: string;
},
fetchImpl: typeof fetch = fetch,
now: () => number = Date.now,
options: { timeoutMs?: number; budget?: ImageWorkBudget } = {}
): Promise<void> => {
const base = new URL(input.baseUrl);
if (
!['http:', 'https:'].includes(base.protocol) ||
base.username ||
base.password ||
base.search ||
base.hash ||
!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename) ||
input.body.length > 16 * 1024 * 1024
) {
throw new ImageUploadError('Invalid image upload.');
}
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const work = (options.budget ?? uploadBudget).run(async () => {
const pathname = `/v1/uploads/${input.kind}/core2026/${input.filename}`;
const expires = String(Math.floor(now() / 1000) + 60);
const requestId = randomUUID();
const digest = createHash('sha256').update(input.body).digest('hex');
const signature = createHmac('sha256', input.secret)
.update(`${expires}.${requestId}.${pathname}.${input.contentType}.${digest}`)
.digest('hex');
const response = await fetchImpl(`${input.baseUrl.replace(/\/$/, '')}${pathname}`, {
method: 'PUT',
redirect: 'error',
signal: controller.signal,
headers: {
'content-type': input.contentType,
'x-image-client': 'core2026',
'x-image-expires': expires,
'x-image-request-id': requestId,
'x-image-signature': signature,
},
body: new Uint8Array(input.body),
});
if (controller.signal.aborted || !response.ok) {
await response.body?.cancel();
throw new ImageUploadError(
controller.signal.aborted
? 'Image upload timed out.'
: `Image repository upload failed with HTTP ${response.status}.`
);
}
const declared = Number(response.headers.get('content-length') ?? 0);
if (declared > MAX_IMAGE_RESPONSE_BYTES) {
await response.body?.cancel();
throw new ImageUploadError('Image response exceeds its size limit.');
}
const reader = response.body?.getReader();
if (!reader) throw new ImageUploadError('Image repository returned an invalid response.');
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const chunk = await reader.read();
if (controller.signal.aborted) throw new ImageUploadError('Image upload timed out.');
if (chunk.done) break;
total += chunk.value.byteLength;
if (total > MAX_IMAGE_RESPONSE_BYTES)
throw new ImageUploadError('Image response exceeds its size limit.');
chunks.push(chunk.value);
}
} catch (error) {
await reader.cancel();
throw error;
} finally {
reader.releaseLock();
}
const payload: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'));
const expected =
input.kind === 'content' ? `uploads/core2026/${input.filename}` : `icons/users/core2026/${input.filename}`;
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== expected) {
throw new ImageUploadError('Image repository returned an unexpected upload path.');
}
});
try {
await Promise.race([
work,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new ImageUploadError('Image upload timed out.'));
}, options.timeoutMs ?? IMAGE_UPLOAD_TIMEOUT_MS);
}),
]);
} catch (error) {
if (error instanceof ImageWorkBusyError || error instanceof ImageUploadError) throw error;
// Request errors can contain credential-bearing URLs and signatures.
throw new ImageUploadError('Image repository upload failed.');
} finally {
clearTimeout(timer);
}
};
+99
View File
@@ -0,0 +1,99 @@
import { createServer, type Server } from 'node:http';
import { once } from 'node:events';
import { describe, expect, it } from 'vitest';
import { ImageWorkBudget, performSignedImageUpload } from '../src/images/imageUpload.js';
const input = {
kind: 'content' as const,
baseUrl: 'https://image.example',
filename: `${'a'.repeat(32)}.png`,
contentType: 'image/png',
body: Buffer.from('test'),
secret: 'synthetic-image-secret',
};
const payload = () => new Response(JSON.stringify({ path: `uploads/core2026/${input.filename}` }));
const listen = async (server: Server) => {
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return `http://127.0.0.1:${(server.address() as { port: number }).port}`;
};
const close = (server: Server) =>
new Promise<void>((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
});
describe('bounded signed image transport', () => {
it('never forwards signed PUT bodies across a real redirect', async () => {
let forwarded = 0;
const target = createServer((_, res) => {
forwarded++;
res.end(JSON.stringify({ path: `uploads/core2026/${input.filename}` }));
});
const targetUrl = await listen(target);
const origin = createServer((_, res) => {
res.writeHead(307, { location: targetUrl });
res.end();
});
const baseUrl = await listen(origin);
try {
await expect(performSignedImageUpload({ ...input, baseUrl })).rejects.toThrow();
expect(forwarded).toBe(0);
} finally {
await close(origin);
await close(target);
}
});
it('bounds streamed bytes independently of Content-Length', async () => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(10_000));
controller.enqueue(new Uint8Array(10_000));
},
});
await expect(performSignedImageUpload(input, async () => new Response(body))).rejects.toThrow('size limit');
});
it('rejects wrong and malformed JSON paths', async () => {
for (const body of ['{', JSON.stringify({ path: 'elsewhere' })]) {
await expect(performSignedImageUpload(input, async () => new Response(body))).rejects.toThrow();
}
});
it('keeps timed-out non-cooperative work admitted until it actually settles', async () => {
const budget = new ImageWorkBudget(1);
let resolve!: (response: Response) => void;
const fetchImpl: typeof fetch = () =>
new Promise<Response>((done) => {
resolve = done;
});
await expect(performSignedImageUpload(input, fetchImpl, Date.now, { timeoutMs: 25, budget })).rejects.toThrow(
'timed out'
);
await expect(performSignedImageUpload(input, async () => payload(), Date.now, { budget })).rejects.toThrow(
'busy'
);
resolve(payload());
await new Promise<void>((done) => setImmediate(done));
await expect(
performSignedImageUpload(input, async () => payload(), Date.now, { budget })
).resolves.toBeUndefined();
});
it.each(['headers', 'body'])(
'aborts a real stalled %s response within the whole request deadline',
async (phase) => {
const server = createServer((_, res) => {
if (phase === 'body') {
res.writeHead(201);
res.write('{');
}
});
const baseUrl = await listen(server);
try {
await expect(
performSignedImageUpload({ ...input, baseUrl }, fetch, Date.now, { timeoutMs: 100 })
).rejects.toThrow('timed out');
} finally {
await close(server);
}
}
);
});
+1
View File
@@ -3,6 +3,7 @@ import { defineConfig } from 'tsdown';
export default defineConfig({ export default defineConfig({
entry: { entry: {
index: 'src/index.ts', index: 'src/index.ts',
'images/imageUpload': 'src/images/imageUpload.ts',
'auth/gameToken': 'src/auth/gameToken.ts', 'auth/gameToken': 'src/auth/gameToken.ts',
'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts', 'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts',
'auth/sessionRevocation': 'src/auth/sessionRevocation.ts', 'auth/sessionRevocation': 'src/auth/sessionRevocation.ts',