[java-plugin-shibd] branch main updated: Implement POST preservation, wire into end of initiator master flow.

Scott Cantor cantor.2 at osu.edu
Tue Sep 30 18:29:00 UTC 2025


This is an automated email from the git hooks/post-receive script.

scantor pushed a commit to branch main
in repository java-plugin-shibd.

View the commit online:
http://git.shibboleth.net/view/?p=java-plugin-shibd.git;a=commit;h=1c2542a135aa8389da5591dd5dd5bae1224264cf

The following commit(s) were added to refs/heads/main by this push:
     new 1c2542a  Implement POST preservation, wire into end of initiator master flow.
1c2542a is described below

commit 1c2542a135aa8389da5591dd5dd5bae1224264cf
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Tue Sep 30 14:28:35 2025 -0400

    Implement POST preservation, wire into end of initiator master flow.
---
 .../session-initiator/session-initiator-beans.xml  |   8 +
 .../session-initiator/session-initiator-flow.xml   |   9 +-
 .../shibboleth/idp/module/conf/sp/sp.properties    |   8 +
 .../net/shibboleth/sp/conf/agents-system.xml       |   6 +-
 .../src/main/java/net/shibboleth/sp/Agent.java     |  26 +-
 .../sp/messaging/RemotedHttpServletRequest.java    |   2 +-
 .../sp/profile/AbstractStateTokenManager.java      |   3 -
 .../java/net/shibboleth/sp/impl/BasicAgent.java    |  43 ++-
 .../sp/impl/StorageServiceStateTokenManager.java   |   2 +
 .../sp/profile/impl/IssueCorrelationCookie.java    |   5 +-
 .../sp/profile/impl/PreservePostData.java          | 311 +++++++++++++++++++++
 .../sp/profile/impl/PreservePostDataTest.java      | 191 +++++++++++++
 12 files changed, 600 insertions(+), 14 deletions(-)

diff --git a/sp-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/session-initiator/session-initiator-beans.xml b/sp-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/session-initiator/session-initiator-beans.xml
index cbff124..c5a1570 100644
--- a/sp-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/session-initiator/session-initiator-beans.xml
+++ b/sp-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/session-initiator/session-initiator-beans.xml
@@ -14,5 +14,13 @@
         class="net.shibboleth.sp.profile.impl.MapResourceToStateToken" scope="prototype"
         p:createOutputObjects="true"
         p:errorFatal="%{sp.stateToken.errorsFatal:false}" />
+        
+    <bean id="PreservePostData"
+        class="net.shibboleth.sp.profile.impl.PreservePostData" scope="prototype"
+        p:errorFatal="%{sp.postData.errorsFatal:false}"
+        p:lifetime="%{sp.postData.lifetime:PT15M}"
+        p:storageService-ref="#{'%{sp.postData.StorageService:shibboleth.StorageService}'.trim()}"
+        p:cookieManager-ref="shibboleth.sp.RemotedCookieManager"
+        p:cookiePrefix="#{'%{sp.postData.cookiePrefix:__Host-_shibsp_post_}'.trim()}" />
     
 </beans>
diff --git a/sp-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/session-initiator/session-initiator-flow.xml b/sp-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/session-initiator/session-initiator-flow.xml
index 9c8ea82..362b68e 100644
--- a/sp-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/session-initiator/session-initiator-flow.xml
+++ b/sp-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/session-initiator/session-initiator-flow.xml
@@ -29,10 +29,17 @@
     
     <subflow-state id="CallInitiatorFlow" subflow="sp/initiator/#{SessionInitiatorIterator.next()}">
         <input name="calledAsSubflow" value="true" />
-        <transition on="proceed" to="EncodeAgentResponse" />
+        <transition on="proceed" to="PreservePostData" />
         <transition on="ReselectFlow" to="CheckIterator" />
     </subflow-state>
     
