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

This commit is contained in:
2026-10-09 07:09:23 +09:00
parent c5716cf144
commit 5771701007
18 changed files with 879 additions and 183 deletions
+40
View File
@@ -1527,6 +1527,46 @@ describe('account self service', () => {
}
});
it('bounds pending icon uploads without changing the owned library limit', async () => {
const { caller, users, sessions, userIconUpload } = buildCaller();
const user = await users.createUser({ username: 'icon-budget', password: 'current-password' });
const session = await sessions.createSession(user);
const png = await sharp({ create: { width: 64, height: 64, channels: 3, background: '#224466' } })
.png()
.toBuffer();
const input = {
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
};
const pending: Array<() => void> = [];
userIconUpload.upload.mockImplementation(
({ filename }) =>
new Promise((resolve) => {
pending.push(() =>
resolve({
picture: `users/core2026/${filename}`,
publicUrl: `https://image.example/${filename}`,
})
);
})
);
const admitted = Array.from({ length: 4 }, () => caller.account.changeIcon(input));
try {
await vi.waitFor(() => expect(userIconUpload.upload).toHaveBeenCalledTimes(4));
await expect(caller.account.changeIcon(input)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
} finally {
pending.forEach((done) => done());
await Promise.all(admitted);
}
expect(await users.listIcons(user.id)).toHaveLength(4);
userIconUpload.upload.mockImplementation(async ({ filename }) => ({
picture: `users/core2026/${filename}`,
publicUrl: `https://image.example/${filename}`,
}));
await expect(caller.account.changeIcon(input)).resolves.toMatchObject({ ok: true });
expect(await users.listIcons(user.id)).toHaveLength(5);
});
it('accepts concurrent uploads until the five-icon limit is reached', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-'));
try {
@@ -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();
});
});
@@ -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(
@@ -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);
}
});
});