fix: restrict content image uploads to chiefs

This commit is contained in:
2026-08-08 02:56:01 +00:00
parent 05f4443e06
commit ebf076a3c8
2 changed files with 84 additions and 67 deletions
+61 -63
View File
@@ -143,79 +143,77 @@ export const boardRouter = router({
})); }));
}), }),
writeArticle: accessAuthedInputProcedure( writeArticle: accessAuthedInputProcedure(
z.object({ z.object({
isSecret: z.boolean(), isSecret: z.boolean(),
title: z.string().trim().max(250), title: z.string().trim().max(250),
content: z.string().trim().max(20000), content: z.string().trim().max(20000),
}) })
) ).mutation(async ({ ctx, input }) => {
.mutation(async ({ ctx, input }) => { const { general, permission } = await getBoardActor(ctx);
const { general, permission } = await getBoardActor(ctx); assertBoardAccess(permission, input.isSecret);
assertBoardAccess(permission, input.isSecret);
if (!input.title && !input.content) { if (!input.title && !input.content) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '제목과 내용이 둘다 비어있습니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '제목과 내용이 둘다 비어있습니다.' });
} }
const post = await ctx.db.boardPost.create({ const post = await ctx.db.boardPost.create({
data: { data: {
nationId: general.nationId, nationId: general.nationId,
isSecret: input.isSecret, isSecret: input.isSecret,
authorGeneralId: general.id, authorGeneralId: general.id,
authorName: general.name, authorName: general.name,
title: input.title, title: input.title,
contentHtml: input.content, contentHtml: input.content,
}, },
select: { id: true }, select: { id: true },
}); });
return { id: post.id }; return { id: post.id };
}), }),
writeComment: accessAuthedInputProcedure( writeComment: accessAuthedInputProcedure(
z.object({ z.object({
postId: z.number().int().positive(), postId: z.number().int().positive(),
content: z.string().trim().max(2000), content: z.string().trim().max(2000),
}) })
) ).mutation(async ({ ctx, input }) => {
.mutation(async ({ ctx, input }) => { const { general, permission } = await getBoardActor(ctx);
const { general, permission } = await getBoardActor(ctx); if (!input.content) {
if (!input.content) { throw new TRPCError({ code: 'BAD_REQUEST', message: '내용이 비어있습니다.' });
throw new TRPCError({ code: 'BAD_REQUEST', message: '내용이 비어있습니다.' }); }
}
const post = await ctx.db.boardPost.findFirst({ const post = await ctx.db.boardPost.findFirst({
where: { where: {
id: input.postId, id: input.postId,
nationId: general.nationId, nationId: general.nationId,
}, },
select: { select: {
id: true, id: true,
isSecret: true, isSecret: true,
}, },
}); });
if (!post) { if (!post) {
throw new TRPCError({ code: 'NOT_FOUND', message: '게시물이 없습니다.' }); throw new TRPCError({ code: 'NOT_FOUND', message: '게시물이 없습니다.' });
} }
assertBoardAccess(permission, post.isSecret); assertBoardAccess(permission, post.isSecret);
const comment = await ctx.db.boardComment.create({ const comment = await ctx.db.boardComment.create({
data: { data: {
postId: post.id, postId: post.id,
nationId: general.nationId, nationId: general.nationId,
isSecret: post.isSecret, isSecret: post.isSecret,
authorGeneralId: general.id, authorGeneralId: general.id,
authorName: general.name, authorName: general.name,
contentText: input.content, contentText: input.content,
}, },
select: { id: true }, select: { id: true },
}); });
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) })).mutation(async ({ ctx, input }) => {
const { permission } = await getBoardActor(ctx); const { permission } = await getBoardActor(ctx);
assertBoardAccess(permission, false); assertBoardAccess(permission, true);
const buffer = parseDataUrl(input.dataUrl); const buffer = parseDataUrl(input.dataUrl);
if (buffer.length > MAX_UPLOAD_BYTES) { if (buffer.length > MAX_UPLOAD_BYTES) {
+23 -4
View File
@@ -267,7 +267,10 @@ describe('board router actor, nation, and secret permissions', () => {
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}`,
})); }));
const fixture = buildContext({ contentImageUpload: { upload } }); const fixture = buildContext({
me: buildGeneral({ officerLevel: 5 }),
contentImageUpload: { upload },
});
const png = await sharp({ const png = await sharp({
create: { width: 64, height: 48, channels: 4, background: '#224466' }, create: { width: 64, height: 48, channels: 4, background: '#224466' },
}) })
@@ -278,15 +281,31 @@ describe('board router actor, nation, and secret permissions', () => {
dataUrl: `data:image/png;base64,${png.toString('base64')}`, dataUrl: `data:image/png;base64,${png.toString('base64')}`,
}); });
expect(result.url).toMatch( expect(result.url).toMatch(/^https:\/\/sam-image\.hided\.net\/uploads\/core2026\/[a-f0-9]{32}\.webp$/);
/^https:\/\/sam-image\.hided\.net\/uploads\/core2026\/[a-f0-9]{32}\.webp$/
);
expect(upload).toHaveBeenCalledWith( expect(upload).toHaveBeenCalledWith(
expect.objectContaining({ contentType: 'image/webp', body: expect.any(Buffer) }) expect.objectContaining({ contentType: 'image/webp', body: expect.any(Buffer) })
); );
expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false }); expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false });
}); });
it('rejects editor image uploads from an ordinary nation member', async () => {
const upload = vi.fn();
const fixture = buildContext({
me: buildGeneral({ officerLevel: 1 }),
contentImageUpload: { upload },
});
await expect(
appRouter.createCaller(fixture.context).board.uploadImage({
dataUrl: 'data:image/png;base64,AA==',
})
).rejects.toMatchObject({
code: 'FORBIDDEN',
message: '권한이 부족합니다. 수뇌부가 아닙니다.',
});
expect(upload).not.toHaveBeenCalled();
});
it('does not reveal whether another nation owns a requested comment target', async () => { it('does not reveal whether another nation owns a requested comment target', async () => {
const fixture = buildContext({ const fixture = buildContext({
me: buildGeneral({ nationId: 3, officerLevel: 5 }), me: buildGeneral({ nationId: 3, officerLevel: 5 }),