Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions dotCMS/src/main/java/com/dotcms/saml/Attributes.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public class Attributes implements Serializable {
private final Object roles;

// Saml object with the NameID.
private final Object nameID;
private final Serializable nameID;

// SAML Session Index
private final String sessionIndex;
Expand Down Expand Up @@ -76,7 +76,7 @@ public Object getRoles()
return roles;
}

public Object getNameID()
public Serializable getNameID()
{
return nameID;
}
Expand Down Expand Up @@ -109,7 +109,7 @@ public static final class Builder {
String firstName = "";
boolean addRoles = false;
Object roles = null;
Object nameID = null;
Serializable nameID = null;
String sessionIndex;
Map<String, Object> additionalAttributes;

Expand Down Expand Up @@ -149,7 +149,7 @@ public Builder roles(final Object roles)
return this;
}

public Builder nameID(final Object nameID)
public Builder nameID(final Serializable nameID)
{
this.nameID = nameID;
return this;
Expand Down Expand Up @@ -187,7 +187,7 @@ public Object getRoles()
return roles;
}

public Object getNameID()
public Serializable getNameID()
{
return nameID;
}
Expand Down
136 changes: 136 additions & 0 deletions dotCMS/src/main/java/com/dotcms/saml/SamlNameID.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package com.dotcms.saml;

import java.io.IOException;
import java.io.InvalidObjectException;
import java.io.ObjectInputStream;
import java.io.Serializable;
import java.util.Objects;

/**
* A serializable holder for a SAML NameID's XML representation.
*
* <p>OpenSAML's concrete NameID implementation does not implement {@link Serializable},
* which prevents it from being stored in Redis-managed sessions. This class lives in the
* webapp classpath (core) so that Tomcat's session deserializer can always find it —
* unlike plugin bundle classes, which are invisible to the webapp classloader.
*
* <p>The full XML string is stored so that the plugin can reconstruct a live
* NameID on demand via {@code SamlUtils.toNameID(SamlNameID)}.
*
* <p>Usage in the plugin:
* <pre>
* // Store:
* attrBuilder.nameID(new SamlNameID(SamlUtils.toXMLObjectString(nameID)));
*
* // Reconstruct:
* NameID nameID = SamlUtils.toNameID((SamlNameID) attributes.getNameID());
* </pre>
*
* @author jsanca
*/
public final class SamlNameID implements Serializable {

private static final long serialVersionUID = 1L;

/** Maximum permitted length of the XML string (guards against heap exhaustion on corrupt sessions). */
private static final int MAX_XML_LENGTH = 8192;

/** Maximum permitted length of the plain NameID value. */
private static final int MAX_VALUE_LENGTH = 1024;

private final String xmlString;

/** The plain NameID value (email, persistent ID, etc.) for cheap retrieval without XML parsing. */
private final String value;

/**
* Constructs a {@code SamlNameID}.
*
* @param xmlString the full XML string of the NameID; must not be {@code null}
* @param value the plain NameID value (e.g. email or opaque ID); must not be {@code null}
*/
public SamlNameID(final String xmlString, final String value) {
this.xmlString = Objects.requireNonNull(xmlString, "xmlString must not be null");
this.value = Objects.requireNonNull(value, "value must not be null");
}

/**
* Returns the XML string representation of the NameID, used to reconstruct a live
* NameID object in the SAML plugin via {@code SamlUtils.toNameID(SamlNameID)}.
*
* <p><strong>Security warning:</strong> the XML string contains the user's SAML identity
* value (email address, persistent ID, etc.) and is therefore PII. It must never be
* written to logs, stored in plaintext audit trails, or returned in API responses.
* Use {@link #getValue()} only when the raw identity string is genuinely required
* (e.g. for hashing before storage), and {@link #toString()} for any diagnostic output.
*
* @return the XML string; never {@code null}
*/
public String getXmlString() {
return xmlString;
}

/**
* Returns the plain NameID value (e.g. email address or opaque persistent ID).
*
* <p><strong>Security warning:</strong> this is raw PII. Pass it only to operations that
* require the actual identity (e.g. {@code SAMLHelper.hashIt()}) — never write it directly
* to logs or API responses. If a correlation token is needed for debugging, hash the value
* first and use only the first few characters of the hash.
*
* @return the NameID value; never {@code null}
*/
public String getValue() {
return value;
}

/**
* Validates invariants after Java deserialization.
*
* <p>Length caps are enforced here in addition to the null/blank checks because
* {@code defaultReadObject()} can restore arbitrarily large strings from a corrupt or
* malicious Redis entry, potentially exhausting heap memory before any application code
* runs.
*/
private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
if (xmlString == null || xmlString.isBlank()) {
throw new InvalidObjectException("SamlNameID: xmlString must not be null or blank");
}
if (xmlString.length() > MAX_XML_LENGTH) {
throw new InvalidObjectException("SamlNameID: xmlString exceeds maximum length of " + MAX_XML_LENGTH);
}
if (value == null || value.isBlank()) {
throw new InvalidObjectException("SamlNameID: value must not be null or blank");
}
if (value.length() > MAX_VALUE_LENGTH) {
throw new InvalidObjectException("SamlNameID: value exceeds maximum length of " + MAX_VALUE_LENGTH);
}
}

