Skip to content

Commit 8be0982

Browse files
authored
Add user profile size limit (elastic#138691)
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 a8ec260 commit 8be0982

File tree

5 files changed

+159
-4
lines changed

5 files changed

+159
-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
@@ -1474,6 +1474,7 @@ public static List<Setting<?>> getSettings(List<SecurityExtension> securityExten
14741474
settingsList.add(TokenService.TOKEN_EXPIRATION);
14751475
settingsList.add(TokenService.DELETE_INTERVAL);
14761476
settingsList.add(TokenService.DELETE_TIMEOUT);
1477+
settingsList.add(ProfileService.MAX_SIZE_SETTING);
14771478
settingsList.addAll(SSLConfigurationSettings.getProfileSettings());
14781479
settingsList.add(ApiKeyService.STORED_HASH_ALGO_SETTING);
14791480
settingsList.add(ApiKeyService.DELETE_TIMEOUT);

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

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@
4040
import org.elasticsearch.common.Strings;
4141
import org.elasticsearch.common.bytes.BytesReference;
4242
import org.elasticsearch.common.lucene.Lucene;
43+
import org.elasticsearch.common.settings.Setting;
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.xcontent.XContentHelper;
4648
import org.elasticsearch.core.TimeValue;
@@ -110,6 +112,15 @@
110112
import static org.elasticsearch.xpack.security.support.SecuritySystemIndices.SECURITY_PROFILE_ORIGIN_FEATURE;
111113

112114
public class ProfileService {
115+
116+
public static final Setting<ByteSizeValue> MAX_SIZE_SETTING = Setting.byteSizeSetting(
117+
"xpack.security.profile.max_size",
118+
ByteSizeValue.ofMb(10), // default: 10 MB
119+
ByteSizeValue.ZERO, // minimum: 0 bytes
120+
ByteSizeValue.ofBytes(Integer.MAX_VALUE),
121+
Setting.Property.NodeScope
122+
);
123+
113124
private static final Logger logger = LogManager.getLogger(ProfileService.class);
114125
private static final String DOC_ID_PREFIX = "profile_";
115126
private static final BackoffPolicy DEFAULT_BACKOFF = BackoffPolicy.exponentialBackoff();
@@ -124,6 +135,7 @@ public class ProfileService {
124135
private final FeatureService featureService;
125136
private final Function<String, DomainConfig> domainConfigLookup;
126137
private final Function<RealmConfig.RealmIdentifier, Authentication.RealmRef> realmRefLookup;
138+
private final ByteSizeValue maxProfileSize;
127139

128140
public ProfileService(
129141
Settings settings,
@@ -142,6 +154,7 @@ public ProfileService(
142154
this.featureService = featureService;
143155
this.domainConfigLookup = realms::getDomainConfig;
144156
this.realmRefLookup = realms::getRealmRef;
157+
this.maxProfileSize = MAX_SIZE_SETTING.get(settings);
145158
}
146159

147160
public void getProfiles(List<String> uids, Set<String> dataKeys, ActionListener<ResultsAndErrors<Profile>> listener) {
@@ -256,10 +269,59 @@ public void updateProfileData(UpdateProfileDataRequest request, ActionListener<A
256269
return;
257270
}
258271

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

265327
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;
@@ -44,6 +45,7 @@
4445
import org.elasticsearch.common.bytes.BytesReference;
4546
import org.elasticsearch.common.hash.MessageDigests;
4647
import org.elasticsearch.common.settings.Settings;
48+
import org.elasticsearch.common.unit.ByteSizeValue;
4749
import org.elasticsearch.common.unit.Fuzziness;
4850
import org.elasticsearch.common.util.concurrent.EsExecutors;
4951
import org.elasticsearch.common.util.set.Sets;
@@ -76,6 +78,7 @@
7678
import org.elasticsearch.xpack.core.security.action.profile.SuggestProfilesRequest;
7779
import org.elasticsearch.xpack.core.security.action.profile.SuggestProfilesRequestTests;
7880
import org.elasticsearch.xpack.core.security.action.profile.SuggestProfilesResponse;
81+
import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataRequest;
7982
import org.elasticsearch.xpack.core.security.authc.Authentication;
8083
import org.elasticsearch.xpack.core.security.authc.AuthenticationTestHelper;
8184
import org.elasticsearch.xpack.core.security.authc.AuthenticationTests;
@@ -94,7 +97,10 @@
9497
import org.junit.After;
9598
import org.junit.Before;
9699

100+
import java.io.ByteArrayInputStream;
97101
import java.io.IOException;
102+
import java.nio.ByteBuffer;
103+
import java.nio.charset.StandardCharsets;
98104
import java.time.Clock;
99105
import java.time.Instant;
100106
import java.util.ArrayList;
@@ -1394,6 +1400,54 @@ public void testProfilesIndexMissingOrUnavailableWhenRetrievingProfilesOfApiKeyO
13941400
assertThat(e.getMessage(), containsString("test unavailable"));
13951401
}
13961402

1403+
public void testSerializationSize() {
1404+
assertThat(ProfileService.serializationSize(Map.of()), is(2));
1405+
assertThat(ProfileService.serializationSize(Map.of("foo", "bar")), is(13));
1406+
assertThrows(
1407+
IllegalArgumentException.class,
1408+
() -> ProfileService.serializationSize(Map.of("bad", new ByteArrayInputStream(new byte[0])))
1409+
);
1410+
}
1411+
1412+
public void testMapFromBytesReference() {
1413+
assertThat(ProfileService.mapFromBytesReference(null), is(Map.of()));
1414+
assertThat(ProfileService.mapFromBytesReference(BytesReference.fromByteBuffer(ByteBuffer.allocate(0))), is(Map.of()));
1415+
assertThat(ProfileService.mapFromBytesReference(newBytesReference("{}")), is(Map.of()));
1416+
assertThat(ProfileService.mapFromBytesReference(newBytesReference("{\"foo\":\"bar\"}")), is(Map.of("foo", "bar")));
1417+
}
1418+
1419+
public void testCombineMaps() {
1420+
assertThat(ProfileService.combineMaps(null, Map.of("a", 1)), is(Map.of("a", 1)));
1421+
assertThat(
1422+
ProfileService.combineMaps(new HashMap<>(Map.of("a", 1, "b", 2)), Map.of("b", 3, "c", 4)),
1423+
is(Map.of("a", 1, "b", 3, "c", 4))
1424+
);
1425+
assertThat(
1426+
ProfileService.combineMaps(new HashMap<>(Map.of("a", new HashMap<>(Map.of("b", "c")))), Map.of("a", Map.of("d", "e"))),
1427+
is(Map.of("a", Map.of("b", "c", "d", "e")))
1428+
);
1429+
}
1430+
1431+
public void testValidateProfileSize() {
1432+
var pd = new ProfileDocument("uid", true, 0L, null, Map.of(), newBytesReference("{}"));
1433+
var vd = new ProfileService.VersionedDocument(pd, 1L, 1L);
1434+
var up = new UpdateProfileDataRequest(
1435+
"uid",
1436+
Map.of("key", "value"),
1437+
Map.of("key", "value"),
1438+
1L,
1439+
1L,
1440+
WriteRequest.RefreshPolicy.NONE
1441+
);
1442+
assertThrows(ElasticsearchException.class, () -> ProfileService.validateProfileSize(vd, up, ByteSizeValue.ZERO));
1443+
ProfileService.validateProfileSize(vd, up, ByteSizeValue.ofBytes(100));
1444+
ProfileService.validateProfileSize(null, up, ByteSizeValue.ZERO);
1445+
}
1446+
1447+
private static BytesReference newBytesReference(String str) {
1448+
return BytesReference.fromByteBuffer(ByteBuffer.wrap(str.getBytes(StandardCharsets.UTF_8)));
1449+
}
1450+
13971451
record SampleDocumentParameter(String uid, String username, List<String> roles, long lastSynchronized) {}
13981452

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

0 commit comments

Comments
 (0)