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 7e3a050..3f14102 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/helpers/mixins.cfm b/helpers/mixins.cfm index 682c10b..4ea836b 100644 --- a/helpers/mixins.cfm +++ b/helpers/mixins.cfm @@ -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"; + } 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/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..157b09b --- /dev/null +++ b/models/middleware/AllowedIPs.cfc @@ -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. + * + *
+ * 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 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"
+ );
+ }
+
+}
diff --git a/models/middleware/ApiKey.cfc b/models/middleware/ApiKey.cfc
new file mode 100644
index 0000000..42b47fc
--- /dev/null
+++ b/models/middleware/ApiKey.cfc
@@ -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;
+ }
+
+}
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/BaseMiddleware.cfc b/models/middleware/BaseMiddleware.cfc
new file mode 100644
index 0000000..e8ef2ce
--- /dev/null
+++ b/models/middleware/BaseMiddleware.cfc
@@ -0,0 +1,185 @@
+/**
+ * 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", {} );
+ }
+
+ /**
+ * 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`
+ */
+ struct function getMiddlewareSettings( required string name ){
+ return structKeyExists( variables.settings.middleware, arguments.name ) ? 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/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/DenyIPs.cfc b/models/middleware/DenyIPs.cfc new file mode 100644 index 0000000..971a83f --- /dev/null +++ b/models/middleware/DenyIPs.cfc @@ -0,0 +1,34 @@ +/** + * 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 denied = toArray( getMetaValue( arguments.event, "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/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/Honeypot.cfc b/models/middleware/Honeypot.cfc
new file mode 100644
index 0000000..64d60ac
--- /dev/null
+++ b/models/middleware/Honeypot.cfc
@@ -0,0 +1,42 @@
+/**
+ * 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 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 ) ) ) { + 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/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/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..942f66f --- /dev/null +++ b/models/middleware/Throttle.cfc @@ -0,0 +1,108 @@ +/** + * 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 meta = getRouteMeta( arguments.event );
+ var spec = structKeyExists( meta, "throttle" ) ? meta.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 ( !structKeyExists( config, "limiters" ) || !structKeyExists( config.limiters, 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..5badc82
--- /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 = getMetaValue( 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..f15509e
--- /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 ( !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." + ); + } + 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 new file mode 100644 index 0000000..cb1f95c --- /dev/null +++ b/test-harness/config/Router.cfc @@ -0,0 +1,130 @@ +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" ); + + // 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( + { + 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..df4bd12 --- /dev/null +++ b/test-harness/handlers/mw.cfc @@ -0,0 +1,23 @@ +/** + * Handler used by the route middleware specs + */ +component { + + function index( event, rc, prc ){ + 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/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; + } + ); + } + +} diff --git a/test-harness/tests/specs/integration/SecurityMiddlewareSpec.cfc b/test-harness/tests/specs/integration/SecurityMiddlewareSpec.cfc new file mode 100644 index 0000000..c26ec56 --- /dev/null +++ b/test-harness/tests/specs/integration/SecurityMiddlewareSpec.cfc @@ -0,0 +1,744 @@ +/** + * 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(){ + 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(){ + 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 + ); + } + +}