+    <action-state id="PreservePostData">
+        <evaluate expression="PreservePostData" />
+        <evaluate expression="'proceed'" />
+        
+        <transition on="proceed" to="EncodeAgentResponse" />
+    </action-state>
+    
     <action-state id="NoPotentialFlow">
         <evaluate expression="'NoPotentialFlow'" />
     </action-state>
diff --git a/sp-conf-impl/src/main/resources/net/shibboleth/idp/module/conf/sp/sp.properties b/sp-conf-impl/src/main/resources/net/shibboleth/idp/module/conf/sp/sp.properties
index 4f45dd4..86c1fe0 100644
--- a/sp-conf-impl/src/main/resources/net/shibboleth/idp/module/conf/sp/sp.properties
+++ b/sp-conf-impl/src/main/resources/net/shibboleth/idp/module/conf/sp/sp.properties
@@ -42,6 +42,14 @@ sp.service.agents.checkInterval = PT5M
 # Request/response correlation control
 #sp.correlation.cookiePrefix = __Host-_shibsp_req_
 
+# POST data preservation controls
+#sp.postData.preservation = true
+#sp.postData.limit = 1048576
+#sp.postData.lifetime = PT15M
+#sp.postData.cookiePrefix = __Host-_shibsp_post_
+#sp.postData.StorageService = shibboleth.StorageService
+#sp.postData.errorsFatal = false
+
 # Uncomment/set to define a default IdP discovery service URL or Function
 #sp.discoveryURL =
 #sp.discoveryURLFunction =
diff --git a/sp-conf-impl/src/main/resources/net/shibboleth/sp/conf/agents-system.xml b/sp-conf-impl/src/main/resources/net/shibboleth/sp/conf/agents-system.xml
index fb99553..5fe5abe 100644
--- a/sp-conf-impl/src/main/resources/net/shibboleth/sp/conf/agents-system.xml
+++ b/sp-conf-impl/src/main/resources/net/shibboleth/sp/conf/agents-system.xml
@@ -28,7 +28,9 @@
         class="net.shibboleth.sp.impl.BasicAgent" abstract="true"
         p:authenticationMethod="%{sp.agent.authn.method:}"
         p:allowedAddressRanges-ref="DefaultAllowedAddressRanges"
-        p:supportsCachedAuthentication="%{sp.agent.authn.cached:true}" />
+        p:supportsCachedAuthentication="%{sp.agent.authn.cached:true}"
+        p:supportsPostPreservation="%{sp.postData.preservation:true}"
+        p:postLimit="%{sp.postData.limit:1048576}" />
 
     <bean id="shibboleth.sp.Application.NoInheritance" class="net.shibboleth.sp.impl.BasicApplication" abstract="true"
         p:allowInheritance="false"
@@ -64,7 +66,7 @@
 
     <bean id="shibboleth.sp.CookieStateTokenManager" class="net.shibboleth.sp.impl.CookieStateTokenManager" lazy-init="true"
         p:cookieManager-ref="shibboleth.sp.RemotedCookieManager"
-        p:cookiePrefix="%{sp.stateToken.cookiePrefix:_Host-shibsp_state_}" />
+        p:cookiePrefix="#{'%{sp.stateToken.cookiePrefix:_Host-shibsp_state_}'.trim()}" />
 
     <!-- More traditional beans akin to IdP service. -->
 
diff --git a/sp-server-api/src/main/java/net/shibboleth/sp/Agent.java b/sp-server-api/src/main/java/net/shibboleth/sp/Agent.java
index a510c56..5cce943 100644
--- a/sp-server-api/src/main/java/net/shibboleth/sp/Agent.java
+++ b/sp-server-api/src/main/java/net/shibboleth/sp/Agent.java
@@ -23,6 +23,7 @@ import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.ThreadSafe;
 
+import net.shibboleth.shared.annotation.constraint.NonNegative;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
 import net.shibboleth.shared.component.IdentifiedComponent;
 import net.shibboleth.shared.net.IPRange;
