diff --git a/chainhook/.env.example b/chainhook/.env.example index 42bd7303..39cdb017 100644 --- a/chainhook/.env.example +++ b/chainhook/.env.example @@ -39,6 +39,20 @@ RATE_LIMIT_MAX_REQUESTS=100 # Range: 1000-3600000 (1 second to 1 hour) RATE_LIMIT_WINDOW_MS=60000 +# Per-address rate limiting (wallet-based, complements IP limiting) +# Maximum tip events per Stacks address within the time window +# Can be reconfigured at runtime via /api/admin/address-rate-limit endpoint +# Range: 1-10000 +ADDRESS_RATE_LIMIT_MAX_REQUESTS=50 + +# Time window for per-address rate limiting in milliseconds +# Range: 1000-3600000 (1 second to 1 hour) +ADDRESS_RATE_LIMIT_WINDOW_MS=60000 + +# Comma-separated list of Stacks addresses that bypass address rate limiting +# Example: SP1ABC...XYZ,SP2DEF...ABC +ADDRESS_RATE_LIMIT_WHITELIST= + # Logging Level LOG_LEVEL=INFO diff --git a/chainhook/address-rate-limit-integration.test.js b/chainhook/address-rate-limit-integration.test.js new file mode 100644 index 00000000..6cc776ce --- /dev/null +++ b/chainhook/address-rate-limit-integration.test.js @@ -0,0 +1,185 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { AddressRateLimiter, parseAddressWhitelist } from './rate-limit.js'; +import { parseTipEvent } from './server.js'; +import { isValidStacksAddress } from './validation.js'; + +const ADDR_A = 'SP1SENDER000000000000000000000000000'; +const ADDR_B = 'SP2RECIPIENT00000000000000000000000'; +const TRUSTED = 'SP3TRUSTED000000000000000000000000A'; + +function makeTipEvent(sender = ADDR_A, recipient = ADDR_B) { + return { + txId: '0xabc123', + blockHeight: 100, + timestamp: 1700000000000, + contract: 'SP123.tipstream', + event: { + event: 'tip-sent', + 'tip-id': 1, + sender, + recipient, + amount: 100000, + fee: 500, + 'net-amount': 99500, + }, + }; +} + +describe('dual rate limiting — IP + address', () => { + it('parseTipEvent extracts sender for address checking', () => { + const evt = makeTipEvent(ADDR_A, ADDR_B); + const tip = parseTipEvent(evt); + assert.ok(tip); + assert.strictEqual(tip.sender, ADDR_A); + assert.strictEqual(tip.recipient, ADDR_B); + }); + + it('address limiter blocks sender after limit is reached', () => { + const limiter = new AddressRateLimiter(2, 1000); + const evt = makeTipEvent(ADDR_A, ADDR_B); + const tip = parseTipEvent(evt); + + assert.strictEqual(limiter.isAllowed(tip.sender), true); + assert.strictEqual(limiter.isAllowed(tip.sender), true); + assert.strictEqual(limiter.isAllowed(tip.sender), false); + }); + + it('address limiter does not block a different sender', () => { + const limiter = new AddressRateLimiter(1, 1000); + limiter.isAllowed(ADDR_A); + assert.strictEqual(limiter.isAllowed(ADDR_A), false); + assert.strictEqual(limiter.isAllowed(ADDR_B), true); + }); + + it('whitelisted sender is never blocked even after many events', () => { + const limiter = new AddressRateLimiter(1, 1000, [TRUSTED]); + for (let i = 0; i < 100; i++) { + assert.strictEqual(limiter.isAllowed(TRUSTED), true); + } + }); + + it('non-tip events do not consume address quota', () => { + const limiter = new AddressRateLimiter(2, 1000); + const nonTipEvent = { + txId: '0xdef', + blockHeight: 101, + timestamp: 1700000001000, + contract: 'SP123.tipstream', + event: { event: 'profile-updated', user: ADDR_A }, + }; + const tip = parseTipEvent(nonTipEvent); + assert.strictEqual(tip, null); + assert.strictEqual(limiter.getRemaining(ADDR_A), 2); + }); +}); + +describe('whitelist management — runtime add/remove', () => { + let limiter; + + beforeEach(() => { + limiter = new AddressRateLimiter(1, 1000); + }); + + it('address is blocked before whitelisting', () => { + limiter.isAllowed(ADDR_A); + assert.strictEqual(limiter.isAllowed(ADDR_A), false); + }); + + it('address is allowed after being added to whitelist', () => { + limiter.isAllowed(ADDR_A); + assert.strictEqual(limiter.isAllowed(ADDR_A), false); + limiter.addToWhitelist(ADDR_A); + assert.strictEqual(limiter.isAllowed(ADDR_A), true); + assert.strictEqual(limiter.isAllowed(ADDR_A), true); + }); + + it('address is blocked again after being removed from whitelist', () => { + limiter.addToWhitelist(ADDR_A); + assert.strictEqual(limiter.isAllowed(ADDR_A), true); + limiter.removeFromWhitelist(ADDR_A); + limiter.isAllowed(ADDR_A); + assert.strictEqual(limiter.isAllowed(ADDR_A), false); + }); + + it('removing a non-whitelisted address is a no-op', () => { + assert.doesNotThrow(() => limiter.removeFromWhitelist(ADDR_B)); + assert.strictEqual(limiter.getWhitelist().length, 0); + }); +}); + +describe('admin endpoint validation logic', () => { + it('isValidStacksAddress accepts a valid SP address', () => { + assert.strictEqual(isValidStacksAddress(ADDR_A), true); + assert.strictEqual(isValidStacksAddress(ADDR_B), true); + assert.strictEqual(isValidStacksAddress(TRUSTED), true); + }); + + it('isValidStacksAddress rejects an invalid address', () => { + assert.strictEqual(isValidStacksAddress('not-an-address'), false); + assert.strictEqual(isValidStacksAddress(''), false); + assert.strictEqual(isValidStacksAddress(null), false); + }); + + it('parseAddressWhitelist integrates with AddressRateLimiter constructor', () => { + const raw = `${ADDR_A},${TRUSTED}`; + const whitelist = parseAddressWhitelist(raw); + const limiter = new AddressRateLimiter(1, 1000, whitelist); + + assert.strictEqual(limiter.isWhitelisted(ADDR_A), true); + assert.strictEqual(limiter.isWhitelisted(TRUSTED), true); + assert.strictEqual(limiter.isWhitelisted(ADDR_B), false); + }); + + it('empty whitelist env var produces no whitelisted addresses', () => { + const whitelist = parseAddressWhitelist(''); + const limiter = new AddressRateLimiter(1, 1000, whitelist); + assert.strictEqual(limiter.getConfig().whitelistSize, 0); + }); +}); + +describe('address rate limit config update', () => { + it('lowering the limit takes effect immediately', () => { + const limiter = new AddressRateLimiter(10, 1000); + for (let i = 0; i < 5; i++) limiter.isAllowed(ADDR_A); + limiter.updateConfig(3, 1000); + assert.strictEqual(limiter.isAllowed(ADDR_A), false); + }); + + it('raising the limit allows previously blocked address', () => { + const limiter = new AddressRateLimiter(2, 1000); + limiter.isAllowed(ADDR_A); + limiter.isAllowed(ADDR_A); + assert.strictEqual(limiter.isAllowed(ADDR_A), false); + limiter.updateConfig(10, 1000); + assert.strictEqual(limiter.isAllowed(ADDR_A), true); + }); + + it('getConfig reflects updated values', () => { + const limiter = new AddressRateLimiter(5, 30000); + limiter.updateConfig(20, 120000); + const config = limiter.getConfig(); + assert.strictEqual(config.maxRequests, 20); + assert.strictEqual(config.windowMs, 120000); + }); +}); + +describe('error response shape for rate-limited address', () => { + it('getRemaining returns 0 when limit is exhausted', () => { + const limiter = new AddressRateLimiter(2, 1000); + limiter.isAllowed(ADDR_A); + limiter.isAllowed(ADDR_A); + assert.strictEqual(limiter.getRemaining(ADDR_A), 0); + assert.strictEqual(limiter.isAllowed(ADDR_A), false); + }); + + it('remaining is included in the error context', () => { + const limiter = new AddressRateLimiter(1, 1000); + limiter.isAllowed(ADDR_A); + const remaining = limiter.getRemaining(ADDR_A); + assert.strictEqual(remaining, 0); + const errorContext = { remaining, address: ADDR_A }; + assert.strictEqual(errorContext.remaining, 0); + assert.strictEqual(errorContext.address, ADDR_A); + }); +}); diff --git a/chainhook/address-rate-limit.test.js b/chainhook/address-rate-limit.test.js new file mode 100644 index 00000000..5cac6252 --- /dev/null +++ b/chainhook/address-rate-limit.test.js @@ -0,0 +1,294 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { + AddressRateLimiter, + parseAddressWhitelist, + validateAddressRateLimitConfig, +} from './rate-limit.js'; + +const ADDR_A = 'SP1SENDER000000000000000000000000000'; +const ADDR_B = 'SP2RECIPIENT00000000000000000000000'; +const ADDR_C = 'SP3TRUSTED000000000000000000000000A'; + +describe('AddressRateLimiter — basic limiting', () => { + let limiter; + + beforeEach(() => { + limiter = new AddressRateLimiter(3, 1000); + }); + + it('allows requests within the limit', () => { + assert.strictEqual(limiter.isAllowed(ADDR_A), true); + assert.strictEqual(limiter.isAllowed(ADDR_A), true); + assert.strictEqual(limiter.isAllowed(ADDR_A), true); + }); + + it('rejects requests that exceed the limit', () => { + limiter.isAllowed(ADDR_A); + limiter.isAllowed(ADDR_A); + limiter.isAllowed(ADDR_A); + assert.strictEqual(limiter.isAllowed(ADDR_A), false); + }); + + it('tracks separate addresses independently', () => { + limiter.isAllowed(ADDR_A); + limiter.isAllowed(ADDR_A); + limiter.isAllowed(ADDR_A); + assert.strictEqual(limiter.isAllowed(ADDR_A), false); + assert.strictEqual(limiter.isAllowed(ADDR_B), true); + }); + + it('resets after the window expires', (t, done) => { + const fast = new AddressRateLimiter(1, 50); + assert.strictEqual(fast.isAllowed(ADDR_A), true); + assert.strictEqual(fast.isAllowed(ADDR_A), false); + setTimeout(() => { + assert.strictEqual(fast.isAllowed(ADDR_A), true); + done(); + }, 100); + }); + + it('returns true for null or undefined address', () => { + assert.strictEqual(limiter.isAllowed(null), true); + assert.strictEqual(limiter.isAllowed(undefined), true); + assert.strictEqual(limiter.isAllowed(''), true); + }); + + it('is case-insensitive for address keys', () => { + const lower = ADDR_A.toLowerCase(); + limiter.isAllowed(ADDR_A); + limiter.isAllowed(ADDR_A); + limiter.isAllowed(ADDR_A); + assert.strictEqual(limiter.isAllowed(lower), false); + }); +}); + +describe('AddressRateLimiter — getRemaining', () => { + let limiter; + + beforeEach(() => { + limiter = new AddressRateLimiter(3, 1000); + }); + + it('returns maxRequests for an unseen address', () => { + assert.strictEqual(limiter.getRemaining(ADDR_A), 3); + }); + + it('decrements correctly after each request', () => { + limiter.isAllowed(ADDR_A); + assert.strictEqual(limiter.getRemaining(ADDR_A), 2); + limiter.isAllowed(ADDR_A); + assert.strictEqual(limiter.getRemaining(ADDR_A), 1); + limiter.isAllowed(ADDR_A); + assert.strictEqual(limiter.getRemaining(ADDR_A), 0); + }); + + it('returns maxRequests for null address', () => { + assert.strictEqual(limiter.getRemaining(null), 3); + }); +}); + +describe('AddressRateLimiter — whitelist', () => { + it('whitelisted addresses are always allowed regardless of limit', () => { + const limiter = new AddressRateLimiter(1, 1000, [ADDR_C]); + limiter.isAllowed(ADDR_C); + limiter.isAllowed(ADDR_C); + assert.strictEqual(limiter.isAllowed(ADDR_C), true); + }); + + it('getRemaining returns maxRequests for whitelisted address', () => { + const limiter = new AddressRateLimiter(2, 1000, [ADDR_C]); + limiter.isAllowed(ADDR_C); + assert.strictEqual(limiter.getRemaining(ADDR_C), 2); + }); + + it('isWhitelisted returns true for a whitelisted address', () => { + const limiter = new AddressRateLimiter(5, 1000, [ADDR_C]); + assert.strictEqual(limiter.isWhitelisted(ADDR_C), true); + }); + + it('isWhitelisted returns false for a non-whitelisted address', () => { + const limiter = new AddressRateLimiter(5, 1000, [ADDR_C]); + assert.strictEqual(limiter.isWhitelisted(ADDR_A), false); + }); + + it('addToWhitelist adds an address at runtime', () => { + const limiter = new AddressRateLimiter(1, 1000); + limiter.isAllowed(ADDR_A); + assert.strictEqual(limiter.isAllowed(ADDR_A), false); + + limiter.addToWhitelist(ADDR_A); + assert.strictEqual(limiter.isAllowed(ADDR_A), true); + assert.strictEqual(limiter.isWhitelisted(ADDR_A), true); + }); + + it('removeFromWhitelist removes an address at runtime', () => { + const limiter = new AddressRateLimiter(1, 1000, [ADDR_C]); + assert.strictEqual(limiter.isAllowed(ADDR_C), true); + + limiter.removeFromWhitelist(ADDR_C); + assert.strictEqual(limiter.isWhitelisted(ADDR_C), false); + limiter.isAllowed(ADDR_C); + assert.strictEqual(limiter.isAllowed(ADDR_C), false); + }); + + it('getWhitelist returns sorted array of whitelisted addresses', () => { + const limiter = new AddressRateLimiter(5, 1000, [ADDR_B, ADDR_A]); + const list = limiter.getWhitelist(); + assert.ok(Array.isArray(list)); + assert.strictEqual(list.length, 2); + assert.ok(list.includes(ADDR_A.toUpperCase())); + assert.ok(list.includes(ADDR_B.toUpperCase())); + assert.deepStrictEqual(list, [...list].sort()); + }); + + it('whitelist constructor deduplicates entries', () => { + const limiter = new AddressRateLimiter(5, 1000, [ADDR_A, ADDR_A, ADDR_A]); + assert.strictEqual(limiter.getWhitelist().length, 1); + }); + + it('addToWhitelist ignores null and non-string values', () => { + const limiter = new AddressRateLimiter(5, 1000); + limiter.addToWhitelist(null); + limiter.addToWhitelist(undefined); + limiter.addToWhitelist(123); + assert.strictEqual(limiter.getWhitelist().length, 0); + }); + + it('whitelist is case-insensitive', () => { + const limiter = new AddressRateLimiter(1, 1000, [ADDR_C.toLowerCase()]); + assert.strictEqual(limiter.isWhitelisted(ADDR_C), true); + assert.strictEqual(limiter.isWhitelisted(ADDR_C.toLowerCase()), true); + }); +}); + +describe('AddressRateLimiter — updateConfig and getConfig', () => { + it('getConfig returns current settings', () => { + const limiter = new AddressRateLimiter(10, 5000); + const config = limiter.getConfig(); + assert.strictEqual(config.maxRequests, 10); + assert.strictEqual(config.windowMs, 5000); + assert.strictEqual(config.whitelistSize, 0); + }); + + it('getConfig reflects whitelist size', () => { + const limiter = new AddressRateLimiter(10, 5000, [ADDR_A, ADDR_B]); + assert.strictEqual(limiter.getConfig().whitelistSize, 2); + }); + + it('updateConfig changes limits immediately', () => { + const limiter = new AddressRateLimiter(1, 1000); + limiter.isAllowed(ADDR_A); + assert.strictEqual(limiter.isAllowed(ADDR_A), false); + + limiter.updateConfig(5, 1000); + assert.strictEqual(limiter.isAllowed(ADDR_A), true); + assert.strictEqual(limiter.isAllowed(ADDR_A), true); + assert.strictEqual(limiter.isAllowed(ADDR_A), true); + }); + + it('updateConfig preserves existing counters', () => { + const limiter = new AddressRateLimiter(3, 1000); + limiter.isAllowed(ADDR_A); + limiter.isAllowed(ADDR_A); + limiter.updateConfig(3, 1000); + assert.strictEqual(limiter.getRemaining(ADDR_A), 1); + }); +}); + +describe('AddressRateLimiter — cleanup', () => { + it('removes expired entries', (t, done) => { + const limiter = new AddressRateLimiter(1, 50); + limiter.isAllowed(ADDR_A); + assert.strictEqual(limiter.requests.size, 1); + + setTimeout(() => { + limiter.cleanup(); + assert.strictEqual(limiter.requests.size, 0); + done(); + }, 100); + }); + + it('retains entries that are still within the window', () => { + const limiter = new AddressRateLimiter(3, 5000); + limiter.isAllowed(ADDR_A); + limiter.isAllowed(ADDR_B); + limiter.cleanup(); + assert.strictEqual(limiter.requests.size, 2); + }); +}); + +describe('parseAddressWhitelist', () => { + it('parses a comma-separated list', () => { + const result = parseAddressWhitelist(`${ADDR_A},${ADDR_B}`); + assert.deepStrictEqual(result, [ADDR_A, ADDR_B]); + }); + + it('trims whitespace around entries', () => { + const result = parseAddressWhitelist(` ${ADDR_A} , ${ADDR_B} `); + assert.deepStrictEqual(result, [ADDR_A, ADDR_B]); + }); + + it('filters out empty entries', () => { + const result = parseAddressWhitelist(`${ADDR_A},,${ADDR_B},`); + assert.strictEqual(result.length, 2); + }); + + it('returns empty array for empty string', () => { + assert.deepStrictEqual(parseAddressWhitelist(''), []); + }); + + it('returns empty array for null', () => { + assert.deepStrictEqual(parseAddressWhitelist(null), []); + }); + + it('returns empty array for undefined', () => { + assert.deepStrictEqual(parseAddressWhitelist(undefined), []); + }); + + it('returns single-element array for one address', () => { + const result = parseAddressWhitelist(ADDR_A); + assert.deepStrictEqual(result, [ADDR_A]); + }); +}); + +describe('validateAddressRateLimitConfig', () => { + it('accepts valid parameters', () => { + const result = validateAddressRateLimitConfig(50, 60000); + assert.strictEqual(result.valid, true); + }); + + it('rejects maxRequests below 1', () => { + const result = validateAddressRateLimitConfig(0, 60000); + assert.strictEqual(result.valid, false); + assert.match(result.error, /maxRequests/); + }); + + it('rejects maxRequests above 10000', () => { + const result = validateAddressRateLimitConfig(20000, 60000); + assert.strictEqual(result.valid, false); + assert.match(result.error, /maxRequests/); + }); + + it('rejects windowMs below 1000', () => { + const result = validateAddressRateLimitConfig(50, 500); + assert.strictEqual(result.valid, false); + assert.match(result.error, /windowMs/); + }); + + it('rejects windowMs above 3600000', () => { + const result = validateAddressRateLimitConfig(50, 4000000); + assert.strictEqual(result.valid, false); + assert.match(result.error, /windowMs/); + }); + + it('rejects non-number maxRequests', () => { + const result = validateAddressRateLimitConfig('50', 60000); + assert.strictEqual(result.valid, false); + }); + + it('rejects NaN values', () => { + assert.strictEqual(validateAddressRateLimitConfig(NaN, 60000).valid, false); + assert.strictEqual(validateAddressRateLimitConfig(50, NaN).valid, false); + }); +}); diff --git a/chainhook/rate-limit.js b/chainhook/rate-limit.js index 14cfec53..83d93e3b 100644 --- a/chainhook/rate-limit.js +++ b/chainhook/rate-limit.js @@ -1,13 +1,14 @@ /** * Rate limiting for the webhook endpoint. - * + * * Implements per-IP rate limiting to prevent abuse and DoS attacks - * on the event ingestion endpoint. + * on the event ingestion endpoint, and per-address rate limiting to + * prevent wallet-based abuse from users rotating IP addresses. */ /** * Simple in-memory rate limiter using sliding window counters. - * Tracks requests per IP address and enforces rate limits. + * Tracks requests per key (IP address or Stacks address) and enforces rate limits. */ export class RateLimiter { /** @@ -148,3 +149,177 @@ export function validateRateLimitConfig(maxRequests, windowMs) { return { valid: true }; } + +/** + * Per-address rate limiter with whitelist support. + * + * Tracks tip submissions per Stacks wallet address using the same sliding + * window algorithm as RateLimiter. Whitelisted addresses bypass all limits. + * Designed to complement IP-based limiting so that users rotating IPs cannot + * exceed the per-address quota. + */ +export class AddressRateLimiter { + /** + * @param {number} maxRequests - Maximum requests allowed per window per address + * @param {number} windowMs - Time window in milliseconds + * @param {string[]} [whitelist] - Addresses that are never rate limited + */ + constructor(maxRequests, windowMs, whitelist = []) { + this.maxRequests = maxRequests; + this.windowMs = windowMs; + this.requests = new Map(); + this.whitelist = new Set( + whitelist.map(a => (typeof a === 'string' ? a.trim().toUpperCase() : '')).filter(Boolean) + ); + } + + _key(address) { + return typeof address === 'string' ? address.trim().toUpperCase() : ''; + } + + isWhitelisted(address) { + return this.whitelist.has(this._key(address)); + } + + /** + * Check if an address has exceeded its rate limit. + * Whitelisted addresses always return true. + * + * @param {string} address - Stacks wallet address + * @returns {boolean} True if the request is allowed + */ + isAllowed(address) { + if (!address || typeof address !== 'string') return true; + if (this.isWhitelisted(address)) return true; + + const now = Date.now(); + const key = this._key(address); + + if (!this.requests.has(key)) { + this.requests.set(key, []); + } + + const timestamps = this.requests.get(key); + const valid = timestamps.filter(ts => now - ts < this.windowMs); + + if (valid.length >= this.maxRequests) { + return false; + } + + valid.push(now); + this.requests.set(key, valid); + return true; + } + + /** + * Get remaining requests for an address within the current window. + * Returns maxRequests for whitelisted addresses. + * + * @param {string} address - Stacks wallet address + * @returns {number} + */ + getRemaining(address) { + if (!address || typeof address !== 'string') return this.maxRequests; + if (this.isWhitelisted(address)) return this.maxRequests; + + const now = Date.now(); + const key = this._key(address); + + if (!this.requests.has(key)) return this.maxRequests; + + const valid = this.requests.get(key).filter(ts => now - ts < this.windowMs); + return Math.max(0, this.maxRequests - valid.length); + } + + /** + * Add an address to the whitelist. + * @param {string} address + */ + addToWhitelist(address) { + if (address && typeof address === 'string') { + this.whitelist.add(this._key(address)); + } + } + + /** + * Remove an address from the whitelist. + * @param {string} address + */ + removeFromWhitelist(address) { + if (address && typeof address === 'string') { + this.whitelist.delete(this._key(address)); + } + } + + /** + * Return the current whitelist as a sorted array of addresses. + * @returns {string[]} + */ + getWhitelist() { + return Array.from(this.whitelist).sort(); + } + + /** + * Update rate limit configuration at runtime. + * Existing counters are preserved; the new limits apply immediately. + * + * @param {number} maxRequests + * @param {number} windowMs + */ + updateConfig(maxRequests, windowMs) { + this.maxRequests = maxRequests; + this.windowMs = windowMs; + } + + /** + * Get current configuration. + * @returns {{ maxRequests: number, windowMs: number, whitelistSize: number }} + */ + getConfig() { + return { + maxRequests: this.maxRequests, + windowMs: this.windowMs, + whitelistSize: this.whitelist.size, + }; + } + + /** + * Remove expired entries to prevent unbounded memory growth. + * Should be called on the same interval as RateLimiter.cleanup(). + */ + cleanup() { + const now = Date.now(); + for (const [key, timestamps] of this.requests.entries()) { + const valid = timestamps.filter(ts => now - ts < this.windowMs); + if (valid.length === 0) { + this.requests.delete(key); + } else { + this.requests.set(key, valid); + } + } + } +} + +/** + * Parse a comma-separated whitelist string from an environment variable. + * Returns an array of trimmed, non-empty address strings. + * + * @param {string} [value] - Raw env var value + * @returns {string[]} + */ +export function parseAddressWhitelist(value) { + if (!value || typeof value !== 'string') return []; + return value.split(',').map(s => s.trim()).filter(Boolean); +} + +/** + * Validate address rate limit configuration parameters. + * Delegates to validateRateLimitConfig since the same bounds apply. + * + * @param {number} maxRequests + * @param {number} windowMs + * @returns {{ valid: boolean, error?: string }} + */ +export function validateAddressRateLimitConfig(maxRequests, windowMs) { + return validateRateLimitConfig(maxRequests, windowMs); +} diff --git a/chainhook/server.js b/chainhook/server.js index be0d3452..c316ad61 100644 --- a/chainhook/server.js +++ b/chainhook/server.js @@ -6,7 +6,7 @@ import { deduplicateEvents } from "./deduplication.js"; import { metrics } from "./metrics.js"; import { validateBearerToken } from "./auth.js"; import { parseAllowedOrigins, getCorsHeaders } from "./cors.js"; -import { RateLimiter, getClientIp, validateRateLimitConfig } from "./rate-limit.js"; +import { RateLimiter, getClientIp, validateRateLimitConfig, AddressRateLimiter, parseAddressWhitelist, validateAddressRateLimitConfig } from "./rate-limit.js"; import { logger } from "./logging.js"; import { setupGracefulShutdown, isShuttingDown } from "./graceful-shutdown.js"; import { createEventStore, createScheduledTipStore, createRefundStore, getRetentionCutoff, parseRetentionDays } from "./storage.js"; @@ -28,6 +28,15 @@ const CORS_ALLOWED_ORIGINS = parseAllowedOrigins(process.env.CORS_ALLOWED_ORIGIN const RATE_LIMIT_MAX_REQUESTS = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || "100", 10); const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || "60000", 10); const rateLimiter = new RateLimiter(RATE_LIMIT_MAX_REQUESTS, RATE_LIMIT_WINDOW_MS); + +const ADDRESS_RATE_LIMIT_MAX_REQUESTS = parseInt(process.env.ADDRESS_RATE_LIMIT_MAX_REQUESTS || "50", 10); +const ADDRESS_RATE_LIMIT_WINDOW_MS = parseInt(process.env.ADDRESS_RATE_LIMIT_WINDOW_MS || "60000", 10); +const ADDRESS_RATE_LIMIT_WHITELIST = parseAddressWhitelist(process.env.ADDRESS_RATE_LIMIT_WHITELIST || ""); +const addressRateLimiter = new AddressRateLimiter( + ADDRESS_RATE_LIMIT_MAX_REQUESTS, + ADDRESS_RATE_LIMIT_WINDOW_MS, + ADDRESS_RATE_LIMIT_WHITELIST +); let eventStore = null; let scheduledTipStore = null; let refundStore = null; @@ -42,6 +51,10 @@ function getRateLimiter() { return rateLimiter; } +function getAddressRateLimiter() { + return addressRateLimiter; +} + async function getEventStore() { if (!eventStore) { if (STORAGE_MODE === "postgres" && !DATABASE_URL) { @@ -290,7 +303,7 @@ function parseTipEvent(event) { }; } -export { parseBody, extractEvents, parseTipEvent, sendJson, getEventStore, checkShutdownState, validatePayloadStructure, validateBlock, validateTransaction, getRateLimiter, wsManager, getRefundStore }; +export { parseBody, extractEvents, parseTipEvent, sendJson, getEventStore, checkShutdownState, validatePayloadStructure, validateBlock, validateTransaction, getRateLimiter, getAddressRateLimiter, wsManager, getRefundStore }; function checkShutdownState(res, requestId) { if (isShuttingDown()) { @@ -375,6 +388,22 @@ const server = http.createServer(async (req, res) => { const newEvents = extractEvents(payload); let insertedCount = 0; let duplicateCount = 0; + + for (const evt of newEvents) { + const tip = parseTipEvent(evt); + if (tip && tip.sender) { + if (!addressRateLimiter.isAllowed(tip.sender)) { + metrics.recordRequest(false); + const remaining = addressRateLimiter.getRemaining(tip.sender); + return sendError( + res, + new RateLimitError("address rate limit exceeded", { remaining, address: tip.sender }), + requestId, + { address: tip.sender, remaining }, + ); + } + } + } if (newEvents.length > 0) { const existingEvents = await store.listEvents(); @@ -974,6 +1003,184 @@ const server = http.createServer(async (req, res) => { } } + // GET /api/admin/address-rate-limit -- get current address rate limit configuration + if (req.method === "GET" && path === "/api/admin/address-rate-limit") { + if (AUTH_TOKEN) { + const authHeader = req.headers.authorization || ""; + if (!validateBearerToken(authHeader, AUTH_TOKEN)) { + return sendError(res, new UnauthorizedError("unauthorized"), requestId, { path }); + } + } + const config = addressRateLimiter.getConfig(); + return sendJson(res, 200, { + maxRequests: config.maxRequests, + windowMs: config.windowMs, + windowSeconds: Math.round(config.windowMs / 1000), + whitelistSize: config.whitelistSize, + }); + } + + // POST /api/admin/address-rate-limit -- update address rate limit configuration + if (req.method === "POST" && path === "/api/admin/address-rate-limit") { + const startTime = Date.now(); + + if (AUTH_TOKEN) { + const authHeader = req.headers.authorization || ""; + if (!validateBearerToken(authHeader, AUTH_TOKEN)) { + return sendError(res, new UnauthorizedError("unauthorized"), requestId, { path }); + } + } + + try { + const body = await parseBody(req); + const maxRequests = parseInt(body.maxRequests, 10); + const windowMs = parseInt(body.windowMs, 10); + + const validation = validateAddressRateLimitConfig(maxRequests, windowMs); + if (!validation.valid) { + return sendError( + res, + new BadRequestError(validation.error), + requestId, + { path, maxRequests: body.maxRequests, windowMs: body.windowMs } + ); + } + + const oldConfig = addressRateLimiter.getConfig(); + addressRateLimiter.updateConfig(maxRequests, windowMs); + const newConfig = addressRateLimiter.getConfig(); + + const processingMs = Date.now() - startTime; + logger.info("Address rate limit configuration updated", { + old_max_requests: oldConfig.maxRequests, + old_window_ms: oldConfig.windowMs, + new_max_requests: newConfig.maxRequests, + new_window_ms: newConfig.windowMs, + request_id: requestId, + processing_ms: processingMs, + }); + + metrics.recordRequest(true); + return sendJson(res, 200, { + ok: true, + previous: { + maxRequests: oldConfig.maxRequests, + windowMs: oldConfig.windowMs, + }, + current: { + maxRequests: newConfig.maxRequests, + windowMs: newConfig.windowMs, + windowSeconds: Math.round(newConfig.windowMs / 1000), + whitelistSize: newConfig.whitelistSize, + }, + }); + } catch (err) { + const processingMs = Date.now() - startTime; + metrics.recordRequest(false); + return sendError(res, err, requestId, { path, processing_ms: processingMs }); + } + } + + // GET /api/admin/address-rate-limit/whitelist -- list whitelisted addresses + if (req.method === "GET" && path === "/api/admin/address-rate-limit/whitelist") { + if (AUTH_TOKEN) { + const authHeader = req.headers.authorization || ""; + if (!validateBearerToken(authHeader, AUTH_TOKEN)) { + return sendError(res, new UnauthorizedError("unauthorized"), requestId, { path }); + } + } + const whitelist = addressRateLimiter.getWhitelist(); + return sendJson(res, 200, { whitelist, total: whitelist.length }); + } + + // POST /api/admin/address-rate-limit/whitelist -- add an address to the whitelist + if (req.method === "POST" && path === "/api/admin/address-rate-limit/whitelist") { + const startTime = Date.now(); + + if (AUTH_TOKEN) { + const authHeader = req.headers.authorization || ""; + if (!validateBearerToken(authHeader, AUTH_TOKEN)) { + return sendError(res, new UnauthorizedError("unauthorized"), requestId, { path }); + } + } + + try { + const body = await parseBody(req); + const { address } = body; + + if (!address || typeof address !== 'string') { + return sendError(res, new BadRequestError("address is required"), requestId, { path }); + } + if (!isValidStacksAddress(address)) { + return sendError(res, new BadRequestError("invalid address format"), requestId, { path }); + } + + addressRateLimiter.addToWhitelist(address); + + const processingMs = Date.now() - startTime; + logger.info("Address added to rate limit whitelist", { + address, + request_id: requestId, + processing_ms: processingMs, + }); + + metrics.recordRequest(true); + return sendJson(res, 200, { + ok: true, + address, + whitelist: addressRateLimiter.getWhitelist(), + }); + } catch (err) { + const processingMs = Date.now() - startTime; + metrics.recordRequest(false); + return sendError(res, err, requestId, { path, processing_ms: processingMs }); + } + } + + // DELETE /api/admin/address-rate-limit/whitelist -- remove an address from the whitelist + if (req.method === "DELETE" && path === "/api/admin/address-rate-limit/whitelist") { + const startTime = Date.now(); + + if (AUTH_TOKEN) { + const authHeader = req.headers.authorization || ""; + if (!validateBearerToken(authHeader, AUTH_TOKEN)) { + return sendError(res, new UnauthorizedError("unauthorized"), requestId, { path }); + } + } + + try { + const body = await parseBody(req); + const { address } = body; + + if (!address || typeof address !== 'string') { + return sendError(res, new BadRequestError("address is required"), requestId, { path }); + } + if (!isValidStacksAddress(address)) { + return sendError(res, new BadRequestError("invalid address format"), requestId, { path }); + } + + addressRateLimiter.removeFromWhitelist(address); + + const processingMs = Date.now() - startTime; + logger.info("Address removed from rate limit whitelist", { + address, + request_id: requestId, + processing_ms: processingMs, + }); + + metrics.recordRequest(true); + return sendJson(res, 200, { + ok: true, + address, + whitelist: addressRateLimiter.getWhitelist(), + }); + } catch (err) { + const processingMs = Date.now() - startTime; + metrics.recordRequest(false); + return sendError(res, err, requestId, { path, processing_ms: processingMs }); + } + } + // GET /health -- health check endpoint (always accessible for orchestration) if (req.method === "GET" && path === "/health") { const store = await getEventStore(); @@ -1026,6 +1233,7 @@ if (isMain) { const store = await getEventStore(); const cleanupInterval = setInterval(async () => { rateLimiter.cleanup(); + addressRateLimiter.cleanup(); try { await store.pruneExpired(getRetentionCutoff(RETENTION_DAYS)); } catch (error) { @@ -1048,6 +1256,8 @@ if (isMain) { auth_enabled: !!AUTH_TOKEN, cors_origins: CORS_ALLOWED_ORIGINS.join(", "), rate_limit: `${RATE_LIMIT_MAX_REQUESTS} requests per ${RATE_LIMIT_WINDOW_MS}ms`, + address_rate_limit: `${ADDRESS_RATE_LIMIT_MAX_REQUESTS} requests per ${ADDRESS_RATE_LIMIT_WINDOW_MS}ms`, + address_whitelist_size: ADDRESS_RATE_LIMIT_WHITELIST.length, storage_mode: STORAGE_MODE, retention_days: RETENTION_DAYS, db_retry_max_attempts: parseInt(process.env.DB_RETRY_MAX_ATTEMPTS || "5", 10),