diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/DefaultJsonWriter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/DefaultJsonWriter.java new file mode 100644 index 000000000000..ce49d3758c74 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/DefaultJsonWriter.java @@ -0,0 +1,48 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.json; + +/** + * Default implementation of {@link JsonWriter}. + * + * @param the type of the objects that this writer will serialize + * @author Phillip Webb + * @author Moritz Halbritter + * @author Dmytro Nosan + */ +final class DefaultJsonWriter implements JsonWriter { + + private final Configuration configuration; + + private final Members members; + + /** + * Creates a {@link DefaultJsonWriter} with the specified configuration and members. + * @param configuration the configuration settings to be used for JSON writing + * @param members the members defining how the object properties should be serialized + */ + DefaultJsonWriter(Configuration configuration, Members members) { + this.configuration = configuration; + this.members = members; + } + + @Override + public void write(T instance, Appendable out) { + this.members.write(instance, new JsonValueWriter(this.configuration, out)); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonValueWriter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonValueWriter.java index dbd8237aebd2..d563bd90b9e2 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonValueWriter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonValueWriter.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ import java.util.function.Consumer; import java.util.function.Predicate; +import org.springframework.boot.json.JsonWriter.Configuration; import org.springframework.boot.json.JsonWriter.MemberPath; import org.springframework.boot.json.JsonWriter.NameProcessor; import org.springframework.boot.json.JsonWriter.ValueProcessor; @@ -46,6 +47,8 @@ */ class JsonValueWriter { + private final Configuration configuration; + private final Appendable out; private MemberPath path = MemberPath.ROOT; @@ -57,8 +60,10 @@ class JsonValueWriter { /** * Create a new {@link JsonValueWriter} instance. * @param out the {@link Appendable} used to receive the JSON output + * @param configuration the configuration settings to be used for JSON writing */ - JsonValueWriter(Appendable out) { + JsonValueWriter(Configuration configuration, Appendable out) { + this.configuration = configuration; this.out = out; } @@ -140,6 +145,7 @@ else if (value instanceof Number || value instanceof Boolean) { */ void start(Series series) { if (series != null) { + validateNestingDepth(); this.activeSeries.push(new ActiveSeries(series)); append(series.openChar); } @@ -267,6 +273,14 @@ private void writeString(Object value) { } } + private void validateNestingDepth() { + int maxNestingDepth = this.configuration.getMaxNestingDepth(); + if (this.activeSeries.size() > maxNestingDepth) { + throw new IllegalStateException("JSON nesting depth (%s) exceeds maximum depth of %s (current path: %s)" + .formatted(this.activeSeries.size(), maxNestingDepth, this.path)); + } + } + private void append(String value) { try { this.out.append(value); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonWriter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonWriter.java index 2125acf506e3..1f7078d44bbd 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonWriter.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/json/JsonWriter.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -140,7 +140,7 @@ static JsonWriter standard() { /** * Factory method to return a {@link JsonWriter} with specific {@link Members member - * mapping}. See {@link JsonValueWriter class-level javadoc} and {@link Members} for + * mapping}. See {@link JsonWriter class-level javadoc} and {@link Members} for * details. * @param the type to write * @param members a consumer, which should configure the members @@ -148,9 +148,23 @@ static JsonWriter standard() { * @see Members */ static JsonWriter of(Consumer> members) { - // Don't inline 'new Members' (must be outside of lambda) - Members initializedMembers = new Members<>(members, false); - return (instance, out) -> initializedMembers.write(instance, new JsonValueWriter(out)); + return of(Configuration.defaults(), members); + } + + /** + * Factory method to return a {@link JsonWriter} with specific {@link Members member + * mapping}. See {@link JsonWriter class-level javadoc} and {@link Members} for + * details. + * @param the type to write + * @param members a consumer, which should configure the members + * @param configuration the configuration settings to be used for JSON writing + * @return a {@link JsonWriter} instance + * @see Members + */ + static JsonWriter of(Configuration configuration, Consumer> members) { + Assert.notNull(members, "'members' must not be null"); + Assert.notNull(configuration, "'configuration' must not be null"); + return new DefaultJsonWriter<>(configuration, new Members<>(members, false)); } /** @@ -1041,4 +1055,48 @@ static ValueProcessor of(UnaryOperator action) { } + /** + * Specifies configuration settings used to configure the {@link JsonWriter}. + */ + final class Configuration { + + private static final int DEFAULT_MAX_NESTING_DEPTH = 256; + + private static final Configuration DEFAULTS = new Configuration(DEFAULT_MAX_NESTING_DEPTH); + + private final int maxNestingDepth; + + private Configuration(int maxNestingDepth) { + this.maxNestingDepth = maxNestingDepth; + } + + /** + * Returns a default {@link Configuration} instance. + * @return a {@link Configuration} instance with default settings + */ + public static Configuration defaults() { + return DEFAULTS; + } + + /** + * Set the maximum nesting depth, representing the number of unclosed objects + * (`{`) and arrays (`[`). + * @param maxNestingDepth the maximum depth + * @return a new {@link Configuration} instance. + */ + public Configuration withMaxNestingDepth(int maxNestingDepth) { + Assert.isTrue(maxNestingDepth >= 0, "'maxNestingDepth' must be positive"); + return new Configuration(maxNestingDepth); + } + + /** + * Retrieves the maximum allowable nesting depth for unclosed objects and arrays. + * @return the maximum nesting depth + */ + public int getMaxNestingDepth() { + return this.maxNestingDepth; + } + + } + } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonValueWriterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonValueWriterTests.java index 94939db8da53..0908b199a372 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonValueWriterTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonValueWriterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ package org.springframework.boot.json; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -26,6 +27,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.json.JsonValueWriter.Series; +import org.springframework.boot.json.JsonWriter.Configuration; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -240,13 +242,47 @@ void endWhenNotStartedThrowsException() { .isThrownBy(() -> valueWriter.end(Series.ARRAY))); } + @Test + void illegalStateExceptionShouldBeThrownWhenCollectionExceededNestingDepth() { + List list = new ArrayList<>(); + list.add(list); + doWrite(Configuration.defaults().withMaxNestingDepth(128), (writer) -> assertThatIllegalStateException() + .isThrownBy(() -> writer.write(list)) + .withMessageStartingWith( + "JSON nesting depth (129) exceeds maximum depth of 128 (current path: [0][0][0][0][0][0][0][0][0][0][0][0]")); + } + + @Test + void illegalStateExceptionShouldBeThrownWhenMapExceededNestingDepth() { + Map map = new LinkedHashMap<>(); + map.put("foo", Map.of("bar", map)); + doWrite(Configuration.defaults().withMaxNestingDepth(128), (writer) -> assertThatIllegalStateException() + .isThrownBy(() -> writer.write(map)) + .withMessageStartingWith( + "JSON nesting depth (129) exceeds maximum depth of 128 (current path: foo.bar.foo.bar.foo.bar.foo")); + } + + @Test + void illegalStateExceptionShouldBeThrownWhenIterableExceededNestingDepth() { + List list = new ArrayList<>(); + list.add(list); + doWrite(Configuration.defaults().withMaxNestingDepth(128), (writer) -> assertThatIllegalStateException() + .isThrownBy(() -> writer.write((Iterable) list::iterator)) + .withMessageStartingWith( + "JSON nesting depth (129) exceeds maximum depth of 128 (current path: [0][0][0][0][0][0][0][0][0][0][0][0]")); + } + private String write(V value) { return doWrite((valueWriter) -> valueWriter.write(value)); } private String doWrite(Consumer action) { + return doWrite(Configuration.defaults(), action); + } + + private String doWrite(Configuration configuration, Consumer action) { StringBuilder out = new StringBuilder(); - action.accept(new JsonValueWriter(out)); + action.accept(new JsonValueWriter(configuration, out)); return out.toString(); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonWriterTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonWriterTests.java index 84bee79b1d55..67fadc90493a 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonWriterTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/json/JsonWriterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.boot.json.JsonWriter.Configuration; import org.springframework.boot.json.JsonWriter.Member; import org.springframework.boot.json.JsonWriter.MemberPath; import org.springframework.boot.json.JsonWriter.Members; @@ -95,6 +96,13 @@ void ofAddingUnnamedSelf() { assertThat(writer.writeToString(PERSON)).isEqualTo(quoted("Spring Boot (10)")); } + @Test + void ofWithCustomConfiguration() { + Configuration configuration = Configuration.defaults().withMaxNestingDepth(36); + JsonWriter writer = JsonWriter.of(configuration, Members::add); + assertThat(writer).hasFieldOrPropertyWithValue("configuration", configuration); + } + @Test void ofAddValue() { JsonWriter writer = JsonWriter.of((members) -> members.add("Spring", "Boot")); @@ -698,6 +706,31 @@ void processNameWhenReturnsEmptyStringThrowsException() { } + /** + * Tests for {@link JsonWriter.Configuration}. + */ + @Nested + class ConfigurationTests { + + @Test + void defaults() { + Configuration configuration = Configuration.defaults(); + assertThat(configuration.getMaxNestingDepth()).isEqualTo(256); + } + + @Test + void withMaxNestingDepth() { + assertThat(Configuration.defaults().withMaxNestingDepth(128).getMaxNestingDepth()).isEqualTo(128); + } + + @Test + void withNegativeMaxNestingDepth() { + assertThatIllegalArgumentException().isThrownBy(() -> Configuration.defaults().withMaxNestingDepth(-1)) + .withMessage("'maxNestingDepth' must be positive"); + } + + } + /** * Tests for {@link ValueProcessor}. */