Files
Shade/packages/shade-server/tests/rate-limit.test.ts
Sterister 680d6386f3 release(v4.8.1): SHADE_DISABLE_RATE_LIMIT env var for single-tenant deploys
Plumbing fix only — both createPrekeyRoutes and createInboxRoutes
already accepted disableRateLimit; standalone.ts just didn't read
the env. Now SHADE_DISABLE_RATE_LIMIT=1 turns off IP rate-limits on
every prekey + inbox route, with a WARN log on startup so operators
see it.

Single-tenant deployments only — multi-tenant relays must leave it
unset. Documented in docs/DEPLOYMENT.md.

Reported by Prism: ~6 pair attempts/hour from a single dev IP +
the sidecar's register call tripped the 5/hour REGISTER_LIMIT every
dev iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:55:57 +02:00

192 lines
7.1 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import { RateLimiter, MemoryRateLimitStore, createPrekeyServer, MemoryPrekeyStore, signPayload } from '../src/index.js';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import { generateIdentityKeyPair, RateLimitError } from '@shade/core';
const crypto = new SubtleCryptoProvider();
describe('RateLimiter', () => {
test('allows requests up to capacity', async () => {
const store = new MemoryRateLimitStore();
const rl = new RateLimiter(store, { capacity: 5, refillPerSecond: 1 });
for (let i = 0; i < 5; i++) {
await rl.consume('user:1');
}
});
test('throws RateLimitError when exhausted', async () => {
const store = new MemoryRateLimitStore();
const rl = new RateLimiter(store, { capacity: 3, refillPerSecond: 0.1 });
for (let i = 0; i < 3; i++) await rl.consume('user:1');
expect(rl.consume('user:1')).rejects.toThrow(RateLimitError);
});
test('different keys have independent limits', async () => {
const store = new MemoryRateLimitStore();
const rl = new RateLimiter(store, { capacity: 2, refillPerSecond: 0.1 });
await rl.consume('user:1');
await rl.consume('user:1');
// user:1 exhausted
expect(rl.consume('user:1')).rejects.toThrow(RateLimitError);
// user:2 still has full capacity
await rl.consume('user:2');
await rl.consume('user:2');
});
test('refills over time', async () => {
const store = new MemoryRateLimitStore();
const rl = new RateLimiter(store, { capacity: 2, refillPerSecond: 100 }); // fast refill for test
await rl.consume('user:1');
await rl.consume('user:1');
// Wait a bit for refill (100 tokens/sec → 2 tokens in 20ms)
await new Promise((r) => setTimeout(r, 30));
// Should be refilled
await rl.consume('user:1');
});
test('RateLimitError has retryAfterSeconds', async () => {
const store = new MemoryRateLimitStore();
const rl = new RateLimiter(store, { capacity: 1, refillPerSecond: 0.5 });
await rl.consume('user:1');
try {
await rl.consume('user:1');
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(RateLimitError);
expect((err as RateLimitError).retryAfterSeconds).toBeGreaterThan(0);
}
});
});
describe('Rate limiting integration with routes', () => {
test('register endpoint rate-limits per IP', async () => {
const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore() });
async function doRegister(addressSuffix: number) {
const identity = await generateIdentityKeyPair(crypto);
const body: any = {
address: `user${addressSuffix}`,
identitySigningKey: Buffer.from(identity.signingPublicKey).toString('base64'),
identityDHKey: Buffer.from(identity.dhPublicKey).toString('base64'),
signedPreKey: {
keyId: 1,
publicKey: Buffer.from(crypto.randomBytes(32)).toString('base64'),
signature: Buffer.from(crypto.randomBytes(64)).toString('base64'),
},
};
const signed = await signPayload(crypto, identity.signingPrivateKey, body);
return app.request('/v1/keys/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '203.0.113.1' },
body: JSON.stringify(signed),
});
}
// Register limit is 5/hour, so after 5 successful, the 6th should fail
const results: number[] = [];
for (let i = 0; i < 7; i++) {
const res = await doRegister(i);
results.push(res.status);
}
// First 5 should succeed (200), rest should be rate-limited (429)
expect(results.filter((s) => s === 200).length).toBeGreaterThanOrEqual(5);
expect(results.filter((s) => s === 429).length).toBeGreaterThanOrEqual(1);
});
// V4.8.1 — `SHADE_DISABLE_RATE_LIMIT=1` in standalone.ts is plumbed
// through to `createPrekeyServer({ disableRateLimit })`. This test
// covers the "what happens when the flag is true" path; the env-var
// → option conversion in standalone.ts is a one-liner verified by
// inspection.
test('register endpoint allows >5/hour from a single IP when disableRateLimit is set', async () => {
const app = createPrekeyServer({
crypto,
store: new MemoryPrekeyStore(),
disableRateLimit: true,
});
async function doRegister(addressSuffix: number) {
const identity = await generateIdentityKeyPair(crypto);
const body: any = {
address: `user${addressSuffix}`,
identitySigningKey: Buffer.from(identity.signingPublicKey).toString('base64'),
identityDHKey: Buffer.from(identity.dhPublicKey).toString('base64'),
signedPreKey: {
keyId: 1,
publicKey: Buffer.from(crypto.randomBytes(32)).toString('base64'),
signature: Buffer.from(crypto.randomBytes(64)).toString('base64'),
},
};
const signed = await signPayload(crypto, identity.signingPrivateKey, body);
return app.request('/v1/keys/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '203.0.113.1' },
body: JSON.stringify(signed),
});
}
const results: number[] = [];
for (let i = 0; i < 12; i++) {
const res = await doRegister(i);
results.push(res.status);
}
// No 429 anywhere — the limit is OFF.
expect(results.filter((s) => s === 429).length).toBe(0);
expect(results.filter((s) => s === 200).length).toBe(12);
});
test('rate limit returns Retry-After header', async () => {
const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore() });
// Burn through the delete limit (5/hour)
const identity = await generateIdentityKeyPair(crypto);
// First register
const regBody: any = {
address: 'ratelimit',
identitySigningKey: Buffer.from(identity.signingPublicKey).toString('base64'),
identityDHKey: Buffer.from(identity.dhPublicKey).toString('base64'),
signedPreKey: {
keyId: 1,
publicKey: Buffer.from(crypto.randomBytes(32)).toString('base64'),
signature: Buffer.from(crypto.randomBytes(64)).toString('base64'),
},
};
const signedReg = await signPayload(crypto, identity.signingPrivateKey, regBody);
await app.request('/v1/keys/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '203.0.113.2' },
body: JSON.stringify(signedReg),
});
// Burn through the delete limit
for (let i = 0; i < 5; i++) {
const delBody = await signPayload(crypto, identity.signingPrivateKey, { address: 'ratelimit' });
await app.request('/v1/keys/ratelimit', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(delBody),
});
}
// 6th should be rate-limited
const delBody = await signPayload(crypto, identity.signingPrivateKey, { address: 'ratelimit' });
const res = await app.request('/v1/keys/ratelimit', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(delBody),
});
expect(res.status).toBe(429);
expect(res.headers.get('Retry-After')).toBeTruthy();
});
});