-
Notifications
You must be signed in to change notification settings - Fork 480
fix(security): prevent origin hostname leak via SAML and add anonymous access warning #34999
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mbiuki
wants to merge
4
commits into
main
Choose a base branch
from
fix/content-api-origin-exposure-34997
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
d1a6c35
fix(security): prevent origin hostname leak via SAML and add anonymou…
mbiuki 2e06073
Merge branch 'main' into fix/content-api-origin-exposure-34997
mbiuki d4e12d4
test: add unit tests for AnonymousAccess.systemSetting() and DotSamlR…
mbiuki 0a3b0ec
fix: use getPropertyName() instead of propName() on SamlName enum
mbiuki File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
130 changes: 130 additions & 0 deletions
130
dotCMS/src/test/java/com/dotcms/auth/providers/saml/v1/DotSamlResourceBuildBaseUrlTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| package com.dotcms.auth.providers.saml.v1; | ||
|
|
||
| import com.dotcms.UnitTestBase; | ||
| import com.dotcms.rest.WebResource; | ||
| import com.dotcms.saml.IdentityProviderConfigurationFactory; | ||
| import com.dotcms.saml.SamlAuthenticationService; | ||
| import com.dotcms.saml.SamlConfigurationService; | ||
| import com.dotcms.saml.SamlName; | ||
| import com.dotmarketing.util.Config; | ||
| import org.junit.After; | ||
| import org.junit.Test; | ||
|
|
||
| import javax.servlet.http.HttpServletRequest; | ||
|
|
||
| import static org.junit.Assert.assertEquals; | ||
| import static org.junit.Assert.assertTrue; | ||
| import static org.mockito.Mockito.mock; | ||
| import static org.mockito.Mockito.when; | ||
|
|
||
| /** | ||
| * Unit tests for {@link DotSamlResource#buildBaseUrlFromRequest(HttpServletRequest)}. | ||
| * | ||
| * Verifies that the logout base URL prefers the configured {@code sPEndpointHostname} | ||
| * over the raw origin hostname from the servlet request, preventing the origin server | ||
| * address from leaking through the SAML logout redirect. | ||
| */ | ||
| public class DotSamlResourceBuildBaseUrlTest extends UnitTestBase { | ||
|
|
||
| private static final String CONFIGURED_HOST = "https://public.cdn.example.com"; | ||
| private static final String REQUEST_SCHEME = "https"; | ||
| private static final String REQUEST_HOST = "origin-server.internal.example.com"; | ||
| private static final int REQUEST_PORT = 443; | ||
|
|
||
| @After | ||
| public void cleanup() { | ||
| Config.setProperty(SamlName.DOT_SAML_SERVICE_PROVIDER_HOST_NAME.getPropertyName(), null); | ||
| } | ||
|
|
||
| private DotSamlResource buildResource() { | ||
| return new DotSamlResource( | ||
| mock(SamlConfigurationService.class), | ||
| mock(SAMLHelper.class), | ||
| mock(SamlAuthenticationService.class), | ||
| mock(IdentityProviderConfigurationFactory.class), | ||
| mock(WebResource.class) | ||
| ); | ||
| } | ||
|
|
||
| private HttpServletRequest mockRequest() { | ||
| final HttpServletRequest request = mock(HttpServletRequest.class); | ||
| when(request.getScheme()).thenReturn(REQUEST_SCHEME); | ||
| when(request.getServerName()).thenReturn(REQUEST_HOST); | ||
| when(request.getServerPort()).thenReturn(REQUEST_PORT); | ||
| return request; | ||
| } | ||
|
|
||
| /** | ||
| * Method to test: {@link DotSamlResource#buildBaseUrlFromRequest(HttpServletRequest)} | ||
| * Given Scenario: {@code sPEndpointHostname} is configured with a CDN/public hostname | ||
| * ExpectedResult: Returned URL uses the configured hostname, not the request server name, | ||
| * preventing the origin hostname from being exposed in the SAML redirect | ||
| */ | ||
| @Test | ||
| public void testBuildBaseUrl_usesConfiguredHostname_whenSet() { | ||
| Config.setProperty(SamlName.DOT_SAML_SERVICE_PROVIDER_HOST_NAME.getPropertyName(), CONFIGURED_HOST); | ||
|
|
||
| final String result = buildResource().buildBaseUrlFromRequest(mockRequest()); | ||
|
|
||
| assertEquals(CONFIGURED_HOST + "/dotAdmin/show-logout", result); | ||
| } | ||
|
|
||
| /** | ||
| * Method to test: {@link DotSamlResource#buildBaseUrlFromRequest(HttpServletRequest)} | ||
| * Given Scenario: {@code sPEndpointHostname} is not configured | ||
| * ExpectedResult: Falls back to constructing the URL from the request scheme, host and port | ||
| */ | ||
| @Test | ||
| public void testBuildBaseUrl_fallsBackToRequestHostname_whenNotConfigured() { | ||
| Config.setProperty(SamlName.DOT_SAML_SERVICE_PROVIDER_HOST_NAME.getPropertyName(), null); | ||
|
|
||
| final String result = buildResource().buildBaseUrlFromRequest(mockRequest()); | ||
|
|
||
| final String expected = REQUEST_SCHEME + "://" + REQUEST_HOST + ":" + REQUEST_PORT + "/dotAdmin/show-logout"; | ||
| assertEquals(expected, result); | ||
| } | ||
|
|
||
| /** | ||
| * Method to test: {@link DotSamlResource#buildBaseUrlFromRequest(HttpServletRequest)} | ||
| * Given Scenario: {@code sPEndpointHostname} is set to an empty string | ||
| * ExpectedResult: Treated as not-set; falls back to the request hostname | ||
| */ | ||
| @Test | ||
| public void testBuildBaseUrl_fallsBackToRequestHostname_whenConfiguredHostIsEmpty() { | ||
| Config.setProperty(SamlName.DOT_SAML_SERVICE_PROVIDER_HOST_NAME.getPropertyName(), ""); | ||
|
|
||
| final String result = buildResource().buildBaseUrlFromRequest(mockRequest()); | ||
|
|
||
| assertTrue("Should fall back to request hostname when config is empty", | ||
| result.contains(REQUEST_HOST)); | ||
| } | ||
|
|
||
| /** | ||
| * Method to test: {@link DotSamlResource#buildBaseUrlFromRequest(HttpServletRequest)} | ||
| * Given Scenario: Configured hostname without trailing slash, result must end in the logout path | ||
| * ExpectedResult: URL always ends with {@code /dotAdmin/show-logout} | ||
| */ | ||
| @Test | ||
| public void testBuildBaseUrl_alwaysEndsWithLogoutPath() { | ||
| Config.setProperty(SamlName.DOT_SAML_SERVICE_PROVIDER_HOST_NAME.getPropertyName(), CONFIGURED_HOST); | ||
|
|
||
| final String result = buildResource().buildBaseUrlFromRequest(mockRequest()); | ||
|
|
||
| assertTrue("URL must end with /dotAdmin/show-logout", result.endsWith("/dotAdmin/show-logout")); | ||
| } | ||
|
|
||
| /** | ||
| * Method to test: {@link DotSamlResource#buildBaseUrlFromRequest(HttpServletRequest)} | ||
| * Given Scenario: Configured hostname is set — result must not contain the origin request hostname | ||
| * ExpectedResult: Origin hostname is absent from the returned URL, preventing origin leakage | ||
| */ | ||
| @Test | ||
| public void testBuildBaseUrl_doesNotLeakOriginHostname_whenConfigured() { | ||
| Config.setProperty(SamlName.DOT_SAML_SERVICE_PROVIDER_HOST_NAME.getPropertyName(), CONFIGURED_HOST); | ||
|
|
||
| final String result = buildResource().buildBaseUrlFromRequest(mockRequest()); | ||
|
|
||
| assertTrue("Origin hostname must not appear in the URL when sPEndpointHostname is configured", | ||
| !result.contains(REQUEST_HOST)); | ||
| } | ||
| } |
129 changes: 129 additions & 0 deletions
129
dotCMS/src/test/java/com/dotcms/rest/AnonymousAccessTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| package com.dotcms.rest; | ||
|
|
||
| import com.dotcms.UnitTestBase; | ||
| import com.dotmarketing.util.Config; | ||
| import org.junit.After; | ||
| import org.junit.Test; | ||
|
|
||
| import static org.junit.Assert.assertEquals; | ||
|
|
||
| /** | ||
| * Unit tests for {@link AnonymousAccess#systemSetting()}. | ||
| * | ||
| * Verifies that the correct {@link AnonymousAccess} level is resolved from the | ||
| * {@code CONTENT_APIS_ALLOW_ANONYMOUS} config property, and that the method | ||
| * behaves consistently across all valid and invalid input values. | ||
| */ | ||
| public class AnonymousAccessTest extends UnitTestBase { | ||
|
|
||
| @After | ||
| public void cleanup() { | ||
| Config.setProperty(AnonymousAccess.CONTENT_APIS_ALLOW_ANONYMOUS, null); | ||
| } | ||
|
|
||
| /** | ||
| * Method to test: {@link AnonymousAccess#systemSetting()} | ||
| * Given Scenario: No property is set in config | ||
| * ExpectedResult: Returns READ (the hardcoded default) | ||
| */ | ||
| @Test | ||
| public void testSystemSetting_defaultsToRead_whenPropertyNotSet() { | ||
| Config.setProperty(AnonymousAccess.CONTENT_APIS_ALLOW_ANONYMOUS, null); | ||
|
|
||
| final AnonymousAccess result = AnonymousAccess.systemSetting(); | ||
|
|
||
| assertEquals("Default should be READ when property is not set", | ||
| AnonymousAccess.READ, result); | ||
| } | ||
|
|
||
| /** | ||
| * Method to test: {@link AnonymousAccess#systemSetting()} | ||
| * Given Scenario: Property is explicitly set to READ | ||
| * ExpectedResult: Returns READ | ||
| */ | ||
| @Test | ||
| public void testSystemSetting_returnsRead_whenSetToRead() { | ||
| Config.setProperty(AnonymousAccess.CONTENT_APIS_ALLOW_ANONYMOUS, "READ"); | ||
|
|
||
| final AnonymousAccess result = AnonymousAccess.systemSetting(); | ||
|
|
||
| assertEquals(AnonymousAccess.READ, result); | ||
| } | ||
|
|
||
| /** | ||
| * Method to test: {@link AnonymousAccess#systemSetting()} | ||
| * Given Scenario: Property is explicitly set to NONE | ||
| * ExpectedResult: Returns NONE (no anonymous access) | ||
| */ | ||
| @Test | ||
| public void testSystemSetting_returnsNone_whenSetToNone() { | ||
| Config.setProperty(AnonymousAccess.CONTENT_APIS_ALLOW_ANONYMOUS, "NONE"); | ||
|
|
||
| final AnonymousAccess result = AnonymousAccess.systemSetting(); | ||
|
|
||
| assertEquals(AnonymousAccess.NONE, result); | ||
| } | ||
|
|
||
| /** | ||
| * Method to test: {@link AnonymousAccess#systemSetting()} | ||
| * Given Scenario: Property is set to WRITE | ||
| * ExpectedResult: Returns WRITE | ||
| */ | ||
| @Test | ||
| public void testSystemSetting_returnsWrite_whenSetToWrite() { | ||
| Config.setProperty(AnonymousAccess.CONTENT_APIS_ALLOW_ANONYMOUS, "WRITE"); | ||
|
|
||
| final AnonymousAccess result = AnonymousAccess.systemSetting(); | ||
|
|
||
| assertEquals(AnonymousAccess.WRITE, result); | ||
| } | ||
|
|
||
| /** | ||
| * Method to test: {@link AnonymousAccess#systemSetting()} | ||
| * Given Scenario: Property is set to an unrecognised value | ||
| * ExpectedResult: Returns NONE (safe default from {@link AnonymousAccess#from}) | ||
| */ | ||
| @Test | ||
| public void testSystemSetting_returnsNone_whenValueIsInvalid() { | ||
| Config.setProperty(AnonymousAccess.CONTENT_APIS_ALLOW_ANONYMOUS, "INVALID_VALUE"); | ||
|
|
||
| final AnonymousAccess result = AnonymousAccess.systemSetting(); | ||
|
|
||
| assertEquals("Invalid value should fall back to NONE (safest default)", | ||
| AnonymousAccess.NONE, result); | ||
| } | ||
|
|
||
| /** | ||
| * Method to test: {@link AnonymousAccess#systemSetting()} | ||
| * Given Scenario: Property value is lowercase | ||
| * ExpectedResult: Returns the matching enum (case-insensitive match) | ||
| */ | ||
| @Test | ||
| public void testSystemSetting_isCaseInsensitive() { | ||
| Config.setProperty(AnonymousAccess.CONTENT_APIS_ALLOW_ANONYMOUS, "read"); | ||
|
|
||
| final AnonymousAccess result = AnonymousAccess.systemSetting(); | ||
|
|
||
| assertEquals("Matching should be case-insensitive", AnonymousAccess.READ, result); | ||
| } | ||
|
|
||
| /** | ||
| * Method to test: {@link AnonymousAccess#systemSetting()} | ||
| * Given Scenario: READ and WRITE settings represent non-private instances | ||
| * that should trigger a security warning at startup | ||
| * ExpectedResult: READ and WRITE are not equal to NONE — confirming the | ||
| * warning branch is reached for those values | ||
| */ | ||
| @Test | ||
| public void testSystemSetting_readAndWrite_areNotNone_warningBranchReached() { | ||
| Config.setProperty(AnonymousAccess.CONTENT_APIS_ALLOW_ANONYMOUS, "READ"); | ||
| final AnonymousAccess read = AnonymousAccess.systemSetting(); | ||
|
|
||
| Config.setProperty(AnonymousAccess.CONTENT_APIS_ALLOW_ANONYMOUS, "WRITE"); | ||
| final AnonymousAccess write = AnonymousAccess.systemSetting(); | ||
|
|
||
| // NONE would not trigger the startup warning; READ and WRITE do | ||
| assertEquals(false, AnonymousAccess.NONE == read); | ||
| assertEquals(false, AnonymousAccess.NONE == write); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this should be probably taken from the SAML Config per site, in addition o a global fallback