Skip to content

Commit ab1d99a

Browse files
authored
Add user profile size limit (elastic#138690)
Add a configurable size limit for user profiles stored in Elasticsearch. This limit prevents large profiles from exhausting heap memory and impacting cluster stability.
1 parent 5b9f60a commit ab1d99a

File tree

5 files changed

+160
-4
lines changed

5 files changed

+160
-4
lines changed

docs/changelog/137712.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 137712
2+
summary: Add User Profile Size Limit Enforced During Profile Updates
3+
area: Security
4+
type: bug
5+
issues: []

x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/profile/ProfileIntegTests.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
package org.elasticsearch.xpack.security.profile;
99

1010
import org.apache.lucene.search.TotalHits;
11+
import org.elasticsearch.ElasticsearchException;
1112
import org.elasticsearch.ResourceNotFoundException;
1213
import org.elasticsearch.action.admin.indices.get.GetIndexAction;
1314
import org.elasticsearch.action.admin.indices.get.GetIndexRequest;
@@ -119,6 +120,7 @@ protected Settings nodeSettings(int nodeOrdinal, Settings otherSettings) {
119120
final Settings.Builder builder = Settings.builder().put(super.nodeSettings(nodeOrdinal, otherSettings));
120121
// This setting tests that the setting is registered
121122
builder.put("xpack.security.authc.domains.my_domain.realms", "file");
123+
builder.put("xpack.security.profile.max_size", "1kb");
122124
// enable anonymous
123125
builder.putList(AnonymousUser.ROLES_SETTING.getKey(), ANONYMOUS_ROLE);
124126
return builder.build();
@@ -338,6 +340,37 @@ public void testUpdateProfileData() {
338340
);
339341
}
340342

343+
public void testUpdateProfileDataHitStorageQuota() {
344+
Profile profile1 = doActivateProfile(RAC_USER_NAME, TEST_PASSWORD_SECURE_STRING);
345+
346+
char[] buf = new char[512]; // half of the 1,024 quota
347+
Arrays.fill(buf, 'a');
348+
String largeValue = new String(buf);
349+
350+
var repeatable = new UpdateProfileDataRequest(
351+
profile1.uid(),
352+
Map.of(),
353+
Map.of("app1", Map.of("key1", largeValue)),
354+
-1,
355+
-1,
356+
WriteRequest.RefreshPolicy.WAIT_UNTIL
357+
);
358+
359+
client().execute(UpdateProfileDataAction.INSTANCE, repeatable).actionGet(); // occupy half of the quota
360+
client().execute(UpdateProfileDataAction.INSTANCE, repeatable).actionGet(); // in-place change, still half quota
361+
362+
var overflow = new UpdateProfileDataRequest(
363+
profile1.uid(),
364+
Map.of(),
365+
Map.of("app1", Map.of("key2", largeValue)),
366+
-1,
367+
-1,
368+
WriteRequest.RefreshPolicy.WAIT_UNTIL
369+
);
370+
371+
assertThrows(ElasticsearchException.class, () -> client().execute(UpdateProfileDataAction.INSTANCE, overflow).actionGet());
372+
}
373+
341374
public void testSuggestProfilesWithName() {
342375
final ProfileService profileService = getInstanceFromRandomNode(ProfileService.class);
343376

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,6 +1596,7 @@ public static List<Setting<?>> getSettings(List<SecurityExtension> securityExten
15961596
settingsList.add(TokenService.TOKEN_EXPIRATION);
15971597
settingsList.add(TokenService.DELETE_INTERVAL);
15981598
settingsList.add(TokenService.DELETE_TIMEOUT);
1599+
settingsList.add(ProfileService.MAX_SIZE_SETTING);
15991600
settingsList.addAll(SSLConfigurationSettings.getProfileSettings());
16001601
settingsList.add(ApiKeyService.STORED_HASH_ALGO_SETTING);
16011602
settingsList.add(ApiKeyService.DELETE_TIMEOUT);

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/profile/ProfileService.java

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@
3939
import org.elasticsearch.common.Strings;
4040
import org.elasticsearch.common.bytes.BytesReference;
4141
import org.elasticsearch.common.lucene.Lucene;
42+
import org.elasticsearch.common.settings.Setting;
4243
import org.elasticsearch.common.settings.Settings;
44+
import org.elasticsearch.common.unit.ByteSizeUnit;
45+
import org.elasticsearch.common.unit.ByteSizeValue;
4346
import org.elasticsearch.common.unit.Fuzziness;
4447
import org.elasticsearch.common.xcontent.XContentHelper;
4548
import org.elasticsearch.core.TimeValue;
@@ -107,6 +110,15 @@
107110
import static org.elasticsearch.xpack.security.support.SecuritySystemIndices.SECURITY_PROFILE_ALIAS;
108111

109112
public class ProfileService {
113+
114+
public static final Setting<ByteSizeValue> MAX_SIZE_SETTING = Setting.byteSizeSetting(
115+
"xpack.security.profile.max_size",
116+
ByteSizeValue.of(10, ByteSizeUnit.MB), // default: 10 MB
117+
ByteSizeValue.ZERO, // minimum: 0 bytes
118+
ByteSizeValue.ofBytes(Integer.MAX_VALUE),
119+
Setting.Property.NodeScope
120+
);
121+
110122
private static final Logger logger = LogManager.getLogger(ProfileService.class);
111123
private static final String DOC_ID_PREFIX = "profile_";
112124
private static final BackoffPolicy DEFAULT_BACKOFF = BackoffPolicy.exponentialBackoff();
@@ -119,6 +131,7 @@ public class ProfileService {
119131
private final SecurityIndexManager profileIndex;
120132
private final Function<String, DomainConfig> domainConfigLookup;
121133
private final Function<RealmConfig.RealmIdentifier, Authentication.RealmRef> realmRefLookup;
134+
private final ByteSizeValue maxProfileSize;
122135

123136
public ProfileService(Settings settings, Clock clock, Client client, SecurityIndexManager profileIndex, Realms realms) {
124137
this.settings = settings;
@@ -127,6 +140,7 @@ public ProfileService(Settings settings, Clock clock, Client client, SecurityInd
127140
this.profileIndex = profileIndex;
128141
this.domainConfigLookup = realms::getDomainConfig;
129142
this.realmRefLookup = realms::getRealmRef;
143+
this.maxProfileSize = MAX_SIZE_SETTING.get(settings);
130144
}
131145

132146
public void getProfiles(List<String> uids, Set<String> dataKeys, ActionListener<ResultsAndErrors<Profile>> listener) {
@@ -241,10 +255,59 @@ public void updateProfileData(UpdateProfileDataRequest request, ActionListener<A
241255
return;
242256
}
243257

244-
doUpdate(
245-
buildUpdateRequest(request.getUid(), builder, request.getRefreshPolicy(), request.getIfPrimaryTerm(), request.getIfSeqNo()),
246-
listener.map(updateResponse -> AcknowledgedResponse.TRUE)
247-
);
258+
getVersionedDocument(request.getUid(), ActionListener.wrap(doc -> {
259+
validateProfileSize(doc, request, maxProfileSize);
260+
261+
doUpdate(
262+
buildUpdateRequest(request.getUid(), builder, request.getRefreshPolicy(), request.getIfPrimaryTerm(), request.getIfSeqNo()),
263+
listener.map(updateResponse -> AcknowledgedResponse.TRUE)
264+
);
265+
}, listener::onFailure));
266+
}
267+
268+
static void validateProfileSize(VersionedDocument doc, UpdateProfileDataRequest request, ByteSizeValue limit) {
269+
if (doc == null) {
270+
return;
271+
}
272+
Map<String, Object> labels = combineMaps(doc.doc.labels(), request.getLabels());
273+
Map<String, Object> data = combineMaps(mapFromBytesReference(doc.doc.applicationData()), request.getData());
274+
ByteSizeValue actualSize = ByteSizeValue.ofBytes(serializationSize(labels) + serializationSize(data));
275+
if (actualSize.compareTo(limit) > 0) {
276+
throw new ElasticsearchStatusException(
277+
Strings.format(
278+
"cannot update profile [%s] because the combined profile size of [%s] exceeds the maximum of [%s]",
279+
request.getUid(),
280+
actualSize,
281+
limit
282+
),
283+
RestStatus.BAD_REQUEST
284+
);
285+
}
286+
}
287+
288+
static Map<String, Object> combineMaps(Map<String, Object> src, Map<String, Object> update) {
289+
Map<String, Object> result = new HashMap<>(); // ensure mutable outer source map for update below
290+
if (src != null) {
291+
result.putAll(src);
292+
}
293+
XContentHelper.update(result, update, false);
294+
return result;
295+
}
296+
297+
static Map<String, Object> mapFromBytesReference(BytesReference bytesRef) {
298+
if (bytesRef == null || bytesRef.length() == 0) {
299+
return Map.of();
300+
}
301+
return XContentHelper.convertToMap(bytesRef, false, XContentType.JSON).v2();
302+
}
303+
304+
static int serializationSize(Map<String, Object> map) {
305+
try (XContentBuilder builder = XContentFactory.jsonBuilder()) {
306+
builder.value(map);
307+
return BytesReference.bytes(builder).length();
308+
} catch (IOException e) {
309+
throw new ElasticsearchException("Error occurred computing serialization size", e); // I/O error should never happen here
310+
}
248311
}
249312

250313
public void suggestProfile(SuggestProfilesRequest request, TaskId parentTaskId, ActionListener<SuggestProfilesResponse> listener) {

x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/profile/ProfileServiceTests.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import org.elasticsearch.action.search.TransportMultiSearchAction;
3232
import org.elasticsearch.action.search.TransportSearchAction;
3333
import org.elasticsearch.action.support.PlainActionFuture;
34+
import org.elasticsearch.action.support.WriteRequest;
3435
import org.elasticsearch.action.update.TransportUpdateAction;
3536
import org.elasticsearch.action.update.UpdateRequest;
3637
import org.elasticsearch.action.update.UpdateRequestBuilder;
@@ -41,6 +42,7 @@
4142
import org.elasticsearch.common.bytes.BytesReference;
4243
import org.elasticsearch.common.hash.MessageDigests;
4344
import org.elasticsearch.common.settings.Settings;
45+
import org.elasticsearch.common.unit.ByteSizeValue;
4446
import org.elasticsearch.common.unit.Fuzziness;
4547
import org.elasticsearch.common.util.concurrent.EsExecutors;
4648
import org.elasticsearch.common.util.set.Sets;
@@ -72,6 +74,7 @@
7274
import org.elasticsearch.xpack.core.security.action.profile.SuggestProfilesRequest;
7375
import org.elasticsearch.xpack.core.security.action.profile.SuggestProfilesRequestTests;
7476
import org.elasticsearch.xpack.core.security.action.profile.SuggestProfilesResponse;
77+
import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataRequest;
7578
import org.elasticsearch.xpack.core.security.authc.Authentication;
7679
import org.elasticsearch.xpack.core.security.authc.AuthenticationTestHelper;
7780
import org.elasticsearch.xpack.core.security.authc.AuthenticationTests;
@@ -90,7 +93,10 @@
9093
import org.junit.Before;
9194
import org.mockito.Mockito;
9295

96+
import java.io.ByteArrayInputStream;
9397
import java.io.IOException;
98+
import java.nio.ByteBuffer;
99+
import java.nio.charset.StandardCharsets;
94100
import java.time.Clock;
95101
import java.time.Instant;
96102
import java.util.ArrayList;
@@ -1324,6 +1330,54 @@ public void testProfilesIndexMissingOrUnavailableWhenRetrievingProfilesOfApiKeyO
13241330
assertThat(e.getMessage(), containsString("test unavailable"));
13251331
}
13261332

1333+
public void testSerializationSize() {
1334+
assertThat(ProfileService.serializationSize(Map.of()), is(2));
1335+
assertThat(ProfileService.serializationSize(Map.of("foo", "bar")), is(13));
1336+
assertThrows(
1337+
IllegalArgumentException.class,
1338+
() -> ProfileService.serializationSize(Map.of("bad", new ByteArrayInputStream(new byte[0])))
1339+
);
1340+
}
1341+
1342+
public void testMapFromBytesReference() {
1343+
assertThat(ProfileService.mapFromBytesReference(null), is(Map.of()));
1344+
assertThat(ProfileService.mapFromBytesReference(BytesReference.fromByteBuffer(ByteBuffer.allocate(0))), is(Map.of()));
1345+
assertThat(ProfileService.mapFromBytesReference(newBytesReference("{}")), is(Map.of()));
1346+
assertThat(ProfileService.mapFromBytesReference(newBytesReference("{\"foo\":\"bar\"}")), is(Map.of("foo", "bar")));
1347+
}
1348+
1349+
public void testCombineMaps() {
1350+
assertThat(ProfileService.combineMaps(null, Map.of("a", 1)), is(Map.of("a", 1)));
1351+
assertThat(
1352+
ProfileService.combineMaps(new HashMap<>(Map.of("a", 1, "b", 2)), Map.of("b", 3, "c", 4)),
1353+
is(Map.of("a", 1, "b", 3, "c", 4))
1354+
);
1355+
assertThat(
1356+
ProfileService.combineMaps(new HashMap<>(Map.of("a", new HashMap<>(Map.of("b", "c")))), Map.of("a", Map.of("d", "e"))),
1357+
is(Map.of("a", Map.of("b", "c", "d", "e")))
1358+
);
1359+
}
1360+
1361+
public void testValidateProfileSize() {
1362+
var pd = new ProfileDocument("uid", true, 0L, null, Map.of(), newBytesReference("{}"));
1363+
var vd = new ProfileService.VersionedDocument(pd, 1L, 1L);
1364+
var up = new UpdateProfileDataRequest(
1365+
"uid",
1366+
Map.of("key", "value"),
1367+
Map.of("key", "value"),
1368+
1L,
1369+
1L,
1370+
WriteRequest.RefreshPolicy.NONE
1371+
);
1372+
assertThrows(ElasticsearchException.class, () -> ProfileService.validateProfileSize(vd, up, ByteSizeValue.ZERO));
1373+
ProfileService.validateProfileSize(vd, up, ByteSizeValue.ofBytes(100));
1374+
ProfileService.validateProfileSize(null, up, ByteSizeValue.ZERO);
1375+
}
1376+
1377+
private static BytesReference newBytesReference(String str) {
1378+
return BytesReference.fromByteBuffer(ByteBuffer.wrap(str.getBytes(StandardCharsets.UTF_8)));
1379+
}
1380+
13271381
record SampleDocumentParameter(String uid, String username, List<String> roles, long lastSynchronized) {}
13281382

13291383
private void mockMultiGetRequest(List<SampleDocumentParameter> sampleDocumentParameters) {

0 commit comments

Comments
 (0)