이미지 업로드와 WebPush 응답의 자원 경계를 제한한다
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user