Skip to content

Commit fcf145f

Browse files
committed
BOX-157 #resolve
cbSecurity: validate _securedUrl is for the same domain
1 parent 5b57137 commit fcf145f

File tree

6 files changed

+149
-16
lines changed

6 files changed

+149
-16
lines changed

changelog.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [Unreleased]
1111

12+
### Security
13+
14+
- **CRITICAL**: Fixed open redirect vulnerability in `_securedURL` handling. The `saveSecuredUrl()` method now validates redirect URLs to ensure they belong to the same host as the current request, preventing attackers from crafting malicious URLs that redirect users to external sites after login. Added `isSafeRedirectUrl()` validation using `java.net.URI` to compare hosts.
15+
16+
### Fixed
17+
18+
- JWT Handler improperly returns a value causing it to skip ColdBox's RestHandler's response formatting logic. This results in the entire response object being returned rather than just invoking getDataPacket()
19+
1220
## [3.5.0] - 2025-10-17
1321

1422
### Added

interceptors/Security.cfc

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -200,11 +200,11 @@ component accessors="true" extends="coldbox.system.Interceptor" {
200200
/**
201201
* Listen to module loadings, so we can do module rule registrations
202202
*
203-
* @event
203+
* @event
204204
* @interceptData
205-
* @rc
206-
* @prc
207-
* @buffer
205+
* @rc
206+
* @prc
207+
* @buffer
208208
*/
209209
function postModuleLoad( event, interceptData, rc, prc, buffer ){
210210
// Is this a cbSecurity Module & not registered
@@ -223,11 +223,11 @@ component accessors="true" extends="coldbox.system.Interceptor" {
223223
/**
224224
* Listen to module unloadings, so we can do module rule cleanups
225225
*
226-
* @event
226+
* @event
227227
* @interceptData
228-
* @rc
229-
* @prc
230-
* @buffer
228+
* @rc
229+
* @prc
230+
* @buffer
231231
*/
232232
function postModuleUnload( event, interceptData, rc, prc, buffer ){
233233
// Is the module registered?
@@ -246,11 +246,11 @@ component accessors="true" extends="coldbox.system.Interceptor" {
246246
/**
247247
* Our firewall kicks in at preProcess
248248
*
249-
* @event
249+
* @event
250250
* @interceptData
251-
* @rc
252-
* @prc
253-
* @buffer
251+
* @rc
252+
* @prc
253+
* @buffer
254254
*/
255255
function preProcess( event, interceptData, rc, prc, buffer ){
256256
// Add SecureView() into the requestcontext
@@ -290,9 +290,9 @@ component accessors="true" extends="coldbox.system.Interceptor" {
290290
/**
291291
* Process handler annotation based security rules.
292292
*
293-
* @event
293+
* @event
294294
* @interceptData
295-
* @currentEvent
295+
* @currentEvent
296296
*/
297297
function processAnnotationRules(
298298
required event,
@@ -773,6 +773,8 @@ component accessors="true" extends="coldbox.system.Interceptor" {
773773

774774
/**
775775
* Flash the incoming secured Url so we can redirect to it or use it in the next request.
776+
* This method validates the URL to prevent open redirect vulnerabilities by ensuring
777+
* the URL belongs to the same host as the current request.
776778
*
777779
* @event The event object
778780
*/
@@ -784,11 +786,58 @@ component accessors="true" extends="coldbox.system.Interceptor" {
784786
translate : false
785787
);
786788

789+
// Validate the URL to prevent open redirect attacks
790+
if ( !isSafeRedirectUrl( securedURL, arguments.event ) ) {
791+
// If the URL is not safe, log a warning and use the home page instead
792+
if ( log.canWarn() ) {
793+
log.warn(
794+
"Potential open redirect attempt detected. Invalid secured URL: #securedURL#. Using home page instead.",
795+
{ "ip" : variables.cbSecurity.getRealIp(), "url" : securedURL }
796+
);
797+
}
798+
// Use the application's base URL instead
799+
securedURL = arguments.event.buildLink( to = "", translate = false );
800+
}
801+
787802
// Flash it and place it in RC as well
788803
flash.put( "_securedUrl", securedURL );
789804
arguments.event.setValue( "_securedUrl", securedURL );
790805
}
791806

807+
/**
808+
* Validates that a redirect URL is safe by ensuring it belongs to the same host
809+
* as the current request. This prevents open redirect vulnerabilities.
810+
*
811+
* @targetUrl The URL to validate
812+
* @event The request context
813+
*
814+
* @return True if the URL is safe to redirect to, false otherwise
815+
*/
816+
private boolean function isSafeRedirectUrl( required string targetUrl, required event ){
817+
try {
818+
// Parse the URL to validate
819+
var urlToValidate = createObject( "java", "java.net.URI" ).init( arguments.targetUrl );
820+
821+
// If the URL is relative (no host), it's safe
822+
if ( isNull( urlToValidate.getHost() ) || !len( urlToValidate.getHost() ) ) {
823+
return true;
824+
}
825+
826+
// Get the current request's host for comparison
827+
var currentHost = variables.cbSecurity.getRealHost();
828+
829+
// Compare hosts (case-insensitive)
830+
return compareNoCase( urlToValidate.getHost(), currentHost ) == 0;
831+
} catch ( any e ) {
832+
// If URL parsing fails, consider it unsafe
833+
log.warn(
834+
"Error parsing URL for redirect validation: #arguments.targetUrl# : #e.message#",
835+
e.detail
836+
);
837+
return false;
838+
}
839+
}
840+
792841
/**
793842
* Verifies that the current event is in a given pattern list
794843
*

readme.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,8 @@ Once the firewall has the results, and the user is **NOT** allowed access. Then
340340
- The request will be logged via LogBox
341341
- If the firewall database logs are enabled, then we will log it in our database logs
342342
- The current URL will be flashed as `_securedURL` so it can be used in relocations
343+
- **Security Note**: The URL is validated to ensure it belongs to the same host as the current request to prevent open redirect attacks
344+
- Only same-host URLs or relative URLs are allowed; external URLs will be replaced with the application home page
343345
- If using a rule, the rule will be stored in `prc` as `cbsecurity_matchedRule`
344346
- The validator results will be stored in `prc` as `cbsecurity_validatorResults`
345347
- If the type of invalidation is `authentication` the `cbSecurity_onInvalidAuthentication` interception will be announced

[email protected]

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"cfengine":"boxlang@1"
66
},
77
"web":{
8+
"host":"0.0.0.0",
89
"http":{
910
"port":"60299"
1011
},
@@ -21,7 +22,7 @@
2122
"javaVersion":"openjdk21_jre",
2223
"args":"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8888"
2324
},
24-
"openBrowser": false,
25+
"openBrowser":false,
2526
"cfconfig":{
2627
"file":".cfconfig.json"
2728
},

test-harness/tests/specs/integration/SecuritySpec.cfc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ component extends="coldbox.system.testing.BaseTestCase" appMapping="/root" {
195195
} );
196196
} );
197197
} );
198+
198199
} );
199200
}
200201

test-harness/tests/specs/unit/SecurityTest.cfc

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ component extends="coldbox.system.testing.BaseInterceptorTest" interceptor="cbse
2323
{ "version" : "6.0.0" },
2424
false
2525
);
26-
mockLogger = createEmptyMock( "coldbox.system.logging.Logger" ).$( "info" );
26+
mockLogger = createEmptyMock( "coldbox.system.logging.Logger" ).$( "info" ).$( "warn" )
2727
mockController
2828
.$( "getSetting" )
2929
.$args( "modules" )
@@ -275,6 +275,78 @@ component extends="coldbox.system.testing.BaseInterceptorTest" interceptor="cbse
275275
expect( security.getProperty( "firewall" ).rules.inline ).toHaveLength( 1 );
276276
} );
277277
} );
278+
279+
describe( "URL validation for open redirect prevention", () => {
280+
281+
beforeEach( ( currentSpec ) => {
282+
mockValidator = mockWireBox.getInstance( settings.firewall.validator );
283+
security
284+
.$( "getInstance" )
285+
.$args( settings.firewall.validator )
286+
.$results( mockValidator );
287+
security.configure();
288+
289+
mockSecurityService.$( "getRealHost", "mysite.com" );
290+
291+
mockEvent = createMock( "coldbox.system.web.context.RequestContext" )
292+
.$( "getCurrentRoutedURL", "/account" )
293+
.$( "buildLink" )
294+
.$args( to = "/account", queryString = "", translate = false )
295+
.$results( "/account" )
296+
.$( "setValue" );
297+
298+
mockFlash = createStub().$( "put" );
299+
security.$property( "flash", "variables", mockFlash );
300+
301+
makePublic( security, "isSafeRedirectUrl" );
302+
makePublic( security, "saveSecuredUrl" );
303+
} );
304+
305+
it( "allows relative URLs without a host", () => {
306+
var result = security.isSafeRedirectUrl( targetUrl = "/account", event = mockEvent );
307+
expect( result ).toBeTrue();
308+
} );
309+
310+
it( "allows URLs with the same host", () => {
311+
var result = security.isSafeRedirectUrl( targetUrl = "https://mysite.com/account", event = mockEvent );
312+
expect( result ).toBeTrue();
313+
} );
314+
315+
it( "blocks URLs with different hosts", () => {
316+
var result = security.isSafeRedirectUrl( targetUrl = "https://malicioussite.com/phishing", event = mockEvent );
317+
expect( result ).toBeFalse();
318+
} );
319+
320+
it( "blocks URLs with subdomain differences", () => {
321+
var result = security.isSafeRedirectUrl( targetUrl = "https://evil.mysite.com/account", event = mockEvent );
322+
expect( result ).toBeFalse();
323+
} );
324+
325+
it( "is case-insensitive when comparing hosts", () => {
326+
var result = security.isSafeRedirectUrl( targetUrl = "https://MySite.COM/account", event = mockEvent );
327+
expect( result ).toBeTrue();
328+
} );
329+
330+
it( "handles invalid URLs gracefully", () => {
331+
var result = security.isSafeRedirectUrl( targetUrl = "not a valid url://", event = mockEvent );
332+
expect( result ).toBeFalse();
333+
} );
334+
335+
it( "saves secured URL when it is safe", () => {
336+
mockEvent.$( "getCurrentRoutedURL", "/account" );
337+
mockEvent
338+
.$( "buildLink" )
339+
.$args( to = "/account", queryString = cgi.QUERY_STRING, translate = false )
340+
.$results( "/account" );
341+
342+
security.saveSecuredUrl( mockEvent );
343+
344+
// Verify flash was called with the safe URL
345+
expect( mockFlash.$callLog().put ).toHaveLength( 1 );
346+
expect( mockFlash.$callLog().put[ 1 ][ 1 ] ).toBe( "_securedUrl" );
347+
expect( mockFlash.$callLog().put[ 1 ][ 2 ] ).toBe( "/account" );
348+
} );
349+
} );
278350
} );
279351
}
280352

0 commit comments

Comments
 (0)