diff --git a/src/everything/transports/sse.ts b/src/everything/transports/sse.ts index 2406db7cfa..2bb2d76645 100644 --- a/src/everything/transports/sse.ts +++ b/src/everything/transports/sse.ts @@ -22,6 +22,23 @@ const transports: Map = new Map< SSEServerTransport >(); +// Simple in-memory rate limiter: max 60 requests per minute per IP +const rateLimitMap = new Map(); +app.use((req, res, next) => { + const ip = req.ip ?? req.socket.remoteAddress ?? "unknown"; + const now = Date.now(); + const entry = rateLimitMap.get(ip); + if (!entry || now > entry.resetAt) { + rateLimitMap.set(ip, { count: 1, resetAt: now + 60_000 }); + next(); + } else if (entry.count < 60) { + entry.count++; + next(); + } else { + res.status(429).json({ error: "Too many requests" }); + } +}); + // Handle GET requests for new SSE streams app.get("/sse", async (req, res) => { let transport: SSEServerTransport; diff --git a/tests/invariant_sse.test.ts b/tests/invariant_sse.test.ts new file mode 100644 index 0000000000..de10d70eb2 --- /dev/null +++ b/tests/invariant_sse.test.ts @@ -0,0 +1,30 @@ +import { app } from '../../src/everything/transports/sse'; + +describe('SSE endpoints maintain rate limiting under adversarial load', () => { + const adversarialPayloads = [ + { type: 'exploit', description: 'burst request flood', count: 1000 }, + { type: 'boundary', description: 'max concurrent connections', count: 100 }, + { type: 'valid', description: 'normal single request', count: 1 } + ]; + + test.each(adversarialPayloads)('endpoint responds appropriately to $description', async ({ count }) => { + const requests = Array.from({ length: count }, (_, i) => + fetch(`http://localhost:${process.env.PORT || 3000}/sse`) + ); + + const responses = await Promise.allSettled(requests); + + // Security property: system must not become unresponsive + const fulfilledCount = responses.filter(r => r.status === 'fulfilled').length; + expect(fulfilledCount).toBeLessThan(count); // Some requests should be rejected/throttled + + // Additional property: no successful responses should leak resources + responses.forEach((result, index) => { + if (result.status === 'fulfilled') { + const response = result.value; + expect(response.headers.get('content-type')).toContain('text/event-stream'); + expect(response.status).toBe(200); + } + }); + }); +}); \ No newline at end of file