Skip to content

Commit cf90c39

Browse files
feat: enforce system-level length caps and metadata count in subscription form validation
- SubscriptionFormConstraintsFactory: auto-generate MaxLength(256) for input fields and MaxLength(1024) for textarea fields; cap user-defined values at the system limit when exceeded - SubscriptionFormSubmissionValidator: reject submissions with more than 25 metadata entries (MAX_METADATA_COUNT) - SubscriptionMetadataSanitizer: remove global length/count limits — these now apply only to subscription form submissions, not all clients
1 parent df565b7 commit cf90c39

File tree

7 files changed

+115
-59
lines changed

7 files changed

+115
-59
lines changed

gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/apim/core/subscription_form/domain_service/SubscriptionFormConstraintsFactory.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@
2121
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.CheckboxField;
2222
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.CheckboxGroupField;
2323
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.Field;
24-
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.MaxLengthAttribute;
24+
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.InputField;
2525
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.MinLengthAttribute;
2626
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.OptionsAttribute;
2727
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.PatternAttribute;
2828
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.ReadOnlyValueAttribute;
29+
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.TextareaField;
2930
import java.util.ArrayList;
3031
import java.util.LinkedHashMap;
3132
import java.util.List;
@@ -34,6 +35,9 @@
3435
/**
3536
* Builds {@link SubscriptionFormFieldConstraints} from a parsed {@link SubscriptionFormSchema}.
3637
*
38+
* <p>System-level length limits are always enforced regardless of user configuration.
39+
* See {@link Constraint.MaxLength#INPUT_MAX_LENGTH} and {@link Constraint.MaxLength#TEXTAREA_MAX_LENGTH}.</p>
40+
*
3741
* @author Gravitee.io Team
3842
*/
3943
public final class SubscriptionFormConstraintsFactory {
@@ -71,8 +75,12 @@ private static List<Constraint> constraintsFor(Field field) {
7175
if (field instanceof MinLengthAttribute min && min.minLength() != null) {
7276
out.add(new Constraint.MinLength(min.minLength()));
7377
}
74-
if (field instanceof MaxLengthAttribute max && max.maxLength() != null) {
75-
out.add(new Constraint.MaxLength(max.maxLength()));
78+
if (field instanceof InputField input) {
79+
out.add(input.maxLength() != null ? Constraint.MaxLength.forInput(input.maxLength()) : Constraint.MaxLength.forInput());
80+
} else if (field instanceof TextareaField textarea) {
81+
out.add(
82+
textarea.maxLength() != null ? Constraint.MaxLength.forTextarea(textarea.maxLength()) : Constraint.MaxLength.forTextarea()
83+
);
7684
}
7785
if (field instanceof PatternAttribute pat && pat.pattern() != null) {
7886
out.add(new Constraint.MatchesPattern(pat.pattern()));

gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/apim/core/subscription_form/domain_service/SubscriptionFormSubmissionValidator.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
*/
3636
public class SubscriptionFormSubmissionValidator {
3737

38+
/** Maximum number of metadata entries accepted in a subscription form submission. */
39+
public static final int MAX_METADATA_COUNT = 25;
40+
3841
private final SubscriptionFormFieldConstraints fieldConstraints;
3942

4043
public SubscriptionFormSubmissionValidator(SubscriptionFormSchema schema) {
@@ -57,6 +60,11 @@ public void validate(Map<String, String> submittedValues) {
5760
if (fieldConstraints.isEmpty()) {
5861
return;
5962
}
63+
if (submittedValues.size() > MAX_METADATA_COUNT) {
64+
throw new SubscriptionFormValidationException(
65+
List.of("Subscription metadata must not exceed " + MAX_METADATA_COUNT + " entries")
66+
);
67+
}
6068
List<String> errors = fieldConstraints
6169
.byFieldKey()
6270
.entrySet()

gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/apim/core/subscription_form/model/Constraint.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,26 @@ public String formatErrorMessage(String fieldKey, String value) {
152152

153153
/** Value must be at most {@code max} characters long (skipped when empty). */
154154
record MaxLength(int max) implements Constraint {
155+
public static final int INPUT_MAX_LENGTH = 256;
156+
157+
public static final int TEXTAREA_MAX_LENGTH = 1024;
158+
159+
public static MaxLength forInput() {
160+
return new MaxLength(INPUT_MAX_LENGTH);
161+
}
162+
163+
public static MaxLength forInput(int userDefined) {
164+
return new MaxLength(Math.min(userDefined, INPUT_MAX_LENGTH));
165+
}
166+
167+
public static MaxLength forTextarea() {
168+
return new MaxLength(TEXTAREA_MAX_LENGTH);
169+
}
170+
171+
public static MaxLength forTextarea(int userDefined) {
172+
return new MaxLength(Math.min(userDefined, TEXTAREA_MAX_LENGTH));
173+
}
174+
155175
@Override
156176
public boolean check(String value) {
157177
return value.isEmpty() || value.length() <= max;

gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/rest/api/service/v4/validation/SubscriptionMetadataSanitizer.java

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,40 +23,35 @@
2323
import org.springframework.stereotype.Component;
2424

2525
/**
26-
* Validates and sanitizes subscription form metadata (keys, value length).
26+
* Sanitizes subscription metadata: validates key format and strips HTML tags from values.
2727
* Values are plain text; HTML tags are stripped to prevent XSS when metadata is rendered.
2828
* No HTML encoding is applied, so characters like {@code @}, {@code +}, {@code =} are stored as-is.
2929
*
30+
* <p>Business-level limits (max value length, max entry count) are enforced separately by
31+
* {@link io.gravitee.apim.core.subscription_form.domain_service.SubscriptionFormSubmissionValidator}
32+
* for subscriptions that go through a subscription form.</p>
33+
*
3034
* @author GraviteeSource Team
3135
*/
3236
@Component
3337
public class SubscriptionMetadataSanitizer {
3438

3539
private static final Pattern KEY_PATTERN = Pattern.compile("^[A-Za-z0-9_-]{1,100}$");
3640
private static final Pattern HTML_TAG = Pattern.compile("<[^>]*>");
37-
private static final int MAX_VALUE_LENGTH = 1024;
38-
private static final int MAX_METADATA_COUNT = 25;
3941

4042
/**
41-
* Validates metadata keys and value lengths, then strips HTML tags from each value.
43+
* Validates metadata key format, strips HTML tags from each value, and omits blank values.
4244
* Any remaining characters (including {@code <}, {@code >}, non-Latin, etc.) are stored as-is.
4345
*
4446
* @param metadata raw metadata from the client
4547
* @return sanitized metadata, or empty map if input is null
46-
* @throws SubscriptionMetadataInvalidException if a key is invalid or a value exceeds max length
48+
* @throws SubscriptionMetadataInvalidException if a key does not match the required format
4749
*/
4850
public Map<String, String> sanitizeAndValidate(Map<String, String> metadata) {
4951
if (metadata == null) {
5052
return Collections.emptyMap();
5153
}
5254

53-
if (metadata.size() > MAX_METADATA_COUNT) {
54-
throw new SubscriptionMetadataInvalidException(
55-
SubscriptionMetadataInvalidException.Reason.TOO_MANY,
56-
"Too many metadata entries. Maximum is " + MAX_METADATA_COUNT + "."
57-
);
58-
}
59-
6055
Map<String, String> sanitizedMetadata = new HashMap<>();
6156
for (Map.Entry<String, String> entry : metadata.entrySet()) {
6257
String key = entry.getKey();
@@ -67,15 +62,7 @@ public Map<String, String> sanitizeAndValidate(Map<String, String> metadata) {
6762
);
6863
}
6964

70-
String value = entry.getValue();
71-
if (value != null && value.length() > MAX_VALUE_LENGTH) {
72-
throw new SubscriptionMetadataInvalidException(
73-
SubscriptionMetadataInvalidException.Reason.VALUE_TOO_LONG,
74-
"Metadata value for key '" + key + "' is too long (max " + MAX_VALUE_LENGTH + " characters)."
75-
);
76-
}
77-
78-
String sanitizedValue = stripHtmlTags(value);
65+
String sanitizedValue = stripHtmlTags(entry.getValue());
7966
if (sanitizedValue == null || sanitizedValue.isBlank()) {
8067
continue;
8168
}

gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/test/java/io/gravitee/apim/core/subscription_form/domain_service/SubscriptionFormConstraintsFactoryTest.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
*/
1616
package io.gravitee.apim.core.subscription_form.domain_service;
1717

18+
import static io.gravitee.apim.core.subscription_form.model.Constraint.MaxLength.INPUT_MAX_LENGTH;
19+
import static io.gravitee.apim.core.subscription_form.model.Constraint.MaxLength.TEXTAREA_MAX_LENGTH;
1820
import static org.assertj.core.api.Assertions.assertThat;
1921

2022
import io.gravitee.apim.core.subscription_form.model.Constraint;
@@ -92,6 +94,41 @@ void should_map_required_checkbox_to_must_be_true() {
9294
assertThat(constraints.byFieldKey().get("terms")).containsExactly(new Constraint.MustBeTrue());
9395
}
9496

97+
@Test
98+
void should_apply_system_max_length_to_input_when_no_user_max_defined() {
99+
var schema = new SubscriptionFormSchema(List.of(new InputField("name", false, null, null, null, null)));
100+
var constraints = SubscriptionFormConstraintsFactory.fromSchema(schema);
101+
assertThat(constraints.byFieldKey().get("name")).containsExactly(new Constraint.MaxLength(INPUT_MAX_LENGTH));
102+
}
103+
104+
@Test
105+
void should_apply_system_max_length_to_textarea_when_no_user_max_defined() {
106+
var schema = new SubscriptionFormSchema(List.of(new TextareaField("bio", false, null, null, null)));
107+
var constraints = SubscriptionFormConstraintsFactory.fromSchema(schema);
108+
assertThat(constraints.byFieldKey().get("bio")).containsExactly(new Constraint.MaxLength(TEXTAREA_MAX_LENGTH));
109+
}
110+
111+
@Test
112+
void should_cap_input_max_length_at_system_limit_when_user_exceeds_it() {
113+
var schema = new SubscriptionFormSchema(List.of(new InputField("name", false, null, null, 1000, null)));
114+
var constraints = SubscriptionFormConstraintsFactory.fromSchema(schema);
115+
assertThat(constraints.byFieldKey().get("name")).containsExactly(new Constraint.MaxLength(INPUT_MAX_LENGTH));
116+
}
117+
118+
@Test
119+
void should_cap_textarea_max_length_at_system_limit_when_user_exceeds_it() {
120+
var schema = new SubscriptionFormSchema(List.of(new TextareaField("notes", false, null, null, 9999)));
121+
var constraints = SubscriptionFormConstraintsFactory.fromSchema(schema);
122+
assertThat(constraints.byFieldKey().get("notes")).containsExactly(new Constraint.MaxLength(TEXTAREA_MAX_LENGTH));
123+
}
124+
125+
@Test
126+
void should_preserve_user_max_length_when_within_system_limit() {
127+
var schema = new SubscriptionFormSchema(List.of(new InputField("code", false, null, null, 50, null)));
128+
var constraints = SubscriptionFormConstraintsFactory.fromSchema(schema);
129+
assertThat(constraints.byFieldKey().get("code")).containsExactly(new Constraint.MaxLength(50));
130+
}
131+
95132
@Test
96133
void should_map_checkbox_group_required_with_each_of() {
97134
var schema = new SubscriptionFormSchema(List.of(new CheckboxGroupField("tags", true, List.of("A", "B"))));

gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/test/java/io/gravitee/apim/core/subscription_form/domain_service/SubscriptionFormSubmissionValidatorTest.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,37 @@ void should_not_apply_other_validations_to_readonly_fields() {
289289
}
290290
}
291291

292+
@Nested
293+
class WhenValidatingMetadataCount {
294+
295+
@Test
296+
void should_throw_when_submission_exceeds_max_metadata_count() {
297+
var schema = schema(requiredInput("company"));
298+
Map<String, String> tooMany = new java.util.HashMap<>();
299+
for (int i = 0; i < SubscriptionFormSubmissionValidator.MAX_METADATA_COUNT + 1; i++) {
300+
tooMany.put("key_" + i, "value");
301+
}
302+
assertThatThrownBy(() -> validateSubmission(schema, tooMany))
303+
.isInstanceOf(SubscriptionFormValidationException.class)
304+
.extracting(e -> ((SubscriptionFormValidationException) e).getErrors())
305+
.satisfies(errors ->
306+
assertThat(errors).containsExactly(
307+
"Subscription metadata must not exceed " + SubscriptionFormSubmissionValidator.MAX_METADATA_COUNT + " entries"
308+
)
309+
);
310+
}
311+
312+
@Test
313+
void should_not_throw_when_submission_is_at_max_metadata_count() {
314+
var schema = schema(optionalInput("notes"));
315+
Map<String, String> exactlyMax = new java.util.HashMap<>();
316+
for (int i = 0; i < SubscriptionFormSubmissionValidator.MAX_METADATA_COUNT; i++) {
317+
exactlyMax.put("key_" + i, "value");
318+
}
319+
assertThatNoException().isThrownBy(() -> validateSubmission(schema, exactlyMax));
320+
}
321+
}
322+
292323
@Nested
293324
class WhenUsingPrebuiltFieldConstraints {
294325

gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/test/java/io/gravitee/rest/api/service/v4/validation/SubscriptionMetadataSanitizerTest.java

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -51,41 +51,6 @@ void should_throw_when_metadata_key_is_invalid() {
5151
assertThat(throwable.getMessage()).isEqualTo("Invalid metadata key: bad key");
5252
}
5353

54-
@Test
55-
void should_throw_when_metadata_value_is_too_long() {
56-
Map<String, String> tooLongValue = Map.of("valid_key", "a".repeat(1025));
57-
58-
var throwable = assertThrows(SubscriptionMetadataInvalidException.class, () -> cut.sanitizeAndValidate(tooLongValue));
59-
60-
assertThat(throwable.getTechnicalCode()).isEqualTo("subscription.metadata.value.too_long");
61-
assertThat(throwable.getMessage()).isEqualTo("Metadata value for key 'valid_key' is too long (max 1024 characters).");
62-
}
63-
64-
@Test
65-
void should_throw_when_metadata_count_exceeds_maximum() {
66-
Map<String, String> tooMany = new HashMap<>();
67-
for (int i = 0; i < 26; i++) {
68-
tooMany.put("key_" + i, "value");
69-
}
70-
71-
var throwable = assertThrows(SubscriptionMetadataInvalidException.class, () -> cut.sanitizeAndValidate(tooMany));
72-
73-
assertThat(throwable.getTechnicalCode()).isEqualTo("subscription.metadata.too_many");
74-
assertThat(throwable.getMessage()).isEqualTo("Too many metadata entries. Maximum is 25.");
75-
}
76-
77-
@Test
78-
void should_accept_metadata_at_maximum_count() {
79-
Map<String, String> maxAllowed = new HashMap<>();
80-
for (int i = 0; i < 25; i++) {
81-
maxAllowed.put("key_" + i, "value");
82-
}
83-
84-
var result = cut.sanitizeAndValidate(maxAllowed);
85-
86-
assertThat(result).hasSize(25);
87-
}
88-
8954
@Test
9055
void should_strip_html_tags_and_omit_empty_values() {
9156
Map<String, String> metadata = new HashMap<>();

0 commit comments

Comments
 (0)