diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index 22e1fe3..dc2624d 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -28,6 +28,8 @@ component { autoRegisterInterceptor : true, // Activate auto request capture cleanups autoClean : true, + // Keys to exclude from auto clean. Use an array/list for global keys, or a struct with "*", "handler.action", or "handler.*" keys + autoCleanExclusions : {}, // Default Policy to use, available are: antisamy, ebay, myspace, slashdot and tinymce defaultPolicy : "ebay", // Custom Policy absolute path, leave empty if not used diff --git a/box.json b/box.json index f6af2a7..3a04cee 100644 --- a/box.json +++ b/box.json @@ -1,7 +1,7 @@ { "name":"AntiSamy", "author":"Ortus Solutions.com ", - "version":"2.3.0", + "version":"2.4.0", "location":"https://downloads.ortussolutions.com/ortussolutions/coldbox-modules/cbantisamy/@build.version@/cbantisamy-@build.version@.zip", "slug":"cbantisamy", "type":"modules", @@ -42,9 +42,9 @@ "build:docs":"task run taskFile=build/Build.cfc target=docs :projectName=`package show slug` :version=`package show version`", "install:dependencies":"install && cd test-harness && install", "release":"recipe build/release.boxr", - "format":"cfformat run helpers,models,test-harness/tests/,ModuleConfig.cfc --overwrite", - "format:watch":"cfformat watch helpers,models,test-harness/tests/,ModuleConfig.cfc ./.cfformat.json", - "format:check":"cfformat check helpers,models,test-harness/tests/,ModuleConfig.cfc ./.cfformat.json", + "format":"cfformat run helpers,interceptors,models,test-harness/tests/,ModuleConfig.cfc --overwrite", + "format:watch":"cfformat watch helpers,interceptors,models,test-harness/tests/,ModuleConfig.cfc ./.cfformat.json", + "format:check":"cfformat check helpers,interceptors,models,test-harness/tests/,ModuleConfig.cfc ./.cfformat.json", "start:lucee":"server start serverConfigFile=server-lucee@5.json", "start:2021":"server start serverConfigFile=server-adobe@2021.json", "stop:lucee":"server stop serverConfigFile=server-lucee@5.json", diff --git a/changelog.md b/changelog.md index 20cf570..7f5842e 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- New `autoCleanExclusions` module setting to exclude specific request collection keys from auto-cleaning globally or scoped to event patterns (`*`, `handler.action`, `handler.*`, `module:handler.*`, `module:*`) +- New `antisamyAutoCleanExclusions` annotation support on handler action methods to declare per-action RC key exclusions at the source +- New `antisamy-autoclean-exclusions` private request value to configure exclusions dynamically at runtime +- Comprehensive tests for all AutoClean exclusion control mechanisms + ## [2.3.0] - 2026-06-25 ## [2.2.0] - 2025-02-19 diff --git a/interceptors/AutoClean.cfc b/interceptors/AutoClean.cfc index 7bcf9e6..5b42c12 100644 --- a/interceptors/AutoClean.cfc +++ b/interceptors/AutoClean.cfc @@ -1,29 +1,218 @@ /** -* Copyright 2005-2007 ColdBox Framework by Luis Majano and Ortus Solutions, Corp -* www.ortussolutions.com -* --- -* This Interceptor if activated automatically cleans the request collection for you -*/ -component extends="coldbox.system.Interceptor"{ + * Copyright 2005-2007 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * This Interceptor if activated automatically cleans the request collection for you + */ +component extends="coldbox.system.Interceptor" { // DI: This is a provider as it needs to javaloaded first property name="antisamy" inject="provider:AntiSamy@CBAntiSamy"; + // The handler method annotation used to exclude request collection keys from auto-cleaning + variables.AUTO_CLEAN_EXCLUSIONS_ANNOTATION = "antisamyAutoCleanExclusions"; + // On request capture function onRequestCapture( event, interceptData, buffer, rc, prc ){ // if not activated, just exit - if( !getModuleSettings( "cbantisamy", "autoClean" ) || !event.getPrivateValue( "antisamy-autoclean", true ) ){ + if ( !getModuleSettings( "cbantisamy", "autoClean" ) || !event.getPrivateValue( "antisamy-autoclean", true ) ) { return; } - rc.keyArray().each( - function( key ){ - if( !isNull( rc[ key ] ) && isSimpleValue( rc[ key ] ) ){ + var exclusions = getAutoCleanExclusions( event ); + + rc.keyArray() + .each( function( key ){ + if ( !arrayFindNoCase( exclusions, key ) && !isNull( rc[ key ] ) && isSimpleValue( rc[ key ] ) ) { rc[ key ] = variables.antiSamy.clean( rc[ key ] ); } + } ); + } + + /** + * Resolve the request collection keys excluded from auto-cleaning for this event action. + */ + private array function getAutoCleanExclusions( required event ){ + var exclusions = []; + + exclusions.append( getConfiguredAutoCleanExclusions( arguments.event ), true ); + + exclusions.append( + normalizeAutoCleanExclusionKeys( getActionAutoCleanExclusionsAnnotation( arguments.event ) ), + true + ); + + exclusions.append( + normalizeAutoCleanExclusionKeys( + arguments.event.getPrivateValue( "antisamy-autoclean-exclusions", [] ) + ), + true + ); + + return exclusions; + } + + /** + * Resolve configured auto-clean exclusions for the current event. + */ + private array function getConfiguredAutoCleanExclusions( required event ){ + var settings = getModuleSettings( "cbantisamy" ); + + if ( !settings.keyExists( "autoCleanExclusions" ) ) { + return []; + } + + if ( !isStruct( settings.autoCleanExclusions ) ) { + return normalizeAutoCleanExclusionKeys( settings.autoCleanExclusions ); + } + + var exclusions = []; + var patterns = getAutoCleanExclusionEventPatterns( arguments.event ); + + settings.autoCleanExclusions.each( function( pattern, keys ){ + if ( arrayFindNoCase( patterns, pattern ) ) { + exclusions.append( normalizeAutoCleanExclusionKeys( keys ), true ); + } + } ); + + return exclusions; + } + + /** + * Build the event patterns checked for configured auto-clean exclusions. + */ + private array function getAutoCleanExclusionEventPatterns( required event ){ + var currentEvent = arguments.event.getCurrentEvent(); + var patterns = [ "*" ]; + + if ( len( currentEvent ) ) { + patterns.append( currentEvent ); + patterns.append( getEventHandlerPattern( currentEvent ) ); + + if ( find( ":", currentEvent ) ) { + patterns.append( listFirst( currentEvent, ":" ) & ":*" ); + } + } + + return patterns; + } + + /** + * Build an event handler wildcard pattern, preserving ColdBox module event prefixes. + */ + private string function getEventHandlerPattern( required string event ){ + return listFirst( arguments.event, "." ) & ".*"; + } + + /** + * Read the target handler action's auto-clean exclusions annotation. + */ + private any function getActionAutoCleanExclusionsAnnotation( required event ){ + var currentEvent = arguments.event.getCurrentEvent(); + + if ( !len( currentEvent ) ) { + return []; + } + + try { + var handlerService = controller.getHandlerService(); + var handlerBean = handlerService.getHandlerBean( currentEvent ); + var action = handlerBean.getMethod(); + var actionMetadata = getComponentActionMetadata( handlerBean.getRunnable(), action ); + + if ( structIsEmpty( actionMetadata ) ) { + var handler = handlerService.newHandler( handlerBean ); + actionMetadata = structKeyExists( handler, action ) ? getMetadata( handler[ action ] ) : handler._actionMetadata( + action + ); + } + + return getAutoCleanExclusionsAnnotationValue( actionMetadata ); + } catch ( any e ) { + return []; + } + } + + /** + * Find action metadata by reading the handler CFC metadata directly. + */ + private struct function getComponentActionMetadata( required string componentPath, required string action ){ + var componentMetadata = getComponentMetadata( arguments.componentPath ); + + if ( !componentMetadata.keyExists( "functions" ) ) { + return {}; + } + + for ( var functionMetadata in componentMetadata.functions ) { + if ( + functionMetadata.keyExists( "name" ) && compareNoCase( functionMetadata.name, arguments.action ) == 0 + ) { + return functionMetadata; } + } + + return {}; + } + + /** + * Read the auto-clean exclusions value from function metadata or docblock annotations. + */ + private any function getAutoCleanExclusionsAnnotationValue( required struct actionMetadata ){ + var exclusions = getStructValueNoCase( + arguments.actionMetadata, + variables.AUTO_CLEAN_EXCLUSIONS_ANNOTATION ); + if ( !isNull( exclusions ) ) { + return exclusions; + } + + if ( arguments.actionMetadata.keyExists( "annotations" ) ) { + exclusions = getStructValueNoCase( + arguments.actionMetadata.annotations, + variables.AUTO_CLEAN_EXCLUSIONS_ANNOTATION + ); + + if ( !isNull( exclusions ) ) { + return exclusions; + } + } + + return []; + } + + /** + * Find a struct value by key without relying on the engine's key case behavior. + */ + private any function getStructValueNoCase( required struct target, required string key ){ + for ( var targetKey in arguments.target ) { + if ( compareNoCase( targetKey, arguments.key ) == 0 ) { + return arguments.target[ targetKey ]; + } + } + } + + /** + * Normalize an exclusion value to an array of key names. + */ + private array function normalizeAutoCleanExclusionKeys( required any keys ){ + var normalizedKeys = []; + + if ( isArray( arguments.keys ) ) { + arguments.keys.each( function( key ){ + if ( isSimpleValue( key ) && len( trim( key ) ) ) { + normalizedKeys.append( trim( key ) ); + } + } ); + } else if ( isSimpleValue( arguments.keys ) ) { + listToArray( arguments.keys ).each( function( key ){ + if ( len( trim( key ) ) ) { + normalizedKeys.append( trim( key ) ); + } + } ); + } + + return normalizedKeys; } } diff --git a/readme.md b/readme.md index 05b6949..5bcc602 100644 --- a/readme.md +++ b/readme.md @@ -89,6 +89,14 @@ moduleSettings = { autoRegisterInterceptor = true, // Activate auto request capture cleanups interceptor autoClean = true, + // Exclude request collection keys from auto clean globally or by event pattern + autoCleanExclusions = { + "*" = [ "csrfToken" ], + "main.login" = [ "password" ], + "api.*" = [ "payloadJSON" ], + "api-v1:Trips.*" = [ "rawNotes" ], + "api-v1:*" = [ "requestSignature" ] + }, // Default Policy to use, available are: antisamy, ebay, myspace, slashdot and tinymce defaultPolicy = "ebay", // Custom Policy absolute path, leave empty if not used @@ -97,6 +105,18 @@ moduleSettings = { }; ``` +### Auto Clean Action Exclusions + +The auto clean interceptor cleans every simple value in the request collection by default. If an action needs to receive a raw value, add the `antisamyAutoCleanExclusions` annotation to the handler method with a comma-delimited list of request collection keys to skip: + +```js +function login( event, rc, prc ) antisamyAutoCleanExclusions="password"{ + // rc.password is not cleaned by the auto clean interceptor for this action. +} +``` + +You can also configure exclusions in module settings using `*` for global exclusions, exact events like `main.login`, handler wildcards like `api.*`, module handler wildcards like `api-v1:Trips.*`, or module wildcards like `api-v1:*`. Configured exclusions and action annotations are merged for the current request. + You can read more about AntiSamy here: https://www.owasp.org/index.php/Category:OWASP_AntiSamy_Project ******************************************************************************** diff --git a/test-harness/config/Coldbox.cfc b/test-harness/config/Coldbox.cfc index 290e6d3..9d93d08 100644 --- a/test-harness/config/Coldbox.cfc +++ b/test-harness/config/Coldbox.cfc @@ -52,6 +52,16 @@ component{ interceptors = [ ]; + moduleSettings = { + cbantisamy : { + autoCleanExclusions : { + "main.configDriven" : [ "apiToken" ], + "api-v1:Trips.*" : [ "moduleToken" ], + "api-v1:*" : [ "moduleSignature" ] + } + } + }; + //LogBox DSL logBox = { // Define Appenders diff --git a/test-harness/handlers/Main.cfc b/test-harness/handlers/Main.cfc index 2898fb9..ba3848d 100644 --- a/test-harness/handlers/Main.cfc +++ b/test-harness/handlers/Main.cfc @@ -1,12 +1,16 @@ /** -* My Event Handler Hint -*/ -component{ + * My Event Handler Hint + */ +component { property name="antisamy" inject="antisamy@cbantisamy"; // Index - any function index( event,rc, prc ){ + any function index( event, rc, prc ) antisamyAutoCleanExclusions="password"{ + rc.data = antisamy.clean( event.getValue( "data", "no data sent" ) ); + } + + any function configDriven( event, rc, prc ){ rc.data = antisamy.clean( event.getValue( "data", "no data sent" ) ); } @@ -14,4 +18,4 @@ component{ any function onAppInit( event, rc, prc ){ } -} \ No newline at end of file +} diff --git a/test-harness/modules_app/api-v1/ModuleConfig.cfc b/test-harness/modules_app/api-v1/ModuleConfig.cfc new file mode 100644 index 0000000..4bbe517 --- /dev/null +++ b/test-harness/modules_app/api-v1/ModuleConfig.cfc @@ -0,0 +1,13 @@ +component { + + this.title = "API v1"; + this.author = "Ortus Solutions, Corp"; + this.description = "Test module for cbantisamy module event coverage"; + this.version = "1.0.0"; + this.cfmapping = "apiV1"; + + function configure(){ + settings = {}; + } + +} diff --git a/test-harness/modules_app/api-v1/handlers/Trips.cfc b/test-harness/modules_app/api-v1/handlers/Trips.cfc new file mode 100644 index 0000000..d90b3ce --- /dev/null +++ b/test-harness/modules_app/api-v1/handlers/Trips.cfc @@ -0,0 +1,9 @@ +component { + + property name="antisamy" inject="antisamy@cbantisamy"; + + any function update( event, rc, prc ){ + rc.data = antisamy.clean( event.getValue( "data", "no data sent" ) ); + } + +} diff --git a/test-harness/tests/specs/interceptor/AutoCleanTest.cfc b/test-harness/tests/specs/interceptor/AutoCleanTest.cfc index 0c20f72..2f61ede 100644 --- a/test-harness/tests/specs/interceptor/AutoCleanTest.cfc +++ b/test-harness/tests/specs/interceptor/AutoCleanTest.cfc @@ -30,6 +30,35 @@ component extends="coldbox.system.testing.BaseTestCase" appMapping="/root" { var rc = event.getCollection(); expect( rc.data ).toBe( "guest" ); } ); + + it( "should skip auto clean exclusions annotated on the current action", function(){ + url.data = "guest"; + url.password = "secret"; + var event = execute( "main.index" ); + var rc = event.getCollection(); + expect( rc.data ).toBe( "guest" ); + expect( rc.password ).toBe( "secret" ); + } ); + + it( "should skip auto clean exclusions configured for the current event", function(){ + url.data = "guest"; + url.apiToken = "token"; + var event = execute( "main.configDriven" ); + var rc = event.getCollection(); + expect( rc.data ).toBe( "guest" ); + expect( rc.apiToken ).toBe( "token" ); + } ); + + it( "should match configured auto clean exclusions for module event patterns", function(){ + url.data = "guest"; + url.moduleToken = "token"; + url.moduleSignature = "signature"; + var event = execute( "api-v1:Trips.update" ); + var rc = event.getCollection(); + expect( rc.data ).toBe( "guest" ); + expect( rc.moduleToken ).toBe( "token" ); + expect( rc.moduleSignature ).toBe( "signature" ); + } ); } ); }