Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ You can configure the behavior of the app with the following environment variabl
- `TEST_DB_URL` - Database url used in `yarn test`
- `LOG_LEVEL` - Integer specifying the log level (`0 | 1 | 2 | 3`). See `src/tools/Logger.ts`
- `PORT` - The port on which the application exposes the api
- `IP_RATE_LIMIT_PER_MINUTE` - Maximum number of page `GET` requests per minute per IP (`0` disables the limiter)
- `MAX_BLOCK_NUMBER` - Integer specifying the maximum block number that is going to be stored - all blocks created later in time will be skipped (used in environments with limited database space e.g. heroku review apps)

## Repository naming convention
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/Application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { TutorialController } from './api/controllers/TutorialController'
import { UserController } from './api/controllers/UserController'
import { frontendErrorMiddleware } from './api/middleware/frontendErrorMiddleware'
import { createFrontendMiddleware } from './api/middleware/FrontendMiddleware'
import { createIpRateLimitMiddleware } from './api/middleware/ipRateLimitMiddleware'
import { createTransactionRouter } from './api/routers/ForcedTransactionRouter'
import { createFrontendRouter } from './api/routers/FrontendRouter'
import { createStatusRouter } from './api/routers/StatusRouter'
Expand Down Expand Up @@ -696,6 +697,9 @@ export class Application {
)

const staticPageController = new StaticPageController(pageContextService)
const ipRateLimitMiddleware = createIpRateLimitMiddleware({
requestsPerMinute: config.ipRateLimitPerMinute,
})

