[java-shib-shared] branch main updated: JSSH-60 - Support for managing multiple prefixed cookies

Scott Cantor cantor.2 at osu.edu
Tue Apr 8 19:24:31 UTC 2025


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

scantor pushed a commit to branch main
in repository java-shib-shared.

View the commit online:
http://git.shibboleth.net/view/?p=java-shib-shared.git;a=commit;h=83ae20037d7f1c6ef1afd74730162deabff658de

The following commit(s) were added to refs/heads/main by this push:
     new 83ae2003 JSSH-60 - Support for managing multiple prefixed cookies
83ae2003 is described below

commit 83ae20037d7f1c6ef1afd74730162deabff658de
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Tue Apr 8 15:24:23 2025 -0400

    JSSH-60 - Support for managing multiple prefixed cookies
    
    https://shibboleth.atlassian.net/browse/JSSH-60
    
    Added purge method, and promoted addCookie variant to public.
---
 .../net/shibboleth/shared/net/CookieManager.java   | 83 +++++++++++++++++++++-
 .../shibboleth/shared/net/CookieManagerTest.java   | 63 +++++++++++++++-
 2 files changed, 142 insertions(+), 4 deletions(-)

diff --git a/shib-networking/src/main/java/net/shibboleth/shared/net/CookieManager.java b/shib-networking/src/main/java/net/shibboleth/shared/net/CookieManager.java
index 9eec0764..ae2c58d5 100644
--- a/shib-networking/src/main/java/net/shibboleth/shared/net/CookieManager.java
+++ b/shib-networking/src/main/java/net/shibboleth/shared/net/CookieManager.java
@@ -16,14 +16,18 @@ package net.shibboleth.shared.net;
 
 import java.time.Duration;
 import java.util.Map;
+import java.util.TreeSet;
 import java.util.function.Predicate;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
+import org.slf4j.Logger;
+
 import jakarta.servlet.http.Cookie;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
+import net.shibboleth.shared.annotation.constraint.NonNegative;
 import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
 import net.shibboleth.shared.annotation.constraint.NotLive;