@@ -38,10 +39,10 @@ import net.shibboleth.shared.net.IPRange;
 public interface Agent extends IdentifiedComponent {
 
     /** ID of default {@link Application}. */
-    @Nonnull @NotEmpty public static String DEFAULT_APPLICATION_ID = "default";
+    @Nonnull @NotEmpty static String DEFAULT_APPLICATION_ID = "default";
     
     /** Method constant for "basic". */
-    @Nonnull @NotEmpty public static String AUTH_METHOD_BASIC = "basic";
+    @Nonnull @NotEmpty static String AUTH_METHOD_BASIC = "basic";
     
     /**
      * Get the network addresses or ranges of addresses from which requests from this agent may
@@ -87,6 +88,27 @@ public interface Agent extends IdentifiedComponent {
      * @return true iff the agent supports cached authentication via cookie
      */
     boolean isSupportsCachedAuthentication();
+    
+    /**
+     * Returns true iff the agent is permitted to submit form data to preserve
+     * during SSO.
+     * 
+     * <p>Defaults to true.</p>
+     * 
+     * @return true iff the agent is permitted to submit form data to preserve
+     */
+    boolean isSupportsPostPreservation();
+    
+    /**
+     * Gets the size limit to apply to form data preservation attempts.
+     * 
+     * <p>The default is 1024 * 1024.</p>
+     * 
+     * <p>Zero indicates no inherent limit.</p>
+     * 
+     * @return size limit for form data preservation
+     */
+    @NonNegative long getPostLimit();
 
     /**
      * Get an {@link Application} associated with this agent.
diff --git a/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletRequest.java b/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletRequest.java
index 7c62d4a..5038e18 100644
--- a/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletRequest.java
+++ b/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletRequest.java
@@ -233,7 +233,7 @@ public class RemotedHttpServletRequest implements HttpServletRequest {
                 final byte[] body = obj.getmember("body").unsafe_string();
                 if (body != null) {
                     final List<Pair<String,String>> qparams =
-                            URISupport.parseQueryString(new String(body, StandardCharsets.UTF_8));
+                            URISupport.parseQueryString(decodeUnsafeString(body));
                     for (final Pair<String,String> p : qparams) {
                         multimap.put(p.getFirst(), p.getSecond());
                     }
diff --git a/sp-server-api/src/main/java/net/shibboleth/sp/profile/AbstractStateTokenManager.java b/sp-server-api/src/main/java/net/shibboleth/sp/profile/AbstractStateTokenManager.java
index 2c7eba3..9ac8b90 100644
--- a/sp-server-api/src/main/java/net/shibboleth/sp/profile/AbstractStateTokenManager.java
+++ b/sp-server-api/src/main/java/net/shibboleth/sp/profile/AbstractStateTokenManager.java
@@ -89,8 +89,6 @@ public abstract class AbstractStateTokenManager extends AbstractIdentifiableInit
         expiration = Constraint.isNotNull(exp, "Expiration cannot be null");
     }
     
-    /** {@inheritDoc} */
-
     /** {@inheritDoc} */
     @Override
     protected void doInitialize() throws ComponentInitializationException {
@@ -101,7 +99,6 @@ public abstract class AbstractStateTokenManager extends AbstractIdentifiableInit
         }
     }
     
-
     /**
      * Generate a state token.
      * 
diff --git a/sp-server-impl/src/main/java/net/shibboleth/sp/impl/BasicAgent.java b/sp-server-impl/src/main/java/net/shibboleth/sp/impl/BasicAgent.java
index 0612719..35e0c1e 100644
--- a/sp-server-impl/src/main/java/net/shibboleth/sp/impl/BasicAgent.java
+++ b/sp-server-impl/src/main/java/net/shibboleth/sp/impl/BasicAgent.java
@@ -29,6 +29,7 @@ import javax.annotation.Nullable;
 import com.google.common.base.Functions;
 import com.google.common.base.MoreObjects;
 
+import net.shibboleth.shared.annotation.constraint.NonNegative;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
 import net.shibboleth.shared.collection.CollectionSupport;
 import net.shibboleth.shared.component.ComponentInitializationException;
@@ -52,9 +53,15 @@ public class BasicAgent extends BasicApplication implements Agent {
     /** Authentication method. */
     @Nullable @NotEmpty String authenticationMethod;
     
