From 05adc8be8864043d096e07239e777ec1ec41281d Mon Sep 17 00:00:00 2001 From: waynercheung Date: Thu, 30 Jul 2026 17:10:44 +0800 Subject: [PATCH 1/4] feat(http): improve JsonFormat output consistency Preserve Fastjson-compatible root-value parsing and add focused regression coverage. Use the default hexadecimal representation for transaction contract names. --- common/src/main/java/org/tron/json/JSON.java | 2 ++ .../org/tron/core/services/http/HttpSelfFormatFieldName.java | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/org/tron/json/JSON.java b/common/src/main/java/org/tron/json/JSON.java index 571b9515ad..ddab0af82a 100644 --- a/common/src/main/java/org/tron/json/JSON.java +++ b/common/src/main/java/org/tron/json/JSON.java @@ -50,6 +50,8 @@ public final class JSON { .configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, true) // Fastjson Feature.IgnoreNotMatch (default ON) — unknown fields silently ignored .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + // Fastjson 1.x rejects non-comment tokens after the root value + .configure(DeserializationFeature.FAIL_ON_TRAILING_TOKENS, true) // Fastjson serializes empty beans as "{}" without error .configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false) // Fastjson omits null-valued fields by default (WriteMapNullValue is OFF by default) diff --git a/framework/src/main/java/org/tron/core/services/http/HttpSelfFormatFieldName.java b/framework/src/main/java/org/tron/core/services/http/HttpSelfFormatFieldName.java index c551259fb3..7435c82107 100644 --- a/framework/src/main/java/org/tron/core/services/http/HttpSelfFormatFieldName.java +++ b/framework/src/main/java/org/tron/core/services/http/HttpSelfFormatFieldName.java @@ -246,7 +246,7 @@ public class HttpSelfFormatFieldName { //authority NameFieldNameMap.put("protocol.authority.permission_name", 1); //Transaction - NameFieldNameMap.put("protocol.Transaction.Contract.ContractName", 1); + // NameFieldNameMap.put("protocol.Transaction.Contract.ContractName", 1); //TransactionInfo NameFieldNameMap.put("protocol.TransactionInfo.resMessage", 1); From 2fd5455c6619245abeae17aa28e8b29225339d52 Mon Sep 17 00:00:00 2001 From: liuxincheng Date: Mon, 27 Jul 2026 18:10:54 +0800 Subject: [PATCH 2/4] fix: harden gRPC HTTP/2 stream limits --- build.gradle | 6 +- .../main/java/org/tron/core/config/README.md | 9 +- .../org/tron/core/config/args/NodeConfig.java | 16 +- common/src/main/resources/reference.conf | 5 +- .../tron/core/config/args/NodeConfigTest.java | 26 +- docs/configuration.md | 8 +- framework/build.gradle | 4 + .../GrpcNettyMaxConcurrentStreamsLimiter.java | 79 +++++ .../tron/common/application/RpcService.java | 3 +- ...cNettyMaxConcurrentStreamsLimiterTest.java | 108 ++++++ .../RpcServiceHttp2SecurityTest.java | 283 ++++++++++++++++ .../org/tron/core/config/args/ArgsTest.java | 2 +- gradle/java-tron.vmoptions | 3 +- gradle/jdk17/java-tron.vmoptions | 3 +- gradle/verification-metadata.xml | 316 +++++++++++------- protocol/build.gradle | 12 +- start.sh | 3 +- start.sh.simple | 1 + 18 files changed, 752 insertions(+), 135 deletions(-) create mode 100644 framework/src/main/java/org/tron/common/application/GrpcNettyMaxConcurrentStreamsLimiter.java create mode 100644 framework/src/test/java/org/tron/common/application/GrpcNettyMaxConcurrentStreamsLimiterTest.java create mode 100644 framework/src/test/java/org/tron/common/application/RpcServiceHttp2SecurityTest.java diff --git a/build.gradle b/build.gradle index e143ab3a94..65e72c0fb7 100644 --- a/build.gradle +++ b/build.gradle @@ -5,6 +5,10 @@ plugins { id 'net.ltgt.errorprone' version '5.0.0' apply false } +ext { + grpcVersion = "1.83.0" +} + allprojects { version = "1.0.0" apply plugin: "java-library" @@ -41,7 +45,7 @@ ext.archInfo = [ // https://github.com/grpc/grpc-java/issues/7690 // https://github.com/grpc/grpc-java/pull/12319, Add support for macOS aarch64 with universal binary // https://github.com/grpc/grpc-java/pull/11371 , 1.64.x is not supported CentOS 7. - ProtocGenVersion: isArm64 || isMac ? '1.81.0' : '1.60.0' + ProtocGenVersion: isArm64 || isMac ? rootProject.grpcVersion : '1.60.0' ], VMOptions: isArm64 ? "${rootDir}/gradle/jdk17/java-tron.vmoptions" : "${rootDir}/gradle/java-tron.vmoptions" ] diff --git a/common/src/main/java/org/tron/core/config/README.md b/common/src/main/java/org/tron/core/config/README.md index 1380c98984..5618cb9fed 100644 --- a/common/src/main/java/org/tron/core/config/README.md +++ b/common/src/main/java/org/tron/core/config/README.md @@ -54,8 +54,9 @@ node { # Number of gRPC thread, default availableProcessors / 2 # thread = 16 - # The maximum number of concurrent calls permitted for each incoming connection - # maxConcurrentCallsPerConnection = + # The maximum number of concurrent calls permitted for each incoming connection, + # default 100. Setting 0 also uses the secure default. + # maxConcurrentCallsPerConnection = 100 # The HTTP/2 flow control window, default 1MB # flowControlWindow = @@ -75,6 +76,10 @@ node { } ``` +> **Upgrade note:** `maxConcurrentCallsPerConnection = 0` previously disabled the limit. +> It now selects the secure default of 100. Configure an explicit positive value if a node +> requires more than 100 concurrent calls on one connection. + ## backup You can customize backup options in the `node.backup` part of `config.conf`, which looks like: ``` diff --git a/common/src/main/java/org/tron/core/config/args/NodeConfig.java b/common/src/main/java/org/tron/core/config/args/NodeConfig.java index 2158f56d0b..91945b5a73 100644 --- a/common/src/main/java/org/tron/core/config/args/NodeConfig.java +++ b/common/src/main/java/org/tron/core/config/args/NodeConfig.java @@ -207,6 +207,8 @@ public static class HttpConfig { @Setter public static class RpcConfig { + public static final int DEFAULT_MAX_CONCURRENT_CALLS_PER_CONNECTION = 100; + private boolean enable = true; private int port = 50051; private boolean solidityEnable = true; @@ -215,7 +217,8 @@ public static class RpcConfig { private int pBFTPort = 50071; private int thread = 0; - private int maxConcurrentCallsPerConnection = 0; + private int maxConcurrentCallsPerConnection = + DEFAULT_MAX_CONCURRENT_CALLS_PER_CONNECTION; private int flowControlWindow = 1048576; private long maxConnectionIdleInMillis = 0; private long maxConnectionAgeInMillis = 0; @@ -358,8 +361,17 @@ private void postProcess() { rpc.thread = (Runtime.getRuntime().availableProcessors() + 1) / 2; } + if (rpc.maxConcurrentCallsPerConnection < 0) { + throw new TronError("node.rpc.maxConcurrentCallsPerConnection must be non-negative, got: " + + rpc.maxConcurrentCallsPerConnection, PARAMETER_INIT); + } if (rpc.maxConcurrentCallsPerConnection == 0) { - rpc.maxConcurrentCallsPerConnection = Integer.MAX_VALUE; + logger.warn("Configuring [node.rpc.maxConcurrentCallsPerConnection] as 0 no longer " + + "disables the limit; using the secure default of {}. Configure an explicit positive " + + "value if more concurrency is required.", + RpcConfig.DEFAULT_MAX_CONCURRENT_CALLS_PER_CONNECTION); + rpc.maxConcurrentCallsPerConnection = + RpcConfig.DEFAULT_MAX_CONCURRENT_CALLS_PER_CONNECTION; } if (rpc.maxConnectionIdleInMillis == 0) { rpc.maxConnectionIdleInMillis = Long.MAX_VALUE; diff --git a/common/src/main/resources/reference.conf b/common/src/main/resources/reference.conf index 25fc4832e5..d8c483d932 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -289,9 +289,8 @@ node { # Number of gRPC threads, 0 = auto (availableProcessors / 2) thread = 0 - # Maximum concurrent calls per incoming connection - # 0 means No limit on concurrent calls per connection - maxConcurrentCallsPerConnection = 0 + # Maximum concurrent calls per incoming connection. 0 falls back to the secure default of 100. + maxConcurrentCallsPerConnection = 100 # HTTP/2 flow control window (bytes), default 1MB flowControlWindow = 1048576 diff --git a/common/src/test/java/org/tron/core/config/args/NodeConfigTest.java b/common/src/test/java/org/tron/core/config/args/NodeConfigTest.java index bbc2d2475e..bcb8b09dd7 100644 --- a/common/src/test/java/org/tron/core/config/args/NodeConfigTest.java +++ b/common/src/test/java/org/tron/core/config/args/NodeConfigTest.java @@ -2,11 +2,13 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import com.typesafe.config.Config; import com.typesafe.config.ConfigFactory; import org.junit.Test; +import org.tron.core.exception.TronError; public class NodeConfigTest { @@ -102,7 +104,8 @@ public void testRpcDefaultsFromReference() { NodeConfig.RpcConfig rpc = nc.getRpc(); // reference.conf provides actual final defaults, no sentinel conversion needed - assertEquals(2147483647, rpc.getMaxConcurrentCallsPerConnection()); + assertEquals(NodeConfig.RpcConfig.DEFAULT_MAX_CONCURRENT_CALLS_PER_CONNECTION, + rpc.getMaxConcurrentCallsPerConnection()); assertEquals(1048576, rpc.getFlowControlWindow()); assertEquals(9223372036854775807L, rpc.getMaxConnectionIdleInMillis()); assertEquals(9223372036854775807L, rpc.getMaxConnectionAgeInMillis()); @@ -122,6 +125,27 @@ public void testRpcUserOverrideZeroNotConverted() { assertEquals(0, nc.getRpc().getMinEffectiveConnection()); } + @Test + public void testRpcZeroConcurrentCallsUsesSecureDefault() { + Config config = withRef( + "node { rpc { maxConcurrentCallsPerConnection = 0 } }"); + NodeConfig nc = NodeConfig.fromConfig(config); + assertEquals(NodeConfig.RpcConfig.DEFAULT_MAX_CONCURRENT_CALLS_PER_CONNECTION, + nc.getRpc().getMaxConcurrentCallsPerConnection()); + } + + @Test + public void testRpcNegativeConcurrentCallsRejected() { + Config config = withRef( + "node { rpc { maxConcurrentCallsPerConnection = -1 } }"); + + TronError exception = assertThrows(TronError.class, + () -> NodeConfig.fromConfig(config)); + + assertTrue(exception.getMessage().contains( + "node.rpc.maxConcurrentCallsPerConnection must be non-negative, got: -1")); + } + @Test public void testRpcUserOverrideExplicitValues() { Config config = withRef( diff --git a/docs/configuration.md b/docs/configuration.md index 28b53b1970..d021326a15 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -104,8 +104,8 @@ node { port = 50051 solidityEnable = true solidityPort = 50061 - # Maximum concurrent calls per connection. 0 = no limit. - maxConcurrentCallsPerConnection = 0 + # Maximum concurrent calls per connection. 0 uses the secure default of 100. + maxConcurrentCallsPerConnection = 100 # Idle connection timeout (ms). 0 = no limit. maxConnectionIdleInMillis = 0 # Minimum active connections required before broadcasting transactions. @@ -114,6 +114,10 @@ node { } ``` +> **Upgrade note:** `node.rpc.maxConcurrentCallsPerConnection = 0` previously meant no limit. +> It now selects the secure default of 100. Configure an explicit positive value if a client +> needs more than 100 concurrent calls on one connection. + To disable an API endpoint that you do not want to expose publicly, set its `Enable` flag to `false` or add endpoints to `node.disabledApi`: ```hocon diff --git a/framework/build.gradle b/framework/build.gradle index 0ce33f253c..8255fc30d1 100644 --- a/framework/build.gradle +++ b/framework/build.gradle @@ -40,6 +40,10 @@ dependencies { // end local libraries implementation group: 'com.beust', name: 'jcommander', version: '1.78' implementation group: 'io.dropwizard.metrics', name: 'metrics-core', version: '3.1.2' + implementation('io.netty:netty-codec-protobuf:4.2.15.Final') { + exclude group: 'com.google.protobuf' + exclude group: 'com.google.protobuf.nano' + } // http implementation 'org.eclipse.jetty:jetty-server:9.4.58.v20250814' implementation 'org.eclipse.jetty:jetty-servlet:9.4.58.v20250814' diff --git a/framework/src/main/java/org/tron/common/application/GrpcNettyMaxConcurrentStreamsLimiter.java b/framework/src/main/java/org/tron/common/application/GrpcNettyMaxConcurrentStreamsLimiter.java new file mode 100644 index 0000000000..cdd71ffee3 --- /dev/null +++ b/framework/src/main/java/org/tron/common/application/GrpcNettyMaxConcurrentStreamsLimiter.java @@ -0,0 +1,79 @@ +/* + * java-tron is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * java-tron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with java-tron. If not, see . + */ + +package org.tron.common.application; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import io.grpc.netty.GrpcHttp2ConnectionHandler; +import io.grpc.netty.InternalProtocolNegotiator; +import io.grpc.netty.InternalProtocolNegotiators; +import io.grpc.netty.NettyServerBuilder; +import io.netty.channel.ChannelHandler; +import io.netty.util.AsciiString; + +/** Enforces the advertised HTTP/2 concurrent stream limit for grpc-netty servers. */ +final class GrpcNettyMaxConcurrentStreamsLimiter { + + private GrpcNettyMaxConcurrentStreamsLimiter() { + } + + static NettyServerBuilder configurePlaintext( + NettyServerBuilder builder, int maxConcurrentStreams) { + checkNotNull(builder, "builder"); + checkArgument(maxConcurrentStreams > 0, "maxConcurrentStreams must be positive"); + builder.maxConcurrentCallsPerConnection(maxConcurrentStreams); + // TODO: Remove this shim after https://github.com/grpc/grpc-java/issues/12930 is fixed. + return builder.protocolNegotiator(newPlaintextNegotiator(maxConcurrentStreams)); + } + + static InternalProtocolNegotiator.ProtocolNegotiator newPlaintextNegotiator( + int maxConcurrentStreams) { + checkArgument(maxConcurrentStreams > 0, "maxConcurrentStreams must be positive"); + return new EnforcingProtocolNegotiator( + InternalProtocolNegotiators.serverPlaintext(), maxConcurrentStreams); + } + + private static final class EnforcingProtocolNegotiator + implements InternalProtocolNegotiator.ProtocolNegotiator { + + private final InternalProtocolNegotiator.ProtocolNegotiator delegate; + private final int maxConcurrentStreams; + + private EnforcingProtocolNegotiator( + InternalProtocolNegotiator.ProtocolNegotiator delegate, int maxConcurrentStreams) { + this.delegate = checkNotNull(delegate, "delegate"); + this.maxConcurrentStreams = maxConcurrentStreams; + } + + @Override + public AsciiString scheme() { + return delegate.scheme(); + } + + @Override + public ChannelHandler newHandler(GrpcHttp2ConnectionHandler grpcHandler) { + // grpc-java builds the connection directly, bypassing Netty's builder-side enforcement. + grpcHandler.connection().remote().maxActiveStreams(maxConcurrentStreams); + return delegate.newHandler(grpcHandler); + } + + @Override + public void close() { + delegate.close(); + } + } +} diff --git a/framework/src/main/java/org/tron/common/application/RpcService.java b/framework/src/main/java/org/tron/common/application/RpcService.java index c398b71ae4..27fcc479f4 100644 --- a/framework/src/main/java/org/tron/common/application/RpcService.java +++ b/framework/src/main/java/org/tron/common/application/RpcService.java @@ -100,8 +100,9 @@ protected NettyServerBuilder initServerBuilder() { serverBuilder = serverBuilder.executor(this.executorService); } // Set configs from config.conf or default value + serverBuilder = GrpcNettyMaxConcurrentStreamsLimiter.configurePlaintext( + serverBuilder, parameter.getMaxConcurrentCallsPerConnection()); serverBuilder - .maxConcurrentCallsPerConnection(parameter.getMaxConcurrentCallsPerConnection()) .flowControlWindow(parameter.getFlowControlWindow()) .maxConnectionIdle(parameter.getMaxConnectionIdleInMillis(), TimeUnit.MILLISECONDS) .maxConnectionAge(parameter.getMaxConnectionAgeInMillis(), TimeUnit.MILLISECONDS) diff --git a/framework/src/test/java/org/tron/common/application/GrpcNettyMaxConcurrentStreamsLimiterTest.java b/framework/src/test/java/org/tron/common/application/GrpcNettyMaxConcurrentStreamsLimiterTest.java new file mode 100644 index 0000000000..fc578ca794 --- /dev/null +++ b/framework/src/test/java/org/tron/common/application/GrpcNettyMaxConcurrentStreamsLimiterTest.java @@ -0,0 +1,108 @@ +/* + * java-tron is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * java-tron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with java-tron. If not, see . + */ + +package org.tron.common.application; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; + +import io.grpc.ChannelLogger; +import io.grpc.ChannelLogger.ChannelLogLevel; +import io.grpc.netty.GrpcHttp2ConnectionHandler; +import io.grpc.netty.InternalProtocolNegotiator; +import io.netty.channel.ChannelHandler; +import io.netty.handler.codec.http2.DefaultHttp2Connection; +import io.netty.handler.codec.http2.DefaultHttp2ConnectionDecoder; +import io.netty.handler.codec.http2.DefaultHttp2ConnectionEncoder; +import io.netty.handler.codec.http2.DefaultHttp2FrameReader; +import io.netty.handler.codec.http2.DefaultHttp2FrameWriter; +import io.netty.handler.codec.http2.Http2Connection; +import io.netty.handler.codec.http2.Http2ConnectionDecoder; +import io.netty.handler.codec.http2.Http2ConnectionEncoder; +import io.netty.handler.codec.http2.Http2Error; +import io.netty.handler.codec.http2.Http2Exception; +import io.netty.handler.codec.http2.Http2FrameWriter; +import io.netty.handler.codec.http2.Http2Settings; +import org.junit.Test; + +public class GrpcNettyMaxConcurrentStreamsLimiterTest { + + private static final ChannelLogger NOOP_LOGGER = new ChannelLogger() { + @Override + public void log(ChannelLogLevel level, String message) { + } + + @Override + public void log(ChannelLogLevel level, String messageFormat, Object... args) { + } + }; + + @Test + public void shouldEnforceMaxStreamsBeforeSettingsAck() throws Exception { + Http2Connection connection = new DefaultHttp2Connection(true); + GrpcHttp2ConnectionHandler grpcHandler = newGrpcHandler(connection); + InternalProtocolNegotiator.ProtocolNegotiator negotiator = + GrpcNettyMaxConcurrentStreamsLimiter.newPlaintextNegotiator(2); + + ChannelHandler negotiationHandler = negotiator.newHandler(grpcHandler); + + assertNotNull(negotiationHandler); + assertEquals(2, connection.remote().maxActiveStreams()); + connection.remote().createStream(1, true); + connection.remote().createStream(3, true); + Http2Exception exception = assertThrows( + Http2Exception.class, () -> connection.remote().createStream(5, true)); + assertEquals(Http2Error.REFUSED_STREAM, exception.error()); + negotiator.close(); + } + + @Test + public void shouldIgnoreClientMaxHeaderListSizeOnServer() throws Exception { + Http2Connection connection = new DefaultHttp2Connection(true); + Http2FrameWriter frameWriter = new DefaultHttp2FrameWriter(); + Http2ConnectionEncoder encoder = + new DefaultHttp2ConnectionEncoder(connection, frameWriter); + long originalMaxHeaderListSize = + encoder.configuration().headersConfiguration().maxHeaderListSize(); + + encoder.remoteSettings(new Http2Settings().maxHeaderListSize(1)); + + assertEquals(originalMaxHeaderListSize, + encoder.configuration().headersConfiguration().maxHeaderListSize()); + encoder.close(); + } + + @Test + public void shouldRejectNonPositiveStreamLimit() { + IllegalArgumentException zeroLimitException = assertThrows(IllegalArgumentException.class, + () -> GrpcNettyMaxConcurrentStreamsLimiter.newPlaintextNegotiator(0)); + assertEquals("maxConcurrentStreams must be positive", zeroLimitException.getMessage()); + IllegalArgumentException negativeLimitException = assertThrows(IllegalArgumentException.class, + () -> GrpcNettyMaxConcurrentStreamsLimiter.newPlaintextNegotiator(-1)); + assertEquals("maxConcurrentStreams must be positive", negativeLimitException.getMessage()); + } + + private static GrpcHttp2ConnectionHandler newGrpcHandler(Http2Connection connection) { + Http2FrameWriter frameWriter = new DefaultHttp2FrameWriter(); + Http2ConnectionEncoder encoder = + new DefaultHttp2ConnectionEncoder(connection, frameWriter); + Http2ConnectionDecoder decoder = new DefaultHttp2ConnectionDecoder( + connection, encoder, new DefaultHttp2FrameReader()); + return new GrpcHttp2ConnectionHandler( + null, decoder, encoder, new Http2Settings(), NOOP_LOGGER) { + }; + } +} diff --git a/framework/src/test/java/org/tron/common/application/RpcServiceHttp2SecurityTest.java b/framework/src/test/java/org/tron/common/application/RpcServiceHttp2SecurityTest.java new file mode 100644 index 0000000000..9ad83cccec --- /dev/null +++ b/framework/src/test/java/org/tron/common/application/RpcServiceHttp2SecurityTest.java @@ -0,0 +1,283 @@ +/* + * java-tron is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * java-tron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with java-tron. If not, see . + */ + +package org.tron.common.application; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.tron.common.math.StrictMathWrapper.min; + +import com.google.protobuf.Empty; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Server; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerServiceDefinition; +import io.grpc.netty.NettyServerBuilder; +import io.grpc.protobuf.ProtoUtils; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http2.DefaultHttp2Headers; +import io.netty.handler.codec.http2.DefaultHttp2HeadersEncoder; +import io.netty.handler.codec.http2.Http2Error; +import io.netty.handler.codec.http2.Http2Headers; +import java.io.ByteArrayOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.config.args.Args; + +public class RpcServiceHttp2SecurityTest { + + private static final int HEADERS_FRAME_TYPE = 0x1; + private static final int RST_STREAM_FRAME_TYPE = 0x3; + private static final int SETTINGS_FRAME_TYPE = 0x4; + private static final int GO_AWAY_FRAME_TYPE = 0x7; + private static final int SETTINGS_MAX_CONCURRENT_STREAMS = 0x3; + private static final byte[] CLIENT_PREFACE = + "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII); + private static final byte[] EMPTY_SETTINGS_FRAME = + new byte[]{0, 0, 0, 4, 0, 0, 0, 0, 0}; + private static final String SERVICE_NAME = "test.HoldService"; + private static final String METHOD_NAME = "Hold"; + private static final String METHOD_PATH = "/" + SERVICE_NAME + "/" + METHOD_NAME; + + private CommonParameter parameter; + private int previousRpcThreadNum; + private int previousMaxConcurrentCalls; + private int previousFlowControlWindow; + private long previousMaxConnectionIdle; + private long previousMaxConnectionAge; + private int previousMaxMessageSize; + private int previousMaxHeaderListSize; + private int previousMaxRstStream; + private int previousSecondsPerWindow; + private boolean previousReflectionServiceEnable; + + @Before + public void setUp() { + parameter = Args.getInstance(); + previousRpcThreadNum = parameter.getRpcThreadNum(); + previousMaxConcurrentCalls = parameter.getMaxConcurrentCallsPerConnection(); + previousFlowControlWindow = parameter.getFlowControlWindow(); + previousMaxConnectionIdle = parameter.getMaxConnectionIdleInMillis(); + previousMaxConnectionAge = parameter.getMaxConnectionAgeInMillis(); + previousMaxMessageSize = parameter.getMaxMessageSize(); + previousMaxHeaderListSize = parameter.getMaxHeaderListSize(); + previousMaxRstStream = parameter.getRpcMaxRstStream(); + previousSecondsPerWindow = parameter.getRpcSecondsPerWindow(); + previousReflectionServiceEnable = parameter.isRpcReflectionServiceEnable(); + + parameter.setRpcThreadNum(0); + parameter.setMaxConcurrentCallsPerConnection(2); + parameter.setFlowControlWindow(NettyServerBuilder.DEFAULT_FLOW_CONTROL_WINDOW); + parameter.setMaxConnectionIdleInMillis(60_000); + parameter.setMaxConnectionAgeInMillis(Long.MAX_VALUE); + parameter.setMaxMessageSize(4 * 1024 * 1024); + parameter.setMaxHeaderListSize(8 * 1024); + parameter.setRpcMaxRstStream(0); + parameter.setRpcSecondsPerWindow(0); + parameter.setRpcReflectionServiceEnable(false); + } + + @After + public void tearDown() { + parameter.setRpcThreadNum(previousRpcThreadNum); + parameter.setMaxConcurrentCallsPerConnection(previousMaxConcurrentCalls); + parameter.setFlowControlWindow(previousFlowControlWindow); + parameter.setMaxConnectionIdleInMillis(previousMaxConnectionIdle); + parameter.setMaxConnectionAgeInMillis(previousMaxConnectionAge); + parameter.setMaxMessageSize(previousMaxMessageSize); + parameter.setMaxHeaderListSize(previousMaxHeaderListSize); + parameter.setRpcMaxRstStream(previousMaxRstStream); + parameter.setRpcSecondsPerWindow(previousSecondsPerWindow); + parameter.setRpcReflectionServiceEnable(previousReflectionServiceEnable); + } + + @Test + public void shouldRejectExcessStreamsBeforeClientAcknowledgesSettings() throws Exception { + TestRpcService rpcService = new TestRpcService(); + Server server = rpcService.newServerBuilder() + .addService(newHoldService()) + .build() + .start(); + + try (Socket socket = new Socket("127.0.0.1", server.getPort())) { + socket.setSoTimeout(5_000); + OutputStream output = socket.getOutputStream(); + output.write(CLIENT_PREFACE); + output.write(EMPTY_SETTINGS_FRAME); + output.write(newHeadersFrame(1)); + output.write(newHeadersFrame(3)); + output.write(newHeadersFrame(5)); + output.flush(); + + assertSettingsAndRefusedStream(socket.getInputStream(), 2, 5); + } finally { + server.shutdownNow(); + assertTrue(server.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + private static ServerServiceDefinition newHoldService() { + MethodDescriptor method = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.BIDI_STREAMING) + .setFullMethodName(MethodDescriptor.generateFullMethodName( + SERVICE_NAME, METHOD_NAME)) + .setRequestMarshaller(ProtoUtils.marshaller(Empty.getDefaultInstance())) + .setResponseMarshaller(ProtoUtils.marshaller(Empty.getDefaultInstance())) + .build(); + + return ServerServiceDefinition.builder(SERVICE_NAME) + .addMethod(method, new ServerCallHandler() { + @Override + public ServerCall.Listener startCall( + ServerCall call, Metadata headers) { + return new ServerCall.Listener() { + }; + } + }) + .build(); + } + + private static byte[] newHeadersFrame(int streamId) throws Exception { + DefaultHttp2HeadersEncoder encoder = new DefaultHttp2HeadersEncoder(); + ByteBuf headerBlock = Unpooled.buffer(); + ByteBuf frame = Unpooled.buffer(); + try { + Http2Headers headers = new DefaultHttp2Headers() + .method("POST") + .scheme("http") + .authority("localhost") + .path(METHOD_PATH) + .set("content-type", "application/grpc") + .set("te", "trailers"); + encoder.encodeHeaders(streamId, headers, headerBlock); + + frame.writeMedium(headerBlock.readableBytes()); + frame.writeByte(HEADERS_FRAME_TYPE); + frame.writeByte(0x4); + frame.writeInt(streamId); + frame.writeBytes(headerBlock); + return ByteBufUtil.getBytes(frame); + } finally { + frame.release(); + headerBlock.release(); + encoder.close(); + } + } + + private static void assertSettingsAndRefusedStream( + InputStream input, long expectedMaxConcurrentStreams, int expectedRefusedStreamId) + throws IOException { + boolean advertisedLimitFound = false; + for (int i = 0; i < 20; i++) { + Http2Frame frame = readFrame(input); + if (frame.type == SETTINGS_FRAME_TYPE && frame.streamId == 0) { + advertisedLimitFound |= hasSetting( + frame.payload, SETTINGS_MAX_CONCURRENT_STREAMS, expectedMaxConcurrentStreams); + } + if (frame.type == RST_STREAM_FRAME_TYPE && frame.streamId == expectedRefusedStreamId) { + assertTrue("Server did not advertise the enforced concurrent-stream limit", + advertisedLimitFound); + assertEquals(4, frame.payload.length); + long errorCode = ByteBuffer.wrap(frame.payload).getInt() & 0xffff_ffffL; + assertEquals(Http2Error.REFUSED_STREAM.code(), errorCode); + return; + } + if (frame.type == GO_AWAY_FRAME_TYPE) { + fail("Server closed the connection instead of refusing only the excess stream"); + } + } + fail("No REFUSED_STREAM response for stream " + expectedRefusedStreamId); + } + + private static boolean hasSetting(byte[] payload, int expectedId, long expectedValue) { + assertEquals("Invalid HTTP/2 SETTINGS payload length", 0, payload.length % 6); + ByteBuffer settings = ByteBuffer.wrap(payload); + while (settings.remaining() >= 6) { + int id = settings.getShort() & 0xffff; + long value = settings.getInt() & 0xffff_ffffL; + if (id == expectedId) { + assertEquals(expectedValue, value); + return true; + } + } + return false; + } + + private static Http2Frame readFrame(InputStream input) throws IOException { + byte[] header = readFully(input, 9); + int payloadLength = + ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff); + int type = header[3] & 0xff; + int streamId = ByteBuffer.wrap(header, 5, 4).getInt() & 0x7fff_ffff; + return new Http2Frame(type, streamId, readFully(input, payloadLength)); + } + + private static byte[] readFully(InputStream input, int length) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(length); + byte[] buffer = new byte[min(length, 1024)]; + while (output.size() < length) { + int read = input.read(buffer, 0, min(buffer.length, length - output.size())); + if (read < 0) { + throw new EOFException("Unexpected end of HTTP/2 frame"); + } + output.write(buffer, 0, read); + } + return output.toByteArray(); + } + + private static final class Http2Frame { + + private final int type; + private final int streamId; + private final byte[] payload; + + private Http2Frame(int type, int streamId, byte[] payload) { + this.type = type; + this.streamId = streamId; + this.payload = payload; + } + } + + private static final class TestRpcService extends RpcService { + + private TestRpcService() { + port = 0; + } + + private NettyServerBuilder newServerBuilder() { + return initServerBuilder(); + } + + @Override + protected void addService(NettyServerBuilder serverBuilder) { + } + } +} diff --git a/framework/src/test/java/org/tron/core/config/args/ArgsTest.java b/framework/src/test/java/org/tron/core/config/args/ArgsTest.java index 076a8ab538..36b8a3269c 100644 --- a/framework/src/test/java/org/tron/core/config/args/ArgsTest.java +++ b/framework/src/test/java/org/tron/core/config/args/ArgsTest.java @@ -103,7 +103,7 @@ public void get() { // gRPC network configs checking Assert.assertEquals(50051, parameter.getRpcPort()); - Assert.assertEquals(Integer.MAX_VALUE, parameter.getMaxConcurrentCallsPerConnection()); + Assert.assertEquals(100, parameter.getMaxConcurrentCallsPerConnection()); Assert .assertEquals(NettyServerBuilder .DEFAULT_FLOW_CONTROL_WINDOW, parameter.getFlowControlWindow()); diff --git a/gradle/java-tron.vmoptions b/gradle/java-tron.vmoptions index e994a33274..cf34689ebd 100644 --- a/gradle/java-tron.vmoptions +++ b/gradle/java-tron.vmoptions @@ -4,4 +4,5 @@ -XX:+PrintGCDateStamps -XX:+CMSParallelRemarkEnabled -XX:ReservedCodeCacheSize=256m --XX:+CMSScavengeBeforeRemark \ No newline at end of file +-XX:+CMSScavengeBeforeRemark +-Dio.netty.allocator.type=pooled diff --git a/gradle/jdk17/java-tron.vmoptions b/gradle/jdk17/java-tron.vmoptions index 7af3123d26..180ff1aa7c 100644 --- a/gradle/jdk17/java-tron.vmoptions +++ b/gradle/jdk17/java-tron.vmoptions @@ -5,4 +5,5 @@ -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -XX:MaxDirectMemorySize=1g --XX:+HeapDumpOnOutOfMemoryError \ No newline at end of file +-XX:+HeapDumpOnOutOfMemoryError +-Dio.netty.allocator.type=pooled diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 832d2728f0..61cdda33ed 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -432,6 +432,14 @@ + + + + + + + + @@ -445,6 +453,11 @@ + + + + + @@ -490,6 +503,14 @@ + + + + + + + + @@ -526,6 +547,11 @@ + + + + + @@ -635,6 +661,17 @@ + + + + + + + + + + + @@ -680,6 +717,11 @@ + + + + + @@ -730,6 +772,11 @@ + + + + + @@ -754,6 +801,14 @@ + + + + + + + + @@ -762,6 +817,14 @@ + + + + + + + + @@ -772,6 +835,11 @@ + + + + + @@ -1103,76 +1171,76 @@ - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + @@ -1183,111 +1251,127 @@ - - - + + + - - + + - - + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + + + + - - - + + + - - + + - - - + + + - - + + + + + + + + + + + + + + + - - - + + + - - + + @@ -1877,6 +1961,14 @@ + + + + + + + + diff --git a/protocol/build.gradle b/protocol/build.gradle index 0ce01a9bfb..ed8914343b 100644 --- a/protocol/build.gradle +++ b/protocol/build.gradle @@ -2,8 +2,6 @@ apply plugin: 'com.google.protobuf' apply from: 'protoLint.gradle' def protobufVersion = '3.25.8' -// keep same version as protoc-gen-grpc-java for arm64 or macOS, see rootProject.archInfo.requires.ProtocGenVersion -def grpcVersion = '1.81.0' dependencies { api group: 'com.google.protobuf', name: 'protobuf-java', version: protobufVersion @@ -12,11 +10,11 @@ dependencies { // checkstyleConfig "com.puppycrawl.tools:checkstyle:${versions.checkstyle}" // google grpc - api group: 'io.grpc', name: 'grpc-netty', version: grpcVersion - api group: 'io.grpc', name: 'grpc-protobuf', version: grpcVersion - api group: 'io.grpc', name: 'grpc-stub', version: grpcVersion - api group: 'io.grpc', name: 'grpc-core', version: grpcVersion - api group: 'io.grpc', name: 'grpc-services', version: grpcVersion + api group: 'io.grpc', name: 'grpc-netty', version: rootProject.grpcVersion + api group: 'io.grpc', name: 'grpc-protobuf', version: rootProject.grpcVersion + api group: 'io.grpc', name: 'grpc-stub', version: rootProject.grpcVersion + api group: 'io.grpc', name: 'grpc-core', version: rootProject.grpcVersion + api group: 'io.grpc', name: 'grpc-services', version: rootProject.grpcVersion // end google grpc diff --git a/start.sh b/start.sh index 89f13cf25a..1472a94dc6 100644 --- a/start.sh +++ b/start.sh @@ -358,7 +358,8 @@ startService() { nohup $JAVACMD -Xms$JVM_MS -Xmx$JVM_MX -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -Xloggc:./gc.log \ -XX:+PrintGCDateStamps -XX:+CMSParallelRemarkEnabled -XX:ReservedCodeCacheSize=256m -XX:+UseCodeCacheFlushing \ -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m \ - -XX:MaxDirectMemorySize=$MAX_DIRECT_MEMORY -XX:+HeapDumpOnOutOfMemoryError \ + -XX:MaxDirectMemorySize=$MAX_DIRECT_MEMORY -Dio.netty.allocator.type=pooled \ + -XX:+HeapDumpOnOutOfMemoryError \ -XX:NewRatio=2 -jar \ $JAR_NAME $FULL_START_OPT >>start.log 2>&1 & checkPid diff --git a/start.sh.simple b/start.sh.simple index 52548dea62..109f0dc85a 100644 --- a/start.sh.simple +++ b/start.sh.simple @@ -137,6 +137,7 @@ startService() { -XX:MetaspaceSize=256m \ -XX:MaxMetaspaceSize=512m \ -XX:MaxDirectMemorySize=1g \ + -Dio.netty.allocator.type=pooled \ -XX:+HeapDumpOnOutOfMemoryError \ -jar "$FULL_NODE_JAR" "${FULL_START_OPT[@]}" \ >> start.log 2>&1 & From a0a53d4421d970ba325a5190de4e4566495b8efd Mon Sep 17 00:00:00 2001 From: waynercheung Date: Thu, 30 Jul 2026 18:15:45 +0800 Subject: [PATCH 3/4] feat(libp2p): upgrade libp2p version --- common/build.gradle | 2 +- .../http/HttpSelfFormatFieldName.java | 2 +- .../tron/core/services/http/JsonFormat.java | 49 +- .../services/http/JsonFormatEscapeTest.java | 698 ++++++++++++++++++ .../src/test/java/org/tron/json/JsonTest.java | 17 + gradle/verification-metadata.xml | 14 +- 6 files changed, 760 insertions(+), 22 deletions(-) create mode 100644 framework/src/test/java/org/tron/core/services/http/JsonFormatEscapeTest.java diff --git a/common/build.gradle b/common/build.gradle index 3fc955f9ad..14d3eb4e63 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -21,7 +21,7 @@ dependencies { api 'org.aspectj:aspectjrt:1.9.8' api 'org.aspectj:aspectjweaver:1.9.8' api 'org.aspectj:aspectjtools:1.9.8' - api group: 'io.github.tronprotocol', name: 'libp2p', version: '2.2.8',{ + api group: 'io.github.tronprotocol', name: 'libp2p', version: '2.2.9',{ exclude group: 'io.grpc', module: 'grpc-context' exclude group: 'io.grpc', module: 'grpc-core' exclude group: 'io.grpc', module: 'grpc-netty' diff --git a/framework/src/main/java/org/tron/core/services/http/HttpSelfFormatFieldName.java b/framework/src/main/java/org/tron/core/services/http/HttpSelfFormatFieldName.java index 7435c82107..c551259fb3 100644 --- a/framework/src/main/java/org/tron/core/services/http/HttpSelfFormatFieldName.java +++ b/framework/src/main/java/org/tron/core/services/http/HttpSelfFormatFieldName.java @@ -246,7 +246,7 @@ public class HttpSelfFormatFieldName { //authority NameFieldNameMap.put("protocol.authority.permission_name", 1); //Transaction - // NameFieldNameMap.put("protocol.Transaction.Contract.ContractName", 1); + NameFieldNameMap.put("protocol.Transaction.Contract.ContractName", 1); //TransactionInfo NameFieldNameMap.put("protocol.TransactionInfo.resMessage", 1); diff --git a/framework/src/main/java/org/tron/core/services/http/JsonFormat.java b/framework/src/main/java/org/tron/core/services/http/JsonFormat.java index e6ccb4e4d1..2fa7d9fbb4 100644 --- a/framework/src/main/java/org/tron/core/services/http/JsonFormat.java +++ b/framework/src/main/java/org/tron/core/services/http/JsonFormat.java @@ -58,7 +58,6 @@ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT import org.tron.common.utils.Commons; import org.tron.common.utils.StringUtil; import org.tron.core.Constant; -import org.tron.json.JSON; import org.tron.protos.contract.BalanceContract; /** @@ -851,29 +850,27 @@ static String escapeBytes(ByteString input) { return ByteArray.toHexString(input.toByteArray()); } - static String escapeBytes(ByteString input, final String fliedName, boolean selfType) { + static String escapeBytes(ByteString input, final String fieldName, boolean selfType) { if (!selfType) { return ByteArray.toHexString(input.toByteArray()); } else { - return escapeBytesSelfType(input, fliedName); + return escapeBytesSelfType(input, fieldName); } } - static String escapeBytesSelfType(ByteString input, final String fliedName) { + static String escapeBytesSelfType(ByteString input, final String fieldName) { //Address - if (HttpSelfFormatFieldName.isAddressFormat(fliedName)) { + if (HttpSelfFormatFieldName.isAddressFormat(fieldName)) { return StringUtil.encode58Check(input.toByteArray()); } //Normal String - if (HttpSelfFormatFieldName.isNameStringFormat(fliedName)) { - String result = new String(input.toByteArray()); - result = result.replaceAll("\"", "\\\\\""); - try { - JSON.parseObject("{\"key\":\"" + result + "\"}"); - return result; - } catch (Exception e) { + if (HttpSelfFormatFieldName.isNameStringFormat(fieldName)) { + // Preserve arbitrary bytes losslessly as hex instead of decoding malformed UTF-8 with + // the platform default charset. + if (!input.isValidUtf8()) { return ByteArray.toHexString(input.toByteArray()); } + return escapeNameStringText(input.toStringUtf8()); } //HEX return ByteArray.toHexString(input.toByteArray()); @@ -904,6 +901,29 @@ static ByteString unescapeBytes(CharSequence input) throws InvalidEscapeSequence // Some of these methods are package-private because Descriptors.java uses // them. + /** + * Escapes a valid UTF-8 name-string without exposing it to the U+FFFF sentinel used by + * {@link StringCharacterIterator}. Keeping this workaround on the new bytes path avoids + * changing the established behavior of {@link #escapeText(String)} for proto string fields. + */ + private static String escapeNameStringText(String input) { + int index = input.indexOf(Character.MAX_VALUE); + if (index < 0) { + return escapeText(input); + } + + StringBuilder result = new StringBuilder(input.length()); + int start = 0; + while (index >= 0) { + result.append(escapeText(input.substring(start, index))); + result.append(Character.MAX_VALUE); + start = index + 1; + index = input.indexOf(Character.MAX_VALUE, start); + } + result.append(escapeText(input.substring(start))); + return result.toString(); + } + /** * Implements JSON string escaping as specified here. *
  • The following characters are escaped by prefixing them with a '\' : @@ -1004,6 +1024,9 @@ static String unescapeText(String input) throws InvalidEscapeSequence { case '\\': builder.append('\\'); break; + case '/': + builder.append('/'); + break; case '"': builder.append('\"'); break; @@ -1358,7 +1381,7 @@ static ByteString unescapeBytesSelfType(String input, final String fliedName) //Normal String -> ByteString if (HttpSelfFormatFieldName.isNameStringFormat(fliedName)) { - return ByteString.copyFromUtf8(input); + return ByteString.copyFromUtf8(unescapeText(input)); } return unescapeBytes(input); diff --git a/framework/src/test/java/org/tron/core/services/http/JsonFormatEscapeTest.java b/framework/src/test/java/org/tron/core/services/http/JsonFormatEscapeTest.java new file mode 100644 index 0000000000..a5c74cc434 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/http/JsonFormatEscapeTest.java @@ -0,0 +1,698 @@ +package org.tron.core.services.http; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.fasterxml.jackson.core.json.JsonReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.google.protobuf.Any; +import com.google.protobuf.ByteString; +import java.nio.charset.StandardCharsets; +import org.junit.Test; +import org.tron.common.utils.ByteArray; +import org.tron.core.capsule.TransactionCapsule; +import org.tron.protos.Protocol.Transaction; +import org.tron.protos.Protocol.Transaction.Contract.ContractType; +import org.tron.protos.contract.AssetIssueContractOuterClass.AssetIssueContract; + +/** + * Escaping and decoding of name-string {@code bytes} fields. + * + *

    Before this fix {@code escapeBytesSelfType} only escaped the double quote, leaving backslash + * and control chars raw, and validated the result with a lenient parser. That let attacker + * controlled on-chain bytes emit invalid JSON and, worse, forge sibling fields on the + * re-parse path used by {@link Util#printTransactionToJSON}. + * + *

    Assertions here always state which parser they use: + *

      + *
    • {@link #strict()} - RFC 8259 baseline. Trailing tokens are rejected. A bare + * {@code readTree} is NOT a strict baseline: it stops after the first value and would + * silently accept trailing payloads and duplicated output.
    • + *
    • {@link org.tron.json.JSON} - the node's own lenient parser, used by + * {@code Util.printTransactionToJSON}. Never used to assert validity, only to reproduce + * what the node itself would hand back to a client.
    • + *
    + */ +public class JsonFormatEscapeTest { + + private static final String URL_FIELD = "protocol.AssetIssueContract.url"; + private static final String DESC_FIELD = "protocol.AssetIssueContract.description"; + private static final String NAME_FIELD = "protocol.AssetIssueContract.name"; + private static final String ABBR_FIELD = "protocol.AssetIssueContract.abbr"; + private static final String CONTRACT_NAME_FIELD = + "protocol.Transaction.Contract.ContractName"; + private static final String[] NAME_STRING_FIELDS = { + "protocol.Return.message", + "protocol.Address.host", + "protocol.Note.memo", + "protocol.AccountUpdateContract.account_name", + "protocol.SetAccountIdContract.account_id", + "protocol.TransferAssetContract.asset_name", + "protocol.WitnessCreateContract.url", + "protocol.WitnessUpdateContract.update_url", + "protocol.AssetIssueContract.name", + "protocol.AssetIssueContract.abbr", + "protocol.AssetIssueContract.description", + "protocol.AssetIssueContract.url", + "protocol.ParticipateAssetIssueContract.asset_name", + "protocol.UpdateAssetContract.url", + "protocol.UpdateAssetContract.description", + "protocol.ExchangeCreateContract.first_token_id", + "protocol.ExchangeCreateContract.second_token_id", + "protocol.ExchangeInjectContract.token_id", + "protocol.ExchangeWithdrawContract.token_id", + "protocol.ExchangeTransactionContract.token_id", + "protocol.AccountId.name", + "protocol.Exchange.first_token_id", + "protocol.Exchange.second_token_id", + "protocol.Account.account_name", + "protocol.Account.asset_issued_name", + "protocol.Account.asset_issued_ID", + "protocol.Account.account_id", + "protocol.authority.permission_name", + "protocol.Transaction.Contract.ContractName", + "protocol.TransactionInfo.resMessage", + "protocol.MarketSellAssetContract.sell_token_id", + "protocol.MarketSellAssetContract.buy_token_id", + "protocol.MarketOrder.sell_token_id", + "protocol.MarketOrder.buy_token_id", + "protocol.MarketOrderPair.sell_token_id", + "protocol.MarketOrderPair.buy_token_id", + "protocol.MarketPriceList.sell_token_id", + "protocol.MarketPriceList.buy_token_id" + }; + + private static final byte[] OWNER = new byte[21]; + + static { + OWNER[0] = 0x41; + } + + /** + * Strict RFC 8259 baseline for the features this issue turns on: structural leniency and + * unescaped control chars. Each is disabled explicitly rather than relying on Jackson's + * defaults, so a future default change cannot silently weaken these assertions. Number + * related leniency that {@link org.tron.json.JSON} also enables is not relevant here and is + * left at Jackson's (already strict) default. + */ + private static ObjectMapper strict() { + return JsonMapper.builder() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .disable(JsonReadFeature.ALLOW_JAVA_COMMENTS) + .disable(JsonReadFeature.ALLOW_SINGLE_QUOTES) + .disable(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES) + .disable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS) + .disable(JsonReadFeature.ALLOW_TRAILING_COMMA) + .disable(JsonReadFeature.ALLOW_LEADING_ZEROS_FOR_NUMBERS) + .build(); + } + + /** Guards the guard: the baseline must actually reject what it claims to reject. */ + @Test + public void testStrictBaselineIsActuallyStrict() { + assertTrue(parsesStrictly("{\"a\":1}")); + assertFalse("trailing token", parsesStrictly("{\"a\":1} garbage")); + assertFalse("duplicated object (print() amplification shape)", + parsesStrictly("{\"a\":1}\n{\"a\":2}")); + assertFalse("java comment", parsesStrictly("{\"a\":1}//x")); + assertFalse("single quotes", parsesStrictly("{'a':1}")); + assertFalse("unquoted field name", parsesStrictly("{a:1}")); + assertFalse("raw control char in string", parsesStrictly("{\"a\":\"x\ny\"}")); + } + + private static String escapeName(byte[] raw, String field) { + return JsonFormat.escapeBytesSelfType(ByteString.copyFrom(raw), field); + } + + private static String escapeName(String raw, String field) { + return escapeName(raw.getBytes(StandardCharsets.UTF_8), field); + } + + private static boolean parsesStrictly(String body) { + try { + strict().readTree(body); + return true; + } catch (Exception e) { + return false; + } + } + + /** Renders one asset issue contract exactly as the visible=true HTTP path would. */ + private static String printContract(AssetIssueContract contract) { + return JsonFormat.printToString(contract, true); + } + + private static AssetIssueContract assetWithDescription(byte[] description) { + return AssetIssueContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(OWNER)) + .setName(ByteString.copyFromUtf8("REALTOKEN")) + .setTotalSupply(1000) + .setDescription(ByteString.copyFrom(description)) + .setUrl(ByteString.copyFromUtf8("https://honest.example")) + .setFreeAssetNetLimit(111) + .setPublicFreeAssetNetLimit(222) + .build(); + } + + // Direct name-string byte escaping + + @Test + public void testAllControlCharsAreEscaped() { + for (int c = 0x00; c <= 0x1F; c++) { + byte[] raw = new byte[] {'a', (byte) c, 'b'}; + String escaped = escapeName(raw, URL_FIELD); + + assertFalse("raw control char 0x" + Integer.toHexString(c) + " leaked into the output", + escaped.chars().anyMatch(ch -> ch <= 0x1F)); + assertTrue("strict parser rejected escaped control char 0x" + Integer.toHexString(c), + parsesStrictly("{\"url\":\"" + escaped + "\"}")); + } + } + + @Test + public void testBackslashIsEscaped() { + assertEquals("C:\\\\dir", escapeName("C:\\dir", URL_FIELD)); + assertTrue(parsesStrictly("{\"url\":\"" + escapeName("C:\\dir", URL_FIELD) + "\"}")); + } + + @Test + public void testDoubleQuoteIsEscaped() { + assertEquals("a\\\"b", escapeName("a\"b", URL_FIELD)); + assertTrue(parsesStrictly("{\"url\":\"" + escapeName("a\"b", URL_FIELD) + "\"}")); + } + + @Test + public void testNewlineIsEscapedNotRaw() { + assertEquals("a\\nb", escapeName("a\nb", URL_FIELD)); + } + + @Test + public void testValidUtf8IsPreserved() { + assertEquals("ok", escapeName("ok", URL_FIELD)); + assertEquals("中文", escapeName("中文", URL_FIELD)); + } + + @Test + public void testInvalidUtf8FallsBackToHexWithoutReplacementChar() { + byte[] raw = new byte[] {0x61, (byte) 0xff, 0x62}; + String escaped = escapeName(raw, URL_FIELD); + + assertEquals("61ff62", escaped); + assertFalse("invalid UTF-8 must not be decoded into U+FFFD mojibake", + escaped.indexOf('�') >= 0); + assertTrue(parsesStrictly("{\"url\":\"" + escaped + "\"}")); + } + + @Test + public void testUnpairedSurrogateBytesFallBackToHexInsteadOfThrowing() { + // 0xED 0xA0 0x80 is the UTF-8 style encoding of an unpaired high surrogate. escapeText() + // would throw on it, so isValidUtf8() must divert it to hex first. + byte[] raw = new byte[] {(byte) 0xed, (byte) 0xa0, (byte) 0x80}; + + assertEquals("eda080", escapeName(raw, URL_FIELD)); + } + + @Test + public void testSupplementaryPlaneIsEmittedAsSurrogateEscapes() { + String emoji = new String(Character.toChars(0x1F600)); + + assertEquals("\\ud83d\\ude00", escapeName(emoji, URL_FIELD)); + } + + // Field integrity through HTTP normalization + + /** + * Reproduces {@code Util.printTransactionToJSON} line 272: + * {@code JSONObject.parseObject(JsonFormat.printToString(contract, selfType))}. + */ + private static org.tron.json.JSONObject reparseAsNodeDoes(AssetIssueContract contract) { + return org.tron.json.JSONObject.parseObject(printContract(contract)); + } + + /** + * The node does not stop at parseObject: it re-serializes with toJSONString() and returns + * that. Pre-fix, that step laundered a forged object into well formed JSON the client could + * not tell apart from a genuine response. Assert the whole chain, not just the parse. + */ + private static void assertNoForgeryThroughNormalization(AssetIssueContract contract) + throws Exception { + org.tron.json.JSONObject parsed = reparseAsNodeDoes(contract); + String normalized = parsed.toJSONString(); + + assertTrue("normalized output must be valid JSON: " + normalized, + parsesStrictly(normalized)); + + // Assert on parsed FIELD VALUES, not on substring presence: the attacker payload is stored + // in description, so the literal text "evil"/"TFAKE" legitimately appears there. What must + // not happen is those values landing in url / name / owner_address. + JsonNode node = strict().readTree(normalized); + assertEquals("url must be the real one", "https://honest.example", + node.get("url").asText()); + assertEquals("name must be the real one", "REALTOKEN", node.get("name").asText()); + assertTrue("owner_address must be the real base58 owner", + node.get("owner_address").asText().startsWith("T")); + assertFalse("owner_address must not be attacker supplied", + node.get("owner_address").asText().contains("TFAKE")); + assertEquals("total_supply must be the real one", 1000, node.get("total_supply").asLong()); + } + + @Test + public void testSingleQuotePayloadCannotForgeSiblingField() throws Exception { + // ALLOW_SINGLE_QUOTES is enabled on the node's lenient parser, so a payload can supply a + // value without ever using a double quote, which the old replaceAll() would have escaped. + byte[] payload = "a\\\",url:'https://evil'}//".getBytes(StandardCharsets.UTF_8); + AssetIssueContract contract = assetWithDescription(payload); + + org.tron.json.JSONObject parsed = reparseAsNodeDoes(contract); + + assertEquals("https://honest.example", String.valueOf(parsed.get("url"))); + assertEquals(new String(payload, StandardCharsets.UTF_8), + String.valueOf(parsed.get("description"))); + assertTrue("strict parser must accept the emitted document", + parsesStrictly(printContract(contract))); + assertNoForgeryThroughNormalization(contract); + } + + @Test + public void testDuplicateKeyPayloadCannotOverrideEarlierFields() throws Exception { + byte[] payload = + "x\\\",owner_address:'TFAKE',name:'FAKE',total_supply:1,url:'https://evil'}//" + .getBytes(StandardCharsets.UTF_8); + AssetIssueContract contract = assetWithDescription(payload); + + org.tron.json.JSONObject parsed = reparseAsNodeDoes(contract); + + assertEquals("REALTOKEN", String.valueOf(parsed.get("name"))); + assertEquals(1000, Integer.parseInt(String.valueOf(parsed.get("total_supply")))); + assertEquals("https://honest.example", String.valueOf(parsed.get("url"))); + assertFalse("owner_address must not be attacker supplied", + String.valueOf(parsed.get("owner_address")).contains("TFAKE")); + assertNoForgeryThroughNormalization(contract); + } + + @Test + public void testUnquotedFieldNamePayloadInjectsNothing() { + AssetIssueContract contract = + assetWithDescription("a\\\",b:1}//".getBytes(StandardCharsets.UTF_8)); + + org.tron.json.JSONObject parsed = reparseAsNodeDoes(contract); + + assertNull("no attacker key may appear", parsed.get("b")); + assertEquals("https://honest.example", String.valueOf(parsed.get("url"))); + } + + @Test + public void testBlockCommentPayloadInjectsNothing() { + AssetIssueContract contract = + assetWithDescription("a\\\",b:1}/*".getBytes(StandardCharsets.UTF_8)); + + org.tron.json.JSONObject parsed = reparseAsNodeDoes(contract); + + assertNull(parsed.get("b")); + assertEquals("https://honest.example", String.valueOf(parsed.get("url"))); + } + + @Test + public void testTrailingTokenPayloadInjectsNothing() { + AssetIssueContract contract = + assetWithDescription("a\\\"}".getBytes(StandardCharsets.UTF_8)); + + org.tron.json.JSONObject parsed = reparseAsNodeDoes(contract); + + assertEquals("https://honest.example", String.valueOf(parsed.get("url"))); + assertTrue(parsesStrictly(printContract(contract))); + } + + // Preservation of fields serialized after attacker-controlled values + + @Test + public void testFieldsAfterPayloadSurvive() { + AssetIssueContract contract = + assetWithDescription("a\\\"}//".getBytes(StandardCharsets.UTF_8)); + + org.tron.json.JSONObject parsed = reparseAsNodeDoes(contract); + + assertEquals("https://honest.example", String.valueOf(parsed.get("url"))); + assertEquals(111, Integer.parseInt(String.valueOf(parsed.get("free_asset_net_limit")))); + assertEquals(222, + Integer.parseInt(String.valueOf(parsed.get("public_free_asset_net_limit")))); + } + + @Test + public void testAssetNameEscapingPreservesFollowingFields() { + byte[] value = "a\\\"}//".getBytes(StandardCharsets.UTF_8); + assertEquals("a\\\\\\\"}//", escapeName(value, NAME_FIELD)); + + AssetIssueContract contract = AssetIssueContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(OWNER)) + .setName(ByteString.copyFrom(value)) + .setUrl(ByteString.copyFromUtf8("https://honest.example")) + .build(); + + assertTrue(parsesStrictly(printContract(contract))); + assertEquals("https://honest.example", + String.valueOf(reparseAsNodeDoes(contract).get("url"))); + } + + @Test + public void testAssetAbbrEscapingPreservesFollowingFields() { + byte[] value = "\\\"}".getBytes(StandardCharsets.UTF_8); + assertEquals("\\\\\\\"}", escapeName(value, ABBR_FIELD)); + + AssetIssueContract contract = AssetIssueContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(OWNER)) + .setAbbr(ByteString.copyFrom(value)) + .setUrl(ByteString.copyFromUtf8("https://honest.example")) + .build(); + + assertTrue(parsesStrictly(printContract(contract))); + assertEquals("https://honest.example", + String.valueOf(reparseAsNodeDoes(contract).get("url"))); + } + + @Test + public void testAllNameStringByteFieldsUseEscapeText() { + String value = "a\\nb"; + String escaped = JsonFormat.escapeText(value); + + assertEquals("a\\\\nb", escaped); + for (String field : NAME_STRING_FIELDS) { + assertTrue(field, HttpSelfFormatFieldName.isNameStringFormat(field)); + assertEquals(field, escaped, escapeName(value, field)); + } + } + + @Test + public void testContractNameRetainsVisibleNameStringMapping() throws Exception { + String value = "contract-name"; + Transaction.Contract.Builder actualContract = Transaction.Contract.newBuilder(); + JsonFormat.merge("{\"ContractName\":\"" + value + "\"}", actualContract, true); + Transaction.Contract expectedContract = Transaction.Contract.newBuilder() + .setContractName(ByteString.copyFromUtf8(value)) + .build(); + Transaction actual = Transaction.newBuilder() + .setRawData(Transaction.raw.newBuilder().addContract(actualContract)) + .build(); + Transaction expected = Transaction.newBuilder() + .setRawData(Transaction.raw.newBuilder().addContract(expectedContract)) + .build(); + + assertEquals(value, escapeName(value, CONTRACT_NAME_FIELD)); + assertEquals(expectedContract.toByteString(), actualContract.build().toByteString()); + assertEquals(expected.getRawData().toByteString(), actual.getRawData().toByteString()); + assertEquals(new TransactionCapsule(expected).getTransactionId(), + new TransactionCapsule(actual).getTransactionId()); + } + + // Inbound decoding and transaction ID derivation + + private static AssetIssueContract mergeDescription(String rawJsonToken) throws Exception { + AssetIssueContract.Builder builder = AssetIssueContract.newBuilder(); + JsonFormat.merge("{\"description\":\"" + rawJsonToken + "\"}", builder, true); + return builder.build(); + } + + private static Transaction transactionWithAsset(AssetIssueContract asset) { + Transaction.Contract contract = Transaction.Contract.newBuilder() + .setType(ContractType.AssetIssueContract) + .setParameter(Any.pack(asset)) + .build(); + return Transaction.newBuilder() + .setRawData(Transaction.raw.newBuilder() + .setTimestamp(1_234_567_890L) + .addContract(contract)) + .build(); + } + + private static void assertDecodedInboundMapping(String rawJsonToken, String expectedValue) + throws Exception { + AssetIssueContract actualAsset = mergeDescription(rawJsonToken); + AssetIssueContract expectedAsset = AssetIssueContract.newBuilder() + .setDescription(ByteString.copyFromUtf8(expectedValue)) + .build(); + Transaction actual = transactionWithAsset(actualAsset); + Transaction expected = transactionWithAsset(expectedAsset); + + assertEquals("visible=true name-string bytes must decode JSON escape sequences", + expectedAsset.toByteString(), actualAsset.toByteString()); + assertEquals("decoded name-string bytes must determine raw_data", + expected.getRawData().toByteString(), actual.getRawData().toByteString()); + assertEquals("the transaction ID must be derived from the decoded raw_data", + new TransactionCapsule(expected).getTransactionId(), + new TransactionCapsule(actual).getTransactionId()); + } + + @Test + public void testInboundNameStringEscapesDetermineRawDataAndTxId() throws Exception { + assertDecodedInboundMapping("plain", "plain"); + assertDecodedInboundMapping("C:\\\\dir", "C:\\dir"); + assertDecodedInboundMapping("a\\nb", "a\nb"); + assertDecodedInboundMapping("a\\\"b", "a\"b"); + assertDecodedInboundMapping("a\\u0041", "aA"); + } + + @Test + public void testInboundNameStringHandlesEscapesAndRejectsMalformedUnicode() throws Exception { + assertDecodedInboundMapping("https:\\/\\/tron.network", "https://tron.network"); + assertDecodedInboundMapping("a\\u+123", "a" + (char) 0x0123); + org.junit.Assert.assertThrows( + "non-hex unicode escapes must still be rejected", + JsonFormat.ParseException.class, + () -> mergeDescription("a\\uZZZZ")); + } + + @Test + public void testValidUtf8NameStringsRoundTripThroughEscaping() throws Exception { + for (String value : new String[] { + "plain", + "C:\\dir", + "a\nb", + "a\"b", + "a\\u0041", + "中文", + "\uFFFF sentinel", + new String(Character.toChars(0x1F600))}) { + ByteString original = ByteString.copyFromUtf8(value); + String escaped = JsonFormat.escapeBytesSelfType(original, DESC_FIELD); + ByteString decoded = JsonFormat.Tokenizer.unescapeBytesSelfType(escaped, DESC_FIELD); + + assertEquals("valid UTF-8 name-string bytes must survive escape and unescape", + original, decoded); + } + } + + @Test + public void testOutboundSerializationDoesNotChangeStoredRawDataOrReportedTxId() { + AssetIssueContract asset = AssetIssueContract.newBuilder() + .setDescription(ByteString.copyFromUtf8("C:\\dir\n\"quoted\"")) + .build(); + Transaction transaction = transactionWithAsset(asset); + ByteString rawDataBefore = transaction.getRawData().toByteString(); + String txIdBefore = ByteArray.toHexString( + new TransactionCapsule(transaction).getTransactionId().getBytes()); + + org.tron.json.JSONObject output = Util.printTransactionToJSON(transaction, true); + + assertEquals("query serialization must not modify the protobuf loaded from storage", + rawDataBefore, transaction.getRawData().toByteString()); + assertEquals("the reported txID must still be derived from the stored raw_data bytes", + txIdBefore, output.getString("txID")); + } + + @Test + public void testInvalidUtf8IsHexAndNotPromisedToRoundTrip() { + byte[] raw = new byte[] {0x61, (byte) 0xff, 0x62}; + String escaped = escapeName(raw, URL_FIELD); + + assertEquals(ByteArray.toHexString(raw), escaped); + assertTrue(parsesStrictly("{\"url\":\"" + escaped + "\"}")); + assertFalse("hex text is not expected to reconstruct the original bytes", + ByteString.copyFromUtf8(escaped).equals(ByteString.copyFrom(raw))); + } + + // U+FFFF handling on the selected name-string bytes path + + @Test + public void testUffffDoesNotTruncateNameStringField() { + // EF BF BF is valid UTF-8, so it must remain text without reaching escapeText's sentinel. + String value = "SAFE\uFFFF TAIL"; + assertTrue(ByteString.copyFromUtf8(value).isValidUtf8()); + + assertEquals(value, escapeName(value, URL_FIELD)); + } + + @Test + public void testUffffLeadingCharDoesNotEmptyTheValue() { + assertEquals("\uFFFFwhole value", escapeName("\uFFFFwhole value", URL_FIELD)); + } + + // Proto string inbound compatibility + + private static String mergeId(String rawJsonToken, boolean visible) throws Exception { + AssetIssueContract.Builder builder = AssetIssueContract.newBuilder(); + JsonFormat.merge("{\"id\":\"" + rawJsonToken + "\"}", builder, visible); + return builder.getId(); + } + + @Test + public void testProtoStringInboundEscapes() throws Exception { + for (boolean visible : new boolean[] {true, false}) { + assertEquals("escaped solidus must be accepted as valid JSON", + "a/b", mergeId("a\\/b", visible)); + assertEquals("signed unicode escape must retain the develop-branch mapping", + "a" + (char) 0x0123, mergeId("a\\u+123", visible)); + assertEquals("standard unicode escape must remain unchanged", + "aA", mergeId("a\\u0041", visible)); + } + } + + @Test + public void testNewlineHeavyValueDoesNotAmplifyOutput() { + byte[] url = new byte[256]; + java.util.Arrays.fill(url, (byte) 'A'); + for (int i = 0; i < 128; i++) { + url[i] = '\n'; + } + AssetIssueContract contract = AssetIssueContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(OWNER)) + .setUrl(ByteString.copyFrom(url)) + .build(); + + String body = printContract(contract); + + assertTrue("output must not blow up relative to the input", body.length() < 1024); + assertTrue(parsesStrictly(body)); + } + + // Deterministic property sweep + + // The fixed seed and bounds give every reviewer the same inputs. For each input: + // P1: escaping never throws. + // P2: the emitted document is strictly valid. + // P3: fields serialized after the payload retain their values under both the strict parser + // and the node's lenient parser; no key is truncated, forged, or dropped. + // P4: serialization does not modify the source protobuf bytes. + // P5: valid UTF-8 name-string bytes survive outbound escaping and inbound decoding. + + @Test + public void testPropertySweep() throws Exception { + int checked = 0; + // every single byte, and every byte framed by ASCII + for (int i = 0; i < 256; i++) { + checked += sweepOne(new byte[] {(byte) i}); + checked += sweepOne(new byte[] {'a', (byte) i, 'b'}); + } + // every 2-byte combination: covers all malformed UTF-8 lead/continuation pairs + for (int i = 0; i < 256; i++) { + for (int j = 0; j < 256; j++) { + checked += sweepOne(new byte[] {(byte) i, (byte) j}); + } + } + // structural payloads built from the characters that matter to a JSON parser + byte[] nasty = "\\\"'{}[],:/*\n\r\tu0 ".getBytes(StandardCharsets.UTF_8); + java.util.Random rnd = new java.util.Random(20260720L); + for (int n = 0; n < 20000; n++) { + byte[] buf = new byte[1 + rnd.nextInt(16)]; + for (int i = 0; i < buf.length; i++) { + buf[i] = rnd.nextInt(2) == 0 + ? nasty[rnd.nextInt(nasty.length)] : (byte) rnd.nextInt(256); + } + checked += sweepOne(buf); + } + // notable code points, including the U+FFFF sentinel and both surrogate halves + for (int cp : new int[] {0x00, 0x1F, 0x20, 0x7F, 0x80, 0x7FF, 0x800, 0xFFFD, 0xFFFE, + 0xFFFF, 0x10000, 0x1F600, 0x10FFFF}) { + checked += sweepOne(new String(Character.toChars(cp)).getBytes(StandardCharsets.UTF_8)); + } + + assertTrue("sweep must actually run", checked > 65000); + } + + /** Returns 1 so the caller can count coverage. Throws AssertionError on any violation. */ + private static int sweepOne(byte[] raw) throws Exception { + AssetIssueContract original = AssetIssueContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(OWNER)) + .setDescription(ByteString.copyFrom(raw)) + .setUrl(ByteString.copyFromUtf8("https://sentinel.example")) + .setFreeAssetNetLimit(4242) + .build(); + + ByteString originalBytes = original.toByteString(); + String json = printContract(original); // P1 + String where = " for input " + ByteArray.toHexString(raw); + assertEquals("P4 source protobuf changed during serialization" + where, + originalBytes, original.toByteString()); // P4 + + JsonNode strictNode; + try { + strictNode = strict().readTree(json); // P2 + } catch (Exception e) { + throw new AssertionError("P2 strict parse failed" + where + ": " + json, e); + } + assertEquals("P3 strict: url" + where, + "https://sentinel.example", strictNode.get("url").asText()); + assertEquals("P3 strict: free_asset_net_limit" + where, + 4242, strictNode.get("free_asset_net_limit").asLong()); + + org.tron.json.JSONObject lenient = org.tron.json.JSONObject.parseObject(json); + assertEquals("P3 lenient: url" + where, + "https://sentinel.example", String.valueOf(lenient.get("url"))); + assertEquals("P3 lenient: free_asset_net_limit" + where, + "4242", String.valueOf(lenient.get("free_asset_net_limit"))); + + ByteString rawBytes = ByteString.copyFrom(raw); + if (rawBytes.isValidUtf8()) { + assertEquals("P5 valid UTF-8 must round-trip" + where, rawBytes, + JsonFormat.Tokenizer.unescapeBytesSelfType( + escapeName(raw, DESC_FIELD), DESC_FIELD)); + } else { + assertEquals("invalid UTF-8 must be emitted as plain hex" + where, + ByteArray.toHexString(raw), escapeName(raw, DESC_FIELD)); + } + return 1; + } + + // Unaffected-format regressions + + @Test + public void testSafeAsciiIsUnchanged() { + // "Safe ASCII" means no backslash, no double quote and no control chars. Plain ASCII as a + // whole is not unchanged, because those three classes are exactly what now gets escaped. + for (String safe : new String[] { + "https://tron.network", + "TronToken", + "abc-123_456.789~xyz", + ""}) { + assertEquals(safe, escapeName(safe, URL_FIELD)); + } + } + + @Test + public void testAddressFieldsAreUnaffected() { + String encoded = JsonFormat.escapeBytesSelfType(ByteString.copyFrom(OWNER), + "protocol.AssetIssueContract.owner_address"); + + assertTrue("address fields must still be base58check", encoded.startsWith("T")); + } + + @Test + public void testNonNameStringBytesFieldStillHex() { + byte[] raw = "a\nb".getBytes(StandardCharsets.UTF_8); + + assertEquals(ByteArray.toHexString(raw), + JsonFormat.escapeBytesSelfType(ByteString.copyFrom(raw), "protocol.Some.unmapped_field")); + } + + @Test + public void testVisibleFalseStillHex() { + byte[] raw = "a\nb".getBytes(StandardCharsets.UTF_8); + + assertEquals(ByteArray.toHexString(raw), + JsonFormat.escapeBytes(ByteString.copyFrom(raw), DESC_FIELD, false)); + } +} diff --git a/framework/src/test/java/org/tron/json/JsonTest.java b/framework/src/test/java/org/tron/json/JsonTest.java index f430188611..081e51d557 100644 --- a/framework/src/test/java/org/tron/json/JsonTest.java +++ b/framework/src/test/java/org/tron/json/JsonTest.java @@ -94,6 +94,23 @@ public void testComment() { obj = JSON.parseObject("{/* comment */\"a\":1}"); assertNotNull(obj); assertEquals(1, obj.getIntValue("a")); + obj = JSON.parseObject("{\"a\":1} /* trailing comment */"); + assertNotNull(obj); + assertEquals(1, obj.getIntValue("a")); + } + + @Test + public void testTrailingNonCommentTokensRejected() { + assertThrows(JSONException.class, + () -> JSON.parseObject("{\"a\":1} {\"b\":2}")); + assertThrows(JSONException.class, + () -> JSONObject.parseObject("{\"a\":1} [2]")); + assertThrows(JSONException.class, + () -> JSON.parse("{\"a\":1} garbage")); + assertThrows(JSONException.class, + () -> JSON.parseArray("[1] [2]")); + assertThrows(JSONException.class, + () -> JSONArray.parseArray("[1] true")); } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 61cdda33ed..6a3e641d5d 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1152,15 +1152,15 @@ - - - + + + - - + + - - + + From 7d0f4b7ff9ddb71c0444378492b369ae2e306e06 Mon Sep 17 00:00:00 2001 From: halibobo1205 Date: Fri, 31 Jul 2026 19:32:45 +0800 Subject: [PATCH 4/4] update a new version. version name:GreatVoyage-v4.8.2-6-g348db25bfd,version code:18825 --- framework/src/main/java/org/tron/program/Version.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/framework/src/main/java/org/tron/program/Version.java b/framework/src/main/java/org/tron/program/Version.java index bf435bf095..f34d440702 100644 --- a/framework/src/main/java/org/tron/program/Version.java +++ b/framework/src/main/java/org/tron/program/Version.java @@ -2,9 +2,9 @@ public class Version { - public static final String VERSION_NAME = "GreatVoyage-v4.8.1.1-173-gaced0d5654"; - public static final String VERSION_CODE = "18817"; - private static final String VERSION = "4.8.2"; + public static final String VERSION_NAME = "GreatVoyage-v4.8.2-6-g348db25bfd"; + public static final String VERSION_CODE = "18825"; + private static final String VERSION = "4.8.2.1"; public static String getVersion() { return VERSION;