Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/main/java/dev/toonformat/jtoon/DecodeOptions.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package dev.toonformat.jtoon;

import java.util.Objects;

/**
* Configuration options for decoding TOON format to Java objects.
*
Expand All @@ -22,13 +24,31 @@ public record DecodeOptions(
*/
public static final DecodeOptions DEFAULT = new DecodeOptions(2, Delimiter.COMMA, true, PathExpansion.OFF);

/**
* Maximum allowed indent to prevent memory exhaustion attacks.
*/
public static final int MAX_ALLOWED_INDENT = 100;

/**
* Creates DecodeOptions with default values.
*/
public DecodeOptions() {
this(2, Delimiter.COMMA, true, PathExpansion.OFF);
}

/**
* Compact constructor with validation.
*/
public DecodeOptions {
if (indent < 0) {
throw new IllegalArgumentException("indent must be non-negative, got: " + indent);
}
if (indent > MAX_ALLOWED_INDENT) {
throw new IllegalArgumentException("indent must be <= " + MAX_ALLOWED_INDENT + ", got: " + indent);
}
delimiter = Objects.requireNonNull(delimiter, "delimiter cannot be null");
}

/**
* Creates DecodeOptions with custom indent, using default delimiter and strict
* mode.
Expand Down
23 changes: 23 additions & 0 deletions src/main/java/dev/toonformat/jtoon/EncodeOptions.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package dev.toonformat.jtoon;

import java.util.Objects;

/**
* Configuration options for encoding data to JToon format.
*
Expand All @@ -26,13 +28,34 @@ public record EncodeOptions(
public static final EncodeOptions DEFAULT = new EncodeOptions(
2, Delimiter.COMMA, false, KeyFolding.OFF, Integer.MAX_VALUE);

/**
* Maximum allowed indent to prevent memory exhaustion attacks.
*/
public static final int MAX_ALLOWED_INDENT = 100;

/**
* Creates EncodeOptions with default values.
*/
public EncodeOptions() {
this(2, Delimiter.COMMA, false, KeyFolding.OFF, Integer.MAX_VALUE);
}

/**
* Compact constructor with validation.
*/
public EncodeOptions {
if (indent < 0) {
throw new IllegalArgumentException("indent must be non-negative, got: " + indent);
}
if (indent > MAX_ALLOWED_INDENT) {
throw new IllegalArgumentException("indent must be <= " + MAX_ALLOWED_INDENT + ", got: " + indent);
}
delimiter = Objects.requireNonNull(delimiter, "delimiter cannot be null");
if (flattenDepth < 0) {
throw new IllegalArgumentException("flattenDepth must be non-negative, got: " + flattenDepth);
}
}

/**
* Creates EncodeOptions with custom indent, using default delimiter and length
* marker.
Expand Down
107 changes: 70 additions & 37 deletions src/main/java/dev/toonformat/jtoon/normalizer/JsonNormalizer.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
Expand All @@ -45,6 +46,15 @@ public final class JsonNormalizer {
*/
public static final ObjectMapper MAPPER = ObjectMapperSingleton.getInstance();

/**
* maximal allowed nesting depth of list.
*/
public static final int MAX_ALLOWED_NESTING_DEPTH = 512;

private static final ThreadLocal<Integer> DEPTH_COUNTER = ThreadLocal.withInitial(() -> 0);
private static final ThreadLocal<Map<Object, Boolean>> VISITED =
ThreadLocal.withInitial(IdentityHashMap::new);

