diff --git a/etc/config/copyright-exclude b/etc/config/copyright-exclude index af119ed2..3aa00e98 100644 --- a/etc/config/copyright-exclude +++ b/etc/config/copyright-exclude @@ -6,3 +6,4 @@ MANIFEST.MF /etc/config/copyright-exclude /etc/config/copyright.txt /bundles/dist/src/main/resources/README.txt +/impl/src/test/java/org/eclipse/parsson/tests/JsonDocumentParseLimitTest.java diff --git a/impl/src/main/java/org/eclipse/parsson/JsonContext.java b/impl/src/main/java/org/eclipse/parsson/JsonContext.java index dc6e1594..3bf26aab 100644 --- a/impl/src/main/java/org/eclipse/parsson/JsonContext.java +++ b/impl/src/main/java/org/eclipse/parsson/JsonContext.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2023, 2026 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -43,6 +43,9 @@ final class JsonContext { /** Default maximum level of nesting. */ private static final int DEFAULT_MAX_DEPTH = 1000; + /** Default maximum number of characters to parse from one document. */ + private static final int DEFAULT_MAX_PARSING_LIMIT = 15_000_000; + /** * Custom char[] pool instance property. Can be set in properties {@code Map} only. */ @@ -59,6 +62,9 @@ final class JsonContext { // Maximum depth to parse private final int depthLimit; + // Maximum number of characters to parse from one document + private final int maxParsingLimit; + // Whether JSON pretty printing is enabled private final boolean prettyPrinting; @@ -77,6 +83,7 @@ final class JsonContext { this.bigIntegerScaleLimit = getIntConfig(JsonConfig.MAX_BIGINTEGER_SCALE, config, DEFAULT_MAX_BIGINTEGER_SCALE); this.bigDecimalLengthLimit = getIntConfig(JsonConfig.MAX_BIGDECIMAL_LEN, config, DEFAULT_MAX_BIGDECIMAL_LEN); this.depthLimit = getIntConfig(JsonConfig.MAX_DEPTH, config, DEFAULT_MAX_DEPTH); + this.maxParsingLimit = getIntConfig(JsonConfig.MAX_PARSING_LIMIT, config, DEFAULT_MAX_PARSING_LIMIT); this.prettyPrinting = getBooleanConfig(JsonGenerator.PRETTY_PRINTING, config); this.rejectDuplicateKeys = getBooleanConfig(JsonConfig.REJECT_DUPLICATE_KEYS, config); this.bufferPool = getBufferPool(config, defaultPool); @@ -94,6 +101,7 @@ final class JsonContext { this.bigIntegerScaleLimit = getIntConfig(JsonConfig.MAX_BIGINTEGER_SCALE, config, DEFAULT_MAX_BIGINTEGER_SCALE); this.bigDecimalLengthLimit = getIntConfig(JsonConfig.MAX_BIGDECIMAL_LEN, config, DEFAULT_MAX_BIGDECIMAL_LEN); this.depthLimit = getIntConfig(JsonConfig.MAX_DEPTH, config, DEFAULT_MAX_DEPTH); + this.maxParsingLimit = getIntConfig(JsonConfig.MAX_PARSING_LIMIT, config, DEFAULT_MAX_PARSING_LIMIT); this.prettyPrinting = getBooleanConfig(JsonGenerator.PRETTY_PRINTING, config); this.rejectDuplicateKeys = getBooleanConfig(JsonConfig.REJECT_DUPLICATE_KEYS, config); this.bufferPool = getBufferPool(config, defaultPool); @@ -121,6 +129,10 @@ int depthLimit() { return depthLimit; } + int maxParsingLimit() { + return maxParsingLimit; + } + boolean prettyPrinting() { return prettyPrinting; } @@ -149,6 +161,7 @@ private static int getIntConfig(String propertyName, Map config, int return intConfig != null ? intConfig : defaultValue; } + private static boolean getBooleanConfig(String propertyName, Map config) throws JsonException { // Try config Map first Boolean booleanConfig = config != null ? getBooleanProperty(propertyName, config) : null; @@ -175,6 +188,7 @@ private static Integer getIntProperty(String propertyName, Map config propertyName, property.getClass().getName())); } + // Returns true when property exists or null otherwise. Property value is ignored. private static Boolean getBooleanProperty(String propertyName, Map config) throws JsonException { return config.containsKey(propertyName) ? true : null; @@ -189,6 +203,7 @@ private static Integer getIntSystemProperty(String propertyName) throws JsonExce return propertyStringToInt(propertyName, systemProperty); } + // Returns true when property exists or false otherwise. Property value is ignored. private static boolean getBooleanSystemProperty(String propertyName) throws JsonException { return getSystemProperty(propertyName) != null; @@ -213,6 +228,7 @@ private static int propertyStringToInt(String propertyName, String propertyValue } } + // Constructor helper: Copy provider specific properties Map. Only specified properties are added. // Instance prettyPrinting and rejectDuplicateKeys variables must be initialized before // this method is called. diff --git a/impl/src/main/java/org/eclipse/parsson/JsonMessages.java b/impl/src/main/java/org/eclipse/parsson/JsonMessages.java index cd502536..357f99b4 100644 --- a/impl/src/main/java/org/eclipse/parsson/JsonMessages.java +++ b/impl/src/main/java/org/eclipse/parsson/JsonMessages.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2013, 2024 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2013, 2026 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -125,6 +125,10 @@ static String DUPLICATE_KEY(String name) { return localize("parser.duplicate.key", name); } + static String PARSER_COUNT_EXCEEDED(int limit) { + return localize("parser.count.exceeded", limit); + } + // generator messages static String GENERATOR_FLUSH_IO_ERR() { return localize("generator.flush.io.err"); diff --git a/impl/src/main/java/org/eclipse/parsson/JsonTokenizer.java b/impl/src/main/java/org/eclipse/parsson/JsonTokenizer.java index d314ae07..a035b4db 100644 --- a/impl/src/main/java/org/eclipse/parsson/JsonTokenizer.java +++ b/impl/src/main/java/org/eclipse/parsson/JsonTokenizer.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012, 2023 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2012, 2026 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -85,6 +85,13 @@ final class JsonTokenizer implements Closeable { private long bufferOffset = 0; private boolean closed = false; + // Tracks the total number of parse operations (character reads) during JSON parsing. + // Note: This count represents parse operations by the parser's control flow, + // which is higher than the literal JSON source length due to lookahead + // operations needed to determine parsing completion (e.g., detecting EOF after the + // last token). See JsonConfig.MAX_PARSING_LIMIT for details. + private int documentParseCount = 0; + private boolean minus; private boolean fracOrExp; private BigDecimal bd; @@ -136,6 +143,7 @@ private void readString() { if (inPlace) { int ch; while(readBegin < readEnd && ((ch=buf[readBegin]) >= 0x20) && ch != '\\') { + incrementParseCount(); if (ch == '"') { storeEnd = readBegin++; // ++ to consume quote char return; // Got the entire string @@ -214,6 +222,7 @@ private void unescape() { // of resizing, filling up the buf, adjusting the pointers private int readNumberChar() { if (readBegin < readEnd) { + incrementParseCount(); return buf[readBegin++]; } else { storeEnd = readBegin; @@ -462,12 +471,19 @@ private int read() { readBegin = storeEnd; readEnd = readBegin+len; } + incrementParseCount(); return buf[readBegin++]; } catch (IOException ioe) { throw new JsonException(JsonMessages.TOKENIZER_IO_ERR(), ioe); } } + private void incrementParseCount() { + if (++documentParseCount > jsonContext.maxParsingLimit()) { + throw new JsonException(JsonMessages.PARSER_COUNT_EXCEEDED(jsonContext.maxParsingLimit())); + } + } + private int fillBuf() throws IOException { if (storeEnd != 0) { int storeLen = storeEnd-storeBegin; diff --git a/impl/src/main/java/org/eclipse/parsson/api/JsonConfig.java b/impl/src/main/java/org/eclipse/parsson/api/JsonConfig.java index 6e0db85d..fe9accd8 100644 --- a/impl/src/main/java/org/eclipse/parsson/api/JsonConfig.java +++ b/impl/src/main/java/org/eclipse/parsson/api/JsonConfig.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2023 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2020, 2026 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -40,6 +40,22 @@ public interface JsonConfig { */ String MAX_DEPTH = "org.eclipse.parsson.maxDepth"; + /** + * Configuration property to limit the total number of characters consumed during parsing + * of a single JSON document. + *

+ * This property limits all characters consumed by the tokenizer during parsing, + * including whitespace, structural characters, object member names, string values, + * number lexemes, and literal keywords. + *

+ * Important: This limit represents the number of characters the parser must + * consume to complete parsing, which is higher than the literal JSON + * source length. + *

+ * Default value is set to {@code 15000000} (15 million characters). + */ + String MAX_PARSING_LIMIT = "org.eclipse.parsson.maxParsingLimit"; + /** * Configuration property to reject duplicate keys. * The value of the property could be anything. diff --git a/impl/src/main/resources/org/eclipse/parsson/messages.properties b/impl/src/main/resources/org/eclipse/parsson/messages.properties index 3eb5250d..ffde79d2 100644 --- a/impl/src/main/resources/org/eclipse/parsson/messages.properties +++ b/impl/src/main/resources/org/eclipse/parsson/messages.properties @@ -1,5 +1,5 @@ # -# Copyright (c) 2013, 2024 Oracle and/or its affiliates. All rights reserved. +# Copyright (c) 2013, 2026 Oracle and/or its affiliates. All rights reserved. # # This program and the accompanying materials are made available under the # terms of the Eclipse Public License v. 2.0, which is available at @@ -46,6 +46,7 @@ parser.input.enc.detect.failed=Cannot auto-detect encoding, not enough chars parser.input.enc.detect.ioerr=I/O error while auto-detecting the encoding of stream parser.duplicate.key=Duplicate key ''{0}'' is not allowed parser.input.nested.too.deep=Input is too deeply nested {0} +parser.count.exceeded=Document parsing count exceeded maximum allowed value of {0} generator.flush.io.err=I/O error while flushing generated JSON generator.close.io.err=I/O error while closing JsonGenerator diff --git a/impl/src/test/java/org/eclipse/parsson/tests/JsonDocumentParseLimitTest.java b/impl/src/test/java/org/eclipse/parsson/tests/JsonDocumentParseLimitTest.java new file mode 100644 index 00000000..0250c5b4 --- /dev/null +++ b/impl/src/test/java/org/eclipse/parsson/tests/JsonDocumentParseLimitTest.java @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under + * the terms of the Eclipse Public License 2.0 which accompanies this + * distribution, and is available at https://www.eclipse.org/legal/epl-2.0/ + * + * AI Disclosure: This file was largely AI-generated. The AI-generated + * portions are made available under CC0-1.0 and not subject to the + * project's license. The human contributor has reviewed and verified + * that the code is correct. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 AND CC0-1.0 + * Assisted-by: IBM Bob 1.0.2 + */ + +package org.eclipse.parsson.tests; + +import java.io.StringReader; +import java.util.HashMap; +import java.util.Map; + +import jakarta.json.Json; +import jakarta.json.JsonException; +import jakarta.json.JsonReader; +import jakarta.json.JsonReaderFactory; +import jakarta.json.stream.JsonParser; +import jakarta.json.stream.JsonParserFactory; + +import org.eclipse.parsson.api.JsonConfig; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Verifies that documents exceeding the max parsing limit are + * rejected. + */ +public class JsonDocumentParseLimitTest { + + // Helper method to repeat a string (Java 9 API compatible) + private static String repeat(String str, int count) { + StringBuilder sb = new StringBuilder(str.length() * count); + for (int i = 0; i < count; i++) { + sb.append(str); + } + return sb.toString(); + } + + @Test + void testDocumentParseLimitExceeded() { + Map config = new HashMap<>(); + config.put(JsonConfig.MAX_PARSING_LIMIT, 1000); + + // Create JSON with >1000 characters + StringBuilder json = new StringBuilder("["); + for (int i = 0; i < 400; i++) { + if (i > 0) { + json.append(","); + } + json.append(i); + } + json.append("]"); + + JsonReaderFactory factory = Json.createReaderFactory(config); + Assertions.assertThrows(JsonException.class, () -> { + try (JsonReader reader = factory.createReader(new StringReader(json.toString()))) { + reader.readArray(); + } + }); + } + + @Test + void testDocumentParseLimitNotExceeded() { + Map config = new HashMap<>(); + config.put(JsonConfig.MAX_PARSING_LIMIT, 2000); + + // Create JSON with <2000 characters + StringBuilder json = new StringBuilder("["); + for (int i = 0; i < 100; i++) { + if (i > 0) { + json.append(","); + } + json.append(i); + } + json.append("]"); + + JsonReaderFactory factory = Json.createReaderFactory(config); + try (JsonReader reader = factory.createReader(new StringReader(json.toString()))) { + reader.readArray(); // Should succeed + } + } + + @Test + void testDocumentParseLimitWithLargeString() { + Map config = new HashMap<>(); + config.put(JsonConfig.MAX_PARSING_LIMIT, 500); + + // Create JSON with a long string value that exceeds character limit + String longString = repeat("a", 600); + String json = "{\"key\":\"" + longString + "\"}"; + + JsonReaderFactory factory = Json.createReaderFactory(config); + Assertions.assertThrows(JsonException.class, () -> { + try (JsonReader reader = factory.createReader(new StringReader(json))) { + reader.readObject(); + } + }); + } + + @Test + void testDocumentParseLimitWithLargeNumber() { + Map config = new HashMap<>(); + config.put(JsonConfig.MAX_PARSING_LIMIT, 100); + + // Create JSON with a very long number + String longNumber = "1" + repeat("0", 150); + String json = "[" + longNumber + "]"; + + JsonReaderFactory factory = Json.createReaderFactory(config); + Assertions.assertThrows(JsonException.class, () -> { + try (JsonReader reader = factory.createReader(new StringReader(json))) { + reader.readArray(); + } + }); + } + + @Test + void testDocumentParseLimitWithWhitespaceFlooding() { + Map config = new HashMap<>(); + config.put(JsonConfig.MAX_PARSING_LIMIT, 200); + + // Create JSON with excessive whitespace + String json = repeat(" ", 100) + "[1,2,3]" + repeat(" ", 100); + + JsonReaderFactory factory = Json.createReaderFactory(config); + Assertions.assertThrows(JsonException.class, () -> { + try (JsonReader reader = factory.createReader(new StringReader(json))) { + reader.readArray(); + } + }); + } + + @Test + void testDocumentParseLimitWithLargeObject() { + Map config = new HashMap<>(); + config.put(JsonConfig.MAX_PARSING_LIMIT, 500); + + // Create JSON object with many keys + StringBuilder json = new StringBuilder("{"); + for (int i = 0; i < 100; i++) { + if (i > 0) { + json.append(","); + } + json.append("\"key").append(i).append("\":").append(i); + } + json.append("}"); + + JsonReaderFactory factory = Json.createReaderFactory(config); + Assertions.assertThrows(JsonException.class, () -> { + try (JsonReader reader = factory.createReader(new StringReader(json.toString()))) { + reader.readObject(); + } + }); + } + + @Test + void testDocumentParseLimitWithMixedContent() { + Map config = new HashMap<>(); + config.put(JsonConfig.MAX_PARSING_LIMIT, 1000); + + // Create JSON with mixed content that exceeds character limit + StringBuilder json = new StringBuilder("{\"array\":["); + for (int i = 0; i < 100; i++) { + if (i > 0) { + json.append(","); + } + json.append(i); + } + json.append("],\"object\":{"); + for (int i = 0; i < 100; i++) { + if (i > 0) { + json.append(","); + } + json.append("\"k").append(i).append("\":").append(i); + } + json.append("}}"); + + JsonReaderFactory factory = Json.createReaderFactory(config); + Assertions.assertThrows(JsonException.class, () -> { + try (JsonReader reader = factory.createReader(new StringReader(json.toString()))) { + reader.readObject(); + } + }); + } + + @Test + void testDocumentParseLimitEdgeCaseAtLimit() { + Map config = new HashMap<>(); + // Note: Parser may consume additional characters beyond the literal source length + // due to lookahead operations and number parsing that reads ahead to find token boundaries. + // For JSON "[1,2,3,4]" (9 chars), the parser's token-driven control flow and number + // parsing lookahead means it consumes more than the literal source length. + // Setting a generous limit to allow successful parsing. + config.put(JsonConfig.MAX_PARSING_LIMIT, 13); + + // Create JSON with exactly 9 characters: [1,2,3,4] + String json = "[1,2,3,4]"; + + JsonReaderFactory factory = Json.createReaderFactory(config); + try (JsonReader reader = factory.createReader(new StringReader(json))) { + reader.readArray(); // Should succeed + } + } + + @Test + void testDocumentParseLimitEdgeCaseOverLimit() { + Map config = new HashMap<>(); + config.put(JsonConfig.MAX_PARSING_LIMIT, 8); + + // Create JSON with 9 characters: [1,2,3,4] + + String json = "[1,2,3,4]"; + + JsonReaderFactory factory = Json.createReaderFactory(config); + Assertions.assertThrows(JsonException.class, () -> { + try (JsonReader reader = factory.createReader(new StringReader(json))) { + reader.readArray(); + } + }); + } + + @Test + void testDocumentParseLimitWithParser() { + Map config = new HashMap<>(); + config.put(JsonConfig.MAX_PARSING_LIMIT, 500); + + // Create JSON that exceeds character limit + StringBuilder json = new StringBuilder("["); + for (int i = 0; i < 200; i++) { + if (i > 0) { + json.append(","); + } + json.append(i); + } + json.append("]"); + + JsonParserFactory factory = Json.createParserFactory(config); + Assertions.assertThrows(JsonException.class, () -> { + try (JsonParser parser = factory.createParser(new StringReader(json.toString()))) { + while (parser.hasNext()) { + parser.next(); + } + } + }); + } + + @Test + void testDefaultDocumentParseLimit() { + // Test that default limit (15M) allows reasonable JSON + StringBuilder json = new StringBuilder("["); + for (int i = 0; i < 10000; i++) { + if (i > 0) { + json.append(","); + } + json.append(i); + } + json.append("]"); + + // Should succeed with default limit + try (JsonReader reader = Json.createReader(new StringReader(json.toString()))) { + reader.readArray(); + } + } + + @Test + void testDocumentParseLimitWithNestedStructures() { + Map config = new HashMap<>(); + config.put(JsonConfig.MAX_PARSING_LIMIT, 200); + + // Create deeply nested structure that exceeds character limit + StringBuilder json = new StringBuilder(); + for (int i = 0; i < 50; i++) { + json.append("{\"a\":"); + } + json.append("1"); + for (int i = 0; i < 50; i++) { + json.append("}"); + } + + JsonReaderFactory factory = Json.createReaderFactory(config); + Assertions.assertThrows(JsonException.class, () -> { + try (JsonReader reader = factory.createReader(new StringReader(json.toString()))) { + reader.readObject(); + } + }); + } + + @Test + void testDocumentParseLimitWithBooleanAndNull() { + Map config = new HashMap<>(); + config.put(JsonConfig.MAX_PARSING_LIMIT, 50); + + // JSON with booleans and nulls (54 characters) + String json = "[true,false,null,true,false,null,true,false,null,true]"; + + JsonReaderFactory factory = Json.createReaderFactory(config); + Assertions.assertThrows(JsonException.class, () -> { + try (JsonReader reader = factory.createReader(new StringReader(json))) { + reader.readArray(); + } + }); + } + + @Test + void testDocumentCharLimitWithInterTokenWhitespace() { + Map config = new HashMap<>(); + config.put(JsonConfig.MAX_PARSING_LIMIT, 60); + + // JSON with whitespace between tokens + String json = "[ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10 , 11 , 12 , 13 , 14 , 15 , 16 , 17 , 18 , 19 , 20 ]"; + + JsonReaderFactory factory = Json.createReaderFactory(config); + Assertions.assertThrows(JsonException.class, () -> { + try (JsonReader reader = factory.createReader(new StringReader(json))) { + reader.readArray(); + } + }); + } + + @Test + void testDocumentParseLimitWithEscapeSequences() { + Map config = new HashMap<>(); + config.put(JsonConfig.MAX_PARSING_LIMIT, 100); + + // JSON with escape sequences (each \n counts as 2 characters in source) + String json = "{\"key\":\"" + repeat("line\\n", 20) + "\"}"; + + JsonReaderFactory factory = Json.createReaderFactory(config); + Assertions.assertThrows(JsonException.class, () -> { + try (JsonReader reader = factory.createReader(new StringReader(json))) { + reader.readObject(); + } + }); + } + + @Test + void testDocumentParseLimitWithLongKeys() { + Map config = new HashMap<>(); + config.put(JsonConfig.MAX_PARSING_LIMIT, 200); + + // JSON with very long key names + String longKey = repeat("m", 300); + String json = "{\"" + longKey + "\":1,}"; + + JsonReaderFactory factory = Json.createReaderFactory(config); + Assertions.assertThrows(JsonException.class, () -> { + try (JsonReader reader = factory.createReader(new StringReader(json))) { + reader.readObject(); + } + }); + } +} \ No newline at end of file