mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
* refactor(cloud): share PostgreSQL schema startup between services * feat(cloud): add durable native push notification gateway * infra(push): define dedicated gateway resources and operational checks * fix(push): bound cross-host admission and simplify gateway configuration * fix(push): validate deploy configuration and preserve topic-error registrations
170 lines
6.7 KiB
TypeScript
170 lines
6.7 KiB
TypeScript
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
|
|
import { Hono } from 'hono'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { ClientIpRateLimiter, clientIpRateLimit } from './client-ip-rate-limit.js'
|
|
|
|
const CAPACITY = PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp
|
|
|
|
function limiterApp(limiter: ClientIpRateLimiter, trustedProxyHops = 0): Hono {
|
|
const app = new Hono()
|
|
app.post('/probe', clientIpRateLimit(limiter, { trustedProxyHops }), (context) =>
|
|
context.json({ ok: true })
|
|
)
|
|
return app
|
|
}
|
|
|
|
describe('client ip rate limiter', () => {
|
|
it('admits exactly the per-minute allowance and refuses the next request', () => {
|
|
const limiter = new ClientIpRateLimiter({ now: () => 1_000 })
|
|
for (let index = 0; index < CAPACITY; index++) {
|
|
expect(limiter.allow('203.0.113.7')).toBe(true)
|
|
}
|
|
expect(limiter.allow('203.0.113.7')).toBe(false)
|
|
})
|
|
|
|
it('keeps one client ip from spending another one budget', () => {
|
|
const limiter = new ClientIpRateLimiter({ now: () => 1_000 })
|
|
for (let index = 0; index < CAPACITY; index++) limiter.allow('203.0.113.7')
|
|
expect(limiter.allow('203.0.113.7')).toBe(false)
|
|
expect(limiter.allow('198.51.100.9')).toBe(true)
|
|
})
|
|
|
|
it('refills over the window rather than resetting on a boundary', () => {
|
|
let clock = 1_000
|
|
const limiter = new ClientIpRateLimiter({ now: () => clock })
|
|
for (let index = 0; index < CAPACITY; index++) limiter.allow('203.0.113.7')
|
|
expect(limiter.allow('203.0.113.7')).toBe(false)
|
|
|
|
// Half a window buys back half the allowance, no more.
|
|
clock += 30_000
|
|
for (let index = 0; index < CAPACITY / 2; index++) {
|
|
expect(limiter.allow('203.0.113.7')).toBe(true)
|
|
}
|
|
expect(limiter.allow('203.0.113.7')).toBe(false)
|
|
})
|
|
|
|
it('bounds what it remembers when a flood of distinct ips arrives', () => {
|
|
let clock = 1_000
|
|
const limiter = new ClientIpRateLimiter({ now: () => clock, maxTrackedIps: 8 })
|
|
for (let index = 0; index < 200; index++) {
|
|
clock += 1
|
|
limiter.allow(`198.51.100.${index}`)
|
|
}
|
|
expect(limiter.trackedIpCount()).toBeLessThanOrEqual(8)
|
|
})
|
|
|
|
it('evicts the least recently used bucket without scanning the map', () => {
|
|
const limiter = new ClientIpRateLimiter({ capacity: 1, maxTrackedIps: 2, now: () => 1_000 })
|
|
limiter.allow('old')
|
|
limiter.allow('recent')
|
|
expect(limiter.allow('old')).toBe(false)
|
|
const entries = vi.spyOn(Map.prototype, 'entries')
|
|
const iterator = vi.spyOn(Map.prototype, Symbol.iterator)
|
|
try {
|
|
limiter.allow('new')
|
|
expect(entries).not.toHaveBeenCalled()
|
|
expect(iterator).not.toHaveBeenCalled()
|
|
} finally {
|
|
entries.mockRestore()
|
|
iterator.mockRestore()
|
|
}
|
|
expect(limiter.available('old')).toBe(false)
|
|
expect(limiter.available('recent')).toBe(true)
|
|
expect(limiter.trackedIpCount()).toBe(2)
|
|
})
|
|
|
|
it('answers 429 with a rate_limited body once the bucket is empty', async () => {
|
|
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000 }))
|
|
const headers = { 'x-forwarded-for': '10.0.0.1, 10.0.0.2, 203.0.113.7' }
|
|
for (let index = 0; index < CAPACITY; index++) {
|
|
expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(200)
|
|
}
|
|
const limited = await app.request('/probe', { method: 'POST', headers })
|
|
expect(limited.status).toBe(429)
|
|
expect(await limited.json()).toEqual({ error: 'rate_limited' })
|
|
})
|
|
|
|
it('buckets on the last forwarded hop, the only one the platform appended', async () => {
|
|
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000 }))
|
|
for (let index = 0; index < CAPACITY; index++) {
|
|
await app.request('/probe', {
|
|
method: 'POST',
|
|
headers: { 'x-forwarded-for': `10.0.0.${index}, 203.0.113.7` }
|
|
})
|
|
}
|
|
const sameClient = await app.request('/probe', {
|
|
method: 'POST',
|
|
headers: { 'x-forwarded-for': '10.9.9.9, 203.0.113.7' }
|
|
})
|
|
expect(sameClient.status).toBe(429)
|
|
const otherClient = await app.request('/probe', {
|
|
method: 'POST',
|
|
headers: { 'x-forwarded-for': '10.0.0.1, 198.51.100.9' }
|
|
})
|
|
expect(otherClient.status).toBe(200)
|
|
})
|
|
|
|
it('gives a spoofed left-most hop no escape from the caller own bucket', async () => {
|
|
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000 }))
|
|
// A caller that rewrites its own x-forwarded-for on every request still ends
|
|
// up behind the one value Cloud Run appended.
|
|
for (let index = 0; index < CAPACITY; index++) {
|
|
const allowed = await app.request('/probe', {
|
|
method: 'POST',
|
|
headers: { 'x-forwarded-for': `198.51.100.${index}, 203.0.113.7` }
|
|
})
|
|
expect(allowed.status).toBe(200)
|
|
}
|
|
const spoofed = await app.request('/probe', {
|
|
method: 'POST',
|
|
headers: { 'x-forwarded-for': '198.51.100.250, 10.1.1.1, 203.0.113.7' }
|
|
})
|
|
expect(spoofed.status).toBe(429)
|
|
})
|
|
|
|
it('skips the configured trusted proxies when counting from the right', async () => {
|
|
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000, capacity: 1 }), 1)
|
|
// <client>, <cloud run>, <load balancer>: one trusted hop after the client.
|
|
const headers = { 'x-forwarded-for': '203.0.113.7, 10.0.0.1' }
|
|
expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(200)
|
|
expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(429)
|
|
expect(
|
|
(
|
|
await app.request('/probe', {
|
|
method: 'POST',
|
|
headers: { 'x-forwarded-for': '198.51.100.9, 10.0.0.1' }
|
|
})
|
|
).status
|
|
).toBe(200)
|
|
})
|
|
|
|
it('trusts nothing when the header is shorter than the configured depth', async () => {
|
|
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000, capacity: 1 }), 1)
|
|
// Only one hop, so the client value the depth points at does not exist.
|
|
const headers = { 'x-forwarded-for': '203.0.113.7' }
|
|
expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(200)
|
|
expect(
|
|
(
|
|
await app.request('/probe', {
|
|
method: 'POST',
|
|
headers: { 'x-forwarded-for': '198.51.100.9' }
|
|
})
|
|
).status
|
|
).toBe(429)
|
|
})
|
|
|
|
it('ignores spoofable x-real-ip and uses a single shared bucket', async () => {
|
|
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000, capacity: 1 }))
|
|
expect(
|
|
(await app.request('/probe', { method: 'POST', headers: { 'x-real-ip': '198.51.100.9' } }))
|
|
.status
|
|
).toBe(200)
|
|
expect(
|
|
(await app.request('/probe', { method: 'POST', headers: { 'x-real-ip': '203.0.113.7' } }))
|
|
.status
|
|
).toBe(429)
|
|
expect((await app.request('/probe', { method: 'POST' })).status).toBe(429)
|
|
expect((await app.request('/probe', { method: 'POST' })).status).toBe(429)
|
|
})
|
|
})
|