이미지 업로드와 WebPush 응답의 자원 경계를 제한한다
This commit is contained in:
@@ -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,16 +232,39 @@ 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
|
||||||
|
.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);
|
const { permission } = await getBoardActor(ctx);
|
||||||
assertBoardAccess(permission, true);
|
assertBoardAccess(permission, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await boardImageBudget.run(async () => {
|
||||||
const buffer = parseDataUrl(input.dataUrl);
|
const buffer = parseDataUrl(input.dataUrl);
|
||||||
if (buffer.length > MAX_UPLOAD_BYTES) {
|
if (buffer.length > MAX_UPLOAD_BYTES) {
|
||||||
throw new TRPCError({ code: 'PAYLOAD_TOO_LARGE', message: '이미지 용량 제한(1MB)을 초과했습니다.' });
|
throw new TRPCError({
|
||||||
|
code: 'PAYLOAD_TOO_LARGE',
|
||||||
|
message: '이미지 용량 제한(1MB)을 초과했습니다.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = await sharp(buffer, { animated: true }).metadata();
|
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) {
|
if (!metadata.format || !metadata.width || !metadata.height) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||||
}
|
}
|
||||||
@@ -249,7 +291,11 @@ export const boardRouter = router({
|
|||||||
animated: isAnimated || format === 'gif',
|
animated: isAnimated || format === 'gif',
|
||||||
resize: needsResize,
|
resize: needsResize,
|
||||||
});
|
});
|
||||||
if (format === 'webp' && !needsResize && webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO) {
|
if (
|
||||||
|
format === 'webp' &&
|
||||||
|
!needsResize &&
|
||||||
|
webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO
|
||||||
|
) {
|
||||||
outputBuffer = buffer;
|
outputBuffer = buffer;
|
||||||
outputFormat = 'webp';
|
outputFormat = 'webp';
|
||||||
} else {
|
} else {
|
||||||
@@ -259,7 +305,10 @@ export const boardRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx.contentImageUpload) {
|
if (!ctx.contentImageUpload) {
|
||||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: '이미지 저장소가 설정되지 않았습니다.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`;
|
const filename = `${randomBytes(16).toString('hex')}.${outputFormat}`;
|
||||||
const uploaded = await ctx.contentImageUpload.upload({
|
const uploaded = await ctx.contentImageUpload.upload({
|
||||||
@@ -268,7 +317,9 @@ export const boardRouter = router({
|
|||||||
body: outputBuffer,
|
body: outputBuffer,
|
||||||
});
|
});
|
||||||
|
|
||||||
const outputMeta = await sharp(outputBuffer, { animated: true }).metadata();
|
const outputMeta = await sharp(outputBuffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
|
||||||
|
.timeout({ seconds: 10 })
|
||||||
|
.metadata();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: uploaded.publicUrl,
|
url: uploaded.publicUrl,
|
||||||
@@ -278,5 +329,19 @@ export const boardRouter = router({
|
|||||||
animated: isAnimated,
|
animated: isAnimated,
|
||||||
size: outputBuffer.length,
|
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;
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +274,21 @@ 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);
|
||||||
|
try {
|
||||||
|
return await iconImageBudget.run(async () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const profiles = await listIconSyncProfiles(ctx, user.id);
|
const profiles = await listIconSyncProfiles(ctx, user.id);
|
||||||
const buffer = decodeImage(input.imageData);
|
const buffer = decodeImage(input.imageData);
|
||||||
const metadata = await sharp(buffer, { animated: true }).metadata();
|
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;
|
const detectedFormat = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
|
||||||
if (!detectedFormat || !ALLOWED_ICON_FORMATS.has(detectedFormat)) {
|
if (!detectedFormat || !ALLOWED_ICON_FORMATS.has(detectedFormat)) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -276,7 +296,12 @@ export const accountRouter = router({
|
|||||||
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
|
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) {
|
if (
|
||||||
|
!metadata.width ||
|
||||||
|
metadata.width < 64 ||
|
||||||
|
metadata.width > 128 ||
|
||||||
|
metadata.height !== metadata.width
|
||||||
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
|
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
|
||||||
@@ -285,14 +310,23 @@ export const accountRouter = router({
|
|||||||
const extension = detectedFormat === 'jpeg' ? 'jpg' : detectedFormat;
|
const extension = detectedFormat === 'jpeg' ? 'jpg' : detectedFormat;
|
||||||
const filename = `${randomBytes(16).toString('hex')}.${extension}`;
|
const filename = `${randomBytes(16).toString('hex')}.${extension}`;
|
||||||
if (!ctx.userIconUpload) {
|
if (!ctx.userIconUpload) {
|
||||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: '이미지 저장소가 설정되지 않았습니다.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const uploaded = await ctx.userIconUpload.upload({
|
const uploaded = await ctx.userIconUpload.upload({
|
||||||
filename,
|
filename,
|
||||||
contentType: ICON_CONTENT_TYPES[extension]!,
|
contentType: ICON_CONTENT_TYPES[extension]!,
|
||||||
body: buffer,
|
body: buffer,
|
||||||
});
|
});
|
||||||
const stored = await ctx.users.addIconForWindow(user.id, uploaded.picture, 0, now, MAX_ACTIVE_ICONS);
|
const stored = await ctx.users.addIconForWindow(
|
||||||
|
user.id,
|
||||||
|
uploaded.picture,
|
||||||
|
0,
|
||||||
|
now,
|
||||||
|
MAX_ACTIVE_ICONS
|
||||||
|
);
|
||||||
if (!stored.ok) {
|
if (!stored.ok) {
|
||||||
if (stored.reason === 'LIMIT') {
|
if (stored.reason === 'LIMIT') {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -311,6 +345,20 @@ export const accountRouter = router({
|
|||||||
profiles,
|
profiles,
|
||||||
flushPublished,
|
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;
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
setPreferredIcon: procedure
|
setPreferredIcon: procedure
|
||||||
.input(z.object({ sessionToken: zSessionToken, iconId: z.string().uuid() }))
|
.input(z.object({ sessionToken: zSessionToken, iconId: z.string().uuid() }))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 성공 검증을 대신하지 않는다.
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user