private static final List<Function<Object, JsonNode>> NORMALIZERS = List.of(
JsonNormalizer::tryNormalizePrimitive,
JsonNormalizer::tryNormalizeBigNumber,
Expand Down Expand Up @@ -88,20 +98,45 @@ public static JsonNode parse(final String json) {
*
* @param value The value to normalize
* @return The normalized JsonNode
* @throws IllegalArgumentException if nesting depth exceeds MAX_DEPTH or circular reference detected
*/
public static JsonNode normalize(final Object value) {
final int currentDepth = DEPTH_COUNTER.get();
if (currentDepth > MAX_ALLOWED_NESTING_DEPTH) {
DEPTH_COUNTER.remove();
throw new IllegalArgumentException("Maximum nesting depth exceeded: " + MAX_ALLOWED_NESTING_DEPTH);
}
DEPTH_COUNTER.set(currentDepth + 1);
try {
return normalizeInternal(value);
} finally {
DEPTH_COUNTER.set(currentDepth);
}
}

private static JsonNode normalizeInternal(final Object value) {
if (value == null) {
return NullNode.getInstance();
} else if (value instanceof JsonNode jsonNode) {
return jsonNode;
} else if (value instanceof Optional<?>) {
return normalize(((Optional<?>) value).orElse(null));
} else if (value instanceof Stream<?>) {
return normalize(((Stream<?>) value).toList());
} else if (value.getClass().isArray()) {
return normalizeArray(value);
} else {
return normalizeWithStrategy(value);
}
final Map<Object, Boolean> visited = VISITED.get();
if (visited.containsKey(value)) {
throw new IllegalArgumentException("Circular reference detected");
}
visited.put(value, Boolean.TRUE);
try {
if (value instanceof Optional<?>) {
return normalize(((Optional<?>) value).orElse(null));
} else if (value instanceof Stream<?>) {
return normalize(((Stream<?>) value).toList());
} else if (value.getClass().isArray()) {
return normalizeArray(value);
} else {
return normalizeWithStrategy(value);
}
} finally {
visited.remove(value);
}
}

Expand Down Expand Up @@ -296,60 +331,58 @@ private static JsonNode tryNormalizePojo(final Object value) {
* Uses direct array population to avoid IntFunction lambda allocations.
*/
private static JsonNode normalizeArray(final Object array) {
if (array instanceof int[] intArr) {
if (array instanceof int[] intArray) {
final ArrayNode node = MAPPER.createArrayNode();
for (int i = 0; i < intArr.length; i++) {
node.add(IntNode.valueOf(intArr[i]));
for (int i : intArray) {
node.add(IntNode.valueOf(i));
}
return node;
} else if (array instanceof long[] longArr) {
} else if (array instanceof long[] longArray) {
final ArrayNode node = MAPPER.createArrayNode();
for (int i = 0; i < longArr.length; i++) {
node.add(LongNode.valueOf(longArr[i]));
for (long l : longArray) {
node.add(LongNode.valueOf(l));
}
return node;
} else if (array instanceof double[] doubleArr) {
} else if (array instanceof double[] doubleArray) {
final ArrayNode node = MAPPER.createArrayNode();
for (int i = 0; i < doubleArr.length; i++) {
final double val = doubleArr[i];
node.add(Double.isFinite(val) ? DoubleNode.valueOf(val) : NullNode.getInstance());
for (final double d : doubleArray) {
node.add(Double.isFinite(d) ? DoubleNode.valueOf(d) : NullNode.getInstance());
}
return node;
} else if (array instanceof float[] floatArr) {
} else if (array instanceof float[] floatArray) {
final ArrayNode node = MAPPER.createArrayNode();
for (int i = 0; i < floatArr.length; i++) {
final float val = floatArr[i];
node.add(Float.isFinite(val) ? FloatNode.valueOf(val) : NullNode.getInstance());
for (final float f : floatArray) {
node.add(Float.isFinite(f) ? FloatNode.valueOf(f) : NullNode.getInstance());
}
return node;
} else if (array instanceof boolean[] boolArr) {
} else if (array instanceof boolean[] boolArray) {
final ArrayNode node = MAPPER.createArrayNode();
for (int i = 0; i < boolArr.length; i++) {
node.add(BooleanNode.valueOf(boolArr[i]));
for (boolean b : boolArray) {
node.add(BooleanNode.valueOf(b));
}
return node;
} else if (array instanceof byte[] byteArr) {
} else if (array instanceof byte[] byteArray) {
final ArrayNode node = MAPPER.createArrayNode();
for (int i = 0; i < byteArr.length; i++) {
node.add(IntNode.valueOf(byteArr[i]));
for (byte by : byteArray) {
node.add(IntNode.valueOf(by));
}
return node;
} else if (array instanceof short[] shortArr) {
} else if (array instanceof short[] shortArray) {
final ArrayNode node = MAPPER.createArrayNode();
for (int i = 0; i < shortArr.length; i++) {
node.add(ShortNode.valueOf(shortArr[i]));
for (short s : shortArray) {
node.add(ShortNode.valueOf(s));
}
return node;
} else if (array instanceof char[] charArr) {
} else if (array instanceof char[] charArray) {
final ArrayNode node = MAPPER.createArrayNode();
for (int i = 0; i < charArr.length; i++) {
node.add(StringNode.valueOf(String.valueOf(charArr[i])));
for (char c : charArray) {
node.add(StringNode.valueOf(String.valueOf(c)));
}
return node;
} else if (array instanceof Object[] objArr) {
} else if (array instanceof Object[] objArray) {
final ArrayNode node = MAPPER.createArrayNode();
for (int i = 0; i < objArr.length; i++) {
node.add(normalize(objArr[i]));
for (Object o : objArray) {
node.add(normalize(o));
}
return node;
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ public static String unescape(final String value) {
*
* @param c The character following a backslash
* @return The unescaped character
* @throws IllegalArgumentException if the escape sequence is invalid
*/
private static char unescapeChar(final char c) {
return switch (c) {
Expand All @@ -134,7 +135,7 @@ private static char unescapeChar(final char c) {
case 't' -> '\t';
case '"' -> '"';
case '\\' -> '\\';
default -> c;
default -> throw new IllegalArgumentException("Invalid escape sequence: \\" + c);
};
}
}
87 changes: 87 additions & 0 deletions src/test/java/dev/toonformat/jtoon/SecurityValidationTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package dev.toonformat.jtoon;

import dev.toonformat.jtoon.normalizer.JsonNormalizer;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class SecurityValidationTest {

@Nested
@DisplayName("EncodeOptions validation")
class EncodeOptionsValidation {
@Test
@DisplayName("should reject negative indent")
void testNegativeIndent() {
assertThrows(IllegalArgumentException.class,
() -> new EncodeOptions(-1, Delimiter.COMMA, false, KeyFolding.OFF, 10));
}

@Test
@DisplayName("should reject indent exceeding MAX_INDENT")
void testExcessiveIndent() {
assertThrows(IllegalArgumentException.class,
() -> new EncodeOptions(EncodeOptions.MAX_ALLOWED_INDENT + 1, Delimiter.COMMA, false, KeyFolding.OFF, 10));
}

@Test
@DisplayName("should reject null delimiter")
void testNullDelimiter() {
assertThrows(NullPointerException.class,
() -> new EncodeOptions(2, null, false, KeyFolding.OFF, 10));
}

@Test
@DisplayName("should reject negative flattenDepth")
void testNegativeFlattenDepth() {
assertThrows(IllegalArgumentException.class,
() -> new EncodeOptions(2, Delimiter.COMMA, false, KeyFolding.SAFE, -1));
}

@Test
@DisplayName("should accept valid options")
void testValidOptions() {
EncodeOptions opts = new EncodeOptions(4, Delimiter.PIPE, true, KeyFolding.SAFE, 5);
assertEquals(4, opts.indent());
assertEquals(Delimiter.PIPE, opts.delimiter());
assertEquals(5, opts.flattenDepth());
}
}

@Nested
@DisplayName("DecodeOptions validation")
class DecodeOptionsValidation {
@Test
@DisplayName("should reject negative indent")
void testNegativeIndent() {
assertThrows(IllegalArgumentException.class,
() -> new DecodeOptions(-1, Delimiter.COMMA, true, PathExpansion.OFF));
}

@Test
@DisplayName("should reject indent exceeding MAX_INDENT")
void testExcessiveIndent() {
assertThrows(IllegalArgumentException.class,
() -> new DecodeOptions(DecodeOptions.MAX_ALLOWED_INDENT + 1, Delimiter.COMMA, true, PathExpansion.OFF));
}

@Test
@DisplayName("should reject null delimiter")
void testNullDelimiter() {
assertThrows(NullPointerException.class,
() -> new DecodeOptions(2, null, true, PathExpansion.OFF));
}
}

@Nested
@DisplayName("JsonNormalizer depth limits")
class JsonNormalizerDepthLimits {
@Test
@DisplayName("should have MAX_DEPTH constant")
void testMaxDepthConstant() {
assertEquals(512, JsonNormalizer.MAX_ALLOWED_NESTING_DEPTH);
}
}
}
Loading
Loading