diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..51f0698 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)", + "Bash(box testbox run:*)", + "WebSearch", + "WebFetch(domain:github.com)" + ], + "deny": [], + "ask": [] + } +} diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index f673803..44e198c 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -3,7 +3,7 @@ component { this.version = "@build.version@+@build.number@"; this.author = "Ortus Solutions"; this.webUrl = "https://github.com/coldbox-modules/cbwire"; - this.dependencies = []; + this.dependencies = [ "cbstorages" ]; this.entryPoint = "cbwire"; this.layoutParentLookup = false; this.viewParentLookup = false; @@ -66,7 +66,25 @@ component { * We recommend always leaving this enabled, but you can disable it * as needed. */ - "checksumValidation": true + "checksumValidation": true, + /** + * Enables Cross-Site Request Forgery (CSRF) protection for CBWIRE requests. + * When enabled, all component actions require a valid CSRF token. + * When disabled, checksum validation still provides security against tampering. + */ + "csrfEnabled": true, + /** + * Specifies the WireBox mapping for the CSRF token storage service. + * The service must implement the ICSRFStorage interface. + * + * Built-in options: + * - "SessionCSRFStorage@cbwire" (default) - Session-based storage, OWASP recommended + * - "CacheCSRFStorage@cbwire" - Cache-based storage for distributed/clustered systems + * + * You can also provide your own custom implementation that implements + * cbwire.models.interfaces.ICSRFStorage + */ + "csrfService": "SessionCSRFStorage@cbwire" }; routes = [ diff --git a/box.json b/box.json index 94856d5..902f220 100644 --- a/box.json +++ b/box.json @@ -22,7 +22,7 @@ ], "contributors":[], "dependencies":{ - "cbcsrf":"^3.1.0+16" + "cbstorages":"^3.0.0" }, "devDependencies":{ "commandbox-cfformat":"*", @@ -45,6 +45,6 @@ "runner":"http://localhost:60299/tests/runner.cfm" }, "installPaths":{ - "cbcsrf":"modules/cbcsrf/" + "cbstorages":"modules/cbstorages/" } } \ No newline at end of file diff --git a/models/CBWIREController.cfc b/models/CBWIREController.cfc index 6b678ee..1724d32 100644 --- a/models/CBWIREController.cfc +++ b/models/CBWIREController.cfc @@ -6,8 +6,8 @@ component accessors="true" singleton { // Injected RequestService so that we can access the current ColdBox RequestContext. property name="requestService" inject="coldbox:requestService"; - // Inject CBCSRF for CSRF token generation and verification - property name="cbcsrf" inject="provider:@cbcsrf"; + // Inject TokenService for CSRF token generation and verification + property name="tokenService" inject="provider:TokenService@cbwire"; // Inject module settings property name="moduleSettings" inject="coldbox:modulesettings:cbwire"; @@ -84,13 +84,13 @@ component accessors="true" singleton { } ); - // Set the CSRF token for the request - local.csrfToken = local.payload._token; - // Validate the CSRF token - local.csrfTokenVerified = variables.wirebox.getInstance( dsl="@cbcsrf" ).verify( local.csrfToken ); - // Check the CSRF token, throw 403 if invalid - if( !local.csrfTokenVerified ){ - throw( type="CBWIREException", message="Page expired." ); + // Verify CSRF token if CSRF protection is enabled + if ( variables.moduleSettings.csrfEnabled ) { + local.csrfToken = local.payload._token; + local.csrfTokenVerified = variables.tokenService.verify( local.csrfToken ); + if( !local.csrfTokenVerified ){ + throw( type="CBWIREException", message="Page expired." ); + } } // Perform additional deserialization of the component snapshots @@ -530,12 +530,15 @@ component accessors="true" singleton { /** * Generates a CSRF token for the current request. + * Returns empty string if CSRF protection is disabled. * * @return string */ function generateCSRFToken() { - // Generate the CSRF token using the cbcsrf library - return variables.cbcsrf.generate(); + // Generate the CSRF token using cbwire's token service if enabled + return variables.moduleSettings.csrfEnabled ? + variables.tokenService.generate() : + ""; } /** diff --git a/models/interfaces/ICSRFStorage.cfc b/models/interfaces/ICSRFStorage.cfc new file mode 100644 index 0000000..354a252 --- /dev/null +++ b/models/interfaces/ICSRFStorage.cfc @@ -0,0 +1,49 @@ +/** + * Interface for CSRF token storage implementations in cbwire. + * + * Implementations must provide thread-safe storage and retrieval + * of CSRF tokens with session-lifetime persistence. + * + * @author Ortus Solutions + */ +interface { + + /** + * Stores a CSRF token for the current session/user context. + * + * @token The CSRF token to store + * + * @return The storage implementation instance (for method chaining) + */ + any function set( required string token ); + + /** + * Retrieves the stored CSRF token for the current session/user context. + * + * @return The stored token, or empty string if no token exists + */ + string function get(); + + /** + * Checks if a CSRF token exists in storage for the current session/user context. + * + * @return True if a token exists, false otherwise + */ + boolean function exists(); + + /** + * Removes the CSRF token from storage (used for rotation/logout). + * + * @return The storage implementation instance (for method chaining) + */ + any function delete(); + + /** + * Clears all CSRF-related data from storage. + * Useful for cleanup or testing scenarios. + * + * @return The storage implementation instance (for method chaining) + */ + any function clear(); + +} diff --git a/models/services/TokenService.cfc b/models/services/TokenService.cfc new file mode 100644 index 0000000..55cb0c3 --- /dev/null +++ b/models/services/TokenService.cfc @@ -0,0 +1,118 @@ +/** + * CBWIRE-specific token service for preventing CSRF attacks + * without interfering with cbcsrf module settings. + * + * Delegates storage operations to a configurable ICSRFStorage implementation. + * + * @author Ortus Solutions + */ +component accessors="true" singleton { + + property name="moduleSettings" inject="coldbox:modulesettings:cbwire"; + property name="wirebox" inject="wirebox"; + + /** + * Lazy-loaded CSRF storage service + */ + variables.csrfService = ""; + + /** + * Cache for application metadata to avoid repeated lookups + */ + variables.appMetadata = {}; + + /** + * Generates a CBWIRE-specific token that doesn't expire. + * Stored using the configured storage implementation and lasts for session lifetime. + * + * @return The generated or existing token + */ + function generate() { + // If we don't have a token yet, generate one + if ( !getCSRFService().exists() ) { + var newToken = generateNewToken(); + getCSRFService().set( newToken ); + } + + return getCSRFService().get(); + } + + /** + * Verifies a CBWIRE token + * + * @token The token to verify + * + * @return True if valid, false otherwise + */ + function verify( required string token ) { + // Verify token exists and matches + return getCSRFService().exists() && getCSRFService().get() == arguments.token; + } + + /** + * Rotates the token (for logout, etc) + * + * @return TokenService instance for chaining + */ + function rotate() { + getCSRFService().delete(); + return this; + } + + // Private methods + + /** + * Lazy-loads the CSRF storage service based on module settings + * + * @return The configured ICSRFStorage implementation + */ + private function getCSRFService() { + if ( !isObject( variables.csrfService ) ) { + variables.csrfService = wirebox.getInstance( moduleSettings.csrfService ); + } + return variables.csrfService; + } + + private function generateNewToken() { + // Generate a cryptographically secure random token + var tokenBase = "#createUUID()##getRealIP()##randRange( 0, 65535, "SHA1PRNG" )##getTickCount()#"; + + // Include session ID if sessions are enabled (cross-platform check) + var sessionId = ""; + if ( isSessionManagementEnabled() ) { + try { + sessionId = session.sessionid; + } catch ( any e ) { + // Handle cases where session scope exists but sessionid property is not yet available, + // or when session operations fail during application startup + } + } + + return uCase( left( hash( tokenBase & sessionId, "SHA-256" ), 40 ) ); + } + + /** + * Checks if session management is enabled in the application (cross-platform) + * + * @return True if session management is enabled, false otherwise + */ + private function isSessionManagementEnabled() { + if ( structIsEmpty( variables.appMetadata ) ) { + variables.appMetadata = getApplicationMetadata(); + } + return structKeyExists( variables.appMetadata, "sessionManagement" ) && variables.appMetadata.sessionManagement; + } + + private function getRealIP() { + var headers = getHTTPRequestData().headers; + + if ( structKeyExists( headers, "x-cluster-client-ip" ) ) { + return headers[ "x-cluster-client-ip" ]; + } + if ( structKeyExists( headers, "X-Forwarded-For" ) ) { + return headers[ "X-Forwarded-For" ]; + } + + return len( CGI.REMOTE_ADDR ) ? CGI.REMOTE_ADDR : "127.0.0.1"; + } +} diff --git a/models/services/csrf/CacheCSRFStorage.cfc b/models/services/csrf/CacheCSRFStorage.cfc new file mode 100644 index 0000000..facf95c --- /dev/null +++ b/models/services/csrf/CacheCSRFStorage.cfc @@ -0,0 +1,70 @@ +/** + * Cache-based CSRF token storage using cbstorages cacheStorage. + * Suitable for distributed/clustered environments where session replication + * is not available or desired. + * + * Tokens are stored in the configured CacheBox cache provider. + * Does not require session management to be enabled - uses cookie/URL fallback. + * + * @author Ortus Solutions + */ +component + accessors="true" + singleton + implements="cbwire.models.interfaces.ICSRFStorage" +{ + + property name="cacheStorage" inject="cacheStorage@cbstorages"; + + /** + * The storage key used for cbwire tokens + */ + variables.STORAGE_KEY = "_cbwire_token"; + + /** + * Stores a CSRF token in cache storage + * + * @token The CSRF token to store + */ + function set( required string token ) { + cacheStorage.set( + variables.STORAGE_KEY, + { "token": arguments.token, "createdAt": now() } + ); + return this; + } + + /** + * Retrieves the stored CSRF token from cache + * + * @return The stored token, or empty string if none exists + */ + string function get() { + var data = cacheStorage.get( variables.STORAGE_KEY, {} ); + return data.keyExists( "token" ) ? data.token : ""; + } + + /** + * Checks if a token exists in cache storage + */ + boolean function exists() { + var data = cacheStorage.get( variables.STORAGE_KEY, {} ); + return data.keyExists( "token" ) && len( data.token ) > 0; + } + + /** + * Removes the token from cache storage + */ + function delete() { + cacheStorage.delete( variables.STORAGE_KEY ); + return this; + } + + /** + * Clears all CSRF data (same as delete for cache storage) + */ + function clear() { + return delete(); + } + +} diff --git a/models/services/csrf/SessionCSRFStorage.cfc b/models/services/csrf/SessionCSRFStorage.cfc new file mode 100644 index 0000000..c4848ed --- /dev/null +++ b/models/services/csrf/SessionCSRFStorage.cfc @@ -0,0 +1,69 @@ +/** + * Session-based CSRF token storage using cbstorages sessionStorage. + * This is the default storage mechanism for cbwire CSRF tokens. + * + * Tokens are stored in the session scope and persist for the session lifetime. + * Requires this.sessionManagement = true in Application.cfc. + * + * @author Ortus Solutions + */ +component + accessors="true" + singleton + implements="cbwire.models.interfaces.ICSRFStorage" +{ + + property name="sessionStorage" inject="sessionStorage@cbstorages"; + + /** + * The storage key used for cbwire tokens + */ + variables.STORAGE_KEY = "_cbwire_token"; + + /** + * Stores a CSRF token in session storage + * + * @token The CSRF token to store + */ + function set( required string token ) { + sessionStorage.set( + variables.STORAGE_KEY, + { "token": arguments.token, "createdAt": now() } + ); + return this; + } + + /** + * Retrieves the stored CSRF token from session + * + * @return The stored token, or empty string if none exists + */ + string function get() { + var data = sessionStorage.get( variables.STORAGE_KEY, {} ); + return data.keyExists( "token" ) ? data.token : ""; + } + + /** + * Checks if a token exists in session storage + */ + boolean function exists() { + var data = sessionStorage.get( variables.STORAGE_KEY, {} ); + return data.keyExists( "token" ) && len( data.token ) > 0; + } + + /** + * Removes the token from session storage + */ + function delete() { + sessionStorage.delete( variables.STORAGE_KEY ); + return this; + } + + /** + * Clears all CSRF data (same as delete for session storage) + */ + function clear() { + return delete(); + } + +} diff --git a/test-harness/layouts/Main.cfm b/test-harness/layouts/Main.cfm index e630291..b6f5c04 100644 --- a/test-harness/layouts/Main.cfm +++ b/test-harness/layouts/Main.cfm @@ -6,7 +6,7 @@