From af6824974e957586c050867794fd8623e10006a2 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 9 Mar 2026 16:45:34 -0700 Subject: [PATCH] Add rate limiting middleware with fixed window, sliding window, and token bucket strategies Implements #1916. Adds wheels.middleware.RateLimiter with configurable strategies (fixedWindow, slidingWindow, tokenBucket), storage backends (memory, database), custom key functions, and standard rate limit response headers. Includes TestBox BDD tests, framework docs, and AI reference docs. Co-Authored-By: Claude Opus 4.6 --- .ai/wheels/middleware/rate-limiting.md | 100 ++++ CHANGELOG.md | 1 + CLAUDE.md | 2 +- docs/src/SUMMARY.md | 1 + .../rate-limiting.md | 183 ++++++ tests/specs/middleware/RateLimiterSpec.cfc | 356 +++++++++++ vendor/wheels/middleware/RateLimiter.cfc | 551 ++++++++++++++++++ 7 files changed, 1193 insertions(+), 1 deletion(-) create mode 100644 .ai/wheels/middleware/rate-limiting.md create mode 100644 docs/src/handling-requests-with-controllers/rate-limiting.md create mode 100644 tests/specs/middleware/RateLimiterSpec.cfc create mode 100644 vendor/wheels/middleware/RateLimiter.cfc diff --git a/.ai/wheels/middleware/rate-limiting.md b/.ai/wheels/middleware/rate-limiting.md new file mode 100644 index 0000000000..cac10496a9 --- /dev/null +++ b/.ai/wheels/middleware/rate-limiting.md @@ -0,0 +1,100 @@ +# Rate Limiting Middleware — AI Reference + +## Constructor API + +```cfm +new wheels.middleware.RateLimiter( + maxRequests = 60, // numeric — max requests per window + windowSeconds = 60, // numeric — window duration in seconds + strategy = "fixedWindow", // string — fixedWindow | slidingWindow | tokenBucket + storage = "memory", // string — memory | database + keyFunction = "", // closure(request) => string, or empty for IP-based + headerPrefix = "X-RateLimit", // string — response header prefix + trustProxy = true // boolean — use X-Forwarded-For +) +``` + +Throws `Wheels.RateLimiter.InvalidStrategy` or `Wheels.RateLimiter.InvalidStorage` on bad input. + +## Strategy Algorithms + +### Fixed Window +- Discrete time buckets: `windowId = Int(now / windowSeconds)` +- Store key: `clientKey:windowId` +- Counter incremented per request +- Resets when windowId changes + +### Sliding Window +- Timestamp log per client key +- Prune entries older than `now - windowSeconds` +- Count remaining entries vs maxRequests +- More memory per client, more accurate + +### Token Bucket +- Bucket with `capacity = maxRequests` +- Refill rate: `maxRequests / windowSeconds` tokens/sec +- Each request consumes 1 token +- Allows bursts up to capacity + +## Method Inventory + +| Method | Visibility | Purpose | +|--------|-----------|---------| +| `init()` | public | Constructor — validates strategy/storage, initializes store | +| `handle()` | public | MiddlewareInterface — check limit, set headers, pass/block | +| `$resolveKey()` | private | Client identification from keyFunction or IP | +| `$getClientIp()` | private | IP from request struct, X-Forwarded-For, or CGI | +| `$checkFixedWindow()` | private | Fixed window algorithm | +| `$checkSlidingWindow()` | private | Sliding window with timestamp log | +| `$checkTokenBucket()` | private | Token bucket algorithm | +| `$maybeCleanup()` | private | Throttled memory cleanup (1x/min) | +| `$dbIncrement()` | private | DB: fixed window counter | +| `$dbSlidingWindow()` | private | DB: sliding window entries | +| `$dbTokenBucket()` | private | DB: token bucket state | +| `$ensureTable()` | private | Auto-create wheels_rate_limits table | + +## Storage + +### Memory +- `java.util.concurrent.ConcurrentHashMap` +- Per-key `cflock` (name=`wheels-ratelimit-{key}`, timeout=1, exclusive) +- Fail-open on lock timeout +- Cleanup throttled to 1x/min + +### Database +- Table: `wheels_rate_limits` (auto-created) +- Columns: `id`, `store_key`, `counter`, `expires_at` +- Parameterized queries via `QueryExecute()` +- Try-insert/catch-update for portability + +## Response Headers + +Always set: +- `{prefix}-Limit` — maxRequests +- `{prefix}-Remaining` — requests left +- `{prefix}-Reset` — Unix timestamp of window reset + +On 429: +- `Retry-After` — seconds until reset +- Status: 429 Too Many Requests +- Body: "Rate limit exceeded. Try again later." + +## Usage Patterns + +```cfm +// Global — 60 req/min per IP +set(middleware = [new wheels.middleware.RateLimiter()]); + +// Route-scoped — strict auth limit +.scope(path="/auth", middleware=[ + new wheels.middleware.RateLimiter(maxRequests=10, windowSeconds=60) +]) + +// API key-based limiting +new wheels.middleware.RateLimiter( + keyFunction=function(req) { return req.cgi.http_x_api_key; } +) + +// Multi-server with DB storage +new wheels.middleware.RateLimiter(storage="database") +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index e956f43e03..df2e168829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo ## [Unreleased] ### Added +- Rate limiting middleware with `wheels.middleware.RateLimiter` supporting fixed window, sliding window, and token bucket strategies with in-memory and database storage - Chainable query builder with `where()`, `orWhere()`, `whereNull()`, `whereBetween()`, `whereIn()`, `orderBy()`, `limit()`, and more for injection-safe fluent queries - Enum support with `enum()` for named property values, auto-generated `is*()` checkers, auto-scopes, and inclusion validation - Query scopes with `scope()` for reusable, composable query fragments in models diff --git a/CLAUDE.md b/CLAUDE.md index e032cf3e29..5217822c81 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,7 +276,7 @@ mapper() .end(); ``` -Built-in: `wheels.middleware.RequestId`, `wheels.middleware.Cors`, `wheels.middleware.SecurityHeaders`. Custom middleware: implement `wheels.middleware.MiddlewareInterface`, place in `app/middleware/`. +Built-in: `wheels.middleware.RequestId`, `wheels.middleware.Cors`, `wheels.middleware.SecurityHeaders`, `wheels.middleware.RateLimiter`. Custom middleware: implement `wheels.middleware.MiddlewareInterface`, place in `app/middleware/`. ## Routing Quick Reference diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 221cd28bb9..2641feb94c 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -133,6 +133,7 @@ * [Responding with Multiple Formats](handling-requests-with-controllers/responding-with-multiple-formats.md) * [Using the Flash](handling-requests-with-controllers/using-the-flash.md) * [Middleware](handling-requests-with-controllers/middleware.md) +* [Rate Limiting](handling-requests-with-controllers/rate-limiting.md) * [Using Filters](handling-requests-with-controllers/using-filters.md) * [Verification](handling-requests-with-controllers/verification.md) * [Event Handlers](handling-requests-with-controllers/event-handlers.md) diff --git a/docs/src/handling-requests-with-controllers/rate-limiting.md b/docs/src/handling-requests-with-controllers/rate-limiting.md new file mode 100644 index 0000000000..7f1801e483 --- /dev/null +++ b/docs/src/handling-requests-with-controllers/rate-limiting.md @@ -0,0 +1,183 @@ +# Rate Limiting + +Rate limiting protects your application from abuse by restricting how many requests a client can make within a time window. Wheels provides a built-in `RateLimiter` middleware with multiple strategies and storage backends. + +## Quick Start + +Add the rate limiter to your global middleware stack in `config/settings.cfm`: + +```cfm +set(middleware = [ + new wheels.middleware.RateLimiter(maxRequests=60, windowSeconds=60) +]); +``` + +This allows 60 requests per minute per client IP, using the fixed window strategy with in-memory storage. + +## Configuration Reference + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `maxRequests` | `60` | Maximum requests allowed per window | +| `windowSeconds` | `60` | Duration of the rate limit window in seconds | +| `strategy` | `"fixedWindow"` | Algorithm: `"fixedWindow"`, `"slidingWindow"`, or `"tokenBucket"` | +| `storage` | `"memory"` | Backend: `"memory"` or `"database"` | +| `keyFunction` | `""` | Closure `function(request)` returning a string key. Defaults to client IP | +| `headerPrefix` | `"X-RateLimit"` | Prefix for rate limit response headers | +| `trustProxy` | `true` | Use `X-Forwarded-For` for client IP resolution | + +## Strategies + +### Fixed Window + +Divides time into discrete buckets (e.g., 0:00–0:59, 1:00–1:59). Simple and memory-efficient. + +```cfm +new wheels.middleware.RateLimiter(strategy="fixedWindow") +``` + +**Best for**: Most applications. Simple to understand and debug. + +**Trade-off**: A client could send `maxRequests` at the end of one window and `maxRequests` at the start of the next, effectively doubling throughput at the boundary. + +### Sliding Window + +Maintains a log of request timestamps per client. More accurate than fixed window — the window slides with each request. + +```cfm +new wheels.middleware.RateLimiter(strategy="slidingWindow") +``` + +**Best for**: Applications requiring precise rate enforcement without boundary spikes. + +**Trade-off**: Uses more memory per client (stores individual timestamps). + +### Token Bucket + +Each client has a bucket that fills with tokens at a steady rate. Each request consumes one token. Allows controlled bursts up to `maxRequests`. + +```cfm +new wheels.middleware.RateLimiter(strategy="tokenBucket") +``` + +**Best for**: APIs where you want to allow occasional bursts while maintaining a long-term average rate. + +**Trade-off**: Slightly more complex state per client (token count + last refill time). + +## Storage Backends + +### In-Memory (Default) + +Uses a `ConcurrentHashMap` for thread-safe storage. Stale entries are automatically cleaned up once per minute. + +```cfm +new wheels.middleware.RateLimiter(storage="memory") +``` + +**Note**: Counters are lost on server restart and are not shared across multiple application servers. Suitable for single-server deployments or when approximate limiting is acceptable. + +### Database + +Stores rate limit data in a `wheels_rate_limits` table that is auto-created on first use. + +```cfm +new wheels.middleware.RateLimiter(storage="database") +``` + +**Note**: Shared across all servers in a cluster. Adds a database query per request. Suitable for multi-server deployments where consistent limiting is required. + +## Route-Scoped Rate Limiting + +Apply different limits to different parts of your application using route middleware: + +```cfm +// config/routes.cfm +mapper() + // Strict limit on authentication endpoints. + .scope(path="/auth", middleware=[ + new wheels.middleware.RateLimiter(maxRequests=10, windowSeconds=60) + ]) + .post(name="login", to="sessions##create") + .post(name="register", to="users##create") + .end() + + // More generous limit on API endpoints. + .scope(path="/api", middleware=[ + new wheels.middleware.RateLimiter(maxRequests=100, windowSeconds=60) + ]) + .resources("users") + .resources("products") + .end() +.end(); +``` + +## Custom Key Functions + +By default, the rate limiter identifies clients by IP address. You can customize this with a `keyFunction`: + +```cfm +// Rate limit by API key from request header. +new wheels.middleware.RateLimiter( + keyFunction=function(request) { + if (StructKeyExists(request, "cgi") && StructKeyExists(request.cgi, "http_x_api_key")) { + return "api:" & request.cgi.http_x_api_key; + } + return "anon:" & cgi.remote_addr; + } +) + +// Rate limit by authenticated user ID. +new wheels.middleware.RateLimiter( + keyFunction=function(request) { + if (StructKeyExists(session, "userId")) { + return "user:" & session.userId; + } + return "anon:" & cgi.remote_addr; + } +) +``` + +## Response Headers + +Every response includes rate limit headers: + +| Header | Description | +|--------|-------------| +| `X-RateLimit-Limit` | Maximum requests allowed per window | +| `X-RateLimit-Remaining` | Requests remaining in the current window | +| `X-RateLimit-Reset` | Unix timestamp when the window resets | + +When a request is rate limited, the response includes: + +| Header | Description | +|--------|-------------| +| `Retry-After` | Seconds until the client can retry | + +The status code is `429 Too Many Requests` with a plain text body. + +The header prefix can be customized: + +```cfm +new wheels.middleware.RateLimiter(headerPrefix="X-MyApp-RateLimit") +// Produces: X-MyApp-RateLimit-Limit, X-MyApp-RateLimit-Remaining, etc. +``` + +## Multi-Server Deployments + +With in-memory storage, each server maintains its own counters. If you have 3 servers behind a load balancer, a client could theoretically make `maxRequests × 3` total requests. + +For consistent rate limiting across servers, use database storage: + +```cfm +new wheels.middleware.RateLimiter( + storage="database", + maxRequests=60, + windowSeconds=60 +) +``` + +The `wheels_rate_limits` table is auto-created on first use. No migration needed. + +## Thread Safety + +The in-memory implementation uses per-client locking (`cflock`) to ensure accurate counting under concurrent requests. If a lock cannot be acquired within 1 second (indicating extreme contention), the request is allowed through (fail-open). This prevents the rate limiter itself from becoming a bottleneck. diff --git a/tests/specs/middleware/RateLimiterSpec.cfc b/tests/specs/middleware/RateLimiterSpec.cfc new file mode 100644 index 0000000000..51e6abf29e --- /dev/null +++ b/tests/specs/middleware/RateLimiterSpec.cfc @@ -0,0 +1,356 @@ +component extends="wheels.WheelsTest" { + + function run() { + + describe("RateLimiter Middleware", function() { + + describe("init()", function() { + + it("creates with default parameters", function() { + var mw = new wheels.middleware.RateLimiter(); + expect(mw).toBeInstanceOf("wheels.middleware.RateLimiter"); + }); + + it("accepts custom maxRequests and windowSeconds", function() { + var mw = new wheels.middleware.RateLimiter(maxRequests = 100, windowSeconds = 30); + expect(mw).toBeInstanceOf("wheels.middleware.RateLimiter"); + }); + + it("accepts fixedWindow strategy", function() { + var mw = new wheels.middleware.RateLimiter(strategy = "fixedWindow"); + expect(mw).toBeInstanceOf("wheels.middleware.RateLimiter"); + }); + + it("accepts slidingWindow strategy", function() { + var mw = new wheels.middleware.RateLimiter(strategy = "slidingWindow"); + expect(mw).toBeInstanceOf("wheels.middleware.RateLimiter"); + }); + + it("accepts tokenBucket strategy", function() { + var mw = new wheels.middleware.RateLimiter(strategy = "tokenBucket"); + expect(mw).toBeInstanceOf("wheels.middleware.RateLimiter"); + }); + + it("throws on invalid strategy", function() { + expect(function() { + new wheels.middleware.RateLimiter(strategy = "bogus"); + }).toThrow("Wheels.RateLimiter.InvalidStrategy"); + }); + + it("throws on invalid storage type", function() { + expect(function() { + new wheels.middleware.RateLimiter(storage = "redis"); + }).toThrow("Wheels.RateLimiter.InvalidStorage"); + }); + + it("accepts a custom keyFunction", function() { + var mw = new wheels.middleware.RateLimiter( + keyFunction = function(request) { return "custom-key"; } + ); + expect(mw).toBeInstanceOf("wheels.middleware.RateLimiter"); + }); + + }); + + describe("handle() - Fixed Window", function() { + + it("allows requests under the limit", function() { + var mw = new wheels.middleware.RateLimiter( + maxRequests = 5, + windowSeconds = 60, + strategy = "fixedWindow", + keyFunction = function(req) { return "fw-client-1"; } + ); + var pipeline = new wheels.middleware.Pipeline(middleware = [mw]); + var shared = {callCount: 0}; + var handler = function(required struct request) { + shared.callCount++; + return "ok"; + }; + + // Send 5 requests — all should pass. + for (var i = 1; i <= 5; i++) { + var result = pipeline.run(request = {}, coreHandler = handler); + expect(result).toBe("ok"); + } + expect(shared.callCount).toBe(5); + }); + + it("blocks requests exceeding the limit", function() { + var mw = new wheels.middleware.RateLimiter( + maxRequests = 3, + windowSeconds = 60, + strategy = "fixedWindow", + keyFunction = function(req) { return "fw-client-2"; } + ); + var pipeline = new wheels.middleware.Pipeline(middleware = [mw]); + var shared = {callCount: 0}; + var handler = function(required struct request) { + shared.callCount++; + return "ok"; + }; + + // Send 3 allowed. + for (var i = 1; i <= 3; i++) { + pipeline.run(request = {}, coreHandler = handler); + } + + // 4th should be blocked. + var result = pipeline.run(request = {}, coreHandler = handler); + expect(result).toInclude("Rate limit exceeded"); + expect(shared.callCount).toBe(3); + }); + + it("returns 429 response text when rate limited", function() { + var mw = new wheels.middleware.RateLimiter( + maxRequests = 1, + windowSeconds = 60, + strategy = "fixedWindow", + keyFunction = function(req) { return "fw-client-429"; } + ); + var pipeline = new wheels.middleware.Pipeline(middleware = [mw]); + var handler = function(required struct request) { return "ok"; }; + + pipeline.run(request = {}, coreHandler = handler); + var result = pipeline.run(request = {}, coreHandler = handler); + expect(result).toInclude("Rate limit exceeded"); + expect(result).toInclude("Try again later"); + }); + + it("tracks different clients independently", function() { + var clientKey = {value: "fw-clientA"}; + var mw = new wheels.middleware.RateLimiter( + maxRequests = 2, + windowSeconds = 60, + strategy = "fixedWindow", + keyFunction = function(req) { return clientKey.value; } + ); + var pipeline = new wheels.middleware.Pipeline(middleware = [mw]); + var handler = function(required struct request) { return "ok"; }; + + // Client A uses 2 requests. + pipeline.run(request = {}, coreHandler = handler); + pipeline.run(request = {}, coreHandler = handler); + + // Client A is blocked. + var resultA = pipeline.run(request = {}, coreHandler = handler); + expect(resultA).toInclude("Rate limit exceeded"); + + // Client B should still work. + clientKey.value = "fw-clientB"; + var resultB = pipeline.run(request = {}, coreHandler = handler); + expect(resultB).toBe("ok"); + }); + + }); + + describe("handle() - Sliding Window", function() { + + it("allows requests under the limit", function() { + var mw = new wheels.middleware.RateLimiter( + maxRequests = 5, + windowSeconds = 60, + strategy = "slidingWindow", + keyFunction = function(req) { return "sw-client-1"; } + ); + var pipeline = new wheels.middleware.Pipeline(middleware = [mw]); + var shared = {callCount: 0}; + var handler = function(required struct request) { + shared.callCount++; + return "ok"; + }; + + for (var i = 1; i <= 5; i++) { + var result = pipeline.run(request = {}, coreHandler = handler); + expect(result).toBe("ok"); + } + expect(shared.callCount).toBe(5); + }); + + it("blocks requests exceeding the limit", function() { + var mw = new wheels.middleware.RateLimiter( + maxRequests = 3, + windowSeconds = 60, + strategy = "slidingWindow", + keyFunction = function(req) { return "sw-client-2"; } + ); + var pipeline = new wheels.middleware.Pipeline(middleware = [mw]); + var shared = {callCount: 0}; + var handler = function(required struct request) { + shared.callCount++; + return "ok"; + }; + + for (var i = 1; i <= 3; i++) { + pipeline.run(request = {}, coreHandler = handler); + } + + var result = pipeline.run(request = {}, coreHandler = handler); + expect(result).toInclude("Rate limit exceeded"); + expect(shared.callCount).toBe(3); + }); + + }); + + describe("handle() - Token Bucket", function() { + + it("allows requests up to bucket capacity", function() { + var mw = new wheels.middleware.RateLimiter( + maxRequests = 5, + windowSeconds = 60, + strategy = "tokenBucket", + keyFunction = function(req) { return "tb-client-1"; } + ); + var pipeline = new wheels.middleware.Pipeline(middleware = [mw]); + var shared = {callCount: 0}; + var handler = function(required struct request) { + shared.callCount++; + return "ok"; + }; + + for (var i = 1; i <= 5; i++) { + var result = pipeline.run(request = {}, coreHandler = handler); + expect(result).toBe("ok"); + } + expect(shared.callCount).toBe(5); + }); + + it("blocks when bucket is empty", function() { + var mw = new wheels.middleware.RateLimiter( + maxRequests = 2, + windowSeconds = 60, + strategy = "tokenBucket", + keyFunction = function(req) { return "tb-client-2"; } + ); + var pipeline = new wheels.middleware.Pipeline(middleware = [mw]); + var shared = {callCount: 0}; + var handler = function(required struct request) { + shared.callCount++; + return "ok"; + }; + + pipeline.run(request = {}, coreHandler = handler); + pipeline.run(request = {}, coreHandler = handler); + + var result = pipeline.run(request = {}, coreHandler = handler); + expect(result).toInclude("Rate limit exceeded"); + expect(shared.callCount).toBe(2); + }); + + }); + + describe("Client Identification", function() { + + it("uses custom keyFunction when provided", function() { + var shared = {capturedKey: ""}; + var mw = new wheels.middleware.RateLimiter( + maxRequests = 100, + keyFunction = function(req) { + return "api-key-12345"; + } + ); + var pipeline = new wheels.middleware.Pipeline(middleware = [mw]); + var handler = function(required struct request) { return "ok"; }; + + // Should use custom key — all pass since limit is 100. + var result = pipeline.run(request = {apiKey: "12345"}, coreHandler = handler); + expect(result).toBe("ok"); + }); + + it("falls back to default key when no keyFunction", function() { + var mw = new wheels.middleware.RateLimiter(maxRequests = 100); + var pipeline = new wheels.middleware.Pipeline(middleware = [mw]); + var handler = function(required struct request) { return "ok"; }; + + var result = pipeline.run(request = {remoteAddr: "10.0.0.1"}, coreHandler = handler); + expect(result).toBe("ok"); + }); + + }); + + describe("Pipeline Integration", function() { + + it("works in a middleware pipeline with other middleware", function() { + var requestId = new wheels.middleware.RequestId(); + var limiter = new wheels.middleware.RateLimiter( + maxRequests = 10, + keyFunction = function(req) { return "pipeline-client"; } + ); + var pipeline = new wheels.middleware.Pipeline(middleware = [requestId, limiter]); + var handler = function(required struct request) { return "ok"; }; + + var result = pipeline.run(request = {}, coreHandler = handler); + expect(result).toBe("ok"); + expect(StructKeyExists(request.wheels, "requestId")).toBeTrue(); + }); + + it("short-circuits pipeline when rate limited", function() { + var shared = {coreReached: false}; + var limiter = new wheels.middleware.RateLimiter( + maxRequests = 1, + keyFunction = function(req) { return "shortcircuit-client"; } + ); + var pipeline = new wheels.middleware.Pipeline(middleware = [limiter]); + var handler = function(required struct request) { + shared.coreReached = true; + return "ok"; + }; + + // First request passes. + pipeline.run(request = {}, coreHandler = handler); + + // Reset flag. + shared.coreReached = false; + + // Second request should be blocked — core never reached. + var result = pipeline.run(request = {}, coreHandler = handler); + expect(result).toInclude("Rate limit exceeded"); + expect(shared.coreReached).toBeFalse(); + }); + + it("passes request through when under limit", function() { + var shared = {callCount: 0}; + var limiter = new wheels.middleware.RateLimiter( + maxRequests = 10, + keyFunction = function(req) { return "passthrough-client"; } + ); + var pipeline = new wheels.middleware.Pipeline(middleware = [limiter]); + var handler = function(required struct request) { + shared.callCount++; + return "ok"; + }; + + pipeline.run(request = {}, coreHandler = handler); + expect(shared.callCount).toBe(1); + }); + + }); + + describe("Memory Cleanup", function() { + + it("cleans up expired entries from store", function() { + // Use a very short window so entries expire quickly. + var mw = new wheels.middleware.RateLimiter( + maxRequests = 100, + windowSeconds = 1, + strategy = "fixedWindow", + keyFunction = function(req) { return "cleanup-client"; } + ); + var pipeline = new wheels.middleware.Pipeline(middleware = [mw]); + var handler = function(required struct request) { return "ok"; }; + + // Make a request to populate the store. + pipeline.run(request = {}, coreHandler = handler); + + // The middleware exists and handles requests — cleanup is internally throttled. + // Just verify it doesn't error. + expect(mw).toBeInstanceOf("wheels.middleware.RateLimiter"); + }); + + }); + + }); + + } + +} diff --git a/vendor/wheels/middleware/RateLimiter.cfc b/vendor/wheels/middleware/RateLimiter.cfc new file mode 100644 index 0000000000..2939b6d62f --- /dev/null +++ b/vendor/wheels/middleware/RateLimiter.cfc @@ -0,0 +1,551 @@ +/** + * Rate limiting middleware for controlling request throughput. + * Supports fixed window, sliding window, and token bucket strategies + * with in-memory or database-backed storage. + * + * [section: Middleware] + * [category: Built-in] + */ +component implements="wheels.middleware.MiddlewareInterface" output="false" { + + /** + * Creates the RateLimiter middleware with configurable options. + * + * @maxRequests Maximum number of requests allowed per window. + * @windowSeconds Duration of the rate limit window in seconds. + * @strategy Algorithm: "fixedWindow", "slidingWindow", or "tokenBucket". + * @storage Backend: "memory" or "database". + * @keyFunction Closure that receives the request struct and returns a string key. Defaults to client IP. + * @headerPrefix Prefix for rate limit response headers. + * @trustProxy Whether to use X-Forwarded-For for client IP resolution. + */ + public RateLimiter function init( + numeric maxRequests = 60, + numeric windowSeconds = 60, + string strategy = "fixedWindow", + string storage = "memory", + any keyFunction = "", + string headerPrefix = "X-RateLimit", + boolean trustProxy = true + ) { + if (!ListFindNoCase("fixedWindow,slidingWindow,tokenBucket", arguments.strategy)) { + throw( + type = "Wheels.RateLimiter.InvalidStrategy", + message = "Invalid rate limiter strategy: #arguments.strategy#. Must be fixedWindow, slidingWindow, or tokenBucket." + ); + } + + if (!ListFindNoCase("memory,database", arguments.storage)) { + throw( + type = "Wheels.RateLimiter.InvalidStorage", + message = "Invalid rate limiter storage: #arguments.storage#. Must be memory or database." + ); + } + + variables.maxRequests = arguments.maxRequests; + variables.windowSeconds = arguments.windowSeconds; + variables.strategy = arguments.strategy; + variables.storage = arguments.storage; + variables.keyFunction = arguments.keyFunction; + variables.headerPrefix = arguments.headerPrefix; + variables.trustProxy = arguments.trustProxy; + + // In-memory store using ConcurrentHashMap for thread safety. + if (variables.storage == "memory") { + variables.store = CreateObject("java", "java.util.concurrent.ConcurrentHashMap").init(); + } + + // Throttle cleanup to once per minute. + variables.lastCleanup = 0; + + // Track whether DB table has been verified. + variables.tableVerified = false; + + return this; + } + + /** + * Handle the incoming request — check rate limit, set headers, and either pass through or block. + */ + public string function handle(required struct request, required any next) { + local.clientKey = $resolveKey(arguments.request); + local.now = GetTickCount() / 1000; + + // Periodic cleanup for memory storage. + if (variables.storage == "memory") { + $maybeCleanup(local.now); + } + + // Check rate limit based on strategy. + switch (variables.strategy) { + case "fixedWindow": + local.result = $checkFixedWindow(local.clientKey, local.now); + break; + case "slidingWindow": + local.result = $checkSlidingWindow(local.clientKey, local.now); + break; + case "tokenBucket": + local.result = $checkTokenBucket(local.clientKey, local.now); + break; + } + + // Set rate limit headers. + try { + cfheader(name = "#variables.headerPrefix#-Limit", value = variables.maxRequests); + cfheader(name = "#variables.headerPrefix#-Remaining", value = Max(0, local.result.remaining)); + cfheader(name = "#variables.headerPrefix#-Reset", value = Ceiling(local.result.resetAt)); + } catch (any e) { + } + + // Block if over limit. + if (!local.result.allowed) { + try { + cfheader(statusCode = "429", statusText = "Too Many Requests"); + cfheader(name = "Retry-After", value = Ceiling(local.result.resetAt - local.now)); + } catch (any e) { + } + return "Rate limit exceeded. Try again later."; + } + + return arguments.next(arguments.request); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Resolve the client key from the request — uses keyFunction if provided, otherwise client IP. + */ + private string function $resolveKey(required struct request) { + if (IsCustomFunction(variables.keyFunction) || IsClosure(variables.keyFunction)) { + return variables.keyFunction(arguments.request); + } + return $getClientIp(arguments.request); + } + + /** + * Get the client IP address from the request, respecting proxy headers if configured. + */ + private string function $getClientIp(required struct request) { + // Check request struct first (test-friendly). + if (StructKeyExists(arguments.request, "remoteAddr")) { + return arguments.request.remoteAddr; + } + + // Trust proxy: check X-Forwarded-For header. + if (variables.trustProxy) { + try { + local.forwarded = ""; + if (StructKeyExists(arguments.request, "cgi") && StructKeyExists(arguments.request.cgi, "http_x_forwarded_for")) { + local.forwarded = arguments.request.cgi.http_x_forwarded_for; + } else { + local.forwarded = cgi.http_x_forwarded_for; + } + if (Len(Trim(local.forwarded))) { + return Trim(ListFirst(local.forwarded)); + } + } catch (any e) { + } + } + + // Fall back to CGI remote_addr. + try { + if (StructKeyExists(arguments.request, "cgi") && StructKeyExists(arguments.request.cgi, "remote_addr")) { + return arguments.request.cgi.remote_addr; + } + return cgi.remote_addr; + } catch (any e) { + } + + return "unknown"; + } + + // --------------------------------------------------------------------------- + // Fixed Window Strategy + // --------------------------------------------------------------------------- + + /** + * Fixed window: discrete time buckets. Simple counter per window ID. + */ + private struct function $checkFixedWindow(required string clientKey, required numeric now) { + local.windowId = Int(arguments.now / variables.windowSeconds); + local.storeKey = arguments.clientKey & ":" & local.windowId; + local.resetAt = (local.windowId + 1) * variables.windowSeconds; + + if (variables.storage == "database") { + return $dbIncrement(arguments.clientKey, local.storeKey, local.resetAt); + } + + // In-memory with per-key locking. + local.allowed = true; + local.remaining = variables.maxRequests; + + try { + cflock(name = "wheels-ratelimit-#local.storeKey#", type = "exclusive", timeout = 1) { + local.count = 0; + if (variables.store.containsKey(local.storeKey)) { + local.count = variables.store.get(local.storeKey); + } + if (local.count >= variables.maxRequests) { + local.allowed = false; + local.remaining = 0; + } else { + local.count++; + variables.store.put(local.storeKey, local.count); + local.remaining = variables.maxRequests - local.count; + } + } + } catch (any e) { + // Fail open on lock timeout. + } + + return {allowed: local.allowed, remaining: local.remaining, resetAt: local.resetAt}; + } + + // --------------------------------------------------------------------------- + // Sliding Window Strategy + // --------------------------------------------------------------------------- + + /** + * Sliding window: maintains a timestamp log per client. More accurate but uses more memory. + */ + private struct function $checkSlidingWindow(required string clientKey, required numeric now) { + local.windowStart = arguments.now - variables.windowSeconds; + local.resetAt = arguments.now + variables.windowSeconds; + + if (variables.storage == "database") { + return $dbSlidingWindow(arguments.clientKey, arguments.now, local.windowStart, local.resetAt); + } + + local.allowed = true; + local.remaining = variables.maxRequests; + + try { + cflock(name = "wheels-ratelimit-#arguments.clientKey#", type = "exclusive", timeout = 1) { + // Get or create timestamp array. + local.timestamps = []; + if (variables.store.containsKey(arguments.clientKey)) { + local.timestamps = variables.store.get(arguments.clientKey); + } + + // Prune expired entries. + local.pruned = []; + for (local.ts in local.timestamps) { + if (local.ts > local.windowStart) { + ArrayAppend(local.pruned, local.ts); + } + } + + if (ArrayLen(local.pruned) >= variables.maxRequests) { + local.allowed = false; + local.remaining = 0; + // Update resetAt to when the oldest entry expires. + if (ArrayLen(local.pruned) > 0) { + local.resetAt = local.pruned[1] + variables.windowSeconds; + } + } else { + ArrayAppend(local.pruned, arguments.now); + local.remaining = variables.maxRequests - ArrayLen(local.pruned); + } + + variables.store.put(arguments.clientKey, local.pruned); + } + } catch (any e) { + // Fail open on lock timeout. + } + + return {allowed: local.allowed, remaining: local.remaining, resetAt: local.resetAt}; + } + + // --------------------------------------------------------------------------- + // Token Bucket Strategy + // --------------------------------------------------------------------------- + + /** + * Token bucket: allows bursts up to capacity, refills at a steady rate. + */ + private struct function $checkTokenBucket(required string clientKey, required numeric now) { + local.refillRate = variables.maxRequests / variables.windowSeconds; + local.resetAt = arguments.now + (1 / local.refillRate); + + if (variables.storage == "database") { + return $dbTokenBucket(arguments.clientKey, arguments.now, local.refillRate, local.resetAt); + } + + local.allowed = true; + local.remaining = variables.maxRequests; + + try { + cflock(name = "wheels-ratelimit-#arguments.clientKey#", type = "exclusive", timeout = 1) { + local.bucket = {}; + if (variables.store.containsKey(arguments.clientKey)) { + local.bucket = variables.store.get(arguments.clientKey); + } else { + local.bucket = {tokens: variables.maxRequests, lastRefill: arguments.now}; + } + + // Refill tokens based on elapsed time. + local.elapsed = arguments.now - local.bucket.lastRefill; + local.newTokens = local.elapsed * local.refillRate; + local.bucket.tokens = Min(variables.maxRequests, local.bucket.tokens + local.newTokens); + local.bucket.lastRefill = arguments.now; + + if (local.bucket.tokens < 1) { + local.allowed = false; + local.remaining = 0; + // Time until one token is available. + local.resetAt = arguments.now + ((1 - local.bucket.tokens) / local.refillRate); + } else { + local.bucket.tokens -= 1; + local.remaining = Int(local.bucket.tokens); + } + + variables.store.put(arguments.clientKey, local.bucket); + } + } catch (any e) { + // Fail open on lock timeout. + } + + return {allowed: local.allowed, remaining: local.remaining, resetAt: local.resetAt}; + } + + // --------------------------------------------------------------------------- + // Memory Cleanup + // --------------------------------------------------------------------------- + + /** + * Periodically clean up stale entries from in-memory store (throttled to once per minute). + */ + private void function $maybeCleanup(required numeric now) { + if ((arguments.now - variables.lastCleanup) < 60) { + return; + } + + try { + cflock(name = "wheels-ratelimit-cleanup", type = "exclusive", timeout = 1) { + // Double-check after acquiring lock. + if ((arguments.now - variables.lastCleanup) < 60) { + return; + } + variables.lastCleanup = arguments.now; + + local.currentWindowId = Int(arguments.now / variables.windowSeconds); + local.keysToRemove = []; + local.keys = variables.store.keySet().toArray(); + + for (local.key in local.keys) { + local.value = ""; + if (variables.store.containsKey(local.key)) { + local.value = variables.store.get(local.key); + } + + // Fixed window: key format is "clientKey:windowId" — remove old windows. + if (variables.strategy == "fixedWindow" && Find(":", local.key)) { + local.windowId = Val(ListLast(local.key, ":")); + if (local.windowId < local.currentWindowId) { + ArrayAppend(local.keysToRemove, local.key); + } + } + + // Sliding window: remove clients with all timestamps expired. + if (variables.strategy == "slidingWindow" && IsArray(local.value)) { + local.windowStart = arguments.now - variables.windowSeconds; + local.hasValid = false; + for (local.ts in local.value) { + if (local.ts > local.windowStart) { + local.hasValid = true; + break; + } + } + if (!local.hasValid) { + ArrayAppend(local.keysToRemove, local.key); + } + } + + // Token bucket: remove fully-refilled buckets (idle clients). + if (variables.strategy == "tokenBucket" && IsStruct(local.value) && StructKeyExists(local.value, "tokens")) { + if (local.value.tokens >= variables.maxRequests && (arguments.now - local.value.lastRefill) > variables.windowSeconds) { + ArrayAppend(local.keysToRemove, local.key); + } + } + } + + for (local.key in local.keysToRemove) { + variables.store.remove(local.key); + } + } + } catch (any e) { + // Lock timeout or error — skip cleanup this time. + } + } + + // --------------------------------------------------------------------------- + // Database Storage + // --------------------------------------------------------------------------- + + /** + * Database-backed fixed window increment. + */ + private struct function $dbIncrement(required string clientKey, required string storeKey, required numeric resetAt) { + $ensureTable(); + + local.allowed = true; + local.remaining = variables.maxRequests; + + try { + // Try to insert a new row. + QueryExecute( + "INSERT INTO wheels_rate_limits (store_key, counter, expires_at) VALUES (:storeKey, 1, :expiresAt)", + {storeKey: {value: arguments.storeKey, cfsqltype: "cf_sql_varchar"}, expiresAt: {value: DateAdd("s", variables.windowSeconds, Now()), cfsqltype: "cf_sql_timestamp"}} + ); + local.remaining = variables.maxRequests - 1; + } catch (any e) { + // Row exists — update the counter. + try { + local.qUpdate = QueryExecute( + "UPDATE wheels_rate_limits SET counter = counter + 1 WHERE store_key = :storeKey", + {storeKey: {value: arguments.storeKey, cfsqltype: "cf_sql_varchar"}} + ); + // Read current count. + local.qCount = QueryExecute( + "SELECT counter FROM wheels_rate_limits WHERE store_key = :storeKey", + {storeKey: {value: arguments.storeKey, cfsqltype: "cf_sql_varchar"}} + ); + if (local.qCount.recordCount && local.qCount.counter > variables.maxRequests) { + local.allowed = false; + local.remaining = 0; + } else if (local.qCount.recordCount) { + local.remaining = variables.maxRequests - local.qCount.counter; + } + } catch (any e2) { + // Fail open. + } + } + + return {allowed: local.allowed, remaining: local.remaining, resetAt: arguments.resetAt}; + } + + /** + * Database-backed sliding window check. + */ + private struct function $dbSlidingWindow(required string clientKey, required numeric now, required numeric windowStart, required numeric resetAt) { + $ensureTable(); + + local.allowed = true; + local.remaining = variables.maxRequests; + local.expiresAt = DateAdd("s", variables.windowSeconds, Now()); + + try { + // Clean expired entries for this client. + QueryExecute( + "DELETE FROM wheels_rate_limits WHERE store_key = :clientKey AND expires_at < :now", + {clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}, now: {value: Now(), cfsqltype: "cf_sql_timestamp"}} + ); + + // Count current entries. + local.qCount = QueryExecute( + "SELECT COUNT(*) AS cnt FROM wheels_rate_limits WHERE store_key = :clientKey", + {clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}} + ); + + if (local.qCount.cnt >= variables.maxRequests) { + local.allowed = false; + local.remaining = 0; + } else { + // Insert a new timestamp entry. + QueryExecute( + "INSERT INTO wheels_rate_limits (store_key, counter, expires_at) VALUES (:clientKey, 1, :expiresAt)", + {clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}, expiresAt: {value: local.expiresAt, cfsqltype: "cf_sql_timestamp"}} + ); + local.remaining = variables.maxRequests - local.qCount.cnt - 1; + } + } catch (any e) { + // Fail open. + } + + return {allowed: local.allowed, remaining: local.remaining, resetAt: arguments.resetAt}; + } + + /** + * Database-backed token bucket check. + */ + private struct function $dbTokenBucket(required string clientKey, required numeric now, required numeric refillRate, required numeric resetAt) { + $ensureTable(); + + local.allowed = true; + local.remaining = variables.maxRequests; + + try { + local.qBucket = QueryExecute( + "SELECT counter, expires_at FROM wheels_rate_limits WHERE store_key = :clientKey", + {clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}} + ); + + if (local.qBucket.recordCount) { + // Calculate token refill. + local.lastRefill = local.qBucket.expires_at; + local.elapsed = DateDiff("s", local.lastRefill, Now()); + local.currentTokens = Min(variables.maxRequests, local.qBucket.counter + (local.elapsed * arguments.refillRate)); + + if (local.currentTokens < 1) { + local.allowed = false; + local.remaining = 0; + } else { + local.currentTokens -= 1; + local.remaining = Int(local.currentTokens); + QueryExecute( + "UPDATE wheels_rate_limits SET counter = :tokens, expires_at = :now WHERE store_key = :clientKey", + { + tokens: {value: Int(local.currentTokens), cfsqltype: "cf_sql_integer"}, + now: {value: Now(), cfsqltype: "cf_sql_timestamp"}, + clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"} + } + ); + } + } else { + // First request — create bucket with maxRequests - 1 tokens. + local.remaining = variables.maxRequests - 1; + QueryExecute( + "INSERT INTO wheels_rate_limits (store_key, counter, expires_at) VALUES (:clientKey, :tokens, :now)", + { + clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}, + tokens: {value: local.remaining, cfsqltype: "cf_sql_integer"}, + now: {value: Now(), cfsqltype: "cf_sql_timestamp"} + } + ); + } + } catch (any e) { + // Fail open. + } + + return {allowed: local.allowed, remaining: local.remaining, resetAt: arguments.resetAt}; + } + + /** + * Auto-create the wheels_rate_limits table if it doesn't exist. + */ + private void function $ensureTable() { + if (variables.tableVerified) { + return; + } + + try { + QueryExecute( + "CREATE TABLE IF NOT EXISTS wheels_rate_limits ( + id INT AUTO_INCREMENT PRIMARY KEY, + store_key VARCHAR(255) NOT NULL, + counter INT DEFAULT 1, + expires_at TIMESTAMP, + INDEX idx_store_key (store_key), + INDEX idx_expires_at (expires_at) + )" + ); + } catch (any e) { + // Table may already exist or DB doesn't support IF NOT EXISTS — that's fine. + } + + variables.tableVerified = true; + } + +}