From 57717010070b69344c158743b84d9157666c1a33 Mon Sep 17 00:00:00 2001 From: hide_d Date: Fri, 9 Oct 2026 07:09:23 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=EC=99=80=20WebPush=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=9D=98=20=EC=9E=90=EC=9B=90=20=EA=B2=BD=EA=B3=84=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/board/index.ts | 191 ++++++++++++------ .../src/services/remoteContentImageStore.ts | 36 +--- app/game-api/test/boardRouter.test.ts | 75 +++++++ .../test/remoteContentImageStore.test.ts | 11 + .../securityTransport.integration.test.ts | 23 +++ .../src/account/remoteUserIconStore.ts | 52 +---- app/gateway-api/src/account/router.ts | 136 +++++++++---- app/gateway-api/src/webPush/coordinator.ts | 8 +- .../src/webPush/sendNotification.ts | 99 +++++++++ app/gateway-api/test/authFlow.test.ts | 40 ++++ .../test/remoteUserIconStore.test.ts | 15 +- .../webPushCoordinator.integration.test.ts | 3 +- app/gateway-api/test/webPushTransport.test.ts | 98 +++++++++ docs/release-operations.md | 28 +++ packages/common/package.json | 4 + packages/common/src/images/imageUpload.ts | 143 +++++++++++++ packages/common/test/imageUpload.test.ts | 99 +++++++++ packages/common/tsdown.config.ts | 1 + 18 files changed, 879 insertions(+), 183 deletions(-) create mode 100644 app/gateway-api/src/webPush/sendNotification.ts create mode 100644 app/gateway-api/test/webPushTransport.test.ts create mode 100644 packages/common/src/images/imageUpload.ts create mode 100644 packages/common/test/imageUpload.test.ts diff --git a/app/game-api/src/router/board/index.ts b/app/game-api/src/router/board/index.ts index 4903a54c..c1f96f70 100644 --- a/app/game-api/src/router/board/index.ts +++ b/app/game-api/src/router/board/index.ts @@ -1,3 +1,9 @@ +import { + ImageWorkBudget, + ImageWorkBusyError, + ImageUploadError, + isRasterImage, +} from '@sammo-ts/common/images/imageUpload'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { randomBytes } from 'crypto'; @@ -7,6 +13,9 @@ import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc. import { getMyGeneral } from '../shared/general.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_LONG_EDGE = 2048; const WEBP_QUALITY = 80; @@ -57,7 +66,7 @@ const buildWebpBuffer = async ( buffer: Buffer, { animated, resize }: { animated: boolean; resize: boolean } ): Promise => { - let pipeline = sharp(buffer, { animated: true }); + let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 }); if (resize) { pipeline = pipeline.resize({ width: MAX_LONG_EDGE, @@ -74,11 +83,16 @@ const buildWebpBuffer = async ( ...(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 => { - let pipeline = sharp(buffer, { animated: true }); + let pipeline = sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS }).timeout({ seconds: 10 }); if (resize) { pipeline = pipeline.resize({ width: MAX_LONG_EDGE, @@ -87,7 +101,12 @@ const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise 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({ @@ -213,70 +232,116 @@ export const boardRouter = router({ return { id: comment.id }; }), - uploadImage: authedProcedure.input(z.object({ dataUrl: z.string().min(1) })).mutation(async ({ ctx, input }) => { - const { permission } = await getBoardActor(ctx); - assertBoardAccess(permission, true); + uploadImage: authedProcedure + .input( + 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); - if (buffer.length > MAX_UPLOAD_BYTES) { - throw new TRPCError({ code: 'PAYLOAD_TOO_LARGE', message: '이미지 용량 제한(1MB)을 초과했습니다.' }); - } + try { + return await boardImageBudget.run(async () => { + 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 (!metadata.format || !metadata.width || !metadata.height) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' }); - } + 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: '이미지 프레임 제한을 초과했습니다.' }); + if (!metadata.format || !metadata.width || !metadata.height) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' }); + } - const format = metadata.format; - const isAvif = metadata.mediaType === 'image/avif'; - const isAnimated = (metadata.pages ?? 1) > 1; - const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE; + const format = metadata.format; + const isAvif = metadata.mediaType === 'image/avif'; + const isAnimated = (metadata.pages ?? 1) > 1; + const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE; - const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']); - if (!allowed.has(format)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' }); - } + const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']); + if (!allowed.has(format)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' }); + } - let outputBuffer = buffer; - let outputFormat = isAvif ? 'avif' : 'webp'; + let outputBuffer = buffer; + let outputFormat = isAvif ? 'avif' : 'webp'; - if (isAvif) { - if (needsResize) { - outputBuffer = await buildAvifBuffer(buffer, true); + if (isAvif) { + if (needsResize) { + 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, - }; - }), + }), }); diff --git a/app/game-api/src/services/remoteContentImageStore.ts b/app/game-api/src/services/remoteContentImageStore.ts index 6dd2ec25..3a5658a3 100644 --- a/app/game-api/src/services/remoteContentImageStore.ts +++ b/app/game-api/src/services/remoteContentImageStore.ts @@ -1,4 +1,4 @@ -import { createHash, createHmac, randomUUID } from 'node:crypto'; +import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload'; export interface ContentImageUploadResult { publicUrl: string; @@ -21,32 +21,16 @@ export class RemoteContentImageStore implements ContentImageUploadStore { if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) { throw new Error('Invalid content image filename.'); } - const pathname = `/v1/uploads/content/core2026/${input.filename}`; - const expires = String(Math.floor(this.now() / 1000) + 60); - const requestId = randomUUID(); - const digest = createHash('sha256').update(input.body).digest('hex'); - const signature = createHmac('sha256', this.secret) - .update(`${expires}.${requestId}.${pathname}.${input.contentType}.${digest}`) - .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, + await performSignedImageUpload( + { + ...input, + kind: 'content', + 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}.`); - } - 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.'); - } + this.fetchImpl, + this.now + ); return { publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${input.filename}` }; } } diff --git a/app/game-api/test/boardRouter.test.ts b/app/game-api/test/boardRouter.test.ts index 298b4d8e..61b48a64 100644 --- a/app/game-api/test/boardRouter.test.ts +++ b/app/game-api/test/boardRouter.test.ts @@ -288,6 +288,81 @@ describe('board router actor, nation, and secret permissions', () => { 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('').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 () => { const upload = vi.fn(async ({ filename }: { filename: string }) => ({ publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`, diff --git a/app/game-api/test/remoteContentImageStore.test.ts b/app/game-api/test/remoteContentImageStore.test.ts index ce2e63e8..dc592d5c 100644 --- a/app/game-api/test/remoteContentImageStore.test.ts +++ b/app/game-api/test/remoteContentImageStore.test.ts @@ -36,4 +36,15 @@ describe('remote content image store', () => { expect(String(captured?.input)).toBe(`https://sam-image.hided.net${pathname}`); 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(); + }); }); diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index cb8ea034..5e117fee 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -1,3 +1,4 @@ +import sharp from 'sharp'; import { buildGameEventChannel } from '@sammo-ts/common'; import { createHash } from 'node:crypto'; 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 () => { const accessToken = await createAccessToken('json-query-body', {}); const general = await requestTrpc('general.me', { diff --git a/app/gateway-api/src/account/remoteUserIconStore.ts b/app/gateway-api/src/account/remoteUserIconStore.ts index 35ab1273..40e4bd58 100644 --- a/app/gateway-api/src/account/remoteUserIconStore.ts +++ b/app/gateway-api/src/account/remoteUserIconStore.ts @@ -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; } -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}` }; } } diff --git a/app/gateway-api/src/account/router.ts b/app/gateway-api/src/account/router.ts index 97cc398c..ecd67426 100644 --- a/app/gateway-api/src/account/router.ts +++ b/app/gateway-api/src/account/router.ts @@ -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() })) diff --git a/app/gateway-api/src/webPush/coordinator.ts b/app/gateway-api/src/webPush/coordinator.ts index 43ded139..7f1ddaf0 100644 --- a/app/gateway-api/src/webPush/coordinator.ts +++ b/app/gateway-api/src/webPush/coordinator.ts @@ -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 diff --git a/app/gateway-api/src/webPush/sendNotification.ts b/app/gateway-api/src/webPush/sendNotification.ts new file mode 100644 index 00000000..036f37d4 --- /dev/null +++ b/app/gateway-api/src/webPush/sendNotification.ts @@ -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 => + 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 => { + parsePushEndpoint(subscription.endpoint); + const details = webPush.generateRequestDetails(subscription, payload, { TTL: 60 * 60 }); + await sendPushRequest(details); +}; diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index f9b15da2..91666fb8 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -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 () => { const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-')); try { diff --git a/app/gateway-api/test/remoteUserIconStore.test.ts b/app/gateway-api/test/remoteUserIconStore.test.ts index bcb9b6ec..761b24ba 100644 --- a/app/gateway-api/test/remoteUserIconStore.test.ts +++ b/app/gateway-api/test/remoteUserIconStore.test.ts @@ -40,9 +40,7 @@ describe('remote user icon store', () => { const pathname = `/v1/uploads/user-icons/core2026/${'a'.repeat(32)}.png`; const digest = createHash('sha256').update(body).digest('hex'); const expected = createHmac('sha256', secret) - .update( - `${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/png.${digest}` - ) + .update(`${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/png.${digest}`) .digest('hex'); 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') }) ).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(); + }); }); diff --git a/app/gateway-api/test/webPushCoordinator.integration.test.ts b/app/gateway-api/test/webPushCoordinator.integration.test.ts index 9aea58c3..9b0efe76 100644 --- a/app/gateway-api/test/webPushCoordinator.integration.test.ts +++ b/app/gateway-api/test/webPushCoordinator.integration.test.ts @@ -1,3 +1,4 @@ +import * as pushSender from '../src/webPush/sendNotification.js'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import webPush from 'web-push'; @@ -214,7 +215,7 @@ integration('web push Gateway persistence boundary', () => { notificationId: notification.id, }, }); - const sender = vi.spyOn(webPush, 'sendNotification').mockRejectedValue({ statusCode: 410 }); + const sender = vi.spyOn(pushSender, 'sendBoundedNotification').mockRejectedValue({ statusCode: 410 }); try { coordinator.start(); await vi.waitFor( diff --git a/app/gateway-api/test/webPushTransport.test.ts b/app/gateway-api/test/webPushTransport.test.ts new file mode 100644 index 00000000..33e5d616 --- /dev/null +++ b/app/gateway-api/test/webPushTransport.test.ts @@ -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((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }); +const transport = + (port: number): Parameters[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); + } + }); +}); diff --git a/docs/release-operations.md b/docs/release-operations.md index e000ab0e..a10d20b6 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -744,3 +744,31 @@ NDJSON frame 512 KiB/stream 64 MiB, 느린 소비자의 write buffer 1 MiB를 실패합니다. 해당 host의 wrapper와 정확한 project를 사용하고 기존 volume·active release를 보존합니다. 외부 공개 stack과 임시 localhost stack의 token 파일은 공유하지 않습니다. 임시 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 성공 검증을 대신하지 않는다. diff --git a/packages/common/package.json b/packages/common/package.json index 1a256cfc..40ad6f04 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -61,6 +61,10 @@ "./util/JosaUtil": { "types": "./dist/util/JosaUtil.d.ts", "default": "./dist/util/JosaUtil.js" + }, + "./images/imageUpload": { + "types": "./dist/images/imageUpload.d.ts", + "default": "./dist/images/imageUpload.js" } }, "scripts": { diff --git a/packages/common/src/images/imageUpload.ts b/packages/common/src/images/imageUpload.ts new file mode 100644 index 00000000..242d221b --- /dev/null +++ b/packages/common/src/images/imageUpload.ts @@ -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(work: () => Promise): Promise { + 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 => { + 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 | 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((_, 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); + } +}; diff --git a/packages/common/test/imageUpload.test.ts b/packages/common/test/imageUpload.test.ts new file mode 100644 index 00000000..ab05909b --- /dev/null +++ b/packages/common/test/imageUpload.test.ts @@ -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((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({ + 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((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((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); + } + } + ); +}); diff --git a/packages/common/tsdown.config.ts b/packages/common/tsdown.config.ts index 603e8b2b..c20f340a 100644 --- a/packages/common/tsdown.config.ts +++ b/packages/common/tsdown.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ entry: { index: 'src/index.ts', + 'images/imageUpload': 'src/images/imageUpload.ts', 'auth/gameToken': 'src/auth/gameToken.ts', 'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts', 'auth/sessionRevocation': 'src/auth/sessionRevocation.ts',