+    /** Whether to support form submission preservation and recovery. */
+    private boolean supportsPostPreservation;
+    
+    /** Limit on size of form data to preserve. */
+    private long postLimit;
+    
     /** Internally configured shared secrets. */
     @Nonnull private Set<String> sharedSecrets;
-    
+        
     /** Application map. */
     @Nonnull private Map<String,Application> applicationMap;
     
@@ -63,6 +70,8 @@ public class BasicAgent extends BasicApplication implements Agent {
         supportsCachedAuthentication = true;
         allowedAddressRanges = CollectionSupport.emptySet();
         authenticationMethod  = null;
+        supportsPostPreservation = true;
+        postLimit = 1024 * 1024;
         sharedSecrets = CollectionSupport.emptySet();
         applicationMap = CollectionSupport.emptyMap();
         
@@ -152,6 +161,38 @@ public class BasicAgent extends BasicApplication implements Agent {
         supportsCachedAuthentication = flag;
     }
     
+    /** {@inheritDoc} */
+    public boolean isSupportsPostPreservation() {
+        return supportsPostPreservation;
+    }
+    
+    /**
+     * Sets whether agent is permitted to submit form data for preservation during SSO.
+     * 
+     * @param flag flag to set
+     */
+    public void setSupportsPostPreservation(final boolean flag) {
+        checkSetterPreconditions();
+        
+        supportsPostPreservation = flag;
+    }
+    
+    /** {@inheritDoc} */
+    public long getPostLimit() {
+        return postLimit;
+    }
+    
+    /**
+     * Sets limit on size of form data to preserve.
+     * 
+     * @param limit size limit or 0 for none
+     */
+    public void setPostLimit(@NonNegative final long limit) {
+        checkSetterPreconditions();
+        
+        postLimit = Constraint.isGreaterThanOrEqual(0, limit, "Post limit cannot be negative.");
+    }
+    
     /**
      * Sets the {@link Application} instances associated with this agent.
      * 
diff --git a/sp-server-impl/src/main/java/net/shibboleth/sp/impl/StorageServiceStateTokenManager.java b/sp-server-impl/src/main/java/net/shibboleth/sp/impl/StorageServiceStateTokenManager.java
index ccd934b..aa5f095 100644
--- a/sp-server-impl/src/main/java/net/shibboleth/sp/impl/StorageServiceStateTokenManager.java
+++ b/sp-server-impl/src/main/java/net/shibboleth/sp/impl/StorageServiceStateTokenManager.java
@@ -65,6 +65,8 @@ public class StorageServiceStateTokenManager extends AbstractStateTokenManager {
         
         if (storageService == null) {
             throw new ComponentInitializationException("StorageService cannot be null");
+        } else if (!storageService.getCapabilities().isServerSide()) {
+            throw new ComponentInitializationException("StorageService cannot be client-side");
         }
     }
 
diff --git a/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/IssueCorrelationCookie.java b/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/IssueCorrelationCookie.java
index 7e9077c..0d8962c 100644
--- a/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/IssueCorrelationCookie.java
+++ b/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/IssueCorrelationCookie.java
@@ -44,9 +44,6 @@ import net.shibboleth.sp.profile.SPConstants;
  * Action that issues a cookie used to record state about a request for
  * later enforcement/evaluation.
  * 
- * <p>Principally used to capture a request message ID for correlation to a
- * response, also tracks whether a request was "passive" or not.</p>
- * 
  * @event {@link EventIds#PROCEED_EVENT_ID}
  * @event {@link EventIds#INVALID_MESSAGE}
  */
@@ -190,7 +187,7 @@ public class IssueCorrelationCookie extends AbstractApplicationAction {
 
             cookieManager.purgeStaleCookies(cookiePrefix);
             
-            log.debug("{} Tracking request ID {} against RelayState token {}", getLogPrefix(), requestID, stateToken);
+            log.debug("{} Tracking request ID {} against state token {}", getLogPrefix(), requestID, stateToken);
 
             final Escaper escaper = UrlEscapers.urlFormParameterEscaper();
             cookieManager.addCookie(cookiePrefix + escaper.escape(stateToken), escaper.escape(requestID));
diff --git a/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/PreservePostData.java b/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/PreservePostData.java
new file mode 100644
index 0000000..26efe23
--- /dev/null
+++ b/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/PreservePostData.java
@@ -0,0 +1,311 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.sp.profile.impl;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.storage.StorageService;
+import org.slf4j.Logger;
+
+import com.google.common.escape.Escaper;
+import com.google.common.net.UrlEscapers;
+
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.annotation.constraint.Positive;
+import net.shibboleth.shared.codec.Base64Support;
+import net.shibboleth.shared.codec.EncodingException;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.net.CookieManager;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+import net.shibboleth.shared.security.IdentifierGenerationStrategy;
+import net.shibboleth.shared.security.IdentifierGenerationStrategy.ProviderType;
+import net.shibboleth.sp.context.AgentRequestContext;
+import net.shibboleth.sp.ddf.DDF;
+import net.shibboleth.sp.messaging.RemotedHttpServletRequest;
+import net.shibboleth.sp.messaging.RemotedHttpServletRequestResponseContext;
+import net.shibboleth.sp.profile.AbstractApplicationAction;
+import net.shibboleth.sp.profile.SPConstants;
+
+/**
+ * Action that detects submitted form data, and when permitted, stores it in a {@link StorageService} and
+ * issues a cookie associated with the active state token to preserve a pointer to the data for recovery.
+ * 
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_MESSAGE}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link EventIds#IO_ERROR}
+ */
+public class PreservePostData extends AbstractApplicationAction {
+    
+    /** Default cookie prefix. */
+    @Nonnull @NotEmpty static public String DEFAULT_COOKIE_PREFIX = "_shibsp_post_";
+    
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(PreservePostData.class);
+    
+    /** Identifier generation. */
+    @NonnullAfterInit private IdentifierGenerationStrategy identifierStrategy;
+    
+    /** Lifetime for data in storage. */
+    @Nonnull private Duration lifetime;
+    
+    /** Storage service for data. */
+    @NonnullAfterInit private StorageService storageService;
+    
+    /** Cookie manager. */
+    @NonnullAfterInit private CookieManager cookieManager;
+    
+    /** Cookie prefix. */
+    @Nonnull private String cookiePrefix;
+
+    /** Whether an error constructing a correlation cookie is fatal. */
+    private boolean errorFatal;
+    
+    /** State token vakue used in cookie name. */
+    @NonnullBeforeExec private String stateToken;
+    
+    /** POST data to preserve. */
+    @NonnullBeforeExec private byte[] postData;
+    
+    /** Constructor. */
+    public PreservePostData() {
+        lifetime = Duration.ofMinutes(15);
+        cookiePrefix = DEFAULT_COOKIE_PREFIX;
+    }    
+    
+    /**
+     * Set {@link IdentifierGenerationStrategy} to use.
+     * 
+     * <p>Defaults to a secure random source that produces 16 byte values.</p>
+     * 
+     * @param strategy identifier generator strategy
+     */
+    public void setIdentifierGenerationStrategy(@Nonnull final IdentifierGenerationStrategy strategy) {
+        checkSetterPreconditions();
+        
+        identifierStrategy = Constraint.isNotNull(strategy, "IdentifierGenerationStrategy cannot be null");
+    }
+    
+    /**
+     * Set the lifetime for data in storage.
+     * 
+     * <p>Defaults to PT15M.</p>
+     * 
+     * @param dur data lifetime
+     */
+    public void setLifetime(@Positive @Nonnull final Duration dur) {
+        checkSetterPreconditions();
+        
+        Constraint.isFalse(lifetime == null || lifetime.isNegative() || lifetime.isZero(),
+                "Lifetime cannot be zero or negative");
+        lifetime = dur;
+    }
+    
+    /**
+     * Set {@link StorageService} to use.
+     * 
+     * @param storage storage service
+     */
+    public void setStorageService(@Nonnull final StorageService storage) {
+        checkSetterPreconditions();
+        
+        storageService = Constraint.isNotNull(storage, "StorageService cannot be null");
+    }
+    
+    /**
+     * Sets the {@link CookieManager} to use.
+     * 
+     * @param manager cookie manager instance
+     */
+    public void setCookieManager(@Nonnull final CookieManager manager) {
+        checkSetterPreconditions();
+        
+        cookieManager = Constraint.isNotNull(manager, "CookieManager cannot be null");
+    }
+    
+    /**
+     * Sets the cookie prefix.
+     * 
+     * <p>Defaults to {@link #DEFAULT_COOKIE_PREFIX}.</p>
+     * 
+     * @param prefix cookie prefix
+     */
+    public void setCookiePrefix(@Nonnull @NotEmpty final String prefix) {
+        checkSetterPreconditions();
+        
+        cookiePrefix = Constraint.isNotNull(StringSupport.trimOrNull(prefix), "Cookie prefix cannot be null or empty");
+    }
+    
+    /**
+     * Sets whether an error computing a state token should result in a fatal event.
+     * 
+     * <p>Defaults to false.</p>
+     * 
+     * @param flag flag to set
+     */
+    public void setErrorFatal(final boolean flag) {
+        checkSetterPreconditions();
+        
+        errorFatal = flag;
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        if (identifierStrategy == null) {
+            identifierStrategy = IdentifierGenerationStrategy.getInstance(ProviderType.SECURE);
+        }
+        
+        if (cookieManager == null) {
+            throw new ComponentInitializationException("CookieManager cannot be null");
+        } else if (storageService == null) {
+            throw new ComponentInitializationException("StorageService cannot be null");
+        } else if (!storageService.getCapabilities().isServerSide()) {
+            throw new ComponentInitializationException("StorageService cannot be client-side");
+        }
+    }    
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+        
+        // First see if anything is even there.
+        final DDF input = ensureAgentRequestContext().getInput();
+        if (input != null) {
+            postData = input.getmember(RemotedHttpServletRequest.STRUCTURE_NAME)
+                    .getmember(RemotedHttpServletRequest.BODY)
+                    .unsafe_string();
+        }
+                
+        if (postData == null) {
+            log.debug("{} No POST data to preserve", getLogPrefix());
+            return false;
+        }
+        
+        assert input != null;
+        final String contentType = input.getmember(RemotedHttpServletRequest.STRUCTURE_NAME)
+                .getmember(RemotedHttpServletRequest.CONTENT_TYPE)
+                .string();
+        if (!"application/x-www-form-urlencoded".equals(contentType)) {
+            log.warn("{} Unsupported content type: {}", getLogPrefix(), contentType);
+            return false;
+        }
+        
+        // Check permission.
+        
+        if (!ensureAgent().isSupportsPostPreservation()) {
+            log.warn("{} POST data supplied for preservation, but not permitted for agent", getLogPrefix());
+            return false;
+        }
+        
+        long limit = ensureAgent().getPostLimit();
+        if (limit > 0 && postData.length > limit) {
+            log.warn("{} POST data supplied for preservation, but size {} exceeds agent limit", getLogPrefix(),
+                    postData.length);
+            return false;
+        }
+        
+        // Check for state token for cookie correlation.
+        
+        if (input != null) {
+            stateToken = input.getmember(SPConstants.STATE).string();
+        }
+        
+        if (stateToken == null) {
+            if (errorFatal) {
+                log.warn("{} Input was missing {} parameter, failing due to POST data preservationr",
+                        getLogPrefix(), SPConstants.STATE);
+                ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MESSAGE);
+            } else {
+                log.warn("{} Input was missing {} parameter, skipping POST data preservation", getLogPrefix(),
+                        SPConstants.STATE);
+            }
+            return false;
+        }
+        
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        final AgentRequestContext agentRequestContext = ensureAgentRequestContext();
+
+        // We do the crazy stuff to accomodate the cookies being set or unset.
+        try {
+            RemotedHttpServletRequestResponseContext.loadCurrent(agentRequestContext.getRemotedHttpServletRequest(),
+                    agentRequestContext.getRemotedHttpServletResponse());
+
+            cookieManager.purgeStaleCookies(cookiePrefix);
+
+            log.debug("{} Preserving {} bytes of POST data against state token {}", getLogPrefix(), postData.length,
+                    stateToken);
+            
+            // Encode the data for storage.
+            final String encoded = Base64Support.encode(postData, false);
+            
+            // Generate a storage key.
+            final String key = identifierStrategy.generateIdentifier(false);
+            
+            if (!storageService.create(ensureAgent().getId() + ".PostData", key, encoded,
+                    Instant.now().plus(lifetime).toEpochMilli())) {
+                log.warn("{} Collision attempting to create storage record for POST data under key {}", getLogPrefix(), key);
+                if (errorFatal) {
+                    ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+                }
+                return;
+            }
+            
+            if (stateToken.length() > 16) {
+                stateToken = stateToken.substring(0, 16);
+            }
+
+            // Save off cookie. Name is decorated with state token prefix, value is the storage key.
+            final Escaper escaper = UrlEscapers.urlFormParameterEscaper();
+            cookieManager.addCookie(cookiePrefix + escaper.escape(stateToken), escaper.escape(key));
+            
+        } catch (final EncodingException e) {
+            log.error("{} Error base64-encoding data", getLogPrefix(), e);
+            if (errorFatal) {
+                ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MESSAGE);
+            }
+        } catch (final IOException e) {
+            log.error("{} Error creating storage record for POST data", getLogPrefix(), e);
+            if (errorFatal) {
+                ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+            }
+        } finally {
+            RemotedHttpServletRequestResponseContext.clearCurrent();
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/sp-server-impl/src/test/java/net/shibboleth/sp/profile/impl/PreservePostDataTest.java b/sp-server-impl/src/test/java/net/shibboleth/sp/profile/impl/PreservePostDataTest.java
new file mode 100644
index 0000000..3ff678c
--- /dev/null
+++ b/sp-server-impl/src/test/java/net/shibboleth/sp/profile/impl/PreservePostDataTest.java
@@ -0,0 +1,191 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.sp.profile.impl;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.storage.StorageRecord;
+import org.opensaml.storage.impl.MemoryStorageService;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.webflow.execution.Event;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import jakarta.servlet.http.Cookie;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.codec.Base64Support;
+import net.shibboleth.shared.codec.DecodingException;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.net.CookieManager;
+import net.shibboleth.shared.net.CookieManager.SameSiteValue;
+import net.shibboleth.shared.primitive.NonnullSupplier;
+import net.shibboleth.sp.ddf.DDF;
+import net.shibboleth.sp.messaging.RemotedHttpServletRequest;
+import net.shibboleth.sp.profile.SPConstants;
+
+/**
+ * Unit test for {@link PreservePostData} action.
+ */
+ at SuppressWarnings("javadoc")
+public class PreservePostDataTest extends BaseAgplicationActionTest {
+
+    @Nonnull @NotEmpty private final static String TEST_STATE = "foo";
+    @Nonnull @NotEmpty private final static String TEST_DATA = "foo=bar&zorkmid=a+b";
+
+    private String requestId;
+    
+    private DDF input;
+    private MockHttpServletRequest request;
+    private MockHttpServletResponse response;
+    
+    private MemoryStorageService storageService;
+    private CookieManager cookieManager;
+    private PreservePostData action;
+        
+    /**
+     * Set up test.
+     * 
+     * @throws ComponentInitializationException
+     */
+    @BeforeMethod
+    public void setUp() throws ComponentInitializationException {
+        super.beforeMethod();
+                
+        request = new MockHttpServletRequest();
+        response = new MockHttpServletResponse();
+        
+        cookieManager = new CookieManager();
+        cookieManager.setHttpServletRequestSupplier(NonnullSupplier.of(request));
+        cookieManager.setHttpServletResponseSupplier(NonnullSupplier.of(response));
+        cookieManager.setCookieLimit(10);
+        cookieManager.setSameSite(SameSiteValue.None);
+        cookieManager.setMaxAge(-1);
+        cookieManager.initialize();
+        
+        storageService = new MemoryStorageService();
+        storageService.setId("test");
+        storageService.setCleanupInterval(Duration.ZERO);
+        storageService.initialize();
+        
+        action = new PreservePostData();
+        action.setCookieManager(cookieManager);
+        action.setStorageService(storageService);
+        action.setErrorFatal(true);
+        action.initialize();
+
+        input = new DDF(null).structure();
+        input.addmember(SPConstants.STATE).string(TEST_STATE);
+        final DDF http = input.addmember(RemotedHttpServletRequest.STRUCTURE_NAME);
+        http.addmember(RemotedHttpServletRequest.CONTENT_TYPE).string("application/x-www-form-urlencoded");
+        http.addmember(RemotedHttpServletRequest.BODY).unsafe_string(TEST_DATA.getBytes());
+        
+        arc.setInput(input);
+    }
+    
+    /**
+     * Tear down test.
+     */
+    @AfterMethod
+    public void tearDown() {
+        action.destroy();
+        cookieManager.destroy();
+        storageService.destroy();
+    }
+        
+    @Test
+    public void testNoData() {
+        input.addmember(RemotedHttpServletRequest.STRUCTURE_NAME).getmember(RemotedHttpServletRequest.BODY).remove();
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        Assert.assertEquals(response.getCookies().length, 0);
+    }
+
+    @Test
+    public void testWrongType() {
+        input.addmember(RemotedHttpServletRequest.STRUCTURE_NAME).getmember(RemotedHttpServletRequest.CONTENT_TYPE).remove();
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        Assert.assertEquals(response.getCookies().length, 0);
+    }
+    
+    @Test
+    public void testDisallowed() {
+        agent.setSupportsPostPreservation(false);
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        Assert.assertEquals(response.getCookies().length, 0);
+    }
+
+    @Test
+    public void testSizeLimit() {
+        agent.setPostLimit(5);
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        Assert.assertEquals(response.getCookies().length, 0);
+    }
+    
+    @Test
+    public void testNoStateToken() {
+        input.addmember(SPConstants.STATE).remove();
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertEvent(event, EventIds.INVALID_MESSAGE);
+    }
+    
+    @Test
+    public void testSuccess() throws IOException, DecodingException {
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        Assert.assertEquals(response.getCookies().length, 1);
+        
+        final Cookie cookie = response.getCookie(PreservePostData.DEFAULT_COOKIE_PREFIX + TEST_STATE);
+        assert cookie != null;
+        Assert.assertEquals(cookie.getMaxAge(), -1);
+        Assert.assertEquals(cookie.getAttribute("SameSite"), SameSiteValue.None.getValue());
+        
+        final String key = cookie.getValue();
+        
+        StorageRecord<String> record = storageService.read(agent.getId() + ".PostData", key);
+        assert record != null;
+        Assert.assertEquals(record.getVersion(), 1);
+        Assert.assertEquals(Base64Support.decode(record.getValue()), TEST_DATA.getBytes());
+    }
+        
+    @Test
+    public void testPurge() throws ComponentInitializationException, DecodingException, InterruptedException {
+        
+        final List<Cookie> cookies = new ArrayList<>(12);
+        for (int i = 0; i < 12; ++i) {
+            cookies.add(new Cookie(PreservePostData.DEFAULT_COOKIE_PREFIX + i, "foo" + i));
+            Thread.sleep(250);
+        }
+        request.setCookies(cookies.toArray(new Cookie[12]));
+        
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        
+        Assert.assertEquals(response.getCookies().length, 3);
+    }
+
+}
\ No newline at end of file

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list