From 2d6904098e0f249ee25647ffaf7aa26f5566f3b3 Mon Sep 17 00:00:00 2001 From: YashIIT0909 <24je0721@iitism.ac.in> Date: Sun, 19 Apr 2026 01:54:16 +0530 Subject: [PATCH 1/4] feat: implement atomic sliding window rate limiting using Lua script in Redis --- src/@types/adapters.ts | 1 + src/adapters/redis-adapter.ts | 6 ++ src/utils/sliding-window-rate-limiter.ts | 60 +++++++++++++++---- .../utils/sliding-window-rate-limiter.spec.ts | 9 +-- 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index c346adb0..650c236e 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -25,4 +25,5 @@ export interface ICacheAdapter { removeRangeByScoreFromSortedSet(key: string, min: number, max: number): Promise getRangeFromSortedSet(key: string, start: number, stop: number): Promise setKeyExpiry(key: string, expiry: number): Promise + eval(script: string, keys: string[], args: string[]): Promise } diff --git a/src/adapters/redis-adapter.ts b/src/adapters/redis-adapter.ts index a3816588..08b0fd99 100644 --- a/src/adapters/redis-adapter.ts +++ b/src/adapters/redis-adapter.ts @@ -92,4 +92,10 @@ export class RedisAdapter implements ICacheAdapter { return this.client.zAdd(key, members) } + + public async eval(script: string, keys: string[], args: string[]): Promise { + await this.connection + debug('eval script with keys %o and args %o', keys, args) + return this.client.eval(script, { keys, arguments: args }) + } } diff --git a/src/utils/sliding-window-rate-limiter.ts b/src/utils/sliding-window-rate-limiter.ts index f6d5d833..436d0f3c 100644 --- a/src/utils/sliding-window-rate-limiter.ts +++ b/src/utils/sliding-window-rate-limiter.ts @@ -5,23 +5,63 @@ import { ICacheAdapter } from '../@types/adapters' const debug = createLogger('sliding-window-rate-limiter') export class SlidingWindowRateLimiter implements IRateLimiter { - public constructor(private readonly cache: ICacheAdapter) {} + public constructor( + private readonly cache: ICacheAdapter, + ) { } public async hit(key: string, step: number, options: IRateLimiterOptions): Promise { const timestamp = Date.now() - const { period } = options + const { period, rate } = options - const [, , entries] = await Promise.all([ - this.cache.removeRangeByScoreFromSortedSet(key, 0, timestamp - period), - this.cache.addToSortedSet(key, { [`${timestamp}:${step}`]: timestamp.toString() }), - this.cache.getRangeFromSortedSet(key, 0, -1), - this.cache.setKeyExpiry(key, period), + const script = ` + local key = KEYS[1] + local timestamp = tonumber(ARGV[1]) + local period = tonumber(ARGV[2]) + local step = tonumber(ARGV[3]) + local max_rate = tonumber(ARGV[4]) + + local windowStart = timestamp - period + + redis.call('ZREMRANGEBYSCORE', key, 0, windowStart) + + local entries = redis.call('ZRANGE', key, 0, -1) + local hits = 0 + for i=1, #entries do + local step_str = string.match(entries[i], "^[^:]+:(%d+)") + if step_str then + hits = hits + tonumber(step_str) + end + end + + if hits >= max_rate then + return 1 + end + + local base_member = timestamp .. ':' .. step + local member = base_member + local counter = 0 + while redis.call('ZSCORE', key, member) do + counter = counter + 1 + member = base_member .. ':' .. counter + end + + redis.call('ZADD', key, timestamp, member) + redis.call('PEXPIRE', key, period) + + return 0 + ` + + const result = await this.cache.eval(script, [key], [ + timestamp.toString(), + period.toString(), + step.toString(), + rate.toString(), ]) - const hits = entries.reduce((acc, timestampAndStep) => acc + Number(timestampAndStep.split(':')[1]), 0) + const isRateLimited = result === 1 - debug('hit count on %s bucket: %d', key, hits) + debug('hit on %s bucket: is rate limited? %s', key, isRateLimited) - return hits > options.rate + return isRateLimited } } diff --git a/test/unit/utils/sliding-window-rate-limiter.spec.ts b/test/unit/utils/sliding-window-rate-limiter.spec.ts index 7e48495f..bb916c0c 100644 --- a/test/unit/utils/sliding-window-rate-limiter.spec.ts +++ b/test/unit/utils/sliding-window-rate-limiter.spec.ts @@ -17,6 +17,7 @@ describe('SlidingWindowRateLimiter', () => { let getKeyStub: Sinon.SinonStub let hasKeyStub: Sinon.SinonStub let setKeyStub: Sinon.SinonStub + let evalStub: Sinon.SinonStub let sandbox: Sinon.SinonSandbox @@ -30,6 +31,7 @@ describe('SlidingWindowRateLimiter', () => { getKeyStub = sandbox.stub() hasKeyStub = sandbox.stub() setKeyStub = sandbox.stub() + evalStub = sandbox.stub() cache = { removeRangeByScoreFromSortedSet: removeRangeByScoreFromSortedSetStub, addToSortedSet: addToSortedSetStub, @@ -38,6 +40,7 @@ describe('SlidingWindowRateLimiter', () => { getKey: getKeyStub, hasKey: hasKeyStub, setKey: setKeyStub, + eval: evalStub, } rateLimiter = new SlidingWindowRateLimiter(cache) }) @@ -48,8 +51,7 @@ describe('SlidingWindowRateLimiter', () => { }) it('returns true if rate limited', async () => { - const now = Date.now() - getRangeFromSortedSetStub.resolves([`${now}:6`, `${now}:4`, `${now}:1`]) + evalStub.resolves(1) const actualResult = await rateLimiter.hit('key', 1, { period: 60000, rate: 10 }) @@ -57,8 +59,7 @@ describe('SlidingWindowRateLimiter', () => { }) it('returns false if not rate limited', async () => { - const now = Date.now() - getRangeFromSortedSetStub.resolves([`${now}:10`]) + evalStub.resolves(0) const actualResult = await rateLimiter.hit('key', 1, { period: 60000, rate: 10 }) From f6fe2d33a22e23d73ae50adb38590bfa1ab649f5 Mon Sep 17 00:00:00 2001 From: YashIIT0909 <24je0721@iitism.ac.in> Date: Sun, 19 Apr 2026 01:54:16 +0530 Subject: [PATCH 2/4] feat: implement atomic sliding window rate limiting using Lua script in Redis --- docker-compose.yml | 18 ++--- script.js | 73 +++++++++++++++++++ src/@types/adapters.ts | 3 + src/adapters/redis-adapter.ts | 8 ++ src/utils/sliding-window-rate-limiter.ts | 60 ++++++++++++--- .../utils/sliding-window-rate-limiter.spec.ts | 11 ++- 6 files changed, 150 insertions(+), 23 deletions(-) create mode 100644 script.js diff --git a/docker-compose.yml b/docker-compose.yml index df7c8d48..c58563ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,8 +77,8 @@ services: default: ipv4_address: 10.10.10.2 - nostream-db: - image: postgres:15 + nostream-db: + image: postgres:14 container_name: nostream-db environment: POSTGRES_DB: nostr_ts_relay @@ -88,9 +88,9 @@ services: - ${PWD}/.nostr/data:/var/lib/postgresql/data - ${PWD}/.nostr/db-logs:/var/log/postgresql - ${PWD}/postgresql.conf:/postgresql.conf - networks: - default: - ipv4_address: 10.10.10.3 + networks: + default: + ipv4_address: 10.10.10.3 command: postgres -c 'config_file=/postgresql.conf' restart: always healthcheck: @@ -100,15 +100,15 @@ services: retries: 5 start_period: 360s - nostream-cache: + nostream-cache: image: redis:7.0.5-alpine3.16 container_name: nostream-cache volumes: - cache:/data command: redis-server --loglevel warning --requirepass nostr_ts_relay - networks: - default: - ipv4_address: 10.10.10.4 + networks: + default: + ipv4_address: 10.10.10.4 restart: always healthcheck: test: [ "CMD", "redis-cli", "ping", "|", "grep", "PONG" ] diff --git a/script.js b/script.js new file mode 100644 index 00000000..8e68b47a --- /dev/null +++ b/script.js @@ -0,0 +1,73 @@ +// test-rate-limit-bypass.js +const WebSocket = require('ws'); +const crypto = require('crypto'); + +// Replace with your local nostream instance URL +const RELAY_URL = 'ws://localhost:8008'; + +// Number of concurrent connections/messages to fire at exactly the same time +const CONCURRENT_REQUESTS = 10; + +async function testRateLimitBypass() { + console.log(`Connecting to ${RELAY_URL}...`); + + // Create connections + const sockets = await Promise.all( + Array.from({ length: CONCURRENT_REQUESTS }).map(() => { + return new Promise((resolve) => { + const ws = new WebSocket(RELAY_URL); + ws.on('open', () => resolve(ws)); + ws.on('error', (err) => console.error('WS Error:', err.message)); + }); + }) + ); + + console.log(`${sockets.length} connections established. Preparing concurrent payload...`); + + let acceptedCount = 0; + let rejectedCount = 0; + + // Listen for responses + sockets.forEach((ws, index) => { + ws.on('message', (data) => { + const msg = data.toString(); + // In Nostr, rate limits usually respond with OK, [eventId], false, "rate-limited: ..." + // or CLOSED, [subId], "rate-limited: ..." + // Nostream specifically sends: ["NOTICE", "rate limited"] or "rate-limited" + if (msg.includes('rate-limited') || msg.includes('rate limited')) { + rejectedCount++; + } else { + acceptedCount++; + } + + if (acceptedCount + rejectedCount === CONCURRENT_REQUESTS) { + console.log('\n--- Test Results ---'); + console.log(`Total Requests Sent: ${CONCURRENT_REQUESTS}`); + console.log(`Accepted: ${acceptedCount}`); + console.log(`Rate Limited (Rejected): ${rejectedCount}`); + console.log('--------------------'); + + if (acceptedCount > 6) { + console.log('⚠️ BYPASS SUCCESSFUL: More requests were accepted than the configured rate limit (5) allowed.'); + } else { + console.log('✅ MITIGATED: The rate limiter successfully blocked duplicate requests in the same millisecond.'); + } + process.exit(0); + } + }); + }); + + // Generate a dummy REQ to trigger rate limiting + const dummyReq = JSON.stringify(['REQ', crypto.randomBytes(4).toString('hex'), { limit: 1 }]); + + console.log('Firing parallel requests in the exact same millisecond...'); + + // Execute all sends simultaneously + await Promise.all(sockets.map(ws => { + return new Promise((resolve) => { + ws.send(dummyReq, resolve); + }); + })); +} + +testRateLimitBypass().catch(console.error); \ No newline at end of file diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index 130d7853..3b6077eb 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -25,8 +25,11 @@ export interface ICacheAdapter { removeRangeByScoreFromSortedSet(key: string, min: number, max: number): Promise getRangeFromSortedSet(key: string, start: number, stop: number): Promise setKeyExpiry(key: string, expiry: number): Promise + deleteKey(key: string): Promise getHKey(key: string, field: string): Promise setHKey(key: string, fields: Record): Promise + + eval(script: string, keys: string[], args: string[]): Promise } diff --git a/src/adapters/redis-adapter.ts b/src/adapters/redis-adapter.ts index 3b8e062f..8e26f3f4 100644 --- a/src/adapters/redis-adapter.ts +++ b/src/adapters/redis-adapter.ts @@ -96,6 +96,7 @@ export class RedisAdapter implements ICacheAdapter { return this.client.zAdd(key, members) } + public async deleteKey(key: string): Promise { await this.connection logger('delete %s key', key) @@ -123,4 +124,11 @@ export class RedisAdapter implements ICacheAdapter { return await this.client.evalSha(this.scriptShas.get(script)!, { keys, arguments: args }) } + + public async evalRaw(script: string, keys: string[], args: string[]): Promise { + await this.connection + logger('eval script with keys %o and args %o', keys, args) + return this.client.eval(script, { keys, arguments: args }) + } + } diff --git a/src/utils/sliding-window-rate-limiter.ts b/src/utils/sliding-window-rate-limiter.ts index 44d94432..430ca8a9 100644 --- a/src/utils/sliding-window-rate-limiter.ts +++ b/src/utils/sliding-window-rate-limiter.ts @@ -5,23 +5,63 @@ import { ICacheAdapter } from '../@types/adapters' const logger = createLogger('sliding-window-rate-limiter') export class SlidingWindowRateLimiter implements IRateLimiter { - public constructor(private readonly cache: ICacheAdapter) {} + public constructor( + private readonly cache: ICacheAdapter, + ) { } public async hit(key: string, step: number, options: IRateLimiterOptions): Promise { const timestamp = Date.now() - const { period } = options + const { period, rate } = options - const [, , entries] = await Promise.all([ - this.cache.removeRangeByScoreFromSortedSet(key, 0, timestamp - period), - this.cache.addToSortedSet(key, { [`${timestamp}:${step}`]: timestamp.toString() }), - this.cache.getRangeFromSortedSet(key, 0, -1), - this.cache.setKeyExpiry(key, period), + const script = ` + local key = KEYS[1] + local timestamp = tonumber(ARGV[1]) + local period = tonumber(ARGV[2]) + local step = tonumber(ARGV[3]) + local max_rate = tonumber(ARGV[4]) + + local windowStart = timestamp - period + + redis.call('ZREMRANGEBYSCORE', key, 0, windowStart) + + local entries = redis.call('ZRANGE', key, 0, -1) + local hits = 0 + for i=1, #entries do + local step_str = string.match(entries[i], "^[^:]+:(%d+)") + if step_str then + hits = hits + tonumber(step_str) + end + end + + if hits >= max_rate then + return 1 + end + + local base_member = timestamp .. ':' .. step + local member = base_member + local counter = 0 + while redis.call('ZSCORE', key, member) do + counter = counter + 1 + member = base_member .. ':' .. counter + end + + redis.call('ZADD', key, timestamp, member) + redis.call('PEXPIRE', key, period) + + return 0 + ` + + const result = await this.cache.eval(script, [key], [ + timestamp.toString(), + period.toString(), + step.toString(), + rate.toString(), ]) - const hits = entries.reduce((acc, timestampAndStep) => acc + Number(timestampAndStep.split(':')[1]), 0) + const isRateLimited = result === 1 - logger('hit count on %s bucket: %d', key, hits) + logger('hit on %s bucket: is rate limited? %s', key, isRateLimited) - return hits > options.rate + return isRateLimited } } diff --git a/test/unit/utils/sliding-window-rate-limiter.spec.ts b/test/unit/utils/sliding-window-rate-limiter.spec.ts index 87cb75a4..3e1b072b 100644 --- a/test/unit/utils/sliding-window-rate-limiter.spec.ts +++ b/test/unit/utils/sliding-window-rate-limiter.spec.ts @@ -17,6 +17,7 @@ describe('SlidingWindowRateLimiter', () => { let getKeyStub: Sinon.SinonStub let hasKeyStub: Sinon.SinonStub let setKeyStub: Sinon.SinonStub + let evalStub: Sinon.SinonStub let sandbox: Sinon.SinonSandbox @@ -30,6 +31,7 @@ describe('SlidingWindowRateLimiter', () => { getKeyStub = sandbox.stub() hasKeyStub = sandbox.stub() setKeyStub = sandbox.stub() + evalStub = sandbox.stub() cache = { removeRangeByScoreFromSortedSet: removeRangeByScoreFromSortedSetStub, addToSortedSet: addToSortedSetStub, @@ -38,7 +40,10 @@ describe('SlidingWindowRateLimiter', () => { getKey: getKeyStub, hasKey: hasKeyStub, setKey: setKeyStub, + eval: evalStub, } as unknown as ICacheAdapter + + rateLimiter = new SlidingWindowRateLimiter(cache) }) @@ -48,8 +53,7 @@ describe('SlidingWindowRateLimiter', () => { }) it('returns true if rate limited', async () => { - const now = Date.now() - getRangeFromSortedSetStub.resolves([`${now}:6`, `${now}:4`, `${now}:1`]) + evalStub.resolves(1) const actualResult = await rateLimiter.hit('key', 1, { period: 60000, rate: 10 }) @@ -57,8 +61,7 @@ describe('SlidingWindowRateLimiter', () => { }) it('returns false if not rate limited', async () => { - const now = Date.now() - getRangeFromSortedSetStub.resolves([`${now}:10`]) + evalStub.resolves(0) const actualResult = await rateLimiter.hit('key', 1, { period: 60000, rate: 10 }) From 2bf0ff551bd8307d3ceb5dc4a7d64790a89af6a7 Mon Sep 17 00:00:00 2001 From: YashIIT0909 <24je0721@iitism.ac.in> Date: Fri, 1 May 2026 16:46:21 +0530 Subject: [PATCH 3/4] docs(changeset): fix: resolve TOCTOU race condition and key collisions in SlidingWindowRateLimiter --- .changeset/metal-snails-prove.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/metal-snails-prove.md diff --git a/.changeset/metal-snails-prove.md b/.changeset/metal-snails-prove.md new file mode 100644 index 00000000..d1f972c1 --- /dev/null +++ b/.changeset/metal-snails-prove.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +fix: resolve TOCTOU race condition and key collisions in SlidingWindowRateLimiter From 00a95af6e192ae917cc4ab3fa524b15869d3bb31 Mon Sep 17 00:00:00 2001 From: YashIIT0909 <24je0721@iitism.ac.in> Date: Fri, 1 May 2026 23:36:51 +0530 Subject: [PATCH 4/4] refactor: optimize sliding window rate limiter Lua script and handle string return types from Redis --- src/adapters/redis-adapter.ts | 6 ---- src/utils/sliding-window-rate-limiter.ts | 35 ++++++++++--------- .../utils/sliding-window-rate-limiter.spec.ts | 14 ++++++++ 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/adapters/redis-adapter.ts b/src/adapters/redis-adapter.ts index 8e26f3f4..0203d02b 100644 --- a/src/adapters/redis-adapter.ts +++ b/src/adapters/redis-adapter.ts @@ -125,10 +125,4 @@ export class RedisAdapter implements ICacheAdapter { } - public async evalRaw(script: string, keys: string[], args: string[]): Promise { - await this.connection - logger('eval script with keys %o and args %o', keys, args) - return this.client.eval(script, { keys, arguments: args }) - } - } diff --git a/src/utils/sliding-window-rate-limiter.ts b/src/utils/sliding-window-rate-limiter.ts index 430ca8a9..c91efae4 100644 --- a/src/utils/sliding-window-rate-limiter.ts +++ b/src/utils/sliding-window-rate-limiter.ts @@ -4,16 +4,7 @@ import { ICacheAdapter } from '../@types/adapters' const logger = createLogger('sliding-window-rate-limiter') -export class SlidingWindowRateLimiter implements IRateLimiter { - public constructor( - private readonly cache: ICacheAdapter, - ) { } - - public async hit(key: string, step: number, options: IRateLimiterOptions): Promise { - const timestamp = Date.now() - const { period, rate } = options - - const script = ` +const SLIDING_WINDOW_RATE_LIMITER_LUA_SCRIPT = ` local key = KEYS[1] local timestamp = tonumber(ARGV[1]) local period = tonumber(ARGV[2]) @@ -27,13 +18,16 @@ export class SlidingWindowRateLimiter implements IRateLimiter { local entries = redis.call('ZRANGE', key, 0, -1) local hits = 0 for i=1, #entries do - local step_str = string.match(entries[i], "^[^:]+:(%d+)") + local step_str = string.match(entries[i], "^[^:]+:([^:]+)") if step_str then - hits = hits + tonumber(step_str) + local entry_step = tonumber(step_str) + if entry_step then + hits = hits + entry_step + end end end - if hits >= max_rate then + if hits + step > max_rate then return 1 end @@ -49,16 +43,25 @@ export class SlidingWindowRateLimiter implements IRateLimiter { redis.call('PEXPIRE', key, period) return 0 - ` +` + +export class SlidingWindowRateLimiter implements IRateLimiter { + public constructor( + private readonly cache: ICacheAdapter, + ) { } + + public async hit(key: string, step: number, options: IRateLimiterOptions): Promise { + const timestamp = Date.now() + const { period, rate } = options - const result = await this.cache.eval(script, [key], [ + const result = await this.cache.eval(SLIDING_WINDOW_RATE_LIMITER_LUA_SCRIPT, [key], [ timestamp.toString(), period.toString(), step.toString(), rate.toString(), ]) - const isRateLimited = result === 1 + const isRateLimited = result === 1 || result === '1' logger('hit on %s bucket: is rate limited? %s', key, isRateLimited) diff --git a/test/unit/utils/sliding-window-rate-limiter.spec.ts b/test/unit/utils/sliding-window-rate-limiter.spec.ts index 3e1b072b..a864a93d 100644 --- a/test/unit/utils/sliding-window-rate-limiter.spec.ts +++ b/test/unit/utils/sliding-window-rate-limiter.spec.ts @@ -58,6 +58,12 @@ describe('SlidingWindowRateLimiter', () => { const actualResult = await rateLimiter.hit('key', 1, { period: 60000, rate: 10 }) expect(actualResult).to.be.true + expect(evalStub).to.have.been.calledOnce + const args = evalStub.firstCall.args + expect(args[1]).to.deep.equal(['key']) + expect(args[2][1]).to.equal('60000') // period + expect(args[2][2]).to.equal('1') // step + expect(args[2][3]).to.equal('10') // max_rate }) it('returns false if not rate limited', async () => { @@ -67,4 +73,12 @@ describe('SlidingWindowRateLimiter', () => { expect(actualResult).to.be.false }) + + it('robustly handles string return types from Redis', async () => { + evalStub.resolves('1') + + const actualResult = await rateLimiter.hit('key', 1, { period: 60000, rate: 10 }) + + expect(actualResult).to.be.true + }) })