You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@directory.apache.org by bd...@apache.org on 2026/05/31 02:06:46 UTC

(directory-scimple) branch develop updated: Enforce ServerConfiguration bulk and filter limits in the request path

This is an automated email from the ASF dual-hosted git repository.

bdemers pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/directory-scimple.git


The following commit(s) were added to refs/heads/develop by this push:
     new e14b79ef Enforce ServerConfiguration bulk and filter limits in the request path
e14b79ef is described below

commit e14b79efbe7aaf2704906407864d30d78d96cc88
Author: Brian Demers <bd...@apache.org>
AuthorDate: Sat May 30 21:53:52 2026 -0400

    Enforce ServerConfiguration bulk and filter limits in the request path
    
    The bulkMaxOperations, bulkMaxPayloadSize, and filterMaxResults values from
    ServerConfiguration were advertised in /ServiceProviderConfig but never applied
    to incoming requests. They are now enforced:
    
    - bulkMaxOperations: BulkResourceImpl.doBulk rejects a request that contains
      more operations than the configured limit with HTTP 413 before any operation
      runs (and before the dependency graph is built).
    
    - bulkMaxPayloadSize: BulkPayloadSizeFilter, bound to POST /Bulk by
      BulkPayloadSizeDynamicFeature, wraps the request entity stream in a
      LimitingInputStream that counts bytes as the parser reads them and raises
      BulkPayloadTooLargeException once the limit is exceeded, without buffering the
      body. The default is raised from 1024 to 2097152 bytes.
    
    - filterMaxResults: BaseResourceTypeResourceImpl.find clamps the page request
      count to the configured ceiling before querying the repository, covering both
      the GET query and POST .search paths. totalResults still reports the real
      match count.
    
    413 responses carry scimType "tooMany" and state the maximum, per RFC 7644
    section 3.7.4. The new providers are registered in ScimResourceHelper, and unit
    tests cover each limit, the streaming payload check, and the error mapper.
---
 .../server/configuration/ServerConfiguration.java  |  11 +-
 .../exception/BulkPayloadTooLargeException.java    |  47 +++++
 .../BulkPayloadTooLargeExceptionMapper.java        |  45 +++++
 .../server/rest/BaseResourceTypeResourceImpl.java  |  48 ++++-
 .../server/rest/BulkPayloadSizeDynamicFeature.java |  62 ++++++
 .../scim/server/rest/BulkPayloadSizeFilter.java    |  56 ++++++
 .../scim/server/rest/BulkResourceImpl.java         |  24 ++-
 .../scim/server/rest/GroupResourceImpl.java        |   7 +-
 .../scim/server/rest/LimitingInputStream.java      |  68 +++++++
 .../scim/server/rest/ScimResourceHelper.java       |  12 +-
 .../directory/scim/server/rest/ScimpleFeature.java |   2 +-
 .../scim/server/rest/UserResourceImpl.java         |   7 +-
 .../BulkPayloadTooLargeExceptionMapperTest.java    |  49 +++++
 .../rest/BaseResourceTypeResourceImplTest.java     | 209 +++++++++++++++++++++
 .../server/rest/BulkPayloadSizeFilterTest.java     | 142 ++++++++++++++
 .../scim/server/rest/BulkResourceImplTest.java     | 114 +++++++++++
 16 files changed, 887 insertions(+), 16 deletions(-)

