From 6ae5de66a5c9a4ce8f1ab92b427b0f65bc664908 Mon Sep 17 00:00:00 2001 From: Elliot Barlas Date: Thu, 6 Nov 2025 18:18:52 -0800 Subject: [PATCH 1/3] Add a profile size limit enforced during profile updates to avoid potential heap memory exhaustion. --- .../security/profile/ProfileService.java | 56 +++++++++++++++++-- .../security/profile/ProfileServiceTests.java | 53 ++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/profile/ProfileService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/profile/ProfileService.java index 43ffd9c90be9c..123df990a8dbd 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/profile/ProfileService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/profile/ProfileService.java @@ -112,6 +112,7 @@ public class ProfileService { private static final BackoffPolicy DEFAULT_BACKOFF = BackoffPolicy.exponentialBackoff(); private static final int DIFFERENTIATOR_UPPER_LIMIT = 9; private static final long ACTIVATE_INTERVAL_IN_MS = TimeValue.timeValueSeconds(30).millis(); + private static final int MAX_PROFILE_SIZE_IN_BYTES = 10_000_000; private final Settings settings; private final Clock clock; @@ -241,10 +242,57 @@ public void updateProfileData(UpdateProfileDataRequest request, ActionListener AcknowledgedResponse.TRUE) - ); + getVersionedDocument(request.getUid(), ActionListener.wrap(doc -> { + validateProfileSize(doc, request, MAX_PROFILE_SIZE_IN_BYTES); + + doUpdate( + buildUpdateRequest(request.getUid(), builder, request.getRefreshPolicy(), request.getIfPrimaryTerm(), request.getIfSeqNo()), + listener.map(updateResponse -> AcknowledgedResponse.TRUE) + ); + }, listener::onFailure)); + } + + static void validateProfileSize(VersionedDocument doc, UpdateProfileDataRequest request, int limit) { + if (doc == null) { + return; + } + Map labels = combineMaps(doc.doc.labels(), request.getLabels()); + Map data = combineMaps(mapFromBytesReference(doc.doc.applicationData()), request.getData()); + int actualSize = serializationSize(labels) + serializationSize(data); + if (actualSize > limit) { + throw new ElasticsearchException( + "cannot update profile [%s] because the combined profile size of [%s] bytes exceeds the maximum of [%s] bytes".formatted( + request.getUid(), + actualSize, + limit + ) + ); + } + } + + static Map combineMaps(Map src, Map update) { + Map result = new HashMap<>(); // ensure mutable outer source map for update below + if (src != null) { + result.putAll(src); + } + XContentHelper.update(result, update, false); + return result; + } + + static Map mapFromBytesReference(BytesReference bytesRef) { + if (bytesRef == null || bytesRef.length() == 0) { + return new HashMap<>(); + } + return XContentHelper.convertToMap(bytesRef, false, XContentType.JSON).v2(); + } + + static int serializationSize(Map map) { + try (XContentBuilder builder = XContentFactory.jsonBuilder()) { + builder.value(map); + return BytesReference.bytes(builder).length(); + } catch (IOException e) { + throw new ElasticsearchException("Error occurred computing serialization size", e); // I/O error should never happen here + } } public void suggestProfile(SuggestProfilesRequest request, TaskId parentTaskId, ActionListener listener) { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/profile/ProfileServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/profile/ProfileServiceTests.java index 65ef90d0b457e..790d1cdb2953f 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/profile/ProfileServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/profile/ProfileServiceTests.java @@ -31,6 +31,7 @@ import org.elasticsearch.action.search.TransportMultiSearchAction; import org.elasticsearch.action.search.TransportSearchAction; import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.action.support.WriteRequest; import org.elasticsearch.action.update.TransportUpdateAction; import org.elasticsearch.action.update.UpdateRequest; import org.elasticsearch.action.update.UpdateRequestBuilder; @@ -72,6 +73,7 @@ import org.elasticsearch.xpack.core.security.action.profile.SuggestProfilesRequest; import org.elasticsearch.xpack.core.security.action.profile.SuggestProfilesRequestTests; import org.elasticsearch.xpack.core.security.action.profile.SuggestProfilesResponse; +import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataRequest; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.AuthenticationTestHelper; import org.elasticsearch.xpack.core.security.authc.AuthenticationTests; @@ -90,7 +92,10 @@ import org.junit.Before; import org.mockito.Mockito; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.Instant; import java.util.ArrayList; @@ -1324,6 +1329,54 @@ public void testProfilesIndexMissingOrUnavailableWhenRetrievingProfilesOfApiKeyO assertThat(e.getMessage(), containsString("test unavailable")); } + public void testSerializationSize() { + assertThat(ProfileService.serializationSize(Map.of()), is(2)); + assertThat(ProfileService.serializationSize(Map.of("foo", "bar")), is(13)); + assertThrows( + IllegalArgumentException.class, + () -> ProfileService.serializationSize(Map.of("bad", new ByteArrayInputStream(new byte[0]))) + ); + } + + public void testMapFromBytesReference() { + assertThat(ProfileService.mapFromBytesReference(null), is(Map.of())); + assertThat(ProfileService.mapFromBytesReference(BytesReference.fromByteBuffer(ByteBuffer.allocate(0))), is(Map.of())); + assertThat(ProfileService.mapFromBytesReference(newBytesReference("{}")), is(Map.of())); + assertThat(ProfileService.mapFromBytesReference(newBytesReference("{\"foo\":\"bar\"}")), is(Map.of("foo", "bar"))); + } + + public void testCombineMaps() { + assertThat(ProfileService.combineMaps(null, Map.of("a", 1)), is(Map.of("a", 1))); + assertThat( + ProfileService.combineMaps(new HashMap<>(Map.of("a", 1, "b", 2)), Map.of("b", 3, "c", 4)), + is(Map.of("a", 1, "b", 3, "c", 4)) + ); + assertThat( + ProfileService.combineMaps(new HashMap<>(Map.of("a", new HashMap<>(Map.of("b", "c")))), Map.of("a", Map.of("d", "e"))), + is(Map.of("a", Map.of("b", "c", "d", "e"))) + ); + } + + public void testValidateProfileSize() { + var pd = new ProfileDocument("uid", true, 0L, null, Map.of(), newBytesReference("{}")); + var vd = new ProfileService.VersionedDocument(pd, 1L, 1L); + var up = new UpdateProfileDataRequest( + "uid", + Map.of("key", "value"), + Map.of("key", "value"), + 1L, + 1L, + WriteRequest.RefreshPolicy.NONE + ); + assertThrows(ElasticsearchException.class, () -> ProfileService.validateProfileSize(vd, up, 0)); + ProfileService.validateProfileSize(vd, up, 100); + ProfileService.validateProfileSize(null, up, 0); + } + + private static BytesReference newBytesReference(String str) { + return BytesReference.fromByteBuffer(ByteBuffer.wrap(str.getBytes(StandardCharsets.UTF_8))); + } + record SampleDocumentParameter(String uid, String username, List roles, long lastSynchronized) {} private void mockMultiGetRequest(List sampleDocumentParameters) { From 5e80c14e22fe1c32e719462d05e3ace4b000c977 Mon Sep 17 00:00:00 2001 From: Elliot Barlas Date: Thu, 6 Nov 2025 18:43:57 -0800 Subject: [PATCH 2/3] Update docs/changelog/137712.yaml --- docs/changelog/137712.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/changelog/137712.yaml diff --git a/docs/changelog/137712.yaml b/docs/changelog/137712.yaml new file mode 100644 index 0000000000000..be83e773a6b64 --- /dev/null +++ b/docs/changelog/137712.yaml @@ -0,0 +1,5 @@ +pr: 137712 +summary: Add User Profile Size Limit Enforced During Profile Updates +area: Security +type: bug +issues: [] From 4cbc3b2a96e67e45b6e6b8d5cef660acb78b56f8 Mon Sep 17 00:00:00 2001 From: Elliot Barlas Date: Thu, 6 Nov 2025 18:57:54 -0800 Subject: [PATCH 3/3] Use Strings#format rather than String#formatted --- .../elasticsearch/xpack/security/profile/ProfileService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/profile/ProfileService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/profile/ProfileService.java index 123df990a8dbd..912434af22389 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/profile/ProfileService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/profile/ProfileService.java @@ -261,7 +261,8 @@ static void validateProfileSize(VersionedDocument doc, UpdateProfileDataRequest int actualSize = serializationSize(labels) + serializationSize(data); if (actualSize > limit) { throw new ElasticsearchException( - "cannot update profile [%s] because the combined profile size of [%s] bytes exceeds the maximum of [%s] bytes".formatted( + Strings.format( + "cannot update profile [%s] because the combined profile size of [%s] bytes exceeds the maximum of [%s] bytes", request.getUid(), actualSize, limit