From 13d48b56c1ffee642f54190f5198c965c3939826 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 12:11:30 +0000 Subject: [PATCH 1/4] feat: add route middleware for ColdBox route-scoped middleware Secure a route or a group where it is declared, with no firewall rules: - Authenticated@cbsecurity: the user must be logged in - Authorized@cbsecurity: logged in and satisfying the `permissions`, `roles` and `mode` (any|all|none) declared in the route `meta()` - JwtAuth@cbsecurity and BasicAuth@cbsecurity: Authorized, authenticating through the JWT or Basic validators - Guard: the base class for custom middleware Denied requests go through the firewall's invalid access flow, so redirect, override and block actions, module overrides, interception points and logging behave exactly like a firewall rule or annotation. The Security interceptor gains public validateAccess() and processInvalidAccess() methods for this; the annotation path now shares processInvalidAccess() with no behavior change. The route specs run only when the installed ColdBox can run route middleware in execute() and group meta (8.3+); the Guard specs run everywhere. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01E8yy4oK5vnaVucmYLg4UtY --- changelog.md | 9 + interceptors/Security.cfc | 67 +++- models/middleware/Authenticated.cfc | 18 + models/middleware/Authorized.cfc | 23 ++ models/middleware/BasicAuth.cfc | 17 + models/middleware/Guard.cfc | 169 ++++++++ models/middleware/JwtAuth.cfc | 17 + test-harness/config/Router.cfc | 64 +++ test-harness/handlers/mw.cfc | 10 + .../resources/MiddlewareSpyInterceptor.cfc | 13 + .../tests/resources/ReadOnlyMiddleware.cfc | 10 + .../specs/integration/MiddlewareSpec.cfc | 375 ++++++++++++++++++ 12 files changed, 789 insertions(+), 3 deletions(-) create mode 100644 models/middleware/Authenticated.cfc create mode 100644 models/middleware/Authorized.cfc create mode 100644 models/middleware/BasicAuth.cfc create mode 100644 models/middleware/Guard.cfc create mode 100644 models/middleware/JwtAuth.cfc create mode 100644 test-harness/config/Router.cfc create mode 100644 test-harness/handlers/mw.cfc create mode 100644 test-harness/tests/resources/MiddlewareSpyInterceptor.cfc create mode 100644 test-harness/tests/resources/ReadOnlyMiddleware.cfc create mode 100644 test-harness/tests/specs/integration/MiddlewareSpec.cfc diff --git a/changelog.md b/changelog.md index 7e3a050..8a5cf4a 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Route middleware** for ColdBox 8.2+ route-scoped middleware (`route().middleware()`). Secure routes and groups right where they are declared, without firewall rules: `Authenticated@cbsecurity` (logged in), `Authorized@cbsecurity` (permissions and roles declared in the route `meta()`), `JwtAuth@cbsecurity` and `BasicAuth@cbsecurity`. They use the firewall's validators, invalid actions (redirect, override, block), interception points and logging. Group-level `meta` (one `group()` securing many routes) and `execute()` support in integration tests require ColdBox 8.3+. +- `Security` interceptor `validateAccess()` and `processInvalidAccess()` public methods, used by the middleware and available to custom integrations. + +### Changed + +- Handler/action annotation security now shares `processInvalidAccess()` with route middleware. Behavior is unchanged. + ## [3.8.0] - 2026-09-21 ### Changed diff --git a/interceptors/Security.cfc b/interceptors/Security.cfc index 803ee3b..b322486 100644 --- a/interceptors/Security.cfc +++ b/interceptors/Security.cfc @@ -371,11 +371,33 @@ component accessors="true" extends="coldbox.system.Interceptor" { required event, required validatorResults, required type + ){ + return processInvalidAccess( + event = arguments.event, + validatorResults = arguments.validatorResults, + source = arguments.type + ); + } + + /** + * Process an invalid access that was detected outside of the firewall rules: handler/action annotations + * or route middleware. It logs, flashes the secured URL, announces the invalid authentication/authorization + * interception point and executes the configured invalid actions (redirect, override or block), exactly like a + * firewall rule would. + * + * @event The request context + * @validatorResults The validation results: { allow:boolean, type:authentication|authorization } + * @source Where the invalid access was detected: handler, action or middleware + */ + function processInvalidAccess( + required event, + required validatorResults, + string source = "middleware" ){ // Log Block if ( log.canWarn() ) { log.warn( - "Invalid #arguments.validatorResults.type# by User (#variables.cbSecurity.getRealIp()#), blocked access to event=#arguments.event.getCurrentEvent()# via annotation (#arguments.type#) security" + "Invalid #arguments.validatorResults.type# by User (#variables.cbSecurity.getRealIp()#), blocked access to event=#arguments.event.getCurrentEvent()# via #arguments.source# security" ); } @@ -385,10 +407,10 @@ component accessors="true" extends="coldbox.system.Interceptor" { // Announce the block event var iData = { "ip" : variables.cbSecurity.getRealIp(), // The offending IP - "rule" : {}, // An empty rule, since it is by annotation security + "rule" : {}, // An empty rule, since it is not by firewall rule security "settings" : getProperties(), // All the config settings, just in case "validatorResults" : arguments.validatorResults, - "annotationType" : arguments.type, + "annotationType" : arguments.source, "processActions" : true // Boolean indicator if the invalid actions should process or not }; announce( "cbSecurity_onInvalid#arguments.validatorResults.type#", iData ); @@ -578,6 +600,45 @@ component accessors="true" extends="coldbox.system.Interceptor" { return variables.validator; } + /** + * Validate access for the current user using the firewall validator, or the validator you pass in. + * This is what route middleware uses so it behaves exactly like a firewall rule or a secured annotation. + * + * @event The request context + * @permissions A list of permissions, any one of them satisfies the check + * @roles A list of roles, any one of them satisfies the check + * @validator An optional WireBox ID of a validator to use instead of the firewall's validator + * + * @return { allow:boolean, type:string(authentication|authorization), messages:string } + */ + struct function validateAccess( + required event, + string permissions = "", + string roles = "", + string validator = "" + ){ + var thisValidator = len( arguments.validator ) ? variables.wirebox.getInstance( arguments.validator ) : getValidator( + arguments.event + ); + + var results = thisValidator.ruleValidator( + rule: variables.rulesLoader + .getRuleTemplate() + .append( { + "permissions" : arguments.permissions, + "roles" : arguments.roles + } ), + controller: variables.controller + ); + + // Verify type, else default to "authentication" + if ( !reFindNoCase( "(authentication|authorization)", results.type ) ) { + results.type = "authentication"; + } + + return results; + } + /********************************* PRIVATE ******************************/ /** diff --git a/models/middleware/Authenticated.cfc b/models/middleware/Authenticated.cfc new file mode 100644 index 0000000..46fde73 --- /dev/null +++ b/models/middleware/Authenticated.cfc @@ -0,0 +1,18 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Route middleware: the user must be logged in using the firewall's validator. Route metadata is ignored, + * use `Authorized@cbsecurity` to also verify permissions or roles. + * + *
+ * route( "/account" ).middleware( "Authenticated@cbsecurity" ).to( "account.index" )
+ * 
+ */ +component extends="cbsecurity.models.middleware.Guard" singleton { + + function init(){ + return super.init( useMeta = false ); + } + +} diff --git a/models/middleware/Authorized.cfc b/models/middleware/Authorized.cfc new file mode 100644 index 0000000..0f73c4f --- /dev/null +++ b/models/middleware/Authorized.cfc @@ -0,0 +1,23 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Route middleware: the user must be logged in and satisfy the permissions or roles declared in the route metadata. + * + *
+ * route( "/admin" )
+ *     .middleware( "Authorized@cbsecurity" )
+ *     .meta( { permissions : "ADMIN" } )
+ *     .to( "admin.index" )
+ *
+ * // every permission is required
+ * route( "/billing" )
+ *     .middleware( "Authorized@cbsecurity" )
+ *     .meta( { permissions : "BILLING_READ,BILLING_WRITE", mode : "all" } )
+ *     .to( "billing.index" )
+ * 
+ */ +component extends="cbsecurity.models.middleware.Guard" singleton { + + +} diff --git a/models/middleware/BasicAuth.cfc b/models/middleware/BasicAuth.cfc new file mode 100644 index 0000000..d808d09 --- /dev/null +++ b/models/middleware/BasicAuth.cfc @@ -0,0 +1,17 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Route middleware: the request must carry valid HTTP Basic credentials, whatever validator the firewall uses. + * + *
+ * route( "/internal/stats" ).middleware( "BasicAuth@cbsecurity" ).to( "stats.index" )
+ * 
+ */ +component extends="cbsecurity.models.middleware.Guard" singleton { + + function init(){ + return super.init( validator = "basic" ); + } + +} diff --git a/models/middleware/Guard.cfc b/models/middleware/Guard.cfc new file mode 100644 index 0000000..33d608b --- /dev/null +++ b/models/middleware/Guard.cfc @@ -0,0 +1,169 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Route middleware that secures a ColdBox route using the same validators, invalid actions + * (redirect, override or block), interception points and logging as the cbsecurity firewall. + * + * It requires the firewall interceptor (`firewall.autoLoadFirewall`, enabled by default). + * + * Use one of the ready made middleware: + * - `Authenticated@cbsecurity`: the user must be logged in + * - `Authorized@cbsecurity`: the user must be logged in and satisfy the route's permissions or roles + * - `JwtAuth@cbsecurity`: like `Authorized` but authenticating with a JWT + * - `BasicAuth@cbsecurity`: like `Authorized` but authenticating with HTTP Basic credentials + * + * Authorization is declared in the route metadata: + * + *
+ * route( "/admin" )
+ *     .middleware( "Authorized@cbsecurity" )
+ *     .meta( { permissions : "ADMIN,EDITOR" } )
+ *     .to( "admin.index" )
+ * 
+ * + * Supported route metadata keys: + * - `permissions`: one, a list or an array of permissions + * - `roles`: one, a list or an array of roles + * - `mode`: how the permissions are verified: any (default), all or none + */ +component { + + // DI + property name="wirebox" inject="wirebox"; + property name="cbSecurity" inject="CBSecurity@cbsecurity"; + + // Validator aliases + variables.VALIDATOR_ALIASES = { + "auth" : "AuthValidator@cbsecurity", + "cbauth" : "CBAuthValidator@cbsecurity", + "jwt" : "JwtAuthValidator@cbsecurity", + "basic" : "BasicAuthValidator@cbsecurity" + }; + + // The WireBox ID of the firewall interceptor + variables.FIREWALL_ID = "interceptor-cbsecurity@global"; + + /** + * Constructor + * + * @permissions Default permissions: one, a list or an array. The route metadata overrides them. + * @roles Default roles: one, a list or an array. Any one role satisfies the check. The route metadata overrides them. + * @mode Default permissions mode: any (one is enough), all (every one is required) or none (the user must not have any of them). The route metadata overrides it. + * @validator An alias (auth, cbauth, jwt, basic) or WireBox ID of the validator to use. Defaults to the firewall's validator. + * @useMeta Read permissions, roles and mode from the route metadata. If false, only the constructor values apply. + */ + function init( + any permissions = "", + any roles = "", + string mode = "any", + string validator = "", + boolean useMeta = true + ){ + variables.permissions = toList( arguments.permissions ); + variables.roles = toList( arguments.roles ); + variables.mode = validateMode( arguments.mode ); + variables.validator = variables.VALIDATOR_ALIASES.keyExists( arguments.validator ) ? variables.VALIDATOR_ALIASES[ + arguments.validator + ] : arguments.validator; + variables.useMeta = arguments.useMeta; + + return this; + } + + /** + * ColdBox route middleware point. Verifies access and, if it fails, processes the firewall's invalid + * actions for the failure type (authentication or authorization). + * + * @event The request context + * @rc The request collection + * @prc The private request collection + * + * @return True if access was denied, so the remaining middleware of the route is skipped + */ + boolean function preProcess( required event, rc, prc ){ + var firewall = getFirewall(); + var routeMeta = variables.useMeta ? arguments.event.getPrivateValue( "currentRouteMeta", {} ) : {}; + var permissions = routeMeta.keyExists( "permissions" ) ? toList( routeMeta.permissions ) : variables.permissions; + var roles = routeMeta.keyExists( "roles" ) ? toList( routeMeta.roles ) : variables.roles; + var mode = routeMeta.keyExists( "mode" ) ? validateMode( routeMeta.mode ) : variables.mode; + + // Validators treat permissions as "any". For "all" and "none", the validator only authenticates + // and we verify the permissions ourselves once we know who the user is. + var inlineCheck = mode == "any"; + var results = firewall.validateAccess( + event : arguments.event, + permissions: inlineCheck ? permissions : "", + roles : roles, + validator : variables.validator + ); + + if ( results.allow && !inlineCheck && listLen( permissions ) ) { + var passed = ( + mode == "all" ? variables.cbSecurity.all( permissions ) : variables.cbSecurity.none( permissions ) + ); + if ( !passed ) { + results.allow = false; + results.type = "authorization"; + results.messages = "The user does not satisfy the [#mode#] permissions check"; + } + } + + // Denied: same flow as a firewall rule or secured annotation + if ( !results.allow ) { + arguments.event.setPrivateValue( "cbSecurity_validatorResults", results ); + firewall.processInvalidAccess( arguments.event, results, "middleware" ); + return true; + } + + // Allowed: store the user in the prc like the firewall does, it may have authenticated in this request + try { + arguments.event.setPrivateValue( + firewall.getProperty( "authentication" ).prcUserVariable, + variables.cbSecurity.getUser() + ); + } catch ( "NoUserLoggedIn" e ) { + // Nothing to store + } + + return false; + } + + /** + * Get the firewall interceptor or throw a helpful exception + */ + private function getFirewall(){ + if ( !variables.wirebox.containsInstance( variables.FIREWALL_ID ) ) { + throw( + type = "cbsecurity.MiddlewareRequiresFirewall", + message = "cbsecurity middleware requires the firewall interceptor to be registered. Make sure the `firewall.autoLoadFirewall` setting is true." + ); + } + return variables.wirebox.getInstance( variables.FIREWALL_ID ); + } + + /** + * Normalize a list or array into a list + */ + private string function toList( required any value ){ + return isArray( arguments.value ) ? arrayToList( arguments.value ) : arguments.value; + } + + /** + * Validate a permissions mode + * + * @mode The mode to validate + * + * @return The same mode if valid + */ + private string function validateMode( required string mode ){ + if ( !listFindNoCase( "any,all,none", arguments.mode ) ) { + throw( + type = "cbsecurity.InvalidMiddlewareMode", + message = "The mode [#arguments.mode#] is invalid. Valid modes are: any, all, none" + ); + } + return lCase( arguments.mode ); + } + +} diff --git a/models/middleware/JwtAuth.cfc b/models/middleware/JwtAuth.cfc new file mode 100644 index 0000000..e39d099 --- /dev/null +++ b/models/middleware/JwtAuth.cfc @@ -0,0 +1,17 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Route middleware: the request must carry a valid JWT, whatever validator the firewall uses. + * + *
+ * route( "/api/orders" ).middleware( "JwtAuth@cbsecurity" ).to( "orders.index" )
+ * 
+ */ +component extends="cbsecurity.models.middleware.Guard" singleton { + + function init(){ + return super.init( validator = "jwt" ); + } + +} diff --git a/test-harness/config/Router.cfc b/test-harness/config/Router.cfc new file mode 100644 index 0000000..10120bc --- /dev/null +++ b/test-harness/config/Router.cfc @@ -0,0 +1,64 @@ +component { + + function configure(){ + // Route middleware needs ColdBox 8.2+ (the router has middlewareGroup) + if ( structKeyExists( this, "middlewareGroup" ) ) { + route( "/mw/open" ).to( "mw.index" ); + route( "/mw/authenticated" ).middleware( "Authenticated@cbsecurity" ).to( "mw.index" ); + route( "/mw/jwt" ).middleware( "JwtAuth@cbsecurity" ).to( "mw.index" ); + + // Authorized: permissions and roles come from the route meta + route( "/mw/authorized" ) + .middleware( "Authorized@cbsecurity" ) + .meta( { permissions : "read" } ) + .to( "mw.index" ); + route( "/mw/admin" ) + .middleware( "Authorized@cbsecurity" ) + .meta( { permissions : "admin" } ) + .to( "mw.index" ); + route( "/mw/all" ) + .middleware( "Authorized@cbsecurity" ) + .meta( { permissions : [ "read", "write" ], mode : "all" } ) + .to( "mw.index" ); + route( "/mw/allMissing" ) + .middleware( "Authorized@cbsecurity" ) + .meta( { permissions : "read,admin", mode : "all" } ) + .to( "mw.index" ); + route( "/mw/none" ) + .middleware( "Authorized@cbsecurity" ) + .meta( { permissions : "admin", mode : "none" } ) + .to( "mw.index" ); + route( "/mw/role" ) + .middleware( "Authorized@cbsecurity" ) + .meta( { roles : "editor" } ) + .to( "mw.index" ); + route( "/mw/roleMissing" ) + .middleware( "Authorized@cbsecurity" ) + .meta( { roles : "admin" } ) + .to( "mw.index" ); + // Authenticated only checks the login, it ignores the meta + route( "/mw/authenticatedIgnoresMeta" ) + .middleware( "Authenticated@cbsecurity" ) + .meta( { permissions : "admin" } ) + .to( "mw.index" ); + + // Group level meta needs ColdBox 8.3+ (the router tracks groupMetaStack) + if ( variables.keyExists( "groupMetaStack" ) ) { + group( + { + pattern : "/mw/group", + middleware : [ "Authorized@cbsecurity" ], + meta : { permissions : "admin" } + }, + function(){ + route( "/inside" ).to( "mw.index" ); + } + ); + } + } + + // Default convention routing + route( "/:handler/:action?" ).end(); + } + +} diff --git a/test-harness/handlers/mw.cfc b/test-harness/handlers/mw.cfc new file mode 100644 index 0000000..6726fd9 --- /dev/null +++ b/test-harness/handlers/mw.cfc @@ -0,0 +1,10 @@ +/** + * Handler used by the route middleware specs + */ +component { + + function index( event, rc, prc ){ + return "mw ok"; + } + +} diff --git a/test-harness/tests/resources/MiddlewareSpyInterceptor.cfc b/test-harness/tests/resources/MiddlewareSpyInterceptor.cfc new file mode 100644 index 0000000..72e4fd6 --- /dev/null +++ b/test-harness/tests/resources/MiddlewareSpyInterceptor.cfc @@ -0,0 +1,13 @@ +/** + * Records the invalid authentication interception data for the middleware specs + */ +component { + + function cbSecurity_onInvalidAuthentication( event, data ){ + request.mwSpy = { + "source" : arguments.data.annotationType, + "type" : arguments.data.validatorResults.type + }; + } + +} diff --git a/test-harness/tests/resources/ReadOnlyMiddleware.cfc b/test-harness/tests/resources/ReadOnlyMiddleware.cfc new file mode 100644 index 0000000..e8fbf21 --- /dev/null +++ b/test-harness/tests/resources/ReadOnlyMiddleware.cfc @@ -0,0 +1,10 @@ +/** + * A custom middleware used by the middleware specs: requires the `read` permission and ignores route meta + */ +component extends="cbsecurity.models.middleware.Guard" { + + function init(){ + return super.init( permissions = "read", useMeta = false ); + } + +} diff --git a/test-harness/tests/specs/integration/MiddlewareSpec.cfc b/test-harness/tests/specs/integration/MiddlewareSpec.cfc new file mode 100644 index 0000000..cfaf1c2 --- /dev/null +++ b/test-harness/tests/specs/integration/MiddlewareSpec.cfc @@ -0,0 +1,375 @@ +/** + * Route middleware specs. These use stub users so no database is required. + */ +component extends="coldbox.system.testing.BaseTestCase" appMapping="/root" { + + this.unloadColdbox = false; + + function beforeAll(){ + super.beforeAll(); + } + + function afterAll(){ + super.afterAll(); + } + + function run(){ + // Route specs need ColdBox 8.3+: execute() runs route middleware and groups can carry meta + var skipRouteSpecs = function(){ + return !routeMiddlewareTestable(); + }; + + describe( "Route Middleware", function(){ + beforeEach( function( currentSpec ){ + setup(); + cbauth = getInstance( "authenticationService@cbauth" ); + firewall = getWireBox().getInstance( "interceptor-cbsecurity@global" ); + cbauth.logout(); + } ); + + afterEach( function( currentSpec ){ + cbauth.logout(); + } ); + + describe( + title = "Authenticated@cbsecurity", + skip = skipRouteSpecs, + body = function(){ + it( "does not interfere with routes that do not use it", function(){ + var event = execute( route = "/mw/open", renderResults = true ); + expect( event.getRenderedContent() ).toBe( "mw ok" ); + } ); + + it( "denies guests with the firewall's invalid authentication action", function(){ + var event = execute( route = "/mw/authenticated", renderResults = true ); + expect( event.getValue( "relocate_event" ) ).toBe( "main.index" ); + expect( event.getRenderedContent() ).notToBe( "mw ok" ); + expect( event.getPrivateValue( "cbSecurity_validatorResults" ).type ).toBe( "authentication" ); + } ); + + it( "allows logged in users and stores them in the prc", function(){ + cbauth.login( buildUser() ); + var event = execute( route = "/mw/authenticated", renderResults = true ); + expect( event.getRenderedContent() ).toBe( "mw ok" ); + expect( event.valueExists( "relocate_event" ) ).toBeFalse(); + expect( event.getPrivateValue( "oCurrentUser" ).getId() ).toBe( 1 ); + } ); + + it( "honors a block action", function(){ + var original = firewall.getProperty( "firewall" ).defaultAuthenticationAction; + try { + firewall.getProperty( "firewall" ).defaultAuthenticationAction = "block"; + var event = execute( route = "/mw/authenticated", renderResults = true ); + var renderData = event.getRenderData(); + expect( renderData.statusCode ).toBe( 401 ); + expect( renderData.data ).toInclude( "Unauthorized" ); + } finally { + firewall.getProperty( "firewall" ).defaultAuthenticationAction = original; + } + } ); + + it( "honors an override action", function(){ + var original = firewall.getProperty( "firewall" ).defaultAuthenticationAction; + try { + firewall.getProperty( "firewall" ).defaultAuthenticationAction = "override"; + var event = execute( route = "/mw/authenticated", renderResults = true ); + expect( event.getCurrentEvent() ).toBe( "main.index" ); + expect( event.valueExists( "relocate_event" ) ).toBeFalse(); + } finally { + firewall.getProperty( "firewall" ).defaultAuthenticationAction = original; + } + } ); + + it( "announces the invalid authentication interception point", function(){ + structDelete( request, "mwSpy" ); + getController() + .getInterceptorService() + .registerInterceptor( + interceptorClass = "tests.resources.MiddlewareSpyInterceptor", + interceptorName = "mwSpyInterceptor" + ); + try { + execute( route = "/mw/authenticated", renderResults = true ); + } finally { + getController().getInterceptorService().unregister( "mwSpyInterceptor" ); + } + expect( request ).toHaveKey( "mwSpy" ); + expect( request.mwSpy.source ).toBe( "middleware" ); + expect( request.mwSpy.type ).toBe( "authentication" ); + } ); + } + ); + + describe( + title = "Authorized@cbsecurity", + skip = skipRouteSpecs, + body = function(){ + it( "denies guests as an authentication failure", function(){ + var event = execute( route = "/mw/authorized", renderResults = true ); + expect( event.getValue( "relocate_event" ) ).toBe( "main.index" ); + expect( event.getPrivateValue( "cbSecurity_validatorResults" ).type ).toBe( "authentication" ); + } ); + + it( "allows a user with one of the route permissions", function(){ + cbauth.login( buildUser() ); + var event = execute( route = "/mw/authorized", renderResults = true ); + expect( event.getRenderedContent() ).toBe( "mw ok" ); + } ); + + it( "denies a user without the route permissions as an authorization failure", function(){ + cbauth.login( buildUser() ); + var event = execute( route = "/mw/admin", renderResults = true ); + expect( event.getValue( "relocate_event" ) ).toBe( "main.index" ); + expect( event.getPrivateValue( "cbSecurity_validatorResults" ).type ).toBe( "authorization" ); + expect( event.getRenderedContent() ).notToBe( "mw ok" ); + } ); + + it( "supports the all mode with an array of permissions", function(){ + cbauth.login( buildUser() ); + expect( execute( route = "/mw/all", renderResults = true ).getRenderedContent() ).toBe( "mw ok" ); + expect( + execute( route = "/mw/allMissing", renderResults = true ).getValue( "relocate_event" ) + ).toBe( "main.index" ); + } ); + + it( "supports the none mode", function(){ + cbauth.login( buildUser() ); + expect( execute( route = "/mw/none", renderResults = true ).getRenderedContent() ).toBe( "mw ok" ); + } ); + + it( "supports roles", function(){ + cbauth.login( buildUser() ); + expect( execute( route = "/mw/role", renderResults = true ).getRenderedContent() ).toBe( "mw ok" ); + expect( + execute( route = "/mw/roleMissing", renderResults = true ).getValue( "relocate_event" ) + ).toBe( "main.index" ); + } ); + + it( "is applied with its meta to every route of a group", function(){ + cbauth.login( buildUser() ); + var event = execute( route = "/mw/group/inside", renderResults = true ); + expect( event.getValue( "relocate_event" ) ).toBe( "main.index" ); + expect( event.getRenderedContent() ).notToBe( "mw ok" ); + } ); + } + ); + + describe( + title = "Authenticated@cbsecurity route meta", + skip = skipRouteSpecs, + body = function(){ + it( "ignores the route meta and only verifies the login", function(){ + cbauth.login( buildUser() ); + var event = execute( route = "/mw/authenticatedIgnoresMeta", renderResults = true ); + expect( event.getRenderedContent() ).toBe( "mw ok" ); + } ); + } + ); + + describe( + title = "JwtAuth@cbsecurity", + skip = skipRouteSpecs, + body = function(){ + it( "denies requests without a token", function(){ + var event = execute( route = "/mw/jwt", renderResults = true ); + expect( event.getValue( "relocate_event" ) ).toBe( "main.index" ); + expect( event.getRenderedContent() ).notToBe( "mw ok" ); + } ); + } + ); + + describe( "Guard@cbsecurity", function(){ + // Use the block action so direct calls do not relocate + beforeEach( function( currentSpec ){ + var settings = firewall.getProperty( "firewall" ); + originals = { + "authn" : settings.defaultAuthenticationAction, + "authz" : settings.defaultAuthorizationAction + }; + settings.defaultAuthenticationAction = "block"; + settings.defaultAuthorizationAction = "block"; + } ); + + afterEach( function( currentSpec ){ + var settings = firewall.getProperty( "firewall" ); + settings.defaultAuthenticationAction = originals.authn; + settings.defaultAuthorizationAction = originals.authz; + } ); + + function newGuard(){ + return getWireBox().getInstance( name = "Guard@cbsecurity", initArguments = arguments ); + } + + function runGuard( required guard ){ + var event = getRequestContext(); + return arguments.guard.preProcess( + event, + event.getCollection(), + event.getPrivateCollection() + ); + } + + it( "resolves the ready made middleware with the right validators", function(){ + expect( prepareMock( getInstance( "BasicAuth@cbsecurity" ) ).$getProperty( "validator" ) ).toBe( "BasicAuthValidator@cbsecurity" ); + expect( prepareMock( getInstance( "JwtAuth@cbsecurity" ) ).$getProperty( "validator" ) ).toBe( "JwtAuthValidator@cbsecurity" ); + } ); + + it( "rejects an invalid mode", function(){ + // WireBox wraps constructor exceptions, so assert on the message + expect( function(){ + newGuard( mode = "bogus" ); + } ).toThrow( regex = "mode \[bogus\] is invalid" ); + } ); + + describe( "custom middleware extending Guard", function(){ + it( "uses its constructor permissions and ignores the route meta", function(){ + cbauth.login( buildUser() ); + var event = getRequestContext(); + // The route meta asks for a permission the user does not have + event.setPrivateValue( "currentRouteMeta", { "permissions" : "admin" } ); + var custom = getWireBox().getInstance( "tests.resources.ReadOnlyMiddleware" ); + expect( runGuard( custom ) ).toBeFalse(); + } ); + + it( "denies users without its constructor permissions", function(){ + cbauth.login( + createStub() + .$( "getId", 2 ) + .$( "hasPermission", false ) + .$( "hasRole", false ) + ); + var custom = getWireBox().getInstance( "tests.resources.ReadOnlyMiddleware" ); + expect( runGuard( custom ) ).toBeTrue(); + } ); + } ); + + it( "reads the permissions from the current route meta", function(){ + cbauth.login( buildUser() ); + var event = getRequestContext(); + event.setPrivateValue( "currentRouteMeta", { "permissions" : "admin" } ); + expect( runGuard( newGuard() ) ).toBeTrue(); + event.setPrivateValue( "currentRouteMeta", { "permissions" : "read" } ); + expect( runGuard( newGuard() ) ).toBeFalse(); + } ); + + describe( "mode any", function(){ + it( "allows when the user has one of the permissions", function(){ + cbauth.login( buildUser() ); + expect( runGuard( newGuard( permissions = "admin,read" ) ) ).toBeFalse(); + } ); + + it( "accepts permissions as an array", function(){ + cbauth.login( buildUser() ); + expect( runGuard( newGuard( permissions = [ "admin", "write" ] ) ) ).toBeFalse(); + } ); + + it( "denies as an authorization failure when no permission matches", function(){ + cbauth.login( buildUser() ); + expect( runGuard( newGuard( permissions = "admin" ) ) ).toBeTrue(); + expect( getRequestContext().getPrivateValue( "cbSecurity_validatorResults" ).type ).toBe( + "authorization" + ); + } ); + + it( "denies guests as an authentication failure", function(){ + expect( runGuard( newGuard( permissions = "read" ) ) ).toBeTrue(); + expect( getRequestContext().getPrivateValue( "cbSecurity_validatorResults" ).type ).toBe( + "authentication" + ); + } ); + } ); + + describe( "mode all", function(){ + it( "allows when the user has every permission", function(){ + cbauth.login( buildUser() ); + expect( runGuard( newGuard( permissions = "read,write", mode = "all" ) ) ).toBeFalse(); + } ); + + it( "denies when one permission is missing", function(){ + cbauth.login( buildUser() ); + expect( runGuard( newGuard( permissions = "read,admin", mode = "all" ) ) ).toBeTrue(); + expect( getRequestContext().getPrivateValue( "cbSecurity_validatorResults" ).type ).toBe( + "authorization" + ); + } ); + + it( "denies guests as an authentication failure", function(){ + expect( runGuard( newGuard( permissions = "read", mode = "all" ) ) ).toBeTrue(); + expect( getRequestContext().getPrivateValue( "cbSecurity_validatorResults" ).type ).toBe( + "authentication" + ); + } ); + } ); + + describe( "mode none", function(){ + it( "allows when the user has none of the permissions", function(){ + cbauth.login( buildUser() ); + expect( runGuard( newGuard( permissions = "admin,delete", mode = "none" ) ) ).toBeFalse(); + } ); + + it( "denies when the user has any of the permissions", function(){ + cbauth.login( buildUser() ); + expect( runGuard( newGuard( permissions = "admin,read", mode = "none" ) ) ).toBeTrue(); + } ); + } ); + + describe( "roles", function(){ + it( "allows when the user has one of the roles", function(){ + cbauth.login( buildUser() ); + expect( runGuard( newGuard( roles = "admin,editor" ) ) ).toBeFalse(); + } ); + + it( "denies when the user has none of the roles", function(){ + cbauth.login( buildUser() ); + expect( runGuard( newGuard( roles = "admin" ) ) ).toBeTrue(); + } ); + } ); + } ); + } ); + } + + /** + * Detects if the installed ColdBox can test route middleware: groups carry meta and execute() runs route middleware. + * Both shipped in ColdBox 8.3.0. We detect the features instead of the version so snapshots and BE builds work. + */ + private boolean function routeMiddlewareTestable(){ + return ( + fileRead( expandPath( "/coldbox/system/web/routing/Router.cfc" ) ).findNoCase( "groupMetaStack" ) > 0 + && + fileRead( expandPath( "/coldbox/system/testing/BaseTestCase.cfc" ) ).findNoCase( "runRouteMiddleware" ) > 0 + ); + } + + /** + * A stub user: id 1, permissions read + write, role editor + */ + private function buildUser(){ + return createStub() + .$( "getId", 1 ) + .$( + method = "hasPermission", + callback = function( permission ){ + var perms = isArray( arguments.permission ) ? arguments.permission : listToArray( + arguments.permission + ); + return perms + .filter( function( item ){ + return listFindNoCase( "read,write", item ) > 0; + } ) + .len() > 0; + } + ) + .$( + method = "hasRole", + callback = function( role ){ + var roles = isArray( arguments.role ) ? arguments.role : listToArray( arguments.role ); + return roles + .filter( function( item ){ + return item == "editor"; + } ) + .len() > 0; + } + ); + } + +} From 113d3fe5d42ea9633cb73142260965fb2e76494e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 18:05:06 +0000 Subject: [PATCH 2/4] feat: add Throttle, ApiKey, AllowedIPs, DenyIPs, EnsureHttps, VerifyCsrf, Honeypot and Signed route middleware Adds RateLimiter and UrlSigner models, signedRoute(), signedUrl() and hasValidSignature() mixins, middleware and signedUrls settings, and specs. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01E8yy4oK5vnaVucmYLg4UtY --- ModuleConfig.cfc | 36 + changelog.md | 3 + helpers/mixins.cfm | 54 ++ models/CBSecurity.cfc | 36 +- models/middleware/AllowedIPs.cfc | 35 + models/middleware/ApiKey.cfc | 85 ++ models/middleware/BaseMiddleware.cfc | 166 ++++ models/middleware/DenyIPs.cfc | 35 + models/middleware/EnsureHttps.cfc | 34 + models/middleware/Honeypot.cfc | 39 + models/middleware/Signed.cfc | 33 + models/middleware/Throttle.cfc | 107 +++ models/middleware/VerifyCsrf.cfc | 38 + models/ratelimit/RateLimiter.cfc | 138 ++++ models/util/UrlSigner.cfc | 201 +++++ test-harness/config/Coldbox.cfc | 6 + test-harness/config/Router.cfc | 66 ++ test-harness/handlers/mw.cfc | 13 + .../integration/SecurityMiddlewareSpec.cfc | 738 ++++++++++++++++++ 19 files changed, 1862 insertions(+), 1 deletion(-) create mode 100644 models/middleware/AllowedIPs.cfc create mode 100644 models/middleware/ApiKey.cfc create mode 100644 models/middleware/BaseMiddleware.cfc create mode 100644 models/middleware/DenyIPs.cfc create mode 100644 models/middleware/EnsureHttps.cfc create mode 100644 models/middleware/Honeypot.cfc create mode 100644 models/middleware/Signed.cfc create mode 100644 models/middleware/Throttle.cfc create mode 100644 models/middleware/VerifyCsrf.cfc create mode 100644 models/ratelimit/RateLimiter.cfc create mode 100644 models/util/UrlSigner.cfc create mode 100644 test-harness/tests/specs/integration/SecurityMiddlewareSpec.cfc diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index 3b77854..95d2336 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -99,6 +99,42 @@ component { * security exploits like XSS, ClickJacking, Host Spoofing, IP Spoofing, Non SSL usage, HSTS and much more. */ securityHeaders : { "enabled" : true }, + /** + * -------------------------------------------------------------------------- + * Route Middleware + * -------------------------------------------------------------------------- + * Defaults for the route middleware: EnsureHttps, AllowedIPs, DenyIPs, ApiKey, Honeypot, VerifyCsrf and Throttle. + * Every value can be overridden per route using `.meta()`. See the docs for the meta keys. + */ + middleware : { + // Trusted proxies (IPs or CIDR ranges). Forwarded headers are only honored from these. + "trustedProxies" : [], + "ensureHttps" : { "redirect" : true }, + "apiKey" : { + "header" : "x-api-key", + "param" : "apiKey", + "keys" : [], + "validator" : "" + }, + "honeypot" : { "field" : "website_url", "silent" : true }, + "throttle" : { + "maxAttempts" : 60, + "decaySeconds" : 60, + "cacheProvider" : "default", + "limiters" : {} + } + }, + /** + * -------------------------------------------------------------------------- + * Signed URLs + * -------------------------------------------------------------------------- + * Used by `signedUrl()`, `signedRoute()` and the `Signed@cbsecurity` middleware. The secret is required. + */ + signedUrls : { + "secret" : getSystemSetting( "CBSECURITY_SIGNING_SECRET", "" ), + "signatureParam" : "signature", + "expiresParam" : "expires" + }, /** * -------------------------------------------------------------------------- * Json Web Tokens Settings diff --git a/changelog.md b/changelog.md index 8a5cf4a..3f14102 100644 --- a/changelog.md +++ b/changelog.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Route middleware** for ColdBox 8.2+ route-scoped middleware (`route().middleware()`). Secure routes and groups right where they are declared, without firewall rules: `Authenticated@cbsecurity` (logged in), `Authorized@cbsecurity` (permissions and roles declared in the route `meta()`), `JwtAuth@cbsecurity` and `BasicAuth@cbsecurity`. They use the firewall's validators, invalid actions (redirect, override, block), interception points and logging. Group-level `meta` (one `group()` securing many routes) and `execute()` support in integration tests require ColdBox 8.3+. +- **More route middleware**, configured with route `meta()` and the new `middleware` module settings: `Throttle@cbsecurity` (named or inline limits on any CacheBox cache, default `default`, with `429` and `Retry-After`), `ApiKey@cbsecurity` (`x-api-key` header or `apiKey` request key by default, both configurable, optional validator service), `AllowedIPs@cbsecurity` and `DenyIPs@cbsecurity` (IPv4, IPv6, CIDR and `trustedProxies`), `EnsureHttps@cbsecurity`, `VerifyCsrf@cbsecurity` (uses cbcsrf) and `Honeypot@cbsecurity`. +- `RateLimiter@cbsecurity`: a cache backed fixed window rate limiter (`hit()`, `tooManyAttempts()`, `attempts()`, `remaining()`, `availableIn()`, `clear()`) for use anywhere. +- **Signed URLs**: `Signed@cbsecurity` middleware, `UrlSigner@cbsecurity` and the `signedRoute()`, `signedUrl()` and `hasValidSignature()` mixins. HMAC-SHA256 signatures with optional expiration, configured with the `signedUrls.secret` setting. - `Security` interceptor `validateAccess()` and `processInvalidAccess()` public methods, used by the middleware and available to custom integrations. ### Changed diff --git a/helpers/mixins.cfm b/helpers/mixins.cfm index 682c10b..bac5511 100644 --- a/helpers/mixins.cfm +++ b/helpers/mixins.cfm @@ -12,4 +12,58 @@ function cbSecure() { return wirebox.getInstance( "CBSecurity@cbSecurity" ); } + + /** + * Build a signed URL for a named route. Params that match a route segment fill the pattern, the rest go in the query string. + * + * @name The route name, same as `event.route()` + * @params The route params + * @expiresIn Seconds until the link expires. Zero means never. + */ + string function signedRoute( required string name, struct params = {}, numeric expiresIn = 0 ) { + var event = getRequestContext(); + var pattern = getInstance( "router@coldbox" ).findRouteByName( arguments.name ).pattern ?: ""; + var routeKeys = {}; + var extras = {}; + + arguments.params.each( function( key, value ){ + if ( reFindNoCase( ":#key#(\W|$)", pattern ) ) { + routeKeys[ key ] = value; + } else { + extras[ key ] = value; + } + } ); + + var link = event.route( arguments.name, routeKeys ); + if ( !extras.isEmpty() ) { + link &= "?" & extras.reduce( function( result, key, value ){ + result.append( encodeForURL( key ) & "=" & encodeForURL( value ) ); + return result; + }, [] ).toList( "&" ); + } + return getInstance( "UrlSigner@cbsecurity" ).sign( link, arguments.expiresIn ); + } + + /** + * Build a signed URL for an event or route path, like `event.buildLink()` but with a real query string. + * + * @to The event or route path + * @queryString Query string as a string or struct + * @expiresIn Seconds until the link expires. Zero means never. + */ + string function signedUrl( required string to, any queryString = "", numeric expiresIn = 0 ) { + var link = getRequestContext().buildLink( + to = arguments.to, + queryString = arguments.queryString, + translate = false + ); + return getInstance( "UrlSigner@cbsecurity" ).sign( link, arguments.expiresIn ); + } + + /** + * Does the current request carry a valid signed URL? + */ + boolean function hasValidSignature() { + return getInstance( "UrlSigner@cbsecurity" ).checkRequest( getRequestContext() ) == "valid"; + } diff --git a/models/CBSecurity.cfc b/models/CBSecurity.cfc index fe9ba5e..b8ed8ca 100644 --- a/models/CBSecurity.cfc +++ b/models/CBSecurity.cfc @@ -117,7 +117,36 @@ component threadsafe singleton accessors="true" { "securityRule" : {} }, securityHeaders : { "enabled" : true }, - securityModules : {} + securityModules : {}, + middleware : { + // Trusted proxies (IPs or CIDR ranges). Forwarded headers are only honored when the request comes from one of these. + "trustedProxies" : [], + // Redirect GET and HEAD requests to HTTPS. Other methods are always denied. + "ensureHttps" : { "redirect" : true }, + // Where to look for the API key, which keys are valid and, optionally, a WireBox ID of a validator with isValidKey( key, event ) + "apiKey" : { + "header" : "x-api-key", + "param" : "apiKey", + "keys" : [], + "validator" : "" + }, + // The form field that bots fill in and humans do not. Silent mode answers 200 so bots think they succeeded. + "honeypot" : { "field" : "website_url", "silent" : true }, + // Rate limiting defaults and named limiters + "throttle" : { + "maxAttempts" : 60, + "decaySeconds" : 60, + "cacheProvider" : "default", + "limiters" : {} + } + }, + signedUrls : { + // The secret used to sign URLs. Required to use signed URLs. + "secret" : "", + // The name of the query params added to a signed URL + "signatureParam" : "signature", + "expiresParam" : "expires" + } }; /** @@ -141,6 +170,11 @@ component threadsafe singleton accessors="true" { if ( isStruct( variables.settings.firewall.logs ) ) { variables.settings.firewall.logs.append( variables.DEFAULT_SETTINGS.firewall.logs, false ); } + variables.DEFAULT_SETTINGS.middleware.each( function( key, value ){ + if ( isStruct( value ) && isStruct( variables.settings.middleware[ key ] ?: "" ) ) { + variables.settings.middleware[ key ].append( value, false ); + } + } ); // Try to discover user service default for cbauth if ( diff --git a/models/middleware/AllowedIPs.cfc b/models/middleware/AllowedIPs.cfc new file mode 100644 index 0000000..5e7a12a --- /dev/null +++ b/models/middleware/AllowedIPs.cfc @@ -0,0 +1,35 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Route middleware that only lets listed IPs through (IPv4, IPv6 and CIDR ranges). Everyone else gets a 403. + * + *
+ * route( "/admin" ).middleware( "AllowedIPs@cbsecurity" ).meta( { allowedIps : "10.0.0.0/8,203.0.113.7" } ).to( "admin.index" )
+ * 
+ * + * The list is required. A route without one throws `cbsecurity.MiddlewareMisconfigured` instead of silently + * allowing or blocking everyone. Behind a proxy, set `middleware.trustedProxies`. + */ +component extends="BaseMiddleware" { + + boolean function preProcess( required event, rc, prc ){ + var routeMeta = getRouteMeta( arguments.event ); + var allowed = toArray( routeMeta.allowedIps ?: "" ); + + if ( !allowed.len() ) { + misconfigured( "AllowedIPs requires the route meta key [allowedIps]" ); + } + + if ( ipMatchesAny( getClientIP( arguments.event ), allowed ) ) { + return false; + } + + return deny( + arguments.event, + 403, + "Your IP address is not allowed" + ); + } + +} diff --git a/models/middleware/ApiKey.cfc b/models/middleware/ApiKey.cfc new file mode 100644 index 0000000..e0f0486 --- /dev/null +++ b/models/middleware/ApiKey.cfc @@ -0,0 +1,85 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Route middleware that requires an API key. It looks in a request header first (default `x-api-key`) and then in the + * request collection (default `apiKey`). A missing or invalid key gets a 401. + * + * Valid keys come from, in order: + * - the route meta key `apiKeys` (a list or array) + * - the `middleware.apiKey.keys` setting + * - a WireBox validator, `middleware.apiKey.validator`, that implements `boolean isValidKey( required string key, required event )` + * + * Route meta overrides: `apiKeyHeader`, `apiKeyParam` and `apiKeys`. + * + * Do not keep keys in source control. Load them from environment variables in your configuration. + */ +component extends="BaseMiddleware" { + + boolean function preProcess( required event, rc, prc ){ + var config = getMiddlewareSettings( "apiKey" ); + var routeMeta = getRouteMeta( arguments.event ); + var header = routeMeta.apiKeyHeader ?: config.header; + var param = routeMeta.apiKeyParam ?: config.param; + var keys = toArray( routeMeta.apiKeys ?: config.keys ); + var validator = config.validator ?: ""; + + if ( !keys.len() && !len( validator ) ) { + misconfigured( + "ApiKey requires keys: use the route meta [apiKeys], the [middleware.apiKey.keys] setting or a [middleware.apiKey.validator]" + ); + } + + // Header first, then the request collection + var incoming = len( header ) ? arguments.event.getHTTPHeader( header, "" ) : ""; + if ( !len( incoming ) && len( param ) ) { + incoming = arguments.event.getValue( param, "" ); + } + + if ( + isSimpleValue( incoming ) && len( incoming ) && isValidKey( + trim( incoming ), + keys, + validator, + arguments.event + ) + ) { + return false; + } + + return deny( + arguments.event, + 401, + "A valid API key is required" + ); + } + + /** + * Verify the key against the static keys and then the validator + */ + private boolean function isValidKey( + required string key, + required array keys, + required string validator, + required event + ){ + // Check every key so timing does not reveal which one matched + var found = false; + for ( var candidate in arguments.keys ) { + if ( safeEquals( arguments.key, candidate ) ) { + found = true; + } + } + if ( found ) { + return true; + } + + if ( len( arguments.validator ) ) { + return variables.wirebox + .getInstance( arguments.validator ) + .isValidKey( arguments.key, arguments.event ); + } + return false; + } + +} diff --git a/models/middleware/BaseMiddleware.cfc b/models/middleware/BaseMiddleware.cfc new file mode 100644 index 0000000..168657b --- /dev/null +++ b/models/middleware/BaseMiddleware.cfc @@ -0,0 +1,166 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Base class for the cbsecurity route middleware that do not authenticate users: EnsureHttps, AllowedIPs, DenyIPs, + * ApiKey, Honeypot, VerifyCsrf, Throttle and Signed. + * + * Settings come from `moduleSettings.cbsecurity.middleware` and every value can be overridden per route with `.meta()`. + * Returning true from `preProcess()` tells ColdBox to skip the rest of the route's middleware. + */ +component accessors="true" { + + // DI + property name="wirebox" inject="wirebox"; + property name="settings" inject="coldbox:moduleSettings:cbsecurity"; + + /** + * Get the metadata of the current route + */ + struct function getRouteMeta( required event ){ + return arguments.event.getPrivateValue( "currentRouteMeta", {} ); + } + + /** + * Get the middleware settings for one middleware, e.g. `apiKey` + */ + struct function getMiddlewareSettings( required string name ){ + return variables.settings.middleware[ arguments.name ] ?: {}; + } + + /** + * Normalize a list or array into a trimmed array without empty items + */ + array function toArray( required any value ){ + var items = isArray( arguments.value ) ? arguments.value : listToArray( arguments.value ); + return items.map( ( item ) => trim( item ) ).filter( ( item ) => len( item ) ); + } + + /** + * Compare two strings in constant time + */ + boolean function safeEquals( required string a, required string b ){ + return createObject( "java", "java.security.MessageDigest" ).isEqual( + arguments.a.getBytes( "UTF-8" ), + arguments.b.getBytes( "UTF-8" ) + ); + } + + /** + * Stops the request and renders a JSON error. Always returns true so you can `return deny( ... )`. + * + * @event The request context + * @statusCode The HTTP status code + * @message The error message + * @headers Extra response headers as name-value pairs + */ + boolean function deny( + required event, + numeric statusCode = 403, + string message = "Forbidden", + struct headers = {} + ){ + for ( var name in arguments.headers ) { + arguments.event.setHTTPHeader( name = name, value = arguments.headers[ name ] ); + } + arguments.event + .renderData( + type = "json", + data = { "error" : true, "messages" : [ arguments.message ] }, + statusCode = arguments.statusCode + ) + .noExecution(); + return true; + } + + /** + * The address that made the TCP connection + */ + string function getRemoteAddr(){ + return len( cgi.remote_addr ) ? trim( listFirst( cgi.remote_addr ) ) : "127.0.0.1"; + } + + /** + * Get the client IP. The X-Forwarded-For header is only honored when the connection comes from a trusted proxy + * (`middleware.trustedProxies`). We walk the header from the right and return the first address that is not a trusted proxy. + * + * @event The request context + */ + string function getClientIP( required event ){ + var remote = getRemoteAddr(); + var proxies = toArray( variables.settings.middleware.trustedProxies ); + + if ( !proxies.len() || !ipMatchesAny( remote, proxies ) ) { + return remote; + } + + var chain = listToArray( arguments.event.getHTTPHeader( "X-Forwarded-For", "" ) ) + .map( ( item ) => trim( item ) ) + .filter( ( item ) => len( item ) ); + for ( var i = chain.len(); i >= 1; i-- ) { + if ( !ipMatchesAny( chain[ i ], proxies ) ) { + return chain[ i ]; + } + } + return remote; + } + + /** + * Does an IP match any of the rules? A rule is an exact IP or a CIDR range, IPv4 or IPv6. + * + * @ip The IP to test + * @rules An array of IPs or CIDR ranges + */ + boolean function ipMatchesAny( required string ip, required array rules ){ + for ( var rule in arguments.rules ) { + if ( ipMatches( arguments.ip, rule ) ) { + return true; + } + } + return false; + } + + /** + * Does an IP match one rule? Invalid input never matches. + */ + boolean function ipMatches( required string ip, required string rule ){ + try { + var inet = createObject( "java", "java.net.InetAddress" ); + var ipBytes = inet.getByName( trim( arguments.ip ) ).getAddress(); + var ruleParts = listToArray( trim( arguments.rule ), "/" ); + var ruleBytes = inet.getByName( ruleParts[ 1 ] ).getAddress(); + + // A different family (v4 vs v6) never matches + if ( arrayLen( ipBytes ) != arrayLen( ruleBytes ) ) { + return false; + } + + var prefix = ruleParts.len() > 1 ? val( ruleParts[ 2 ] ) : arrayLen( ruleBytes ) * 8; + if ( prefix < 0 || prefix > arrayLen( ruleBytes ) * 8 ) { + return false; + } + + for ( var i = 1; i <= arrayLen( ruleBytes ); i++ ) { + var bits = min( 8, max( 0, prefix - ( ( i - 1 ) * 8 ) ) ); + if ( bits == 0 ) { + break; + } + var mask = bitAnd( bitShln( 255, 8 - bits ), 255 ); + if ( bitAnd( ipBytes[ i ], mask ) != bitAnd( ruleBytes[ i ], mask ) ) { + return false; + } + } + return true; + } catch ( any e ) { + return false; + } + } + + /** + * Throw a configuration error + */ + function misconfigured( required string message ){ + throw( type = "cbsecurity.MiddlewareMisconfigured", message = arguments.message ); + } + +} diff --git a/models/middleware/DenyIPs.cfc b/models/middleware/DenyIPs.cfc new file mode 100644 index 0000000..8159e2e --- /dev/null +++ b/models/middleware/DenyIPs.cfc @@ -0,0 +1,35 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Route middleware that blocks listed IPs (IPv4, IPv6 and CIDR ranges) with a 403. Everyone else passes. + * + *
+ * route( "/api" ).middleware( "DenyIPs@cbsecurity" ).meta( { denyIps : "198.51.100.0/24" } ).to( "api.index" )
+ * 
+ * + * The list is required. A route without one throws `cbsecurity.MiddlewareMisconfigured`. Behind a proxy, set + * `middleware.trustedProxies`. + */ +component extends="BaseMiddleware" { + + boolean function preProcess( required event, rc, prc ){ + var routeMeta = getRouteMeta( arguments.event ); + var denied = toArray( routeMeta.denyIps ?: "" ); + + if ( !denied.len() ) { + misconfigured( "DenyIPs requires the route meta key [denyIps]" ); + } + + if ( ipMatchesAny( getClientIP( arguments.event ), denied ) ) { + return deny( + arguments.event, + 403, + "Your IP address is blocked" + ); + } + + return false; + } + +} diff --git a/models/middleware/EnsureHttps.cfc b/models/middleware/EnsureHttps.cfc new file mode 100644 index 0000000..bd148ac --- /dev/null +++ b/models/middleware/EnsureHttps.cfc @@ -0,0 +1,34 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Route middleware that forces HTTPS. GET and HEAD requests are redirected with a 301, any other method is denied + * with a 403 because a redirect would drop the request body. + * + * Detection uses `event.isSSL()`, which honors the usual proxy headers (X-Forwarded-Proto and X-Scheme). + * + * Settings: `middleware.ensureHttps.redirect` (default true). Route meta override: `redirectToHttps` (boolean). + */ +component extends="BaseMiddleware" { + + boolean function preProcess( required event, rc, prc ){ + if ( arguments.event.isSSL() ) { + return false; + } + + var routeMeta = getRouteMeta( arguments.event ); + var redirect = routeMeta.keyExists( "redirectToHttps" ) ? routeMeta.redirectToHttps : getMiddlewareSettings( + "ensureHttps" + ).redirect; + + if ( redirect && listFindNoCase( "GET,HEAD", arguments.event.getHTTPMethod() ) ) { + // getUrl() replaced getFullUrl() in ColdBox 8 + var currentUrl = structKeyExists( arguments.event, "getUrl" ) ? arguments.event.getUrl() : arguments.event.getFullUrl(); + arguments.event.relocate( URL = replaceNoCase( currentUrl, "http:", "https:" ), statusCode = 301 ); + return true; + } + + return deny( arguments.event, 403, "HTTPS is required" ); + } + +} diff --git a/models/middleware/Honeypot.cfc b/models/middleware/Honeypot.cfc new file mode 100644 index 0000000..2d8ddeb --- /dev/null +++ b/models/middleware/Honeypot.cfc @@ -0,0 +1,39 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Route middleware that catches bots with a hidden form field. People never see the field, bots fill it in. + * + * Add the field to your form and hide it with CSS (not `type="hidden"`, bots skip those): + * + *
+ * 
+ * 
+ * + * Settings: `middleware.honeypot.field` (default `website_url`) and `middleware.honeypot.silent` (default true). + * Route meta overrides: `honeypotField` and `honeypotSilent`. When silent, bots get a 200 "OK" and the handler never runs. + * When not silent they get a 422. + */ +component extends="BaseMiddleware" { + + boolean function preProcess( required event, rc, prc ){ + var config = getMiddlewareSettings( "honeypot" ); + var routeMeta = getRouteMeta( arguments.event ); + var field = routeMeta.honeypotField ?: config.field; + var silent = routeMeta.honeypotSilent ?: config.silent; + + var value = arguments.event.getValue( field, "" ); + if ( !isSimpleValue( value ) || !len( trim( value ) ) ) { + return false; + } + + if ( silent ) { + arguments.event.renderData( type = "text", data = "OK", statusCode = 200 ).noExecution(); + return true; + } + return deny( arguments.event, 422, "Unprocessable request" ); + } + +} diff --git a/models/middleware/Signed.cfc b/models/middleware/Signed.cfc new file mode 100644 index 0000000..a977c1e --- /dev/null +++ b/models/middleware/Signed.cfc @@ -0,0 +1,33 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Route middleware that only lets requests with a valid signed URL through. Create the links with the `signedRoute()` + * and `signedUrl()` helpers or `UrlSigner@cbsecurity`. + * + *
+ * route( "/invoices/:id/download" ).as( "invoice.download" ).middleware( "Signed@cbsecurity" ).to( "invoices.download" )
+ * 
+ * + * A missing, altered or expired signature gets a 403, with the reason (missing, invalid or expired) stored in + * `prc.cbSecurity_signatureStatus` for your own handling. Requires the `signedUrls.secret` setting. + */ +component extends="BaseMiddleware" { + + property name="urlSigner" inject="UrlSigner@cbsecurity"; + + boolean function preProcess( required event, rc, prc ){ + var status = variables.urlSigner.checkRequest( arguments.event ); + if ( status == "valid" ) { + return false; + } + + arguments.event.setPrivateValue( "cbSecurity_signatureStatus", status ); + return deny( + arguments.event, + 403, + status == "expired" ? "This link has expired" : "This link is not valid" + ); + } + +} diff --git a/models/middleware/Throttle.cfc b/models/middleware/Throttle.cfc new file mode 100644 index 0000000..452ef2e --- /dev/null +++ b/models/middleware/Throttle.cfc @@ -0,0 +1,107 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Route middleware that rate limits requests. Over the limit gets a 429 with a `Retry-After` header. Allowed + * requests get `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers. + * + * Use a named limiter from the `middleware.throttle.limiters` setting: + * + *
+ * route( "/login" ).middleware( "Throttle@cbsecurity" ).meta( { throttle : "login" } ).to( "sessions.create" )
+ * 
+ * + * Or set the limits inline on the route. Unset values come from the `middleware.throttle` defaults: + * + *
+ * .meta( { throttle : { maxAttempts : 10, decaySeconds : 60, by : "user", cacheProvider : "redis" } } )
+ * 
+ * + * Limiter keys: + * - `maxAttempts`: requests per window + * - `decaySeconds`: the window length + * - `cacheProvider`: the CacheBox cache that counts (default `default`) + * - `by`: `ip` (default) or `user`. `user` falls back to the IP for guests. + * - `name`: groups routes under one counter (default: the limiter name, or the route pattern) + */ +component extends="BaseMiddleware" { + + property name="rateLimiter" inject="RateLimiter@cbsecurity"; + property name="cbSecurity" inject="CBSecurity@cbsecurity"; + + boolean function preProcess( required event, rc, prc ){ + var limit = resolveLimit( arguments.event ); + var key = limit.name & ":" & identify( arguments.event, limit.by ); + var cache = limit.cacheProvider; + + if ( variables.rateLimiter.tooManyAttempts( key, limit.maxAttempts, cache ) ) { + return deny( + arguments.event, + 429, + "Too many requests", + { + "Retry-After" : variables.rateLimiter.availableIn( key, cache ), + "X-RateLimit-Limit" : limit.maxAttempts, + "X-RateLimit-Remaining" : 0 + } + ); + } + + variables.rateLimiter.hit( key, limit.decaySeconds, cache ); + + arguments.event.setHTTPHeader( name = "X-RateLimit-Limit", value = limit.maxAttempts ); + arguments.event.setHTTPHeader( + name = "X-RateLimit-Remaining", + value = variables.rateLimiter.remaining( key, limit.maxAttempts, cache ) + ); + return false; + } + + /** + * Build the limit from the defaults, the named limiter and the route meta + */ + private struct function resolveLimit( required event ){ + var config = getMiddlewareSettings( "throttle" ); + var spec = getRouteMeta( arguments.event ).throttle ?: {}; + var limit = { + "maxAttempts" : config.maxAttempts, + "decaySeconds" : config.decaySeconds, + "cacheProvider" : config.cacheProvider, + "by" : "ip", + "name" : "" + }; + + // A string names a limiter + if ( isSimpleValue( spec ) ) { + if ( len( spec ) ) { + if ( !( config.limiters ?: {} ).keyExists( spec ) ) { + misconfigured( "The throttle limiter [#spec#] is not defined in [middleware.throttle.limiters]" ); + } + limit.name = spec; + spec = config.limiters[ spec ]; + } else { + spec = {}; + } + } + limit.append( spec, true ); + + if ( !listFindNoCase( "ip,user", limit.by ) ) { + misconfigured( "The throttle [by] value [#limit.by#] is invalid. Use ip or user" ); + } + if ( !len( limit.name ) ) { + limit.name = arguments.event.getPrivateValue( "currentRoute", arguments.event.getCurrentEvent() ); + } + return limit; + } + + /** + * Who are we counting + */ + private string function identify( required event, required string by ){ + if ( arguments.by == "user" && variables.cbSecurity.isLoggedIn() ) { + return "user-" & variables.cbSecurity.getUser().getId(); + } + return "ip-" & getClientIP( arguments.event ); + } + +} diff --git a/models/middleware/VerifyCsrf.cfc b/models/middleware/VerifyCsrf.cfc new file mode 100644 index 0000000..e902a97 --- /dev/null +++ b/models/middleware/VerifyCsrf.cfc @@ -0,0 +1,38 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Route middleware that verifies a CSRF token on unsafe requests (anything except GET, HEAD and OPTIONS) using the + * bundled cbcsrf module. The token is read from the `csrf` request collection key or the `x-csrf-token` header, + * the same places the cbcsrf auto verifier uses. A missing or invalid token gets a 403. + * + * Route meta override: `csrfKey`, the key the token was generated for (default `default`). + * + * Unlike the cbcsrf auto verifier, this middleware also runs in integration tests. + */ +component extends="BaseMiddleware" { + + boolean function preProcess( required event, rc, prc ){ + if ( listFindNoCase( "GET,HEAD,OPTIONS", arguments.event.getHTTPMethod() ) ) { + return false; + } + + var token = arguments.event.getValue( "csrf", arguments.event.getHTTPHeader( "x-csrf-token", "" ) ); + var key = getRouteMeta( arguments.event ).csrfKey ?: "default"; + + if ( + isSimpleValue( token ) && len( token ) && variables.wirebox + .getInstance( "@cbcsrf" ) + .verify( token, key ) + ) { + return false; + } + + return deny( + arguments.event, + 403, + "The CSRF token is missing or invalid" + ); + } + +} diff --git a/models/ratelimit/RateLimiter.cfc b/models/ratelimit/RateLimiter.cfc new file mode 100644 index 0000000..d9c51b5 --- /dev/null +++ b/models/ratelimit/RateLimiter.cfc @@ -0,0 +1,138 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * A cache backed, fixed window rate limiter. Use it directly for anything that is not a route, like login attempts, + * password resets or API calls inside a service. The `Throttle@cbsecurity` middleware is built on top of it. + * + *
+ * limiter = getInstance( "RateLimiter@cbsecurity" )
+ * if ( limiter.tooManyAttempts( "login:#rc.email#", 5 ) ) {
+ *     // wait limiter.availableIn( "login:#rc.email#" ) seconds
+ * }
+ * limiter.hit( "login:#rc.email#", 60 )
+ * 
+ * + * Keys are stored in a CacheBox cache. Use a shared cache (Redis, Couchbase, a database) when you run more than one + * server, otherwise every server counts on its own. + */ +component singleton threadSafe { + + // DI + property name="cachebox" inject="cachebox"; + + // The prefix of every cache key + variables.KEY_PREFIX = "cbsecurity-ratelimit-"; + + function init(){ + return this; + } + + /** + * Record an attempt. A key starts its window with its first hit. + * + * @key The key to limit, e.g. `login:127.0.0.1` + * @decaySeconds How long the window lasts + * @cacheProvider The name of the CacheBox cache to use + * + * @return The number of attempts in the current window + */ + numeric function hit( + required string key, + numeric decaySeconds = 60, + string cacheProvider = "default" + ){ + lock name="#variables.KEY_PREFIX##hash( arguments.key )#" type="exclusive" timeout="5" throwOnTimeout="true" { + var record = readRecord( arguments.key, arguments.cacheProvider ); + var now = nowInSeconds(); + + if ( isNull( record ) ) { + record = { "attempts" : 0, "resetAt" : now + arguments.decaySeconds }; + } + record.attempts++; + + getCache( arguments.cacheProvider ).set( + variables.KEY_PREFIX & arguments.key, + record, + ceiling( ( record.resetAt - now ) / 60 ) + 1, + 0 + ); + } + return record.attempts; + } + + /** + * Has the key reached its limit? + */ + boolean function tooManyAttempts( + required string key, + required numeric maxAttempts, + string cacheProvider = "default" + ){ + return attempts( arguments.key, arguments.cacheProvider ) >= arguments.maxAttempts; + } + + /** + * Attempts made in the current window + */ + numeric function attempts( required string key, string cacheProvider = "default" ){ + var record = readRecord( arguments.key, arguments.cacheProvider ); + return isNull( record ) ? 0 : record.attempts; + } + + /** + * Attempts left in the current window, never below zero + */ + numeric function remaining( + required string key, + required numeric maxAttempts, + string cacheProvider = "default" + ){ + return max( 0, arguments.maxAttempts - attempts( arguments.key, arguments.cacheProvider ) ); + } + + /** + * Seconds until the window resets, zero if there is no window + */ + numeric function availableIn( required string key, string cacheProvider = "default" ){ + var record = readRecord( arguments.key, arguments.cacheProvider ); + return isNull( record ) ? 0 : max( 0, record.resetAt - nowInSeconds() ); + } + + /** + * Reset a key, e.g. after a successful login + */ + RateLimiter function clear( required string key, string cacheProvider = "default" ){ + getCache( arguments.cacheProvider ).clear( variables.KEY_PREFIX & arguments.key ); + return this; + } + + /** + * Read the live record or null. Expired windows are removed. + */ + private function readRecord( required string key, required string cacheProvider ){ + var cache = getCache( arguments.cacheProvider ); + var record = cache.get( variables.KEY_PREFIX & arguments.key ); + + if ( isNull( record ) ) { + return; + } + if ( record.resetAt <= nowInSeconds() ) { + cache.clear( variables.KEY_PREFIX & arguments.key ); + return; + } + return record; + } + + private function getCache( required string name ){ + return variables.cachebox.getCache( arguments.name ); + } + + /** + * Epoch seconds + */ + private numeric function nowInSeconds(){ + return int( createObject( "java", "java.lang.System" ).currentTimeMillis() / 1000 ); + } + +} diff --git a/models/util/UrlSigner.cfc b/models/util/UrlSigner.cfc new file mode 100644 index 0000000..843e5ff --- /dev/null +++ b/models/util/UrlSigner.cfc @@ -0,0 +1,201 @@ +/** + * Copyright since 2016 by Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Signs URLs and verifies them. A signed URL carries an HMAC-SHA256 signature of its path and query string, so + * nobody can change the path or a parameter, and an optional expiration time, without invalidating it. + * + *
+ * signer = getInstance( "UrlSigner@cbsecurity" )
+ * link   = signer.sign( "https://example.com/invoices/42/download", 3600 )
+ * signer.isValid( link ) // true until the hour is up
+ * 
+ * + * What is signed: the URL path and every query parameter except the signature, with parameters sorted by name. + * Parameter names are case insensitive, a trailing slash on the path is ignored, and the scheme, host and fragment are ignored, so a URL still validates behind proxies and load balancers. + * + * Settings (`moduleSettings.cbsecurity.signedUrls`): `secret` (required), `signatureParam` and `expiresParam`. + */ +component accessors="true" singleton threadSafe { + + // DI + property name="settings" inject="coldbox:moduleSettings:cbsecurity"; + + function init(){ + return this; + } + + /** + * Sign a URL, absolute or relative. Any signature or expiration already on it is replaced. + * + * @url The URL to sign + * @expiresIn Seconds from now until the link expires. Zero means it never expires. + * + * @return The URL with the expiration (if any) and signature added to its query string + */ + string function sign( required string url, numeric expiresIn = 0 ){ + var config = getConfig(); + var target = listFirst( arguments.url, "##" ); + var base = listFirst( target, "?" ); + var pairs = parsePairs( find( "?", target ) ? listRest( target, "?" ) : "" ).filter( ( pair ) => { + return compareNoCase( pair.name, config.signatureParam ) != 0 && compareNoCase( + pair.name, + config.expiresParam + ) != 0; + } ); + + if ( arguments.expiresIn > 0 ) { + pairs.append( { + "name" : config.expiresParam, + "value" : nowInSeconds() + int( arguments.expiresIn ) + } ); + } + + var signature = computeSignature( base, pairs ); + pairs.append( { "name" : config.signatureParam, "value" : signature } ); + + return base & "?" & pairs + .map( ( pair ) => encodeForURL( pair.name ) & "=" & encodeForURL( pair.value ) ) + .toList( "&" ); + } + + /** + * Verify a URL + * + * @url The URL to verify, absolute or relative + */ + boolean function isValid( required string url ){ + return check( arguments.url ) == "valid"; + } + + /** + * Verify a URL and say why it failed + * + * @url The URL to verify, absolute or relative + * + * @return valid, missing (no signature), invalid (the URL was altered) or expired + */ + string function check( required string url ){ + var config = getConfig(); + var target = listFirst( arguments.url, "##" ); + var base = listFirst( target, "?" ); + var all = parsePairs( find( "?", target ) ? listRest( target, "?" ) : "" ); + + var given = all.filter( ( pair ) => compareNoCase( pair.name, config.signatureParam ) == 0 ); + if ( !given.len() ) { + return "missing"; + } + + var signed = all.filter( ( pair ) => compareNoCase( pair.name, config.signatureParam ) != 0 ); + if ( !safeEquals( given[ 1 ].value, computeSignature( base, signed ) ) ) { + return "invalid"; + } + + var expires = signed.filter( ( pair ) => compareNoCase( pair.name, config.expiresParam ) == 0 ); + if ( expires.len() ) { + if ( !isNumeric( expires[ 1 ].value ) || expires[ 1 ].value <= nowInSeconds() ) { + return "expired"; + } + } + return "valid"; + } + + /** + * Rebuild the URL of the current request the same way `event.buildLink()` and `event.route()` build links. The + * query string comes from the `URL` scope. + * + * @event The request context + */ + string function requestUrl( required event ){ + var query = []; + for ( var key in url ) { + if ( isSimpleValue( url[ key ] ) ) { + query.append( encodeForURL( key ) & "=" & encodeForURL( url[ key ] ) ); + } + } + return arguments.event.getSESBaseURL() & "/" & arguments.event.getCurrentRoutedURL() & ( + query.len() ? "?" & query.toList( "&" ) : "" + ); + } + + /** + * Verify the URL of the current request + * + * @event The request context + * + * @return valid, missing, invalid or expired + */ + string function checkRequest( required event ){ + return check( requestUrl( arguments.event ) ); + } + + /************************************ PRIVATE ************************************/ + + /** + * Signature of the canonical form: the decoded path, a question mark and the sorted `name=value` pairs + */ + private string function computeSignature( required string base, required array pairs ){ + var secret = getConfig().secret; + // Names are lower cased because engines upper case the keys of the URL scope + var items = arguments.pairs.map( ( pair ) => lCase( pair.name ) & "=" & pair.value ); + arraySort( items, "text" ); + return lCase( + hmac( + normalizePath( arguments.base ) & "?" & items.toList( "&" ), + secret, + "HMACSHA256" + ) + ); + } + + /** + * Drop the scheme and host, collapse repeated slashes and decode + */ + private string function normalizePath( required string base ){ + var path = reReplace( + arguments.base, + "^[a-zA-Z][a-zA-Z0-9+.\-]*://[^/]*", + "" + ); + path = reReplace( path, "/{2,}", "/", "all" ); + path = urlDecode( path ); + path = left( path, 1 ) == "/" ? path : "/" & path; + // A trailing slash does not change the route + return len( path ) > 1 && right( path, 1 ) == "/" ? left( path, len( path ) - 1 ) : path; + } + + /** + * Parse a query string into an array of decoded name-value pairs + */ + private array function parsePairs( required string query ){ + return listToArray( arguments.query, "&" ).map( ( item ) => { + return { + "name" : urlDecode( listFirst( item, "=" ) ), + "value" : find( "=", item ) ? urlDecode( listRest( item, "=" ) ) : "" + }; + } ); + } + + private struct function getConfig(){ + var config = variables.settings.signedUrls; + if ( !len( config.secret ?: "" ) ) { + throw( + type = "cbsecurity.SigningSecretMissing", + message = "Signed URLs need a secret. Set [signedUrls.secret] in your cbsecurity module settings, for example from the CBSECURITY_SIGNING_SECRET environment variable." + ); + } + return config; + } + + private boolean function safeEquals( required string a, required string b ){ + return createObject( "java", "java.security.MessageDigest" ).isEqual( + arguments.a.getBytes( "UTF-8" ), + arguments.b.getBytes( "UTF-8" ) + ); + } + + private numeric function nowInSeconds(){ + return int( createObject( "java", "java.lang.System" ).currentTimeMillis() / 1000 ); + } + +} diff --git a/test-harness/config/Coldbox.cfc b/test-harness/config/Coldbox.cfc index bd37b8d..e3cc500 100644 --- a/test-harness/config/Coldbox.cfc +++ b/test-harness/config/Coldbox.cfc @@ -64,6 +64,12 @@ // CB Security cbSecurity : { + signedUrls : { secret : "harness-signing-secret" }, + + middleware : { + throttle : { limiters : { strict : { maxAttempts : 2, decaySeconds : 60 } } } + }, + basicAuth : { users : { "lmajano" : { password : 'test', permissions : "", roles : "admin" }, diff --git a/test-harness/config/Router.cfc b/test-harness/config/Router.cfc index 10120bc..cb1f95c 100644 --- a/test-harness/config/Router.cfc +++ b/test-harness/config/Router.cfc @@ -42,6 +42,72 @@ component { .meta( { permissions : "admin" } ) .to( "mw.index" ); + // Non authenticating middleware + route( "/mw/httpsNoRedirect" ) + .middleware( "EnsureHttps@cbsecurity" ) + .meta( { redirectToHttps : false } ) + .to( "mw.index" ); + route( "/mw/https" ).middleware( "EnsureHttps@cbsecurity" ).to( "mw.index" ); + + route( "/mw/ip/allowed" ) + .middleware( "AllowedIPs@cbsecurity" ) + .meta( { allowedIps : "10.0.0.0/8,127.0.0.1,::1" } ) + .to( "mw.index" ); + route( "/mw/ip/notAllowed" ) + .middleware( "AllowedIPs@cbsecurity" ) + .meta( { allowedIps : "203.0.113.0/24" } ) + .to( "mw.index" ); + route( "/mw/ip/unconfigured" ).middleware( "AllowedIPs@cbsecurity" ).to( "mw.index" ); + route( "/mw/ip/denied" ) + .middleware( "DenyIPs@cbsecurity" ) + .meta( { denyIps : "127.0.0.1,::1" } ) + .to( "mw.index" ); + route( "/mw/ip/notDenied" ) + .middleware( "DenyIPs@cbsecurity" ) + .meta( { denyIps : "203.0.113.0/24" } ) + .to( "mw.index" ); + + route( "/mw/apikey/custom" ) + .middleware( "ApiKey@cbsecurity" ) + .meta( { apiKeys : [ "key-one" ], apiKeyParam : "token", apiKeyHeader : "x-token" } ) + .to( "mw.index" ); + route( "/mw/apikey/unconfigured" ).middleware( "ApiKey@cbsecurity" ).to( "mw.index" ); + route( "/mw/apikey" ) + .middleware( "ApiKey@cbsecurity" ) + .meta( { apiKeys : "key-one,key-two" } ) + .to( "mw.index" ); + + route( "/mw/honeypot/loud" ) + .middleware( "Honeypot@cbsecurity" ) + .meta( { honeypotSilent : false, honeypotField : "nickname" } ) + .to( "mw.index" ); + route( "/mw/honeypot" ).middleware( "Honeypot@cbsecurity" ).to( "mw.index" ); + + route( "/mw/csrf" ).middleware( "VerifyCsrf@cbsecurity" ).to( "mw.index" ); + + route( "/mw/throttle/named" ) + .middleware( "Throttle@cbsecurity" ) + .meta( { throttle : "strict" } ) + .to( "mw.index" ); + route( "/mw/throttle/inline" ) + .middleware( "Throttle@cbsecurity" ) + .meta( { throttle : { maxAttempts : 1, decaySeconds : 60 } } ) + .to( "mw.index" ); + route( "/mw/throttle/unknown" ) + .middleware( "Throttle@cbsecurity" ) + .meta( { throttle : "nope" } ) + .to( "mw.index" ); + route( "/mw/throttle/badCache" ) + .middleware( "Throttle@cbsecurity" ) + .meta( { throttle : { cacheProvider : "doesNotExist" } } ) + .to( "mw.index" ); + + route( pattern = "/mw/signed/:id", name = "mw.signed" ) + .middleware( "Signed@cbsecurity" ) + .to( "mw.index" ); + route( "/mw/link" ).to( "mw.link" ); + route( "/mw/hasSignature" ).to( "mw.hasSignature" ); + // Group level meta needs ColdBox 8.3+ (the router tracks groupMetaStack) if ( variables.keyExists( "groupMetaStack" ) ) { group( diff --git a/test-harness/handlers/mw.cfc b/test-harness/handlers/mw.cfc index 6726fd9..df4bd12 100644 --- a/test-harness/handlers/mw.cfc +++ b/test-harness/handlers/mw.cfc @@ -7,4 +7,17 @@ component { return "mw ok"; } + // Exercises the signedRoute() and signedUrl() mixins + function link( event, rc, prc ){ + return rc.kind == "route" ? signedRoute( "mw.signed", { id : 7, ref : "a b" }, 60 ) : signedUrl( + "/mw/signed/9", + { ref : "x" } + ); + } + + // Exercises the hasValidSignature() mixin + function hasSignature( event, rc, prc ){ + return hasValidSignature() ? "yes" : "no"; + } + } diff --git a/test-harness/tests/specs/integration/SecurityMiddlewareSpec.cfc b/test-harness/tests/specs/integration/SecurityMiddlewareSpec.cfc new file mode 100644 index 0000000..08e0050 --- /dev/null +++ b/test-harness/tests/specs/integration/SecurityMiddlewareSpec.cfc @@ -0,0 +1,738 @@ +/** + * Specs for the non authenticating route middleware: EnsureHttps, AllowedIPs, DenyIPs, ApiKey, Honeypot, + * VerifyCsrf, Throttle and Signed, plus the RateLimiter, UrlSigner and signed URL mixins. + * + * Paths that depend on request headers or the connection address are tested by calling the middleware directly with + * a mocked event. Everything else goes through real routes. + */ +component extends="coldbox.system.testing.BaseTestCase" appMapping="/root" { + + this.unloadColdbox = false; + + function beforeAll(){ + super.beforeAll(); + } + + function afterAll(){ + super.afterAll(); + } + + function run(){ + // Route specs need ColdBox 8.3+: execute() runs route middleware and groups can carry meta + var skipRouteSpecs = function(){ + return !routeMiddlewareTestable(); + }; + + describe( "cbsecurity route middleware", function(){ + beforeEach( function( currentSpec ){ + setup(); + // Throttle counters live in the default cache + getWireBox() + .getInstance( "cachebox" ) + .getCache( "default" ) + .clearAll(); + // The signed URL checks read the URL scope, so start from an empty one (the runner fills it) + originalUrl = duplicate( url ); + structClear( url ); + } ); + + afterEach( function( currentSpec ){ + structClear( url ); + structAppend( url, originalUrl ); + } ); + + /************************** IP MATCHING **************************/ + + describe( "IP matching", function(){ + beforeEach( function(){ + mw = getInstance( "AllowedIPs@cbsecurity" ); + } ); + + it( "matches exact IPv4 addresses", function(){ + expect( mw.ipMatches( "10.1.2.3", "10.1.2.3" ) ).toBeTrue(); + expect( mw.ipMatches( "10.1.2.4", "10.1.2.3" ) ).toBeFalse(); + } ); + + it( "matches IPv4 CIDR ranges", function(){ + expect( mw.ipMatches( "10.200.1.1", "10.0.0.0/8" ) ).toBeTrue(); + expect( mw.ipMatches( "11.0.0.1", "10.0.0.0/8" ) ).toBeFalse(); + expect( mw.ipMatches( "192.168.1.77", "192.168.1.64/26" ) ).toBeTrue(); + expect( mw.ipMatches( "192.168.1.128", "192.168.1.64/26" ) ).toBeFalse(); + expect( mw.ipMatches( "8.8.8.8", "0.0.0.0/0" ) ).toBeTrue(); + } ); + + it( "matches IPv6 addresses and ranges", function(){ + expect( mw.ipMatches( "::1", "::1" ) ).toBeTrue(); + expect( mw.ipMatches( "2001:db8::5", "2001:db8::/32" ) ).toBeTrue(); + expect( mw.ipMatches( "2001:db9::5", "2001:db8::/32" ) ).toBeFalse(); + } ); + + it( "never matches across IP families", function(){ + expect( mw.ipMatches( "127.0.0.1", "::1" ) ).toBeFalse(); + expect( mw.ipMatches( "::1", "127.0.0.1/8" ) ).toBeFalse(); + } ); + + it( "does not match invalid input", function(){ + expect( mw.ipMatches( "not-an-ip", "10.0.0.0/8" ) ).toBeFalse(); + expect( mw.ipMatches( "10.0.0.1", "10.0.0.0/99" ) ).toBeFalse(); + } ); + + it( "matches any rule in a list", function(){ + expect( mw.ipMatchesAny( "10.0.0.9", [ "203.0.113.0/24", "10.0.0.0/8" ] ) ).toBeTrue(); + expect( mw.ipMatchesAny( "10.0.0.9", [] ) ).toBeFalse(); + } ); + } ); + + describe( "client IP and trusted proxies", function(){ + beforeEach( function(){ + mw = prepareMock( getInstance( "AllowedIPs@cbsecurity" ) ); + ev = prepareMock( getRequestContext() ); + settings = mw.getSettings(); + original = duplicate( settings.middleware.trustedProxies ); + } ); + + afterEach( function(){ + settings.middleware.trustedProxies = original; + } ); + + it( "uses the connection address when no proxies are trusted", function(){ + mw.$( "getRemoteAddr", "198.51.100.9" ); + ev.$( "getHTTPHeader", "1.2.3.4" ); + expect( mw.getClientIP( ev ) ).toBe( "198.51.100.9" ); + } ); + + it( "ignores X-Forwarded-For when the connection is not a trusted proxy", function(){ + settings.middleware.trustedProxies = [ "10.0.0.0/8" ]; + mw.$( "getRemoteAddr", "198.51.100.9" ); + ev.$( "getHTTPHeader", "1.2.3.4" ); + expect( mw.getClientIP( ev ) ).toBe( "198.51.100.9" ); + } ); + + it( "reads X-Forwarded-For from a trusted proxy, skipping trusted hops from the right", function(){ + settings.middleware.trustedProxies = [ "10.0.0.0/8" ]; + mw.$( "getRemoteAddr", "10.0.0.2" ); + ev.$( "getHTTPHeader", "6.6.6.6, 203.0.113.50, 10.0.0.1" ); + expect( mw.getClientIP( ev ) ).toBe( "203.0.113.50" ); + } ); + } ); + + /************************** EnsureHttps **************************/ + + describe( "EnsureHttps@cbsecurity", function(){ + beforeEach( function(){ + mw = getInstance( "EnsureHttps@cbsecurity" ); + ev = prepareMock( getRequestContext() ); + } ); + + it( "lets HTTPS requests through", function(){ + ev.$( "isSSL", true ); + expect( mw.preProcess( ev ) ).toBeFalse(); + } ); + + it( "redirects GET requests to HTTPS with a 301", function(){ + ev.$( "isSSL", false ) + .$( "getHTTPMethod", "GET" ) + .$( "getUrl", "http://example.com/mw/https" ); + ev.$( "relocate" ); + expect( mw.preProcess( ev ) ).toBeTrue(); + var call = ev.$callLog().relocate[ 1 ]; + expect( call.URL ).toBe( "https://example.com/mw/https" ); + expect( call.statusCode ).toBe( 301 ); + } ); + + it( "denies non GET requests with a 403 instead of redirecting", function(){ + ev.$( "isSSL", false ).$( "getHTTPMethod", "POST" ); + ev.$( "relocate" ); + expect( mw.preProcess( ev ) ).toBeTrue(); + expect( ev.$callLog().relocate ).toBeEmpty(); + expect( ev.getRenderData().statusCode ).toBe( 403 ); + } ); + + it( "denies instead of redirecting when the route turns redirects off", function(){ + ev.$( "isSSL", false ).$( "getHTTPMethod", "GET" ); + ev.setPrivateValue( "currentRouteMeta", { redirectToHttps : false } ); + ev.$( "relocate" ); + expect( mw.preProcess( ev ) ).toBeTrue(); + expect( ev.$callLog().relocate ).toBeEmpty(); + expect( ev.getRenderData().statusCode ).toBe( 403 ); + } ); + } ); + + /************************** AllowedIPs / DenyIPs **************************/ + + describe( + title = "AllowedIPs@cbsecurity and DenyIPs@cbsecurity", + skip = skipRouteSpecs, + body = function(){ + it( "allows a listed address", function(){ + var event = get( route = "/mw/ip/allowed", renderResults = true ); + expect( event.getRenderedContent() ).toBe( "mw ok" ); + } ); + + it( "denies an address that is not listed", function(){ + var event = get( route = "/mw/ip/notAllowed", renderResults = true ); + expect( event.getRenderData().statusCode ).toBe( 403 ); + expect( event.getRenderedContent() ).notToBe( "mw ok" ); + } ); + + it( "throws when AllowedIPs has no list", function(){ + expect( function(){ + get( route = "/mw/ip/unconfigured" ); + } ).toThrow( "cbsecurity.MiddlewareMisconfigured" ); + } ); + + it( "blocks a denied address", function(){ + var event = get( route = "/mw/ip/denied", renderResults = true ); + expect( event.getRenderData().statusCode ).toBe( 403 ); + } ); + + it( "lets other addresses through DenyIPs", function(){ + var event = get( route = "/mw/ip/notDenied", renderResults = true ); + expect( event.getRenderedContent() ).toBe( "mw ok" ); + } ); + + it( "uses a spoofed client address only from trusted proxies", function(){ + var mw = prepareMock( getInstance( "DenyIPs@cbsecurity" ) ); + var ev = prepareMock( getRequestContext() ); + ev.setPrivateValue( "currentRouteMeta", { denyIps : "203.0.113.50" } ); + mw.$( "getRemoteAddr", "198.51.100.9" ); + ev.$( "getHTTPHeader", "203.0.113.50" ); + // Not a trusted proxy, so the header is ignored and the request passes + expect( mw.preProcess( ev ) ).toBeFalse(); + } ); + } + ); + + /************************** ApiKey **************************/ + + describe( "ApiKey@cbsecurity", function(){ + it( "reads the key from the apiKey request key by default", function(){ + var event = get( + route = "/mw/apikey", + params = { apiKey : "key-two" }, + renderResults = true + ); + expect( event.getRenderedContent() ).toBe( "mw ok" ); + } ); + + it( "denies a missing key with a 401", function(){ + var event = get( route = "/mw/apikey", renderResults = true ); + expect( event.getRenderData().statusCode ).toBe( 401 ); + } ); + + it( "denies a wrong key with a 401", function(){ + var event = get( + route = "/mw/apikey", + params = { apiKey : "key-three" }, + renderResults = true + ); + expect( event.getRenderData().statusCode ).toBe( 401 ); + } ); + + it( "supports a custom request key from the route meta", function(){ + var event = get( + route = "/mw/apikey/custom", + params = { token : "key-one" }, + renderResults = true + ); + expect( event.getRenderedContent() ).toBe( "mw ok" ); + } ); + + it( "ignores the default request key when the route names another", function(){ + var event = get( route = "/mw/apikey/custom", params = { apiKey : "key-one" } ); + expect( event.getRenderData().statusCode ).toBe( 401 ); + } ); + + it( "throws when no keys or validator are configured", function(){ + expect( function(){ + get( route = "/mw/apikey/unconfigured" ); + } ).toThrow( "cbsecurity.MiddlewareMisconfigured" ); + } ); + + describe( "headers", function(){ + beforeEach( function(){ + mw = prepareMock( getInstance( "ApiKey@cbsecurity" ) ); + ev = prepareMock( getRequestContext() ); + ev.setPrivateValue( "currentRouteMeta", { apiKeys : "abc123" } ); + } ); + + it( "reads x-api-key by default", function(){ + ev.$( "getHTTPHeader", "abc123" ); + expect( mw.preProcess( ev ) ).toBeFalse(); + } ); + + it( "denies a wrong header value", function(){ + ev.$( "getHTTPHeader", "zzz" ); + expect( mw.preProcess( ev ) ).toBeTrue(); + expect( ev.getRenderData().statusCode ).toBe( 401 ); + } ); + + it( "asks for the configured header", function(){ + ev.setPrivateValue( "currentRouteMeta", { apiKeys : "abc123", apiKeyHeader : "x-token" } ); + ev.$( "getHTTPHeader", "abc123" ); + mw.preProcess( ev ); + expect( ev.$callLog().getHTTPHeader[ 1 ][ 1 ] ).toBe( "x-token" ); + } ); + + it( "asks for x-api-key when nothing is configured", function(){ + ev.$( "getHTTPHeader", "abc123" ); + mw.preProcess( ev ); + expect( ev.$callLog().getHTTPHeader[ 1 ][ 1 ] ).toBe( "x-api-key" ); + } ); + + it( "falls back to a validator service", function(){ + var validator = createStub().$( "isValidKey", true ); + var wb = createStub().$( "getInstance", validator ); + mw.$property( "wirebox", "variables", wb ); + ev.setPrivateValue( "currentRouteMeta", {} ); + mw.getSettings().middleware.apiKey.validator = "FakeKeyValidator"; + try { + ev.$( "getHTTPHeader", "from-db" ); + expect( mw.preProcess( ev ) ).toBeFalse(); + expect( validator.$callLog().isValidKey[ 1 ][ 1 ] ).toBe( "from-db" ); + } finally { + mw.getSettings().middleware.apiKey.validator = ""; + } + } ); + } ); + } ); + + /************************** Honeypot **************************/ + + describe( + title = "Honeypot@cbsecurity", + skip = skipRouteSpecs, + body = function(){ + it( "lets requests with an empty trap through", function(){ + var event = post( + route = "/mw/honeypot", + params = { name : "Ana" }, + renderResults = true + ); + expect( event.getRenderedContent() ).toBe( "mw ok" ); + } ); + + it( "ignores a whitespace only trap", function(){ + var event = post( + route = "/mw/honeypot", + params = { website_url : " " }, + renderResults = true + ); + expect( event.getRenderedContent() ).toBe( "mw ok" ); + } ); + + it( "answers a filled trap with a silent 200 and skips the handler", function(){ + var event = post( + route = "/mw/honeypot", + params = { website_url : "http://spam.example" }, + renderResults = true + ); + expect( event.getRenderData().statusCode ).toBe( 200 ); + expect( event.getRenderedContent() ).notToBe( "mw ok" ); + } ); + + it( "answers a filled trap with a 422 when not silent, using the configured field", function(){ + var event = post( + route = "/mw/honeypot/loud", + params = { nickname : "bot" }, + renderResults = true + ); + expect( event.getRenderData().statusCode ).toBe( 422 ); + } ); + + it( "ignores the default field when the route names another", function(){ + var ok = post( + route = "/mw/honeypot/loud", + params = { website_url : "x" }, + renderResults = true + ); + expect( ok.getRenderedContent() ).toBe( "mw ok" ); + } ); + } + ); + + /************************** VerifyCsrf **************************/ + + describe( + title = "VerifyCsrf@cbsecurity", + skip = skipRouteSpecs, + body = function(){ + it( "does not check safe methods", function(){ + var event = get( route = "/mw/csrf", renderResults = true ); + expect( event.getRenderedContent() ).toBe( "mw ok" ); + } ); + + it( "denies an unsafe request without a token", function(){ + var event = post( route = "/mw/csrf", renderResults = true ); + expect( event.getRenderData().statusCode ).toBe( 403 ); + } ); + + it( "denies an unsafe request with a bad token", function(){ + var event = post( + route = "/mw/csrf", + params = { csrf : "nope" }, + renderResults = true + ); + expect( event.getRenderData().statusCode ).toBe( 403 ); + } ); + + it( "allows an unsafe request with a valid token", function(){ + var token = getInstance( "@cbcsrf" ).generate(); + var event = post( + route = "/mw/csrf", + params = { csrf : token }, + renderResults = true + ); + expect( event.getRenderedContent() ).toBe( "mw ok" ); + } ); + + it( "accepts the token in the x-csrf-token header", function(){ + var token = getInstance( "@cbcsrf" ).generate(); + var mw = getInstance( "VerifyCsrf@cbsecurity" ); + var ev = prepareMock( getRequestContext() ); + ev.$( "getHTTPMethod", "DELETE" ).$( "getHTTPHeader", token ); + expect( mw.preProcess( ev ) ).toBeFalse(); + } ); + } + ); + + /************************** RateLimiter **************************/ + + describe( "RateLimiter@cbsecurity", function(){ + beforeEach( function(){ + limiter = getInstance( "RateLimiter@cbsecurity" ); + } ); + + it( "starts at zero", function(){ + expect( limiter.attempts( "k1" ) ).toBe( 0 ); + expect( limiter.availableIn( "k1" ) ).toBe( 0 ); + expect( limiter.tooManyAttempts( "k1", 3 ) ).toBeFalse(); + } ); + + it( "counts hits and reports remaining attempts", function(){ + expect( limiter.hit( "k2", 60 ) ).toBe( 1 ); + expect( limiter.hit( "k2", 60 ) ).toBe( 2 ); + expect( limiter.attempts( "k2" ) ).toBe( 2 ); + expect( limiter.remaining( "k2", 5 ) ).toBe( 3 ); + expect( limiter.remaining( "k2", 1 ) ).toBe( 0 ); + } ); + + it( "reports too many attempts at the limit", function(){ + limiter.hit( "k3", 60 ); + limiter.hit( "k3", 60 ); + expect( limiter.tooManyAttempts( "k3", 3 ) ).toBeFalse(); + limiter.hit( "k3", 60 ); + expect( limiter.tooManyAttempts( "k3", 3 ) ).toBeTrue(); + } ); + + it( "reports when the window resets", function(){ + limiter.hit( "k4", 60 ); + expect( limiter.availableIn( "k4" ) ).toBeBetween( 1, 60 ); + } ); + + it( "opens a new window after the old one ends", function(){ + limiter.hit( "k5", 1 ); + sleep( 2100 ); + expect( limiter.attempts( "k5" ) ).toBe( 0 ); + expect( limiter.hit( "k5", 1 ) ).toBe( 1 ); + } ); + + it( "clears a key", function(){ + limiter.hit( "k6", 60 ); + limiter.clear( "k6" ); + expect( limiter.attempts( "k6" ) ).toBe( 0 ); + } ); + + it( "keeps keys apart", function(){ + limiter.hit( "k7a", 60 ); + expect( limiter.attempts( "k7b" ) ).toBe( 0 ); + } ); + + it( "uses the named cache provider", function(){ + expect( function(){ + limiter.hit( "k8", 60, "doesNotExist" ); + } ).toThrow(); + } ); + } ); + + /************************** Throttle **************************/ + + describe( + title = "Throttle@cbsecurity", + skip = skipRouteSpecs, + body = function(){ + it( "allows requests up to the named limiter's limit and then returns 429", function(){ + expect( get( route = "/mw/throttle/named", renderResults = true ).getRenderedContent() ).toBe( + "mw ok" + ); + expect( get( route = "/mw/throttle/named", renderResults = true ).getRenderedContent() ).toBe( + "mw ok" + ); + var blocked = get( route = "/mw/throttle/named", renderResults = true ); + expect( blocked.getRenderData().statusCode ).toBe( 429 ); + } ); + + it( "supports inline limits on the route", function(){ + expect( get( route = "/mw/throttle/inline", renderResults = true ).getRenderedContent() ).toBe( + "mw ok" + ); + expect( get( route = "/mw/throttle/inline" ).getRenderData().statusCode ).toBe( 429 ); + } ); + + it( "counts each route separately", function(){ + get( route = "/mw/throttle/inline" ); + expect( get( route = "/mw/throttle/inline" ).getRenderData().statusCode ).toBe( 429 ); + expect( get( route = "/mw/throttle/named", renderResults = true ).getRenderedContent() ).toBe( + "mw ok" + ); + } ); + + it( "throws for an unknown limiter name", function(){ + expect( function(){ + get( route = "/mw/throttle/unknown" ); + } ).toThrow( "cbsecurity.MiddlewareMisconfigured" ); + } ); + + it( "uses the cacheProvider from the limiter and fails loudly if it does not exist", function(){ + expect( function(){ + get( route = "/mw/throttle/badCache" ); + } ).toThrow(); + } ); + + it( "sets rate limit headers on allowed requests and Retry-After on denied ones", function(){ + var mw = getInstance( "Throttle@cbsecurity" ); + var ev = prepareMock( getRequestContext() ); + ev.$( "setHTTPHeader", ev ); + ev.setPrivateValue( + "currentRouteMeta", + { throttle : { maxAttempts : 1, decaySeconds : 60 } } + ); + ev.setPrivateValue( "currentRoute", "header-spec" ); + + expect( mw.preProcess( ev ) ).toBeFalse(); + var names = ev + .$callLog() + .setHTTPHeader + .map( function( c ){ + return c.name; + } ); + expect( names ).toInclude( "X-RateLimit-Limit" ); + expect( names ).toInclude( "X-RateLimit-Remaining" ); + + expect( mw.preProcess( ev ) ).toBeTrue(); + var denied = ev + .$callLog() + .setHTTPHeader + .map( function( c ){ + return c.name; + } ); + expect( denied ).toInclude( "Retry-After" ); + } ); + + it( "defaults to the default cache and counts users separately when by is user", function(){ + var mw = prepareMock( getInstance( "Throttle@cbsecurity" ) ); + var ev = prepareMock( getRequestContext() ); + ev.$( "setHTTPHeader", ev ); + ev.setPrivateValue( "currentRouteMeta", { throttle : { maxAttempts : 1, by : "user" } } ); + ev.setPrivateValue( "currentRoute", "by-user-spec" ); + var cbs = createStub().$( "isLoggedIn", true ); + cbs.$( "getUser", createStub().$( "getId", 1 ) ); + mw.$property( "cbSecurity", "variables", cbs ); + expect( mw.preProcess( ev ) ).toBeFalse(); + expect( mw.preProcess( ev ) ).toBeTrue(); + + cbs.$( "getUser", createStub().$( "getId", 2 ) ); + expect( mw.preProcess( ev ) ).toBeFalse(); + } ); + } + ); + + /************************** UrlSigner **************************/ + + describe( "UrlSigner@cbsecurity", function(){ + beforeEach( function(){ + signer = getInstance( "UrlSigner@cbsecurity" ); + } ); + + it( "signs and validates a URL", function(){ + var link = signer.sign( "https://example.com/invoices/42?ref=a" ); + expect( link ).toInclude( "signature=" ); + expect( signer.isValid( link ) ).toBeTrue(); + expect( signer.check( link ) ).toBe( "valid" ); + } ); + + it( "reports a URL without a signature as missing", function(){ + expect( signer.check( "https://example.com/invoices/42" ) ).toBe( "missing" ); + } ); + + it( "detects a changed path", function(){ + var link = signer.sign( "https://example.com/invoices/42" ); + expect( signer.check( replace( link, "/42", "/43" ) ) ).toBe( "invalid" ); + } ); + + it( "detects a changed, added or removed parameter", function(){ + var link = signer.sign( "https://example.com/d?ref=a" ); + expect( signer.check( replace( link, "ref=a", "ref=b" ) ) ).toBe( "invalid" ); + expect( signer.check( link & "&admin=1" ) ).toBe( "invalid" ); + expect( signer.check( replace( link, "ref=a&", "" ) ) ).toBe( "invalid" ); + } ); + + it( "detects a tampered signature", function(){ + var link = signer.sign( "https://example.com/d" ); + expect( signer.check( left( link, len( link ) - 1 ) & "0" ) ).toBe( "invalid" ); + } ); + + it( "ignores the scheme, host and parameter order", function(){ + var link = signer.sign( "https://internal.local/d?a=1&b=2" ); + var signature = reMatch( "signature=[a-f0-9]+", link )[ 1 ]; + expect( signer.isValid( "http://public.example.com/d?b=2&a=1&" & signature ) ).toBeTrue(); + } ); + + it( "expires links", function(){ + var link = signer.sign( "https://example.com/d", 1 ); + expect( signer.check( link ) ).toBe( "valid" ); + sleep( 2100 ); + expect( signer.check( link ) ).toBe( "expired" ); + } ); + + it( "cannot have its expiration extended", function(){ + var link = signer.sign( "https://example.com/d", 1 ); + var other = reReplace( link, "expires=\d+", "expires=99999999999" ); + expect( signer.check( other ) ).toBe( "invalid" ); + } ); + + it( "replaces an existing signature when signing again", function(){ + var once = signer.sign( "https://example.com/d?ref=a" ); + var twice = signer.sign( once ); + expect( listLen( twice, "&" ) ).toBe( 2 ); + expect( signer.isValid( twice ) ).toBeTrue(); + } ); + + it( "encodes parameter values", function(){ + var link = signer.sign( "https://example.com/d?note=a%20b%26c" ); + expect( signer.isValid( link ) ).toBeTrue(); + } ); + + it( "refuses to sign without a secret", function(){ + var original = signer.getSettings().signedUrls.secret; + signer.getSettings().signedUrls.secret = ""; + try { + expect( function(){ + signer.sign( "https://example.com/d" ); + } ).toThrow( "cbsecurity.SigningSecretMissing" ); + } finally { + signer.getSettings().signedUrls.secret = original; + } + } ); + + it( "rejects a signature made with a different secret", function(){ + var link = signer.sign( "https://example.com/d" ); + var original = signer.getSettings().signedUrls.secret; + signer.getSettings().signedUrls.secret = "another-secret"; + try { + expect( signer.check( link ) ).toBe( "invalid" ); + } finally { + signer.getSettings().signedUrls.secret = original; + } + } ); + } ); + + /************************** Signed middleware + mixins **************************/ + + describe( + title = "Signed@cbsecurity and the signed URL mixins", + skip = skipRouteSpecs, + body = function(){ + it( "denies an unsigned request with a 403", function(){ + var event = get( route = "/mw/signed/42", renderResults = true ); + expect( event.getRenderData().statusCode ).toBe( 403 ); + expect( event.getPrivateValue( "cbSecurity_signatureStatus" ) ).toBe( "missing" ); + } ); + + it( "allows a request for a link made with the signer", function(){ + var link = getInstance( "UrlSigner@cbsecurity" ).sign( + getRequestContext().route( "mw.signed", { id : 42 } ), + 60 + ); + applyQuery( link ); + var event = get( route = "/mw/signed/42", renderResults = true ); + expect( event.getRenderedContent() ).toBe( "mw ok" ); + } ); + + it( "denies a request when the route param was changed", function(){ + var link = getInstance( "UrlSigner@cbsecurity" ).sign( + getRequestContext().route( "mw.signed", { id : 42 } ), + 60 + ); + applyQuery( link ); + var event = get( route = "/mw/signed/43", renderResults = true ); + expect( event.getRenderData().statusCode ).toBe( 403 ); + expect( event.getPrivateValue( "cbSecurity_signatureStatus" ) ).toBe( "invalid" ); + } ); + + it( "denies an expired link and says why", function(){ + var link = getInstance( "UrlSigner@cbsecurity" ).sign( + getRequestContext().route( "mw.signed", { id : 42 } ), + 1 + ); + applyQuery( link ); + sleep( 2100 ); + var event = get( route = "/mw/signed/42", renderResults = true ); + expect( event.getRenderData().statusCode ).toBe( 403 ); + expect( event.getPrivateValue( "cbSecurity_signatureStatus" ) ).toBe( "expired" ); + } ); + + it( "signedRoute() fills route params, puts the rest in the query string and signs", function(){ + var event = get( + route = "/mw/link", + params = { kind : "route" }, + renderResults = true + ); + var link = event.getRenderedContent(); + expect( reFindNoCase( "/mw/signed/7/?\?", link ) ).toBeGT( 0 ); + expect( reFindNoCase( "ref=a(%20|\+)b", link ) ).toBeGT( 0 ); + expect( link ).toInclude( "expires=" ); + expect( getInstance( "UrlSigner@cbsecurity" ).isValid( link ) ).toBeTrue(); + } ); + + it( "signedUrl() builds a real query string and signs", function(){ + var event = get( + route = "/mw/link", + params = { kind : "url" }, + renderResults = true + ); + var link = event.getRenderedContent(); + expect( reFindNoCase( "/mw/signed/9/?\?", link ) ).toBeGT( 0 ); + expect( reFindNoCase( "ref=x", link ) ).toBeGT( 0 ); + expect( getInstance( "UrlSigner@cbsecurity" ).isValid( link ) ).toBeTrue(); + } ); + + it( "hasValidSignature() reflects the current request", function(){ + expect( get( route = "/mw/hasSignature", renderResults = true ).getRenderedContent() ).toBe( "no" ); + } ); + } + ); + } ); + } + + /** + * Put a signed link's query string in the URL scope, which is where the middleware reads it + */ + private function applyQuery( required string link ){ + listToArray( listRest( arguments.link, "?" ), "&" ).each( function( pair ){ + url[ urlDecode( listFirst( pair, "=" ) ) ] = urlDecode( listRest( pair, "=" ) ); + } ); + } + + /** + * Detects if the installed ColdBox can test route middleware: groups carry meta and execute() runs route middleware. + */ + private boolean function routeMiddlewareTestable(){ + return ( + fileRead( expandPath( "/coldbox/system/web/routing/Router.cfc" ) ).findNoCase( "groupMetaStack" ) > 0 + && + fileRead( expandPath( "/coldbox/system/testing/BaseTestCase.cfc" ) ).findNoCase( "runRouteMiddleware" ) > 0 + ); + } + +} From 41cfe839cf70ea370731fdca5bc59b644c04b52e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 18:10:28 +0000 Subject: [PATCH 3/4] fix: avoid Adobe parsing issues and skip route specs on ColdBox without route middleware - Replace elvis with struct literal operands, which Adobe ColdFusion misparses - Build signedRoute() with loops instead of closures - Skip the ApiKey route specs when the installed ColdBox cannot run route middleware Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01E8yy4oK5vnaVucmYLg4UtY --- helpers/mixins.cfm | 23 +++-- models/middleware/BaseMiddleware.cfc | 4 +- models/middleware/Throttle.cfc | 5 +- .../integration/SecurityMiddlewareSpec.cfc | 90 ++++++++++--------- 4 files changed, 65 insertions(+), 57 deletions(-) diff --git a/helpers/mixins.cfm b/helpers/mixins.cfm index bac5511..4ea836b 100644 --- a/helpers/mixins.cfm +++ b/helpers/mixins.cfm @@ -22,24 +22,23 @@ */ string function signedRoute( required string name, struct params = {}, numeric expiresIn = 0 ) { var event = getRequestContext(); - var pattern = getInstance( "router@coldbox" ).findRouteByName( arguments.name ).pattern ?: ""; + var found = getInstance( "router@coldbox" ).findRouteByName( arguments.name ); + var pattern = structKeyExists( found, "pattern" ) ? found.pattern : ""; var routeKeys = {}; - var extras = {}; + var extras = []; - arguments.params.each( function( key, value ){ - if ( reFindNoCase( ":#key#(\W|$)", pattern ) ) { - routeKeys[ key ] = value; + // Params that match a route segment fill the pattern, the rest become query params + for ( var key in arguments.params ) { + if ( reFindNoCase( ":" & key & "(\W|$)", pattern ) ) { + routeKeys[ key ] = arguments.params[ key ]; } else { - extras[ key ] = value; + extras.append( encodeForURL( key ) & "=" & encodeForURL( arguments.params[ key ] ) ); } - } ); + } var link = event.route( arguments.name, routeKeys ); - if ( !extras.isEmpty() ) { - link &= "?" & extras.reduce( function( result, key, value ){ - result.append( encodeForURL( key ) & "=" & encodeForURL( value ) ); - return result; - }, [] ).toList( "&" ); + if ( extras.len() ) { + link &= "?" & extras.toList( "&" ); } return getInstance( "UrlSigner@cbsecurity" ).sign( link, arguments.expiresIn ); } diff --git a/models/middleware/BaseMiddleware.cfc b/models/middleware/BaseMiddleware.cfc index 168657b..596cae1 100644 --- a/models/middleware/BaseMiddleware.cfc +++ b/models/middleware/BaseMiddleware.cfc @@ -25,7 +25,9 @@ component accessors="true" { * Get the middleware settings for one middleware, e.g. `apiKey` */ struct function getMiddlewareSettings( required string name ){ - return variables.settings.middleware[ arguments.name ] ?: {}; + return structKeyExists( variables.settings.middleware, arguments.name ) ? variables.settings.middleware[ + arguments.name + ] : {}; } /** diff --git a/models/middleware/Throttle.cfc b/models/middleware/Throttle.cfc index 452ef2e..942f66f 100644 --- a/models/middleware/Throttle.cfc +++ b/models/middleware/Throttle.cfc @@ -62,7 +62,8 @@ component extends="BaseMiddleware" { */ private struct function resolveLimit( required event ){ var config = getMiddlewareSettings( "throttle" ); - var spec = getRouteMeta( arguments.event ).throttle ?: {}; + var meta = getRouteMeta( arguments.event ); + var spec = structKeyExists( meta, "throttle" ) ? meta.throttle : {}; var limit = { "maxAttempts" : config.maxAttempts, "decaySeconds" : config.decaySeconds, @@ -74,7 +75,7 @@ component extends="BaseMiddleware" { // A string names a limiter if ( isSimpleValue( spec ) ) { if ( len( spec ) ) { - if ( !( config.limiters ?: {} ).keyExists( spec ) ) { + if ( !structKeyExists( config, "limiters" ) || !structKeyExists( config.limiters, spec ) ) { misconfigured( "The throttle limiter [#spec#] is not defined in [middleware.throttle.limiters]" ); } limit.name = spec; diff --git a/test-harness/tests/specs/integration/SecurityMiddlewareSpec.cfc b/test-harness/tests/specs/integration/SecurityMiddlewareSpec.cfc index 08e0050..c26ec56 100644 --- a/test-harness/tests/specs/integration/SecurityMiddlewareSpec.cfc +++ b/test-harness/tests/specs/integration/SecurityMiddlewareSpec.cfc @@ -206,48 +206,54 @@ component extends="coldbox.system.testing.BaseTestCase" appMapping="/root" { /************************** ApiKey **************************/ describe( "ApiKey@cbsecurity", function(){ - it( "reads the key from the apiKey request key by default", function(){ - var event = get( - route = "/mw/apikey", - params = { apiKey : "key-two" }, - renderResults = true - ); - expect( event.getRenderedContent() ).toBe( "mw ok" ); - } ); - - it( "denies a missing key with a 401", function(){ - var event = get( route = "/mw/apikey", renderResults = true ); - expect( event.getRenderData().statusCode ).toBe( 401 ); - } ); - - it( "denies a wrong key with a 401", function(){ - var event = get( - route = "/mw/apikey", - params = { apiKey : "key-three" }, - renderResults = true - ); - expect( event.getRenderData().statusCode ).toBe( 401 ); - } ); - - it( "supports a custom request key from the route meta", function(){ - var event = get( - route = "/mw/apikey/custom", - params = { token : "key-one" }, - renderResults = true - ); - expect( event.getRenderedContent() ).toBe( "mw ok" ); - } ); - - it( "ignores the default request key when the route names another", function(){ - var event = get( route = "/mw/apikey/custom", params = { apiKey : "key-one" } ); - expect( event.getRenderData().statusCode ).toBe( 401 ); - } ); - - it( "throws when no keys or validator are configured", function(){ - expect( function(){ - get( route = "/mw/apikey/unconfigured" ); - } ).toThrow( "cbsecurity.MiddlewareMisconfigured" ); - } ); + describe( + title = "routes", + skip = skipRouteSpecs, + body = function(){ + it( "reads the key from the apiKey request key by default", function(){ + var event = get( + route = "/mw/apikey", + params = { apiKey : "key-two" }, + renderResults = true + ); + expect( event.getRenderedContent() ).toBe( "mw ok" ); + } ); + + it( "denies a missing key with a 401", function(){ + var event = get( route = "/mw/apikey", renderResults = true ); + expect( event.getRenderData().statusCode ).toBe( 401 ); + } ); + + it( "denies a wrong key with a 401", function(){ + var event = get( + route = "/mw/apikey", + params = { apiKey : "key-three" }, + renderResults = true + ); + expect( event.getRenderData().statusCode ).toBe( 401 ); + } ); + + it( "supports a custom request key from the route meta", function(){ + var event = get( + route = "/mw/apikey/custom", + params = { token : "key-one" }, + renderResults = true + ); + expect( event.getRenderedContent() ).toBe( "mw ok" ); + } ); + + it( "ignores the default request key when the route names another", function(){ + var event = get( route = "/mw/apikey/custom", params = { apiKey : "key-one" } ); + expect( event.getRenderData().statusCode ).toBe( 401 ); + } ); + + it( "throws when no keys or validator are configured", function(){ + expect( function(){ + get( route = "/mw/apikey/unconfigured" ); + } ).toThrow( "cbsecurity.MiddlewareMisconfigured" ); + } ); + } + ); describe( "headers", function(){ beforeEach( function(){ From 8534bd80f20d264ab173c8435ea02b79dd749e88 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 18:15:10 +0000 Subject: [PATCH 4/4] fix: read route meta with an explicit key check instead of the elvis operator Adobe ColdFusion ignores a false value on the left of ?:, so a route's honeypotSilent = false fell back to the default. Add getMetaValue() and use it. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01E8yy4oK5vnaVucmYLg4UtY --- models/middleware/AllowedIPs.cfc | 3 +-- models/middleware/ApiKey.cfc | 9 ++++----- models/middleware/BaseMiddleware.cfc | 17 +++++++++++++++++ models/middleware/DenyIPs.cfc | 3 +-- models/middleware/Honeypot.cfc | 11 +++++++---- models/middleware/VerifyCsrf.cfc | 2 +- models/util/UrlSigner.cfc | 2 +- 7 files changed, 32 insertions(+), 15 deletions(-) diff --git a/models/middleware/AllowedIPs.cfc b/models/middleware/AllowedIPs.cfc index 5e7a12a..157b09b 100644 --- a/models/middleware/AllowedIPs.cfc +++ b/models/middleware/AllowedIPs.cfc @@ -14,8 +14,7 @@ component extends="BaseMiddleware" { boolean function preProcess( required event, rc, prc ){ - var routeMeta = getRouteMeta( arguments.event ); - var allowed = toArray( routeMeta.allowedIps ?: "" ); + var allowed = toArray( getMetaValue( arguments.event, "allowedIps" ) ); if ( !allowed.len() ) { misconfigured( "AllowedIPs requires the route meta key [allowedIps]" ); diff --git a/models/middleware/ApiKey.cfc b/models/middleware/ApiKey.cfc index e0f0486..42b47fc 100644 --- a/models/middleware/ApiKey.cfc +++ b/models/middleware/ApiKey.cfc @@ -18,11 +18,10 @@ component extends="BaseMiddleware" { boolean function preProcess( required event, rc, prc ){ var config = getMiddlewareSettings( "apiKey" ); - var routeMeta = getRouteMeta( arguments.event ); - var header = routeMeta.apiKeyHeader ?: config.header; - var param = routeMeta.apiKeyParam ?: config.param; - var keys = toArray( routeMeta.apiKeys ?: config.keys ); - var validator = config.validator ?: ""; + var header = getMetaValue( arguments.event, "apiKeyHeader", config.header ); + var param = getMetaValue( arguments.event, "apiKeyParam", config.param ); + var keys = toArray( getMetaValue( arguments.event, "apiKeys", config.keys ) ); + var validator = structKeyExists( config, "validator" ) ? config.validator : ""; if ( !keys.len() && !len( validator ) ) { misconfigured( diff --git a/models/middleware/BaseMiddleware.cfc b/models/middleware/BaseMiddleware.cfc index 596cae1..e8ef2ce 100644 --- a/models/middleware/BaseMiddleware.cfc +++ b/models/middleware/BaseMiddleware.cfc @@ -21,6 +21,23 @@ component accessors="true" { return arguments.event.getPrivateValue( "currentRouteMeta", {} ); } + /** + * Read one route metadata value, or a default when the key is not there. This is an explicit key check + * on purpose: the elvis operator treats a false value as missing on some engines. + * + * @event The request context + * @key The metadata key + * @defaultValue The value to return when the key is not defined + */ + any function getMetaValue( + required event, + required string key, + any defaultValue = "" + ){ + var routeMeta = getRouteMeta( arguments.event ); + return structKeyExists( routeMeta, arguments.key ) ? routeMeta[ arguments.key ] : arguments.defaultValue; + } + /** * Get the middleware settings for one middleware, e.g. `apiKey` */ diff --git a/models/middleware/DenyIPs.cfc b/models/middleware/DenyIPs.cfc index 8159e2e..971a83f 100644 --- a/models/middleware/DenyIPs.cfc +++ b/models/middleware/DenyIPs.cfc @@ -14,8 +14,7 @@ component extends="BaseMiddleware" { boolean function preProcess( required event, rc, prc ){ - var routeMeta = getRouteMeta( arguments.event ); - var denied = toArray( routeMeta.denyIps ?: "" ); + var denied = toArray( getMetaValue( arguments.event, "denyIps" ) ); if ( !denied.len() ) { misconfigured( "DenyIPs requires the route meta key [denyIps]" ); diff --git a/models/middleware/Honeypot.cfc b/models/middleware/Honeypot.cfc index 2d8ddeb..64d60ac 100644 --- a/models/middleware/Honeypot.cfc +++ b/models/middleware/Honeypot.cfc @@ -19,10 +19,13 @@ component extends="BaseMiddleware" { boolean function preProcess( required event, rc, prc ){ - var config = getMiddlewareSettings( "honeypot" ); - var routeMeta = getRouteMeta( arguments.event ); - var field = routeMeta.honeypotField ?: config.field; - var silent = routeMeta.honeypotSilent ?: config.silent; + var config = getMiddlewareSettings( "honeypot" ); + var field = getMetaValue( arguments.event, "honeypotField", config.field ); + var silent = getMetaValue( + arguments.event, + "honeypotSilent", + config.silent + ); var value = arguments.event.getValue( field, "" ); if ( !isSimpleValue( value ) || !len( trim( value ) ) ) { diff --git a/models/middleware/VerifyCsrf.cfc b/models/middleware/VerifyCsrf.cfc index e902a97..5badc82 100644 --- a/models/middleware/VerifyCsrf.cfc +++ b/models/middleware/VerifyCsrf.cfc @@ -18,7 +18,7 @@ component extends="BaseMiddleware" { } var token = arguments.event.getValue( "csrf", arguments.event.getHTTPHeader( "x-csrf-token", "" ) ); - var key = getRouteMeta( arguments.event ).csrfKey ?: "default"; + var key = getMetaValue( arguments.event, "csrfKey", "default" ); if ( isSimpleValue( token ) && len( token ) && variables.wirebox diff --git a/models/util/UrlSigner.cfc b/models/util/UrlSigner.cfc index 843e5ff..f15509e 100644 --- a/models/util/UrlSigner.cfc +++ b/models/util/UrlSigner.cfc @@ -178,7 +178,7 @@ component accessors="true" singleton threadSafe { private struct function getConfig(){ var config = variables.settings.signedUrls; - if ( !len( config.secret ?: "" ) ) { + if ( !structKeyExists( config, "secret" ) || !len( config.secret ) ) { throw( type = "cbsecurity.SigningSecretMissing", message = "Signed URLs need a secret. Set [signedUrls.secret] in your cbsecurity module settings, for example from the CBSECURITY_SIGNING_SECRET environment variable."