const apiServer = new ApiServer(config.port, logger, {
routers: [
Expand All @@ -722,6 +726,7 @@ export class Application {
],
middleware: [
createFrontendMiddleware(),
ipRateLimitMiddleware,
(ctx, next) => frontendErrorMiddleware(ctx, next, pageContextService),
],
forceHttps: config.forceHttps,
Expand Down
135 changes: 135 additions & 0 deletions packages/backend/src/api/middleware/ipRateLimitMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { expect, mockFn } from 'earl'
import { Context, Next } from 'koa'

import { createIpRateLimitMiddleware } from './ipRateLimitMiddleware'

describe(createIpRateLimitMiddleware.name, () => {
describe('GET requests', () => {
it('returns 429 after exceeding the configured limit', async () => {
const now = () => 0
const middleware = createIpRateLimitMiddleware({
requestsPerMinute: 2,
now,
})
const ctx = createContext()
const next = mockFn<Next>(async () => undefined)

await middleware(ctx, next)
await middleware(ctx, next)
await middleware(ctx, next)

expect(next).toHaveBeenCalledTimes(2)
expect(ctx.status).toEqual(429)
expect(ctx.body).toEqual(
'Too many requests. Please try again in about a minute.'
)
expect(ctx.headers.get('Retry-After')).toEqual('60')
})

it('allows requests again after a new minute starts', async () => {
let currentTime = 0
const middleware = createIpRateLimitMiddleware({
requestsPerMinute: 1,
now: () => currentTime,
})
const ctx = createContext()
let nextCalls = 0
const next: Next = async () => {
nextCalls += 1
}

await middleware(ctx, next)
await middleware(ctx, next)

currentTime = 60_000

await middleware(ctx, next)

expect(nextCalls).toEqual(2)
})
})

it('does not limit non-GET requests', async () => {
const middleware = createIpRateLimitMiddleware({
requestsPerMinute: 1,
now: () => 0,
})
const ctx = createContext({ method: 'POST' })
let nextCalls = 0
const next: Next = async () => {
nextCalls += 1
}

await middleware(ctx, next)
await middleware(ctx, next)
await middleware(ctx, next)

expect(nextCalls).toEqual(3)
expect(ctx.status).not.toEqual(429)
})

it('does not limit /status checks', async () => {
const middleware = createIpRateLimitMiddleware({
requestsPerMinute: 1,
now: () => 0,
})
const ctx = createContext({ path: '/status' })
let nextCalls = 0
const next: Next = async () => {
nextCalls += 1
}

await middleware(ctx, next)
await middleware(ctx, next)
await middleware(ctx, next)

expect(nextCalls).toEqual(3)
})

it('isolates limits per IP', async () => {
const middleware = createIpRateLimitMiddleware({
requestsPerMinute: 1,
now: () => 0,
})

const firstIpContext = createContext({ ip: '203.0.113.1' })
const secondIpContext = createContext({ ip: '203.0.113.2' })
let nextCalls = 0
const next: Next = async () => {
nextCalls += 1
}

await middleware(firstIpContext, next)
await middleware(firstIpContext, next)
await middleware(secondIpContext, next)

expect(firstIpContext.status).toEqual(429)
expect(secondIpContext.status).not.toEqual(429)
expect(nextCalls).toEqual(2)
})
})

function createContext({
method = 'GET',
path = '/users/0x123/balance-changes',
ip = '203.0.113.1',
}: {
method?: string
path?: string
ip?: string
} = {}) {
const headers = new Map<string, string>()
const ctx = {
method,
path,
ip,
headers,
set(name: string, value: string) {
headers.set(name, value)
},
} as unknown as Context & {
headers: Map<string, string>
}

return ctx
}
93 changes: 93 additions & 0 deletions packages/backend/src/api/middleware/ipRateLimitMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Context, Middleware } from 'koa'

const ONE_MINUTE_MS = 60_000
const CLEANUP_INTERVAL = 256

interface IpBucket {
window: number
requests: number
}

interface Options {
requestsPerMinute: number
now?: () => number
}

export function createIpRateLimitMiddleware(options: Options): Middleware {
const requestsPerMinute = Math.floor(options.requestsPerMinute)
const now = options.now ?? (() => Date.now())
const buckets = new Map<string, IpBucket>()
let requestsSinceCleanup = 0

if (requestsPerMinute <= 0) {
return async (_ctx, next) => {
await next()
}
}

return async (ctx, next) => {
if (!shouldRateLimit(ctx)) {
await next()
return
}

const currentTime = now()
const currentWindow = Math.floor(currentTime / ONE_MINUTE_MS)
const ip = ctx.ip
const bucket = buckets.get(ip)

if (bucket && bucket.window === currentWindow) {
bucket.requests += 1
} else {
buckets.set(ip, {
window: currentWindow,
requests: 1,
})
}

cleanupExpiredBuckets(currentWindow)

const requests = buckets.get(ip)?.requests ?? 0
if (requests > requestsPerMinute) {
const nextWindowStart = (currentWindow + 1) * ONE_MINUTE_MS
const retryAfterSeconds = Math.max(
1,
Math.ceil((nextWindowStart - currentTime) / 1000)
)

ctx.set('Retry-After', retryAfterSeconds.toString())
ctx.status = 429
ctx.body = 'Too many requests. Please try again in about a minute.'
return
}

await next()
}

function cleanupExpiredBuckets(currentWindow: number) {
requestsSinceCleanup += 1

if (requestsSinceCleanup < CLEANUP_INTERVAL) {
return
}
requestsSinceCleanup = 0

for (const [bucketIp, bucket] of buckets.entries()) {
if (bucket.window < currentWindow) {
buckets.delete(bucketIp)
}
}
}
}

function shouldRateLimit(ctx: Context): boolean {
if (ctx.method !== 'GET') {
return false
}

return !isStatusPath(ctx.path)
}

function isStatusPath(path: string): boolean {
return path === '/status' || path.startsWith('/status/')
}
1 change: 1 addition & 0 deletions packages/backend/src/config/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ export interface Config<T extends TradingMode = TradingMode> {
freshStart: boolean
forceHttps: boolean
basicAuth?: string
ipRateLimitPerMinute: number
starkex: StarkexConfig<T>
}
1 change: 1 addition & 0 deletions packages/backend/src/config/environments/config.local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function getLocalConfig(env: Env): Config {
enablePreprocessing: env.boolean('ENABLE_PREPROCESSING', true),
freshStart: env.boolean('FRESH_START', false),
forceHttps: false,
ipRateLimitPerMinute: env.integer('IP_RATE_LIMIT_PER_MINUTE', 0),
starkex: {
...starkexConfig,
blockchain: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function getProductionConfig(env: Env): Config {
enablePreprocessing: env.boolean('ENABLE_PREPROCESSING', true),
freshStart: false,
forceHttps: true,
ipRateLimitPerMinute: env.integer('IP_RATE_LIMIT_PER_MINUTE', 0),
starkex: getStarkexConfig(env),
}
}
Loading