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 792de21e68..0464947da5 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 - Composable pagination view helpers: `paginationInfo()`, `previousPageLink()`, `nextPageLink()`, `firstPageLink()`, `lastPageLink()`, `pageNumberLinks()`, and `paginationNav()` for building custom pagination UIs - Route model binding with `binding=true` on resource routes or `set(routeModelBinding=true)` globally to auto-resolve model instances from route key parameters - Chainable query builder with `where()`, `orWhere()`, `whereNull()`, `whereBetween()`, `whereIn()`, `orderBy()`, `limit()`, and more for injection-safe fluent queries 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 518029faed..642629c872 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) * [Route Model Binding](handling-requests-with-controllers/route-model-binding.md) * [Using Filters](handling-requests-with-controllers/using-filters.md) * [Verification](handling-requests-with-controllers/verification.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; + } + +}