diff --git a/scim-server/src/main/java/org/apache/directory/scim/server/configuration/ServerConfiguration.java b/scim-server/src/main/java/org/apache/directory/scim/server/configuration/ServerConfiguration.java
index bb05b672..d9802c5f 100644
--- a/scim-server/src/main/java/org/apache/directory/scim/server/configuration/ServerConfiguration.java
+++ b/scim-server/src/main/java/org/apache/directory/scim/server/configuration/ServerConfiguration.java
@@ -37,17 +37,18 @@ import org.apache.directory.scim.spec.schema.ServiceProviderConfiguration.Suppor
 public class ServerConfiguration {
   
   static final int BULK_MAXIMUM_OPERATIONS = 100;
-  static final int BULK_MAXIMUM_PAYLOAD_SIZE = 1024;
-  
+  // Maximum bulk request payload in bytes (2 MB); see RFC 7644 §3.7.
+  static final int BULK_MAXIMUM_PAYLOAD_SIZE = 2_097_152;
+
   static final int FILTER_MAXIMUM_RESULTS = 100;
 
   String id = "spc";
-  
+
   boolean supportsChangePassword = false;
-  
+
   boolean supportsBulk = true;
   int bulkMaxOperations = BULK_MAXIMUM_OPERATIONS;
-  int bulkMaxPayloadSize = BULK_MAXIMUM_PAYLOAD_SIZE;  //TODO what should this be?
+  int bulkMaxPayloadSize = BULK_MAXIMUM_PAYLOAD_SIZE;
   
   boolean supportsETag = true;
   
diff --git a/scim-server/src/main/java/org/apache/directory/scim/server/exception/BulkPayloadTooLargeException.java b/scim-server/src/main/java/org/apache/directory/scim/server/exception/BulkPayloadTooLargeException.java
new file mode 100644
index 00000000..44fab17a
--- /dev/null
+++ b/scim-server/src/main/java/org/apache/directory/scim/server/exception/BulkPayloadTooLargeException.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.directory.scim.server.exception;
+
+/**
+ * Thrown while streaming a {@code /Bulk} request body when the number of bytes
+ * read exceeds the configured {@code bulkMaxPayloadSize}. It is raised lazily
+ * from the request entity stream as the body is parsed (so the whole payload is
+ * never buffered) and mapped to an HTTP 413 response by
+ * {@code BulkPayloadTooLargeExceptionMapper} per RFC 7644 §3.7.4.
+ */
+public class BulkPayloadTooLargeException extends RuntimeException {
+
+  private final long maxPayloadSize;
+
+  /**
+   * @param maxPayloadSize the configured maximum payload size, in bytes, that was exceeded
+   */
+  public BulkPayloadTooLargeException(long maxPayloadSize) {
+    super("Bulk request payload exceeds the maximum allowed size of " + maxPayloadSize + " bytes");
+    this.maxPayloadSize = maxPayloadSize;
+  }
+
+  /**
+   * @return the configured maximum payload size, in bytes
+   */
+  public long getMaxPayloadSize() {
+    return maxPayloadSize;
+  }
+}
diff --git a/scim-server/src/main/java/org/apache/directory/scim/server/exception/BulkPayloadTooLargeExceptionMapper.java b/scim-server/src/main/java/org/apache/directory/scim/server/exception/BulkPayloadTooLargeExceptionMapper.java
new file mode 100644
index 00000000..8161b348
--- /dev/null
+++ b/scim-server/src/main/java/org/apache/directory/scim/server/exception/BulkPayloadTooLargeExceptionMapper.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.directory.scim.server.exception;
+
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response.Status;
+import jakarta.ws.rs.ext.Provider;
+import org.apache.directory.scim.protocol.Constants;
+import org.apache.directory.scim.protocol.ErrorMessageType;
+import org.apache.directory.scim.protocol.data.ErrorResponse;
+
+/**
+ * Maps {@link BulkPayloadTooLargeException} (raised while streaming an
+ * oversized {@code /Bulk} body) to an HTTP 413 response carrying a SCIM
+ * {@code tooMany} error that states the maximum payload size, per
+ * RFC 7644 §3.7.4.
+ */
+@Provider
+@Produces({Constants.SCIM_CONTENT_TYPE, MediaType.APPLICATION_JSON})
+public class BulkPayloadTooLargeExceptionMapper extends BaseScimExceptionMapper<BulkPayloadTooLargeException> {
+
+  @Override
+  protected ErrorResponse errorResponse(BulkPayloadTooLargeException exception) {
+    return new ErrorResponse(Status.REQUEST_ENTITY_TOO_LARGE, exception.getMessage())
+      .setScimType(ErrorMessageType.TOO_MANY);
+  }
+}
diff --git a/scim-server/src/main/java/org/apache/directory/scim/server/rest/BaseResourceTypeResourceImpl.java b/scim-server/src/main/java/org/apache/directory/scim/server/rest/BaseResourceTypeResourceImpl.java
index 3b4d9796..e26596b8 100644
--- a/scim-server/src/main/java/org/apache/directory/scim/server/rest/BaseResourceTypeResourceImpl.java
+++ b/scim-server/src/main/java/org/apache/directory/scim/server/rest/BaseResourceTypeResourceImpl.java
@@ -51,6 +51,8 @@ import org.apache.directory.scim.core.repository.extensions.ProcessingExtension;
 import org.apache.directory.scim.core.repository.extensions.ClientFilterException;
 import org.apache.directory.scim.protocol.adapter.FilterWrapper;
 import org.apache.directory.scim.protocol.BaseResourceTypeResource;
+import org.apache.directory.scim.server.configuration.ServerConfiguration;
+import org.apache.directory.scim.spec.filter.PageRequest;
 import org.apache.directory.scim.spec.filter.attribute.AttributeReference;
 import org.apache.directory.scim.spec.filter.attribute.AttributeReferenceListWrapper;
 import org.apache.directory.scim.protocol.data.ListResponse;
@@ -76,6 +78,8 @@ public abstract class BaseResourceTypeResourceImpl<T extends ScimResource> imple
 
   private final Class<T> resourceClass;
 
+  private final ServerConfiguration serverConfiguration;
+
   // TODO: Field injection of UriInfo, Request should work with all implementations
   // CDI can be used directly in Jakarta WS 4
   @Context
@@ -88,10 +92,23 @@ public abstract class BaseResourceTypeResourceImpl<T extends ScimResource> imple
   HttpHeaders headers;
 
   public BaseResourceTypeResourceImpl(SchemaRegistry schemaRegistry, RepositoryRegistry repositoryRegistry, Class<T> resourceClass) {
+    this(schemaRegistry, repositoryRegistry, resourceClass, new ServerConfiguration());
+  }
+
+  /**
+   * Creates a new instance with an explicit {@link ServerConfiguration}.
+   *
+   * @param schemaRegistry      the schema registry
+   * @param repositoryRegistry  the repository registry
+   * @param resourceClass       the SCIM resource class managed by this endpoint
+   * @param serverConfiguration the server configuration, used to enforce limits
+   */
+  public BaseResourceTypeResourceImpl(SchemaRegistry schemaRegistry, RepositoryRegistry repositoryRegistry, Class<T> resourceClass, ServerConfiguration serverConfiguration) {
     this.schemaRegistry = schemaRegistry;
     this.repositoryRegistry = repositoryRegistry;
     this.resourceClass = resourceClass;
     this.attributeUtil = new AttributeUtil(schemaRegistry);
+    this.serverConfiguration = serverConfiguration;
   }
 
   public Repository<T> getRepository() {
@@ -221,7 +238,8 @@ public abstract class BaseResourceTypeResourceImpl<T extends ScimResource> imple
     excludedAttributeReferences = qualifyReferences(excludedAttributeReferences);
 
     Filter filter = request.getFilter();
-    ScimRequestContext requestContext = new ScimRequestContext(attributeReferences, excludedAttributeReferences, request.getPageRequest(),  request.getSortRequest(), null);
+    PageRequest pageRequest = clampPageRequest(request.getPageRequest());
+    ScimRequestContext requestContext = new ScimRequestContext(attributeReferences, excludedAttributeReferences, pageRequest, request.getSortRequest(), null);
 
     ListResponse<T> listResponse = new ListResponse<>();
 
@@ -431,6 +449,34 @@ public abstract class BaseResourceTypeResourceImpl<T extends ScimResource> imple
       .collect(Collectors.toUnmodifiableSet());
   }
 
+  /**
+   * Returns a {@link PageRequest} whose {@code count} is capped at
+   * {@link ServerConfiguration#getFilterMaxResults()} when that ceiling is
+   * positive (i.e. enabled). A null or negative {@code count} is treated as
+   * "no limit / invalid" and is replaced by the ceiling. A count of 0 is
+   * preserved (RFC 7644: zero means return no resources, only totalResults).
+   * If {@code original} is null it is returned as-is.
+   */
+  private PageRequest clampPageRequest(PageRequest original) {
+    if (original == null) {
+      return null;
+    }
+    int ceiling = serverConfiguration.getFilterMaxResults();
+    if (ceiling <= 0) {
+      return original;
+    }
+    Integer count = original.getCount();
+    if (count != null && count == 0) {
+      return original;
+    }
+    if (count == null || count < 0 || count > ceiling) {
+      return new PageRequest()
+        .setStartIndex(original.getStartIndex())
+        .setCount(ceiling);
+    }
+    return original;
+  }
+
   @FunctionalInterface
   private interface UpdateFunction<T extends ScimResource> {
     T update(ScimRequestContext requestContext, Repository<T> repository) throws ResourceException;
diff --git a/scim-server/src/main/java/org/apache/directory/scim/server/rest/BulkPayloadSizeDynamicFeature.java b/scim-server/src/main/java/org/apache/directory/scim/server/rest/BulkPayloadSizeDynamicFeature.java
new file mode 100644
index 00000000..bbb05e2f
--- /dev/null
+++ b/scim-server/src/main/java/org/apache/directory/scim/server/rest/BulkPayloadSizeDynamicFeature.java
@@ -0,0 +1,62 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you 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 org.apache.directory.scim.server.rest;
+
+import jakarta.inject.Inject;
+import jakarta.ws.rs.container.DynamicFeature;
+import jakarta.ws.rs.container.ResourceInfo;
+import jakarta.ws.rs.core.FeatureContext;
+import jakarta.ws.rs.ext.Provider;
+
+import org.apache.directory.scim.server.configuration.ServerConfiguration;
+
+/**
+ * A JAX-RS {@link DynamicFeature} that registers {@link BulkPayloadSizeFilter}
+ * only for the {@code BulkResourceImpl#doBulk} endpoint.
+ *
+ * <p>Keeping the filter off all other routes ensures that the bounded-read
+ * logic does not interfere with ordinary resource payloads.</p>
+ */
+@Provider
+public class BulkPayloadSizeDynamicFeature implements DynamicFeature {
+
+  private final ServerConfiguration serverConfiguration;
+
+  @Inject
+  public BulkPayloadSizeDynamicFeature(ServerConfiguration serverConfiguration) {
+    this.serverConfiguration = serverConfiguration;
+  }
+
+  /** No-arg constructor for CDI proxying. */
+  public BulkPayloadSizeDynamicFeature() {
+    this(null);
+  }
+
+  @Override
+  public void configure(ResourceInfo resourceInfo, FeatureContext context) {
+    if (BulkResourceImpl.class.equals(resourceInfo.getResourceClass())
+        && "doBulk".equals(resourceInfo.getResourceMethod().getName())) {
+      ServerConfiguration effective = serverConfiguration != null
+          ? serverConfiguration
+          : new ServerConfiguration();
+      context.register(new BulkPayloadSizeFilter(effective));
+    }
+  }
+}
diff --git a/scim-server/src/main/java/org/apache/directory/scim/server/rest/BulkPayloadSizeFilter.java b/scim-server/src/main/java/org/apache/directory/scim/server/rest/BulkPayloadSizeFilter.java
new file mode 100644
index 00000000..ec59c8a8
--- /dev/null
+++ b/scim-server/src/main/java/org/apache/directory/scim/server/rest/BulkPayloadSizeFilter.java
@@ -0,0 +1,56 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you 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 org.apache.directory.scim.server.rest;
+
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ContainerRequestFilter;
+
+import org.apache.directory.scim.server.configuration.ServerConfiguration;
+
+/**
+ * A JAX-RS {@link ContainerRequestFilter} that enforces the
+ * {@code bulkMaxPayloadSize} limit from {@link ServerConfiguration}.
+ *
+ * <p>The filter wraps the request entity stream in a {@link LimitingInputStream},
+ * which counts bytes as the entity parser reads them. When the running total
+ * exceeds the configured limit, a {@code BulkPayloadTooLargeException} is thrown
+ * and mapped to HTTP 413 (RFC 7644 §3.7.4). A limit of {@code 0} or less disables
+ * the check.</p>
+ *
+ * <p>The filter is registered only for the {@code POST /Bulk} endpoint by
+ * {@link BulkPayloadSizeDynamicFeature}.</p>
+ */
+class BulkPayloadSizeFilter implements ContainerRequestFilter {
+
+  private final ServerConfiguration serverConfiguration;
+
+  BulkPayloadSizeFilter(ServerConfiguration serverConfiguration) {
+    this.serverConfiguration = serverConfiguration;
+  }
+
+  @Override
+  public void filter(ContainerRequestContext ctx) {
+    long limit = serverConfiguration.getBulkMaxPayloadSize();
+    if (limit <= 0) {
+      return;
+    }
+    ctx.setEntityStream(new LimitingInputStream(ctx.getEntityStream(), limit));
+  }
+}
diff --git a/scim-server/src/main/java/org/apache/directory/scim/server/rest/BulkResourceImpl.java b/scim-server/src/main/java/org/apache/directory/scim/server/rest/BulkResourceImpl.java
index 4f5ceb03..fa8e9fbd 100644
--- a/scim-server/src/main/java/org/apache/directory/scim/server/rest/BulkResourceImpl.java
+++ b/scim-server/src/main/java/org/apache/directory/scim/server/rest/BulkResourceImpl.java
@@ -43,6 +43,7 @@ import org.apache.directory.scim.protocol.data.BulkOperation.Method;
 import org.apache.directory.scim.protocol.data.BulkOperation.StatusWrapper;
 import org.apache.directory.scim.protocol.data.BulkRequest;
 import org.apache.directory.scim.protocol.data.BulkResponse;
+import org.apache.directory.scim.protocol.ErrorMessageType;
 import org.apache.directory.scim.protocol.data.ErrorResponse;
 import org.apache.directory.scim.spec.resources.BaseResource;
 import org.apache.directory.scim.spec.resources.ScimResource;
@@ -51,6 +52,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import org.apache.directory.scim.core.schema.SchemaRegistry;
+import org.apache.directory.scim.server.configuration.ServerConfiguration;
 
 @ApplicationScoped
 public class BulkResourceImpl implements BulkResource {
@@ -96,19 +98,37 @@ public class BulkResourceImpl implements BulkResource {
 
   private final RepositoryRegistry repositoryRegistry;
 
+  private final ServerConfiguration serverConfiguration;
+
   @Inject
-  public BulkResourceImpl(SchemaRegistry schemaRegistry, RepositoryRegistry repositoryRegistry) {
+  public BulkResourceImpl(SchemaRegistry schemaRegistry, RepositoryRegistry repositoryRegistry, ServerConfiguration serverConfiguration) {
     this.schemaRegistry = schemaRegistry;
     this.repositoryRegistry = repositoryRegistry;
+    this.serverConfiguration = serverConfiguration;
   }
 
   public BulkResourceImpl() {
     // CDI
-    this(null, null);
+    this(null, null, null);
+  }
+
+  /** Package-visible constructor for tests that don't need limit enforcement. */
+  BulkResourceImpl(SchemaRegistry schemaRegistry, RepositoryRegistry repositoryRegistry) {
+    this(schemaRegistry, repositoryRegistry, new ServerConfiguration());
   }
 
   @Override
   public Response doBulk(BulkRequest request, UriInfo uriInfo) {
+    int maxOperations = serverConfiguration.getBulkMaxOperations();
+    if (maxOperations > 0 && request.getOperations().size() > maxOperations) {
+      // RFC 7644 §3.7.4: return 413 with scimType "tooMany", stating the maximum.
+      ErrorResponse error = new ErrorResponse(
+        Status.REQUEST_ENTITY_TOO_LARGE,
+        "Bulk request exceeds the maximum allowed number of operations (" + maxOperations + ")");
+      error.setScimType(ErrorMessageType.TOO_MANY);
+      return Response.status(Status.REQUEST_ENTITY_TOO_LARGE).entity(error).build();
+    }
+
     BulkResponse response;
     int errorCount = 0;
     Integer requestFailOnErrors = request.getFailOnErrors();
diff --git a/scim-server/src/main/java/org/apache/directory/scim/server/rest/GroupResourceImpl.java b/scim-server/src/main/java/org/apache/directory/scim/server/rest/GroupResourceImpl.java
index b7e5bf07..779bd93a 100644
--- a/scim-server/src/main/java/org/apache/directory/scim/server/rest/GroupResourceImpl.java
+++ b/scim-server/src/main/java/org/apache/directory/scim/server/rest/GroupResourceImpl.java
@@ -23,6 +23,7 @@ import jakarta.enterprise.context.ApplicationScoped;
 import jakarta.inject.Inject;
 import org.apache.directory.scim.core.repository.RepositoryRegistry;
 import org.apache.directory.scim.protocol.GroupResource;
+import org.apache.directory.scim.server.configuration.ServerConfiguration;
 import org.apache.directory.scim.spec.resources.ScimGroup;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -34,12 +35,12 @@ public class GroupResourceImpl extends BaseResourceTypeResourceImpl<ScimGroup> i
     private static final Logger log = LoggerFactory.getLogger(GroupResourceImpl.class);
 
   @Inject
-  public GroupResourceImpl(SchemaRegistry schemaRegistry, RepositoryRegistry repositoryRegistry) {
-    super(schemaRegistry, repositoryRegistry, ScimGroup.class);
+  public GroupResourceImpl(SchemaRegistry schemaRegistry, RepositoryRegistry repositoryRegistry, ServerConfiguration serverConfiguration) {
+    super(schemaRegistry, repositoryRegistry, ScimGroup.class, serverConfiguration);
   }
 
   public GroupResourceImpl() {
     // CDI
-    this(null, null);
+    this(null, null, new ServerConfiguration());
   }
 }
diff --git a/scim-server/src/main/java/org/apache/directory/scim/server/rest/LimitingInputStream.java b/scim-server/src/main/java/org/apache/directory/scim/server/rest/LimitingInputStream.java
new file mode 100644
index 00000000..23f4ebd3
--- /dev/null
+++ b/scim-server/src/main/java/org/apache/directory/scim/server/rest/LimitingInputStream.java
@@ -0,0 +1,68 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you 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 org.apache.directory.scim.server.rest;
+
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.apache.directory.scim.server.exception.BulkPayloadTooLargeException;
+
+/**
+ * An {@link InputStream} wrapper that counts the bytes read from the underlying
+ * stream and throws {@link BulkPayloadTooLargeException} as soon as the running
+ * total exceeds {@code limit}. Exactly {@code limit} bytes are permitted; the
+ * {@code limit+1}-th byte triggers the exception.
+ *
+ * <p>Because it counts as the consumer reads, the request body is never fully
+ * buffered: it streams straight into the entity parser and is rejected the
+ * moment it crosses the configured ceiling.</p>
+ */
+class LimitingInputStream extends FilterInputStream {
+
+  private final long limit;
+  private long count;
+
+  LimitingInputStream(InputStream in, long limit) {
+    super(in);
+    this.limit = limit;
+  }
+
+  @Override
+  public int read() throws IOException {
+    int b = in.read();
+    if (b != -1 && ++count > limit) {
+      throw new BulkPayloadTooLargeException(limit);
+    }
+    return b;
+  }
+
+  @Override
+  public int read(byte[] b, int off, int len) throws IOException {
+    int read = in.read(b, off, len);
+    if (read > 0) {
+      count += read;
+      if (count > limit) {
+        throw new BulkPayloadTooLargeException(limit);
+      }
+    }
+    return read;
+  }
+}
diff --git a/scim-server/src/main/java/org/apache/directory/scim/server/rest/ScimResourceHelper.java b/scim-server/src/main/java/org/apache/directory/scim/server/rest/ScimResourceHelper.java
index 4a5aef01..627ff3c7 100644
--- a/scim-server/src/main/java/org/apache/directory/scim/server/rest/ScimResourceHelper.java
+++ b/scim-server/src/main/java/org/apache/directory/scim/server/rest/ScimResourceHelper.java
@@ -55,16 +55,26 @@ public final class ScimResourceHelper {
     WebApplicationExceptionMapper.class,
     UnsupportedOperationExceptionMapper.class,
     MutabilityExceptionMapper.class,
+    BulkPayloadTooLargeExceptionMapper.class,
     GenericExceptionMapper.class);
 
   static final Set<Class<?>> MEDIA_TYPE_SUPPORT_CLASSES = Set.of(
     ScimJacksonXmlBindJsonProvider.class
   );
 
+  /**
+   * JAX-RS {@code DynamicFeature}s registered by {@link ScimpleFeature}. These are
+   * not resources or exception mappers; they bind request/response filters to
+   * specific endpoints (e.g. the bulk payload-size guard on {@code POST /Bulk}).
+   */
+  static final Set<Class<?>> FEATURE_CLASSES = Set.of(
+    BulkPayloadSizeDynamicFeature.class);
+
   static final Set<Class<?>> SCIMPLE_CLASSES = Stream.of(
       RESOURCE_CLASSES,
       EXCEPTION_MAPPER_CLASSES,
-      MEDIA_TYPE_SUPPORT_CLASSES)
+      MEDIA_TYPE_SUPPORT_CLASSES,
+      FEATURE_CLASSES)
       .flatMap(Collection::stream)
       .collect(Collectors.toUnmodifiableSet());
 
diff --git a/scim-server/src/main/java/org/apache/directory/scim/server/rest/ScimpleFeature.java b/scim-server/src/main/java/org/apache/directory/scim/server/rest/ScimpleFeature.java
index 3828d72b..3ab6dafa 100644
--- a/scim-server/src/main/java/org/apache/directory/scim/server/rest/ScimpleFeature.java
+++ b/scim-server/src/main/java/org/apache/directory/scim/server/rest/ScimpleFeature.java
@@ -33,7 +33,7 @@ public class ScimpleFeature implements Feature {
 
   @Override
   public boolean configure(FeatureContext context) {
-    Stream.of(EXCEPTION_MAPPER_CLASSES, MEDIA_TYPE_SUPPORT_CLASSES)
+    Stream.of(EXCEPTION_MAPPER_CLASSES, MEDIA_TYPE_SUPPORT_CLASSES, FEATURE_CLASSES)
       .flatMap(Collection::stream)
       .forEach(context::register);
     return true;
diff --git a/scim-server/src/main/java/org/apache/directory/scim/server/rest/UserResourceImpl.java b/scim-server/src/main/java/org/apache/directory/scim/server/rest/UserResourceImpl.java
index a3e8116e..2256220c 100644
--- a/scim-server/src/main/java/org/apache/directory/scim/server/rest/UserResourceImpl.java
+++ b/scim-server/src/main/java/org/apache/directory/scim/server/rest/UserResourceImpl.java
@@ -23,6 +23,7 @@ import jakarta.enterprise.context.ApplicationScoped;
 import jakarta.inject.Inject;
 import org.apache.directory.scim.core.repository.RepositoryRegistry;
 import org.apache.directory.scim.protocol.UserResource;
+import org.apache.directory.scim.server.configuration.ServerConfiguration;
 import org.apache.directory.scim.spec.resources.ScimUser;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -38,12 +39,12 @@ public class UserResourceImpl extends BaseResourceTypeResourceImpl<ScimUser> imp
     private static final Logger log = LoggerFactory.getLogger(UserResourceImpl.class);
 
   @Inject
-  public UserResourceImpl(SchemaRegistry schemaRegistry, RepositoryRegistry repositoryRegistry) {
-    super(schemaRegistry, repositoryRegistry, ScimUser.class);
+  public UserResourceImpl(SchemaRegistry schemaRegistry, RepositoryRegistry repositoryRegistry, ServerConfiguration serverConfiguration) {
+    super(schemaRegistry, repositoryRegistry, ScimUser.class, serverConfiguration);
   }
 
   public UserResourceImpl() {
     // CDI
-    this(null, null);
+    this(null, null, new ServerConfiguration());
   }
 }
diff --git a/scim-server/src/test/java/org/apache/directory/scim/server/exception/BulkPayloadTooLargeExceptionMapperTest.java b/scim-server/src/test/java/org/apache/directory/scim/server/exception/BulkPayloadTooLargeExceptionMapperTest.java
new file mode 100644
index 00000000..f798935e
--- /dev/null
+++ b/scim-server/src/test/java/org/apache/directory/scim/server/exception/BulkPayloadTooLargeExceptionMapperTest.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.directory.scim.server.exception;
+
+import jakarta.ws.rs.core.Response;
+
+import org.apache.directory.scim.protocol.ErrorMessageType;
+import org.apache.directory.scim.protocol.data.ErrorResponse;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class BulkPayloadTooLargeExceptionMapperTest {
+
+  /**
+   * RFC 7644 §3.7.4: an exceeded payload yields HTTP 413, scimType "tooMany", and a
+   * detail that states the maximum payload size.
+   */
+  @Test
+  public void mapsToHttp413_withTooMany_andMaxSizeInDetail() {
+    BulkPayloadTooLargeExceptionMapper mapper = new BulkPayloadTooLargeExceptionMapper();
+
+    Response response = mapper.toResponse(new BulkPayloadTooLargeException(2_097_152));
+
+    assertThat(response.getStatus())
+      .isEqualTo(Response.Status.REQUEST_ENTITY_TOO_LARGE.getStatusCode());
+    assertThat(response.getEntity()).isInstanceOf(ErrorResponse.class);
+    ErrorResponse error = (ErrorResponse) response.getEntity();
+    assertThat(error.getScimType()).isEqualTo(ErrorMessageType.TOO_MANY);
+    assertThat(error.getDetail()).contains("2097152");
+  }
+}
diff --git a/scim-server/src/test/java/org/apache/directory/scim/server/rest/BaseResourceTypeResourceImplTest.java b/scim-server/src/test/java/org/apache/directory/scim/server/rest/BaseResourceTypeResourceImplTest.java
index 5e95ff9a..f3c5fc6d 100644
--- a/scim-server/src/test/java/org/apache/directory/scim/server/rest/BaseResourceTypeResourceImplTest.java
+++ b/scim-server/src/test/java/org/apache/directory/scim/server/rest/BaseResourceTypeResourceImplTest.java
@@ -19,6 +19,7 @@
 
 package org.apache.directory.scim.server.rest;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.*;
 import static org.junit.jupiter.api.Assertions.*;
@@ -33,10 +34,17 @@ import jakarta.ws.rs.core.Response;
 import jakarta.ws.rs.core.Response.Status;
 
 import jakarta.ws.rs.core.UriInfo;
+import org.apache.directory.scim.core.repository.RepositoryRegistry;
+import org.apache.directory.scim.core.repository.ScimRequestContext;
+import org.apache.directory.scim.core.schema.SchemaRegistry;
 import org.apache.directory.scim.protocol.exception.ScimException;
+import org.apache.directory.scim.server.configuration.ServerConfiguration;
 import org.apache.directory.scim.spec.exception.ResourceException;
+import org.apache.directory.scim.spec.filter.FilterResponse;
+import org.apache.directory.scim.spec.filter.PageRequest;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 
 import org.apache.directory.scim.core.repository.Repository;
@@ -212,6 +220,207 @@ public class BaseResourceTypeResourceImplTest {
     assertThat(exception.getError().getDetail(), is("Provider not defined"));
   }
 
+  // ---------------------------------------------------------------------------
+  // filterMaxResults clamping tests
+  // ---------------------------------------------------------------------------
+
+  /** Builds a UserResourceImpl with the given ceiling and a mock repository. */
+  @SuppressWarnings("unchecked")
+  private UserResourceImpl buildUserImplWithCeiling(int ceiling, Repository<ScimUser> userRepository) throws Exception {
+    SchemaRegistry schemaRegistry = new SchemaRegistry();
+    RepositoryRegistry repositoryRegistry = new RepositoryRegistry(schemaRegistry);
+    when(userRepository.getExtensionList()).thenReturn(Collections.emptyList());
+    repositoryRegistry.registerRepository(ScimUser.class, userRepository);
+
+    ServerConfiguration config = new ServerConfiguration();
+    config.setFilterMaxResults(ceiling);
+
+    return new UserResourceImpl(schemaRegistry, repositoryRegistry, config);
+  }
+
+  /** Returns a FilterResponse with one dummy user and the given totalResults. */
+  private FilterResponse<ScimUser> singleUserResponse(int totalResults) {
+    ScimUser user = new ScimUser();
+    user.setId("test-id");
+    user.setUserName("test-user");
+    return new FilterResponse<>(List.of(user), totalResults);
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testFind_clampViaGET() throws Exception {
+    // GET with count=200, ceiling=50 -> captured PageRequest.count == 50
+    Repository<ScimUser> userRepository = mock(Repository.class);
+    UserResourceImpl impl = buildUserImplWithCeiling(50, userRepository);
+
+    ArgumentCaptor<ScimRequestContext> ctxCaptor = ArgumentCaptor.forClass(ScimRequestContext.class);
+    when(userRepository.find(any(), ctxCaptor.capture())).thenReturn(singleUserResponse(1));
+
+    SearchRequest searchRequest = new SearchRequest();
+    searchRequest.setAttributes(Collections.emptySet());
+    searchRequest.setExcludedAttributes(Collections.emptySet());
+    searchRequest.setStartIndex(1);
+    searchRequest.setCount(200);
+
+    Response response = impl.find(searchRequest);
+
+    assertThat(response.getStatus()).isEqualTo(Status.OK.getStatusCode());
+    ScimRequestContext captured = ctxCaptor.getValue();
+    assertThat(captured.getPageRequest())
+      .isPresent()
+      .get()
+      .extracting(PageRequest::getCount)
+      .isEqualTo(50);
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testFind_clampViaSearchPost() throws Exception {
+    // POST .search with count=200, ceiling=50 -> clamped
+    Repository<ScimUser> userRepository = mock(Repository.class);
+    UserResourceImpl impl = buildUserImplWithCeiling(50, userRepository);
+
+    ArgumentCaptor<ScimRequestContext> ctxCaptor = ArgumentCaptor.forClass(ScimRequestContext.class);
+    when(userRepository.find(any(), ctxCaptor.capture())).thenReturn(singleUserResponse(1));
+
+    SearchRequest searchRequest = new SearchRequest();
+    searchRequest.setAttributes(Collections.emptySet());
+    searchRequest.setExcludedAttributes(Collections.emptySet());
+    searchRequest.setCount(200);
+
+    impl.find(searchRequest);
+
+    PageRequest captured = ctxCaptor.getValue().getPageRequest().orElseThrow();
+    assertThat(captured.getCount()).isEqualTo(50);
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testFind_nonDefaultServerConfigRead() throws Exception {
+    // count=200, ceiling=50 -> captured count==50
+    Repository<ScimUser> userRepository = mock(Repository.class);
+    UserResourceImpl impl = buildUserImplWithCeiling(50, userRepository);
+
+    ArgumentCaptor<ScimRequestContext> ctxCaptor = ArgumentCaptor.forClass(ScimRequestContext.class);
+    when(userRepository.find(any(), ctxCaptor.capture())).thenReturn(singleUserResponse(200));
+
+    SearchRequest searchRequest = new SearchRequest();
+    searchRequest.setAttributes(Collections.emptySet());
+    searchRequest.setExcludedAttributes(Collections.emptySet());
+    searchRequest.setCount(200);
+
+    Response response = impl.find(searchRequest);
+
+    assertThat(response.getStatus()).isEqualTo(Status.OK.getStatusCode());
+    PageRequest captured = ctxCaptor.getValue().getPageRequest().orElseThrow();
+    assertThat(captured.getCount()).isEqualTo(50);
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testFind_countBelowLimitUnchanged() throws Exception {
+    // count=10, ceiling=50 -> captured count==10 (unchanged)
+    Repository<ScimUser> userRepository = mock(Repository.class);
+    UserResourceImpl impl = buildUserImplWithCeiling(50, userRepository);
+
+    ArgumentCaptor<ScimRequestContext> ctxCaptor = ArgumentCaptor.forClass(ScimRequestContext.class);
+    when(userRepository.find(any(), ctxCaptor.capture())).thenReturn(singleUserResponse(10));
+
+    SearchRequest searchRequest = new SearchRequest();
+    searchRequest.setAttributes(Collections.emptySet());
+    searchRequest.setExcludedAttributes(Collections.emptySet());
+    searchRequest.setCount(10);
+
+    impl.find(searchRequest);
+
+    PageRequest captured = ctxCaptor.getValue().getPageRequest().orElseThrow();
+    assertThat(captured.getCount()).isEqualTo(10);
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testFind_nullCountClamped() throws Exception {
+    // count=null (not specified), ceiling=50 -> captured count==50
+    Repository<ScimUser> userRepository = mock(Repository.class);
+    UserResourceImpl impl = buildUserImplWithCeiling(50, userRepository);
+
+    ArgumentCaptor<ScimRequestContext> ctxCaptor = ArgumentCaptor.forClass(ScimRequestContext.class);
+    when(userRepository.find(any(), ctxCaptor.capture())).thenReturn(singleUserResponse(0));
+
+    SearchRequest searchRequest = new SearchRequest();
+    searchRequest.setAttributes(Collections.emptySet());
+    searchRequest.setExcludedAttributes(Collections.emptySet());
+    // count deliberately not set (null)
+
+    impl.find(searchRequest);
+
+    PageRequest captured = ctxCaptor.getValue().getPageRequest().orElseThrow();
+    assertThat(captured.getCount()).isEqualTo(50);
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testFind_zeroCountPreserved() throws Exception {
+    // count=0 (RFC 7644: return no resources, only totalResults) must NOT be clamped
+    Repository<ScimUser> userRepository = mock(Repository.class);
+    UserResourceImpl impl = buildUserImplWithCeiling(50, userRepository);
+
+    ArgumentCaptor<ScimRequestContext> ctxCaptor = ArgumentCaptor.forClass(ScimRequestContext.class);
+    when(userRepository.find(any(), ctxCaptor.capture())).thenReturn(singleUserResponse(0));
+
+    SearchRequest searchRequest = new SearchRequest();
+    searchRequest.setAttributes(Collections.emptySet());
+    searchRequest.setExcludedAttributes(Collections.emptySet());
+    searchRequest.setCount(0);
+
+    impl.find(searchRequest);
+
+    PageRequest captured = ctxCaptor.getValue().getPageRequest().orElseThrow();
+    assertThat(captured.getCount()).isEqualTo(0);
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testFind_ceilingZeroDisablesClamp() throws Exception {
+    // filterMaxResults=0 means "disabled" — any count must pass through unchanged
+    Repository<ScimUser> userRepository = mock(Repository.class);
+    UserResourceImpl impl = buildUserImplWithCeiling(0, userRepository);
+
+    ArgumentCaptor<ScimRequestContext> ctxCaptor = ArgumentCaptor.forClass(ScimRequestContext.class);
+    when(userRepository.find(any(), ctxCaptor.capture())).thenReturn(singleUserResponse(200));
+
+    SearchRequest searchRequest = new SearchRequest();
+    searchRequest.setAttributes(Collections.emptySet());
+    searchRequest.setExcludedAttributes(Collections.emptySet());
+    searchRequest.setCount(200);
+
+    impl.find(searchRequest);
+
+    PageRequest captured = ctxCaptor.getValue().getPageRequest().orElseThrow();
+    assertThat(captured.getCount()).isEqualTo(200);
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testFind_negativeCountClamped() throws Exception {
+    // count=-1 is invalid; must be normalized to the ceiling (50), not passed through
+    Repository<ScimUser> userRepository = mock(Repository.class);
+    UserResourceImpl impl = buildUserImplWithCeiling(50, userRepository);
+
+    ArgumentCaptor<ScimRequestContext> ctxCaptor = ArgumentCaptor.forClass(ScimRequestContext.class);
+    when(userRepository.find(any(), ctxCaptor.capture())).thenReturn(singleUserResponse(0));
+
+    SearchRequest searchRequest = new SearchRequest();
+    searchRequest.setAttributes(Collections.emptySet());
+    searchRequest.setExcludedAttributes(Collections.emptySet());
+    searchRequest.setCount(-1);
+
+    impl.find(searchRequest);
+
+    PageRequest captured = ctxCaptor.getValue().getPageRequest().orElseThrow();
+    assertThat(captured.getCount()).isEqualTo(50);
+  }
+
   private ScimUser getScimUser() throws PhoneNumberParseException {
     ScimUser user = new ScimUser();
 
diff --git a/scim-server/src/test/java/org/apache/directory/scim/server/rest/BulkPayloadSizeFilterTest.java b/scim-server/src/test/java/org/apache/directory/scim/server/rest/BulkPayloadSizeFilterTest.java
new file mode 100644
index 00000000..158204ea
--- /dev/null
+++ b/scim-server/src/test/java/org/apache/directory/scim/server/rest/BulkPayloadSizeFilterTest.java
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.directory.scim.server.rest;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+import jakarta.ws.rs.container.ContainerRequestContext;
+
+import org.apache.directory.scim.server.configuration.ServerConfiguration;
+import org.apache.directory.scim.server.exception.BulkPayloadTooLargeException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class BulkPayloadSizeFilterTest {
+
+  @Mock
+  ContainerRequestContext ctx;
+
+  // -------------------------------------------------------------------------
+  // Filter: limit <= 0 -> pass-through (stream untouched, not wrapped)
+  // -------------------------------------------------------------------------
+
+  @Test
+  public void limitZero_passThrough() {
+    ServerConfiguration config = new ServerConfiguration();
+    config.setBulkMaxPayloadSize(0);
+
+    new BulkPayloadSizeFilter(config).filter(ctx);
+
+    verify(ctx, never()).getEntityStream();
+    verify(ctx, never()).setEntityStream(any());
+  }
+
+  @Test
+  public void limitNegative_passThrough() {
+    ServerConfiguration config = new ServerConfiguration();
+    config.setBulkMaxPayloadSize(-1);
+
+    new BulkPayloadSizeFilter(config).filter(ctx);
+
+    verify(ctx, never()).getEntityStream();
+    verify(ctx, never()).setEntityStream(any());
+  }
+
+  // -------------------------------------------------------------------------
+  // Filter: positive limit -> wraps the entity stream with a LimitingInputStream
+  // (no buffering); a within-limit body still streams through unchanged.
+  // -------------------------------------------------------------------------
+
+  @Test
+  public void positiveLimit_wrapsStream_andBodyStreamsThrough() throws Exception {
+    byte[] body = "small body".getBytes(StandardCharsets.UTF_8);
+    ServerConfiguration config = new ServerConfiguration();
+    config.setBulkMaxPayloadSize(1024);
+    when(ctx.getEntityStream()).thenReturn(new ByteArrayInputStream(body));
+
+    new BulkPayloadSizeFilter(config).filter(ctx);
+
+    ArgumentCaptor<InputStream> streamCaptor = ArgumentCaptor.forClass(InputStream.class);
+    verify(ctx).setEntityStream(streamCaptor.capture());
+    assertThat(streamCaptor.getValue()).isInstanceOf(LimitingInputStream.class);
+    // The wrapper is transparent for a within-limit body: the parser sees all bytes.
+    assertThat(streamCaptor.getValue().readAllBytes()).isEqualTo(body);
+  }
+
+  // -------------------------------------------------------------------------
+  // LimitingInputStream: counts as it is read and throws at limit+1, without
+  // ever buffering the whole body.
+  // -------------------------------------------------------------------------
+
+  @Test
+  public void stream_exactlyLimitBytes_readsAllWithoutThrowing() throws Exception {
+    byte[] body = "1234567890".getBytes(StandardCharsets.UTF_8); // 10 bytes
+    LimitingInputStream stream = new LimitingInputStream(new ByteArrayInputStream(body), 10);
+
+    assertThat(stream.readAllBytes()).isEqualTo(body);
+  }
+
+  @Test
+  public void stream_limitPlusOneBytes_throwsBulkPayloadTooLarge() {
+    byte[] body = "12345678901".getBytes(StandardCharsets.UTF_8); // 11 bytes = limit+1
+    LimitingInputStream stream = new LimitingInputStream(new ByteArrayInputStream(body), 10);
+
+    assertThatThrownBy(stream::readAllBytes)
+      .isInstanceOf(BulkPayloadTooLargeException.class)
+      .extracting(e -> ((BulkPayloadTooLargeException) e).getMaxPayloadSize())
+      .isEqualTo(10L);
+  }
+
+  @Test
+  public void stream_singleByteReads_throwAtLimitPlusOne() {
+    byte[] body = new byte[4]; // limit is 3
+    LimitingInputStream stream = new LimitingInputStream(new ByteArrayInputStream(body), 3);
+
+    assertThatThrownBy(() -> {
+      for (int i = 0; i < body.length; i++) {
+        stream.read();
+      }
+    }).isInstanceOf(BulkPayloadTooLargeException.class);
+  }
+
+  @Test
+  public void stream_hugeLimit_smallBody_passesThrough() throws Exception {
+    // A very large configured limit must not cause any pre-allocation; a small
+    // body simply streams through.
+    byte[] body = "hello".getBytes(StandardCharsets.UTF_8);
+    LimitingInputStream stream =
+      new LimitingInputStream(new ByteArrayInputStream(body), Integer.MAX_VALUE);
+
+    assertThat(stream.readAllBytes()).isEqualTo(body);
+  }
+}
diff --git a/scim-server/src/test/java/org/apache/directory/scim/server/rest/BulkResourceImplTest.java b/scim-server/src/test/java/org/apache/directory/scim/server/rest/BulkResourceImplTest.java
index 4d7f1cac..9bfa98df 100644
--- a/scim-server/src/test/java/org/apache/directory/scim/server/rest/BulkResourceImplTest.java
+++ b/scim-server/src/test/java/org/apache/directory/scim/server/rest/BulkResourceImplTest.java
@@ -23,9 +23,11 @@ import jakarta.enterprise.inject.Instance;
 import jakarta.ws.rs.core.Response;
 import jakarta.ws.rs.core.UriBuilder;
 import jakarta.ws.rs.core.UriInfo;
+import org.apache.directory.scim.server.configuration.ServerConfiguration;
 import org.apache.directory.scim.server.exception.UnableToCreateResourceException;
 import org.apache.directory.scim.core.repository.Repository;
 import org.apache.directory.scim.core.repository.RepositoryRegistry;
+import org.apache.directory.scim.protocol.ErrorMessageType;
 import org.apache.directory.scim.protocol.data.BulkOperation;
 import org.apache.directory.scim.protocol.data.BulkRequest;
 import org.apache.directory.scim.protocol.data.BulkResponse;
@@ -49,6 +51,8 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.atLeast;
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 public class BulkResourceImplTest {
@@ -224,4 +228,114 @@ public class BulkResourceImplTest {
         .setResponse(new ErrorResponse(Response.Status.BAD_REQUEST, "Expected Test Exception when bob is created"))
         .setStatus(new BulkOperation.StatusWrapper(Response.Status.BAD_REQUEST)));
   }
+
+  // ---------------------------------------------------------------------------
+  // bulkMaxOperations enforcement
+  // ---------------------------------------------------------------------------
+
+  @Test
+  public void testBulkMaxOperationsExceeded() throws Exception {
+    // limit=2, 3 ops -> 413; repository must not be called
+    SchemaRegistry schemaRegistry = new SchemaRegistry();
+    RepositoryRegistry repositoryRegistry = new RepositoryRegistry(schemaRegistry);
+    Repository<ScimUser> userRepository = mock(Repository.class);
+    repositoryRegistry.registerRepository(ScimUser.class, userRepository);
+
+    ServerConfiguration config = new ServerConfiguration();
+    config.setBulkMaxOperations(2);
+
+    BulkRequest bulkRequest = new BulkRequest()
+      .setOperations(List.of(
+        new BulkOperation().setMethod(BulkOperation.Method.POST).setPath("/Users").setData(new ScimUser().setUserName("a")),
+        new BulkOperation().setMethod(BulkOperation.Method.POST).setPath("/Users").setData(new ScimUser().setUserName("b")),
+        new BulkOperation().setMethod(BulkOperation.Method.POST).setPath("/Users").setData(new ScimUser().setUserName("c"))
+      ));
+
+    BulkResourceImpl impl = new BulkResourceImpl(schemaRegistry, repositoryRegistry, config);
+    UriInfo uriInfo = mock(UriInfo.class);
+
+    Response response = impl.doBulk(bulkRequest, uriInfo);
+
+    assertThat(response.getStatus()).isEqualTo(Response.Status.REQUEST_ENTITY_TOO_LARGE.getStatusCode());
+    assertThat(response.getEntity()).isInstanceOf(ErrorResponse.class);
+    // RFC 7644 §3.7.4: 413 states the maximum operation count and uses scimType "tooMany".
+    ErrorResponse error = (ErrorResponse) response.getEntity();
+    assertThat(error.getScimType()).isEqualTo(ErrorMessageType.TOO_MANY);
+    assertThat(error.getDetail()).contains("2");
+    verify(userRepository, never()).create(any(), any());
+    verify(userRepository, never()).update(any(), any(), any());
+    verify(userRepository, never()).delete(any());
+  }
+
+  @Test
+  public void testBulkMaxOperationsAtLimit() throws Exception {
+    // limit=2, 2 ops -> success
+    SchemaRegistry schemaRegistry = new SchemaRegistry();
+    RepositoryRegistry repositoryRegistry = new RepositoryRegistry(schemaRegistry);
+    Repository<ScimUser> userRepository = mock(Repository.class);
+    repositoryRegistry.registerRepository(ScimUser.class, userRepository);
+
+    ScimUser createdA = new ScimUser();
+    createdA.setId("id-a");
+    ScimUser createdB = new ScimUser();
+    createdB.setId("id-b");
+    when(userRepository.create(any(), any()))
+      .thenReturn(createdA)
+      .thenReturn(createdB);
+
+    ServerConfiguration config = new ServerConfiguration();
+    config.setBulkMaxOperations(2);
+
+    BulkRequest bulkRequest = new BulkRequest()
+      .setOperations(List.of(
+        new BulkOperation().setMethod(BulkOperation.Method.POST).setPath("/Users").setBulkId("bulk-a").setData(new ScimUser().setUserName("a")),
+        new BulkOperation().setMethod(BulkOperation.Method.POST).setPath("/Users").setBulkId("bulk-b").setData(new ScimUser().setUserName("b"))
+      ));
+
+    BulkResourceImpl impl = new BulkResourceImpl(schemaRegistry, repositoryRegistry, config);
+    UriInfo uriInfo = mock(UriInfo.class);
+    UriBuilder uriBuilder = mock(UriBuilder.class);
+    when(uriInfo.getBaseUriBuilder()).thenReturn(uriBuilder);
+    when(uriBuilder.path(any(String.class))).thenReturn(uriBuilder);
+    when(uriBuilder.build())
+      .thenReturn(URI.create("https://scim.example.com/Users/id-a"))
+      .thenReturn(URI.create("https://scim.example.com/Users/id-b"));
+
+    Response response = impl.doBulk(bulkRequest, uriInfo);
+
+    assertThat(response.getStatus()).isEqualTo(Response.Status.OK.getStatusCode());
+  }
+
+  @Test
+  public void testBulkMaxOperationsNonDefaultConfig() throws Exception {
+    // limit=5, 6 ops -> 413
+    SchemaRegistry schemaRegistry = new SchemaRegistry();
+    RepositoryRegistry repositoryRegistry = new RepositoryRegistry(schemaRegistry);
+    Repository<ScimUser> userRepository = mock(Repository.class);
+    repositoryRegistry.registerRepository(ScimUser.class, userRepository);
+
+    ServerConfiguration config = new ServerConfiguration();
+    config.setBulkMaxOperations(5);
+
+    List<BulkOperation> ops = List.of(
+      new BulkOperation().setMethod(BulkOperation.Method.POST).setPath("/Users").setData(new ScimUser().setUserName("u1")),
+      new BulkOperation().setMethod(BulkOperation.Method.POST).setPath("/Users").setData(new ScimUser().setUserName("u2")),
+      new BulkOperation().setMethod(BulkOperation.Method.POST).setPath("/Users").setData(new ScimUser().setUserName("u3")),
+      new BulkOperation().setMethod(BulkOperation.Method.POST).setPath("/Users").setData(new ScimUser().setUserName("u4")),
+      new BulkOperation().setMethod(BulkOperation.Method.POST).setPath("/Users").setData(new ScimUser().setUserName("u5")),
+      new BulkOperation().setMethod(BulkOperation.Method.POST).setPath("/Users").setData(new ScimUser().setUserName("u6"))
+    );
+
+    BulkRequest bulkRequest = new BulkRequest().setOperations(ops);
+    BulkResourceImpl impl = new BulkResourceImpl(schemaRegistry, repositoryRegistry, config);
+    UriInfo uriInfo = mock(UriInfo.class);
+
+    Response response = impl.doBulk(bulkRequest, uriInfo);
+
+    assertThat(response.getStatus()).isEqualTo(Response.Status.REQUEST_ENTITY_TOO_LARGE.getStatusCode());
+    ErrorResponse error = (ErrorResponse) response.getEntity();
+    assertThat(error.getScimType()).isEqualTo(ErrorMessageType.TOO_MANY);
+    assertThat(error.getDetail()).contains("5");
+    verify(userRepository, never()).create(any(), any());
+  }
 }