@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (!(o instanceof SamlNameID)) {
return false;
}
final SamlNameID other = (SamlNameID) o;
return Objects.equals(xmlString, other.xmlString);
}

@Override
public int hashCode() {
return Objects.hash(xmlString);
}

/**
* Returns a redacted representation to prevent accidental PII leakage in logs.
* The XML string contains the user's SAML identity value and must never appear in logs.
*/
@Override
public String toString() {
return "SamlNameID{xmlString='[REDACTED]'}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.dotcms.datagen.UserDataGen;
import com.dotcms.saml.Attributes;
import com.dotcms.saml.DotSamlConstants;
import com.dotcms.saml.SamlNameID;
import com.dotcms.saml.IdentityProviderConfiguration;
import com.dotcms.saml.SamlAuthenticationService;
import com.dotcms.saml.SamlConfigurationService;
Expand Down Expand Up @@ -108,6 +109,9 @@ public void renderMetadataXML(Writer writer, IdentityProviderConfiguration ident

@Override
public String getValue(Object samlObject) {
if (samlObject instanceof SamlNameID) {
return ((SamlNameID) samlObject).getValue();
}
return samlObject.toString();
}

Expand All @@ -118,6 +122,14 @@ public List<String> getValues(Object samlObject) {
}
}

/**
* Creates a minimal {@link SamlNameID} suitable for tests that do not need real SAML XML.
* The XML stub is not valid OpenSAML XML; it only satisfies the non-blank invariant.
*/
private static SamlNameID testNameID(final String value) {
return new SamlNameID("<nameID>" + value + "</nameID>", value);
}

/**
* Method to test: testing the {@link SAMLHelper#doLogin(HttpServletRequest, HttpServletResponse, IdentityProviderConfiguration, User, LoginServiceAPI)}
* Given Scenario: tries to log in the admin user
Expand Down Expand Up @@ -204,7 +216,7 @@ public void testResolveUser() throws DotDataException, DotSecurityException, IOE
additionalAttributes.put("prop-key2", "prop-value2");

final Attributes nativeUserAttributes = new Attributes.Builder().firstName(nativeFirstName + "Updated")
.lastName(nativeLastName).nameID(nativeUser.getUserId()).email(nativeEmailAddress).additionalAttributes(additionalAttributes).build();
.lastName(nativeLastName).nameID(testNameID(nativeUser.getUserId())).email(nativeEmailAddress).additionalAttributes(additionalAttributes).build();

// recover with SAML the native user
final User recoveredNativeUser = samlHelper.resolveUser(nativeUserAttributes, identityProviderConfiguration);
Expand All @@ -220,7 +232,7 @@ public void testResolveUser() throws DotDataException, DotSecurityException, IOE

// creates an user from saml
final Attributes samlUserAttributes = new Attributes.Builder().firstName(samlFirstName)
.lastName(samlLastName).nameID(samlNameId).email(samlEmailAddress).build();
.lastName(samlLastName).nameID(testNameID(samlNameId)).email(samlEmailAddress).build();

// recover with SAML the new saml user
final User samlUser = samlHelper.resolveUser(samlUserAttributes, identityProviderConfiguration);
Expand Down Expand Up @@ -283,7 +295,7 @@ public void testResolveUserEmailRepeated() throws DotDataException, DotSecurityE

// creates an user from saml
final Attributes samlUserAttributes = new Attributes.Builder().firstName(nativeFirstName)
.lastName(nativeLastName).nameID("123" + nativeNameId).email(email).build(); // diff id, same email
.lastName(nativeLastName).nameID(testNameID("123" + nativeNameId)).email(email).build(); // diff id, same email

// recover with SAML the new user
final User recoveredNewUser = samlHelper.resolveUser(samlUserAttributes, identityProviderConfiguration);
Expand Down Expand Up @@ -338,7 +350,7 @@ public void testResolveUser_byEmailAddress() throws DotDataException, DotSecurit
.emailAddress(nativeEmailAddress).nextPersisted();

final Attributes nativeUserAttributes = new Attributes.Builder().firstName(nativeFirstName + "Updated")
.lastName(nativeLastName).nameID("xxxxxxxxxxx").email(nativeEmailAddress).build();
.lastName(nativeLastName).nameID(testNameID("xxxxxxxxxxx")).email(nativeEmailAddress).build();

// recover with SAML the native user
final User recoveredNativeUser = samlHelper.resolveUser(nativeUserAttributes, identityProviderConfiguration);
Expand Down Expand Up @@ -423,12 +435,13 @@ public void test_createNewUser_non_Attrs() throws NoSuchAlgorithmException {
company.setAuthType(Company.AUTH_TYPE_EA);
when(companyAPI.getDefaultCompany()).thenReturn(company);
final IdentityProviderConfiguration identityProviderConfiguration = mock(IdentityProviderConfiguration.class);
final Attributes attrs = new Attributes.Builder().nameID(UUID.randomUUID()).build();
final String nameIdValue = UUID.randomUUID().toString();
final Attributes attrs = new Attributes.Builder().nameID(testNameID(nameIdValue)).build();

final User user = samlHelper.createNewUser(APILocator.systemUser(), attrs, identityProviderConfiguration);

Assert.assertNotNull(user);
Assert.assertEquals(samlHelper.hashIt(attrs.getNameID().toString()), user.getUserId());
Assert.assertEquals(samlHelper.hashIt(nameIdValue), user.getUserId());
}

/**
Expand All @@ -449,12 +462,12 @@ public void test_createNewUser_known_Attrs() throws NoSuchAlgorithmException {
when(companyAPI.getDefaultCompany()).thenReturn(company);
final IdentityProviderConfiguration identityProviderConfiguration = mock(IdentityProviderConfiguration.class);
final String uuid = UUID.randomUUID().toString();
final Attributes attrs = new Attributes.Builder().nameID(uuid).email(uuid+"@dotcms.com.cr").firstName("John").lastName("Sn").build();
final Attributes attrs = new Attributes.Builder().nameID(testNameID(uuid)).email(uuid+"@dotcms.com.cr").firstName("John").lastName("Sn").build();

final User user = samlHelper.createNewUser(APILocator.systemUser(), attrs, identityProviderConfiguration);

Assert.assertNotNull(user);
Assert.assertEquals(samlHelper.hashIt(attrs.getNameID().toString()), user.getUserId());
Assert.assertEquals(samlHelper.hashIt(uuid), user.getUserId());
Assert.assertEquals(uuid+"@dotcms.com.cr", attrs.getEmail());
Assert.assertEquals("John", attrs.getFirstName());
Assert.assertEquals("Sn", attrs.getLastName());
Expand Down
1 change: 1 addition & 0 deletions hotfix_tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ Release-25.07.10 LTS
44. https://github.com/dotCMS/private-issues/issues/651 : Stored XSS → RCE: low-priv backend user name bypasses Xss filter, runs in admin session, deploys OSGi bundle #651
45. https://github.com/dotCMS/private-issues/issues/642 : sec: Privilege Escalation + RCE via OSGi bundle upload (low-priv backend user → CMS Administrator) #642
46. https://github.com/dotCMS/core/issues/36851 : Native, configurable HTML minification in the core rendering engine #36851
47. https://github.com/dotCMS/core/issues/37085 : Upgrade BouncyCastle to 1.85 across all three bundled locations (CVE-2026-59638) #37085
Loading