@@ -50,6 +54,9 @@ import net.shibboleth.shared.primitive.StringSupport;
  */
 public class CookieManager extends AbstractInitializableComponent {
     
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(CookieManager.class);
+    
     /** Whether we're on a platform with {@link Cookie#setAttribute(String, String)}. */
     private final boolean hasSetAttribute;
 
@@ -83,6 +90,9 @@ public class CookieManager extends AbstractInitializableComponent {
     /** Additional cookie attributes. */
     @Nonnull private Map<String,String> cookieAttributes;
     
+    /** Limit on numbeer of cookies when purging. */
+    @NonNegative private int cookieLimit;
+    
     /** Constructor. */
     public CookieManager() {
         httpOnly = true;
@@ -90,6 +100,7 @@ public class CookieManager extends AbstractInitializableComponent {
         maxAge = -1;
         sameSiteCondition = PredicateSupport.alwaysTrue();
         cookieAttributes = CollectionSupport.emptyMap();
+        cookieLimit = 0;
         hasSetAttribute = ReflectionSupport.getMethod(Cookie.class, "setAttribute", String.class, String.class) != null;
     }
 
@@ -324,6 +335,32 @@ public class CookieManager extends AbstractInitializableComponent {
             cookieAttributes = CollectionSupport.emptyMap();
         }
     }
+    
+    /**
+     * Gets the limit on cookies of a given set when purging.
+     * 
+     * @return the limit or 0 for unlimited
+     * 
+     * @since 9.2.0
+     */
+    @NonNegative public int getCookieLimit() {
+        return cookieLimit;
+    }
+
+    /**
+     * Sets the limit on cookies of a given set when purging.
+     * 
+     * <p>Defaults to 0, no limit.</p>
+     * 
+     * @param limit limit to set or 0 for unlimited
+     * 
+     * @since 9.2.0
+     */
+    public void setCookieLimit(@NonNegative final int limit) {
+        checkSetterPreconditions();
+        
+        cookieLimit = Constraint.isGreaterThanOrEqual(0, limit, "");
+    }
 
     /** {@inheritDoc} */
     protected void doInitialize() throws ComponentInitializationException {
@@ -334,8 +371,7 @@ public class CookieManager extends AbstractInitializableComponent {
         }
         
         if (!hasSetAttribute && (sameSite != null || !cookieAttributes.isEmpty())) {
-            LoggerFactory.getLogger(CookieManager.class).info(
-                    "Running on Servlet API < 6.0.0, some features are degraded");
+            log.info("Running on Servlet API < 6.0.0, some features are degraded");
         }
     }
 
@@ -358,7 +394,7 @@ public class CookieManager extends AbstractInitializableComponent {
      * 
      * @since 9.1.0
      */
-    protected void addCookie(@Nonnull @NotEmpty final String name, @Nonnull @NotEmpty final String value,
+    public void addCookie(@Nonnull @NotEmpty final String name, @Nonnull @NotEmpty final String value,
             final int overrideMaxAge) {
         checkComponentActive();
         
@@ -451,6 +487,47 @@ public class CookieManager extends AbstractInitializableComponent {
         return defValue;
     }
     
+    /**
+     * Unset cookies matching a given prefix in excess of the configured amount. 
+     * 
+     * @param prefix cookie name prefix to match on
+     * 
+     * @since 9.2.0
+     */
+    public void purgeStaleCookies(@Nonnull @NotEmpty final String prefix) {
+        final Cookie[] cookies = getHttpServletRequest().getCookies();
+        if (cookies == null || cookies.length == 0) {
+            return;
+        }
+        
+        // Build a list of matching names with the specified prefix we can sort.
+        final TreeSet<String> sortedNames = new TreeSet<>();
+        for (final Cookie c : cookies) {
+            if (c.getName().startsWith(prefix)) {
+                sortedNames.add(c.getName());
+            }
+        }
+
+        // This is off by one because we're about to set one.
+        int maxCookies = cookieLimit;
+        int purgedCookies = 0;
+        
+        for (final String nameToPurge : sortedNames.descendingSet()) {
+            if (maxCookies > 0) {
+                // Keep it but count against limit.
+                --maxCookies;
+            } else {
+                // We're over the limit, so everything here and older gets cleaned up.
+                unsetCookie(nameToPurge);
+                ++purgedCookies;
+            }
+        }
+        
+        if (purgedCookies > 0) {
+            log.debug("Purged {} stale cookie(s) with prefix '{}'", purgedCookies, prefix);
+        }
+    }
+    
     /**
      * Turn the servlet context path into an appropriate cookie path.
      * 
diff --git a/shib-networking/src/test/java/net/shibboleth/shared/net/CookieManagerTest.java b/shib-networking/src/test/java/net/shibboleth/shared/net/CookieManagerTest.java
index 28bae324..06ac393c 100644
--- a/shib-networking/src/test/java/net/shibboleth/shared/net/CookieManagerTest.java
+++ b/shib-networking/src/test/java/net/shibboleth/shared/net/CookieManagerTest.java
@@ -14,11 +14,22 @@
 
 package net.shibboleth.shared.net;
 
+import java.security.SecureRandom;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.random.RandomGenerator;
+
 import javax.annotation.Nonnull;
 
+import org.apache.commons.codec.BinaryEncoder;
+import org.apache.commons.codec.EncoderException;
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.codec.binary.StringUtils;
 import org.springframework.mock.web.MockHttpServletRequest;
 import org.springframework.mock.web.MockHttpServletResponse;
 import org.testng.Assert;
+import org.testng.annotations.BeforeClass;
 import org.testng.annotations.Test;
 
 import jakarta.servlet.http.Cookie;
@@ -31,6 +42,15 @@ import net.shibboleth.shared.primitive.NonnullSupplier;
 @SuppressWarnings("javadoc")
 public class CookieManagerTest {
 
+    private RandomGenerator random;
+    private BinaryEncoder encoder;
+    
+    @BeforeClass
+    public void setUp() {
+        random = new SecureRandom();
+        encoder = new Hex();
+    }
+    
     @Test public void testInitFailure() {
         CookieManager cm = new CookieManager();
         try {
@@ -59,6 +79,7 @@ public class CookieManagerTest {
         cm.setHttpServletRequestSupplier(new NonnullSupplier<>() { @Nonnull public HttpServletRequest get() {return request;}});
         cm.setHttpServletResponseSupplier(new NonnullSupplier<>() { @Nonnull public HttpServletResponse get() {return response;}});
         cm.setCookiePath("/idp");
+        cm.setSameSite("None");
         cm.initialize();
 
         cm.addCookie("foo", "bar");
@@ -70,6 +91,7 @@ public class CookieManagerTest {
         Assert.assertNull(cookie.getDomain());
         Assert.assertTrue(cookie.getSecure());
         Assert.assertEquals(cookie.getMaxAge(), -1);
+        Assert.assertEquals(cookie.getAttribute("SameSite"), "None");
     }
 
     @Test public void testCookieNoPath() throws ComponentInitializationException {
@@ -80,6 +102,7 @@ public class CookieManagerTest {
         CookieManager cm = new CookieManager();
         cm.setHttpServletRequestSupplier(new NonnullSupplier<>() { @Nonnull public HttpServletRequest get() {return request;}});
         cm.setHttpServletResponseSupplier(new NonnullSupplier<>() { @Nonnull public HttpServletResponse get() {return response;}});
+        cm.setSameSite("Strict");
         cm.initialize();
         
         cm.addCookie("foo", "bar");
@@ -91,6 +114,7 @@ public class CookieManagerTest {
         Assert.assertNull(cookie.getDomain());
         Assert.assertTrue(cookie.getSecure());
         Assert.assertEquals(cookie.getMaxAge(), -1);
+        Assert.assertEquals(cookie.getAttribute("SameSite"), "Strict");
     }
 
     @Test public void testCookieUnset() throws ComponentInitializationException {
@@ -114,4 +138,41 @@ public class CookieManagerTest {
         Assert.assertTrue(cookie.getSecure());
         Assert.assertEquals(cookie.getMaxAge(), 0);
     }
-}
+    
+    @Test public void testPurge() throws ComponentInitializationException, InterruptedException, EncoderException {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        final List<Cookie> cookies = new ArrayList<>(12);
+        for (int i = 0; i < 12; ++i) {
+            cookies.add(new Cookie(getCookieName(), "foo" + i));
+            Thread.sleep(250);
+        }
+        request.setCookies(cookies.toArray(new Cookie[12]));
+        
+        CookieManager cm = new CookieManager();
+        cm.setHttpServletRequestSupplier(new NonnullSupplier<>() { @Nonnull public HttpServletRequest get() {return request;}});
+        cm.setHttpServletResponseSupplier(new NonnullSupplier<>() { @Nonnull public HttpServletResponse get() {return response;}});
+        cm.setCookieLimit(10);
+        cm.initialize();
+
+        cm.purgeStaleCookies("_test_");
+        
+        final Cookie[] respCookies = response.getCookies();
+        Assert.assertEquals(respCookies.length, 2);
+        Assert.assertEquals(respCookies[0].getMaxAge(), 0);
+        Assert.assertEquals(respCookies[1].getMaxAge(), 0);
+        // The purge is in reverse order, so the first one should be "newer" and thus a later sorted name from the second.
+        Assert.assertTrue(respCookies[0].getName().compareTo(respCookies[1].getName()) > 0);
+    }
+
+    @Nonnull private String getCookieName() throws EncoderException {
+        final byte[] buf = new byte[16];
+        random.nextBytes(buf);
+
+        final Instant now = Instant.now();
+        
+        return "_test_" + now.toEpochMilli() + '_' + StringUtils.newStringUsAscii(encoder.encode(buf));
+    }
+
+}
\ 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