Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions ModuleConfig.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ 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+.
- **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

- Handler/action annotation security now shares `processInvalidAccess()` with route middleware. Behavior is unchanged.

## [3.8.0] - 2026-09-21

### Changed
Expand Down
53 changes: 53 additions & 0 deletions helpers/mixins.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,57 @@
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 found = getInstance( "router@coldbox" ).findRouteByName( arguments.name );
var pattern = structKeyExists( found, "pattern" ) ? found.pattern : "";
var routeKeys = {};
var extras = [];

// 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.append( encodeForURL( key ) & "=" & encodeForURL( arguments.params[ key ] ) );
}
}

var link = event.route( arguments.name, routeKeys );
if ( extras.len() ) {
link &= "?" & extras.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";
}
</cfscript>
67 changes: 64 additions & 3 deletions interceptors/Security.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}

Expand All @@ -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 );
Expand Down Expand Up @@ -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 ******************************/

/**
Expand Down
36 changes: 35 additions & 1 deletion models/CBSecurity.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
};

/**
Expand All @@ -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 (
Expand Down
34 changes: 34 additions & 0 deletions models/middleware/AllowedIPs.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* 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.
*
* <pre>
* route( "/admin" ).middleware( "AllowedIPs@cbsecurity" ).meta( { allowedIps : "10.0.0.0/8,203.0.113.7" } ).to( "admin.index" )
* </pre>
*
* 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 allowed = toArray( getMetaValue( arguments.event, "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"
);
}

}
84 changes: 84 additions & 0 deletions models/middleware/ApiKey.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* 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 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(
"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;
}

}
Loading
Loading