From 8c846e150a434596990c4412878b73b2f18dc11c Mon Sep 17 00:00:00 2001 From: waynercheung Date: Sat, 10 Oct 2026 14:28:40 +0800 Subject: [PATCH 1/9] feat(api): bound transaction field occurrences before parsing Scan the wire bytes before parsing on the API entry points that accept a binary Transaction, and reject the request once it exceeds 50,000 field occurrences: - TransactionAdmissionGuard counts every tag with CodedInputStream, recurses into known sub-messages by descriptor, and recurses into the contract payload with the type the node unpacks it into, following protobuf merge semantics. Group encoding and nesting deeper than 32 are rejected. Block, database and P2P parsing do not use it. - For gRPC, every method whose request is a Transaction gets a request marshaller that reads the request with the inbound size bound, runs the guard and hands the same bytes to the original marshaller. A rejection returns a sentinel instead of throwing, and a handler wrapper closes the call with INVALID_ARGUMENT before the service method runs. This affects BroadcastTransaction, GetTransactionSignWeight, GetTransactionApprovedList, GetShieldTransactionHash and CreateCommonTransaction. - /wallet/broadcasthex runs the guard before Transaction.parseFrom. - Admission rejections use one fixed client message and one DEBUG line per rejected request, without a stack trace. Across the full mainnet history (15.79 billion transactions), the maximum observed count was 3,926 field occurrences. This is an API admission policy, not a consensus-validity limit: previously tolerated encodings and unusually complex transactions may now be rejected at these entry points. Consensus, execution and block validation are unchanged. --- .../tron/common/application/RpcService.java | 66 ++ .../admission/AdmissionCheckedHandler.java | 73 ++ .../GuardedTransactionMarshaller.java | 147 ++++ .../TransactionAdmissionException.java | 25 + .../admission/TransactionAdmissionGuard.java | 349 +++++++++ .../org/tron/core/services/RpcApiService.java | 8 +- .../http/servlets/BroadcastHexServlet.java | 14 +- .../interfaceOnPBFT/RpcApiServiceOnPBFT.java | 8 +- .../RpcApiServiceOnSolidity.java | 8 +- .../application/RpcServiceAdmissionTest.java | 619 +++++++++++++++ .../core/admission/AdmissionSupportTest.java | 294 +++++++ .../TransactionAdmissionGuardTest.java | 727 ++++++++++++++++++ .../RpcServiceRegistrationAdmissionTest.java | 157 ++++ .../BroadcastHexServletAdmissionTest.java | 116 +++ 14 files changed, 2598 insertions(+), 13 deletions(-) create mode 100644 framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java create mode 100644 framework/src/main/java/org/tron/core/admission/GuardedTransactionMarshaller.java create mode 100644 framework/src/main/java/org/tron/core/admission/TransactionAdmissionException.java create mode 100644 framework/src/main/java/org/tron/core/admission/TransactionAdmissionGuard.java create mode 100644 framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java create mode 100644 framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java create mode 100644 framework/src/test/java/org/tron/core/admission/TransactionAdmissionGuardTest.java create mode 100644 framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java create mode 100644 framework/src/test/java/org/tron/core/services/http/servlets/BroadcastHexServletAdmissionTest.java 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 3b12ae27628..d47f5f8eab3 100644 --- a/framework/src/main/java/org/tron/common/application/RpcService.java +++ b/framework/src/main/java/org/tron/common/application/RpcService.java @@ -15,9 +15,16 @@ package org.tron.common.application; +import io.grpc.MethodDescriptor; import io.grpc.Server; +import io.grpc.ServerMethodDefinition; +import io.grpc.ServerServiceDefinition; +import io.grpc.ServiceDescriptor; import io.grpc.netty.NettyServerBuilder; +import io.grpc.protobuf.ProtoMethodDescriptorSupplier; import io.grpc.protobuf.services.ProtoReflectionService; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; @@ -25,11 +32,15 @@ import org.springframework.beans.factory.annotation.Autowired; import org.tron.common.es.ExecutorServiceManager; import org.tron.common.parameter.CommonParameter; +import org.tron.core.admission.AdmissionCheckedHandler; +import org.tron.core.admission.GuardedTransactionMarshaller; +import org.tron.core.admission.TransactionAdmissionGuard; import org.tron.core.config.args.Args; import org.tron.core.services.filter.LiteFnQueryGrpcInterceptor; import org.tron.core.services.ratelimiter.PrometheusInterceptor; import org.tron.core.services.ratelimiter.RateLimiterInterceptor; import org.tron.core.services.ratelimiter.RpcApiAccessInterceptor; +import org.tron.protos.Protocol.Transaction; @Slf4j(topic = "rpc") public abstract class RpcService extends AbstractService { @@ -118,6 +129,61 @@ protected NettyServerBuilder initServerBuilder() { protected abstract void addService(NettyServerBuilder serverBuilder); + /** Guards every method whose request is a {@code Transaction}; other methods are kept. */ + protected static ServerServiceDefinition guardTransactionMethods( + ServerServiceDefinition definition) { + return guardTransactionMethods(definition, Args.getInstance().getMaxMessageSize(), + TransactionAdmissionGuard.MAX_TX_OCCURRENCES); + } + + static ServerServiceDefinition guardTransactionMethods(ServerServiceDefinition definition, + int maxMessageSize, int maxOccurrences) { + ServiceDescriptor service = definition.getServiceDescriptor(); + ServiceDescriptor.Builder serviceBuilder = ServiceDescriptor.newBuilder(service.getName()) + .setSchemaDescriptor(service.getSchemaDescriptor()); + List> methods = new ArrayList<>(); + for (ServerMethodDefinition method : definition.getMethods()) { + MethodDescriptor descriptor = method.getMethodDescriptor(); + if (isTransactionRequest(descriptor)) { + @SuppressWarnings("unchecked") + ServerMethodDefinition original = + (ServerMethodDefinition) method; + MethodDescriptor guarded = original.getMethodDescriptor().toBuilder( + new GuardedTransactionMarshaller( + original.getMethodDescriptor().getRequestMarshaller(), + maxMessageSize, maxOccurrences), + original.getMethodDescriptor().getResponseMarshaller()).build(); + methods.add(ServerMethodDefinition.create(guarded, + new AdmissionCheckedHandler<>(original.getServerCallHandler()))); + } else { + methods.add(method); + } + } + // grpc requires the same MethodDescriptor instances in both descriptors. + for (ServerMethodDefinition method : methods) { + serviceBuilder.addMethod(method.getMethodDescriptor()); + } + ServerServiceDefinition.Builder builder = + ServerServiceDefinition.builder(serviceBuilder.build()); + for (ServerMethodDefinition method : methods) { + builder.addMethod(method); + } + return builder.build(); + } + + // The schema decides when there is one; otherwise the request marshaller's prototype. + static boolean isTransactionRequest(MethodDescriptor descriptor) { + Object schema = descriptor.getSchemaDescriptor(); + if (schema instanceof ProtoMethodDescriptorSupplier) { + return Transaction.getDescriptor().equals( + ((ProtoMethodDescriptorSupplier) schema).getMethodDescriptor().getInputType()); + } + MethodDescriptor.Marshaller request = descriptor.getRequestMarshaller(); + return request instanceof MethodDescriptor.PrototypeMarshaller + && ((MethodDescriptor.PrototypeMarshaller) request).getMessagePrototype() + instanceof Transaction; + } + protected void addInterceptor(NettyServerBuilder serverBuilder) { // add a ratelimiter interceptor serverBuilder.intercept(this.rateLimiterInterceptor); diff --git a/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java b/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java new file mode 100644 index 00000000000..39d5029b6cc --- /dev/null +++ b/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java @@ -0,0 +1,73 @@ +package org.tron.core.admission; + +import io.grpc.ForwardingServerCallListener.SimpleForwardingServerCallListener; +import io.grpc.Grpc; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.Status; +import lombok.extern.slf4j.Slf4j; +import org.tron.core.admission.TransactionAdmissionException.Reason; +import org.tron.protos.Protocol.Transaction; + +/** + * Closes the call with {@code INVALID_ARGUMENT} when {@link GuardedTransactionMarshaller} hands + * over a rejection sentinel; the service method is never invoked. + */ +@Slf4j(topic = "rpc") +public final class AdmissionCheckedHandler implements ServerCallHandler { + + private final ServerCallHandler delegate; + + public AdmissionCheckedHandler(ServerCallHandler delegate) { + this.delegate = delegate; + } + + public ServerCallHandler getDelegate() { + return delegate; + } + + @Override + public ServerCall.Listener startCall(ServerCall call, + Metadata headers) { + ServerCall.Listener next = delegate.startCall(call, headers); + return new SimpleForwardingServerCallListener(next) { + // Listener callbacks of one call are serialized by grpc. + private boolean rejected; + + @Override + public void onMessage(Transaction message) { + if (rejected) { + return; + } + Reason reason = GuardedTransactionMarshaller.rejectionReason(message); + if (reason != null) { + rejected = true; + if (logger.isDebugEnabled()) { + logger.debug("gRPC {} from {} rejected by admission check: {}", + call.getMethodDescriptor().getFullMethodName(), + call.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR), reason); + } + call.close(Status.INVALID_ARGUMENT + .withDescription(TransactionAdmissionException.REJECTION_MESSAGE), new Metadata()); + return; + } + super.onMessage(message); + } + + @Override + public void onHalfClose() { + if (!rejected) { + super.onHalfClose(); + } + } + + @Override + public void onReady() { + if (!rejected) { + super.onReady(); + } + } + }; + } +} diff --git a/framework/src/main/java/org/tron/core/admission/GuardedTransactionMarshaller.java b/framework/src/main/java/org/tron/core/admission/GuardedTransactionMarshaller.java new file mode 100644 index 00000000000..f4a91c3ae56 --- /dev/null +++ b/framework/src/main/java/org/tron/core/admission/GuardedTransactionMarshaller.java @@ -0,0 +1,147 @@ +package org.tron.core.admission; + +import com.google.protobuf.InvalidProtocolBufferException; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import java.io.ByteArrayInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.Map; +import java.util.zip.ZipException; +import org.tron.common.math.StrictMathWrapper; +import org.tron.core.admission.TransactionAdmissionException.Reason; +import org.tron.protos.Protocol.Transaction; + +/** + * Reads the request with a size bound and runs {@link TransactionAdmissionGuard} before the + * original marshaller parses the same bytes. A rejection returns a per-{@link Reason} sentinel + * instead of throwing, because grpc logs every marshaller exception at SEVERE; the + * {@link AdmissionCheckedHandler} closes the call. Interceptors see a sentinel as an empty + * {@code Transaction}. + */ +public final class GuardedTransactionMarshaller + implements MethodDescriptor.PrototypeMarshaller { + + static final int MAX_INITIAL_BUFFER = 1 << 20; + private static final int DEFAULT_INITIAL_BUFFER = 8 * 1024; + + // Compared by identity; never the default instance. + private static final Map REJECTED = new EnumMap<>(Reason.class); + + static { + for (Reason reason : Reason.values()) { + REJECTED.put(reason, Transaction.newBuilder().build()); + } + } + + private final MethodDescriptor.Marshaller delegate; + private final int maxMessageSize; + private final int maxOccurrences; + + public GuardedTransactionMarshaller(MethodDescriptor.Marshaller delegate, + int maxMessageSize, int maxOccurrences) { + this.delegate = delegate; + this.maxMessageSize = maxMessageSize; + this.maxOccurrences = maxOccurrences; + } + + @Override + public InputStream stream(Transaction value) { + return delegate.stream(value); + } + + @Override + public Transaction parse(InputStream stream) { + byte[] bytes; + try { + bytes = readBounded(stream, maxMessageSize); + } catch (ZipException | EOFException e) { + return REJECTED.get(Reason.MALFORMED); + } catch (IOException e) { + throw Status.INTERNAL.withDescription("failed to read request").withCause(e) + .asRuntimeException(); + } + if (bytes == null) { + return REJECTED.get(Reason.TOO_LARGE); + } + try { + TransactionAdmissionGuard.checkTransaction(bytes, maxOccurrences); + } catch (TransactionAdmissionException e) { + return REJECTED.get(e.getReason()); + } + try { + return delegate.parse(new ByteArrayInputStream(bytes)); + } catch (StatusRuntimeException e) { + if (e.getCause() instanceof InvalidProtocolBufferException) { + return REJECTED.get(Reason.MALFORMED); + } + throw e; + } + } + + static Reason rejectionReason(Transaction request) { + for (Map.Entry entry : REJECTED.entrySet()) { + if (entry.getValue() == request) { + return entry.getKey(); + } + } + return null; + } + + @Override + public Transaction getMessagePrototype() { + return Transaction.getDefaultInstance(); + } + + @Override + public Class getMessageClass() { + return Transaction.class; + } + + /** Returns the whole stream, or {@code null} once more than {@code max} bytes are seen. */ + static byte[] readBounded(InputStream stream, int max) throws IOException { + if (max < 0) { + throw new IllegalArgumentException("max must not be negative"); + } + long limit = (long) max + 1; + int hint = stream.available(); + // A decompressing stream reports 1 until it reaches the end, which says nothing about size. + long initial = hint > 1 ? hint : DEFAULT_INITIAL_BUFFER; + initial = StrictMathWrapper.min(initial, StrictMathWrapper.min(limit, MAX_INITIAL_BUFFER)); + byte[] buffer = new byte[(int) StrictMathWrapper.max(initial, 1)]; + int size = 0; + while (true) { + if (size == buffer.length) { + // Probe for end of stream first, so an exact size hint never costs a copy. + int next = stream.read(); + if (next < 0) { + break; + } + long grown = StrictMathWrapper.min( + StrictMathWrapper.min((long) buffer.length * 2, limit), Integer.MAX_VALUE - 8); + if (grown <= buffer.length) { + return null; // cannot hold more than this; the message is over the bound + } + buffer = Arrays.copyOf(buffer, (int) grown); + buffer[size++] = (byte) next; + if (size > max) { + return null; + } + continue; + } + int read = stream.read(buffer, size, buffer.length - size); + if (read < 0) { + break; + } + size += read; + if (size > max) { + return null; + } + } + return size == buffer.length ? buffer : Arrays.copyOf(buffer, size); + } +} diff --git a/framework/src/main/java/org/tron/core/admission/TransactionAdmissionException.java b/framework/src/main/java/org/tron/core/admission/TransactionAdmissionException.java new file mode 100644 index 00000000000..54daa25170e --- /dev/null +++ b/framework/src/main/java/org/tron/core/admission/TransactionAdmissionException.java @@ -0,0 +1,25 @@ +package org.tron.core.admission; + +/** Admission rejection of untrusted input; carries no stack trace. */ +public class TransactionAdmissionException extends RuntimeException { + + public static final String REJECTION_MESSAGE = "transaction rejected by admission check"; + + public enum Reason { + MALFORMED, + OVER_BUDGET, + TOO_DEEP, + TOO_LARGE + } + + private final Reason reason; + + TransactionAdmissionException(Reason reason, String detail) { + super(detail == null ? reason.name() : reason + ": " + detail, null, false, false); + this.reason = reason; + } + + public Reason getReason() { + return reason; + } +} diff --git a/framework/src/main/java/org/tron/core/admission/TransactionAdmissionGuard.java b/framework/src/main/java/org/tron/core/admission/TransactionAdmissionGuard.java new file mode 100644 index 00000000000..dc63d11109f --- /dev/null +++ b/framework/src/main/java/org/tron/core/admission/TransactionAdmissionGuard.java @@ -0,0 +1,349 @@ +package org.tron.core.admission; + +import com.google.protobuf.Any; +import com.google.protobuf.CodedInputStream; +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.GeneratedMessageV3; +import com.google.protobuf.Internal; +import com.google.protobuf.WireFormat; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.tron.core.actuator.TransactionFactory; +import org.tron.core.admission.TransactionAdmissionException.Reason; +import org.tron.protos.Protocol.Transaction; +import org.tron.protos.Protocol.Transaction.Contract.ContractType; +import org.tron.protos.contract.ShieldContract.ShieldedTransferContract; + +/** + * Counts every field occurrence of an externally submitted {@code Transaction}, including the + * contract payload that {@code TransactionCapsule.getOwner} unpacks, and rejects it before parsing + * once the budget is exceeded. + * + *

Never use it for block, database or P2P parsing: a valid block must not fail a local, + * non-consensus limit. + */ +public final class TransactionAdmissionGuard { + + // About 12.7 times the largest count in mainnet history (3,926, a contract deployment). + public static final int MAX_TX_OCCURRENCES = 50_000; + + static final int MAX_DEPTH = 32; + + private static final Descriptor TRANSACTION = Transaction.getDescriptor(); + private static final Descriptor CONTRACT = Transaction.Contract.getDescriptor(); + private static final Descriptor ANY = Any.getDescriptor(); + + private static final int CONTRACT_TYPE_TAG = makeTag( + Transaction.Contract.TYPE_FIELD_NUMBER, WireFormat.WIRETYPE_VARINT); + private static final int CONTRACT_PARAMETER_TAG = makeTag( + Transaction.Contract.PARAMETER_FIELD_NUMBER, WireFormat.WIRETYPE_LENGTH_DELIMITED); + private static final int ANY_TYPE_URL_TAG = makeTag( + Any.TYPE_URL_FIELD_NUMBER, WireFormat.WIRETYPE_LENGTH_DELIMITED); + private static final int ANY_VALUE_TAG = makeTag( + Any.VALUE_FIELD_NUMBER, WireFormat.WIRETYPE_LENGTH_DELIMITED); + + private static final ConcurrentMap CHILDREN = new ConcurrentHashMap<>(); + private static final ConcurrentMap PAYLOADS = new ConcurrentHashMap<>(); + + private TransactionAdmissionGuard() { + } + + // WireFormat.makeTag is not public. + private static int makeTag(int fieldNumber, int wireType) { + return (fieldNumber << 3) | wireType; + } + + public static void checkTransaction(byte[] data, int maxOccurrences) { + check(data, TRANSACTION, maxOccurrences); + } + + public static long countTransaction(byte[] data) { + return count(data, TRANSACTION); + } + + static void check(byte[] data, Descriptor root, int maxOccurrences) { + if (maxOccurrences < 0) { + throw new IllegalArgumentException("maxOccurrences must not be negative"); + } + run(data, root, new Budget(maxOccurrences)); + } + + static long count(byte[] data, Descriptor root) { + Budget budget = new Budget(Long.MAX_VALUE); + run(data, root, budget); + return budget.used; + } + + private static void run(byte[] data, Descriptor root, Budget budget) { + if (data == null) { + throw new TransactionAdmissionException(Reason.MALFORMED, "null payload"); + } + try { + scan(data, CodedInputStream.newInstance(data), root, null, budget, 1); + } catch (IOException e) { + throw new TransactionAdmissionException(Reason.MALFORMED, e.getMessage()); + } + } + + private static void scan(byte[] data, CodedInputStream in, Descriptor descriptor, + Effective effective, Budget budget, int depth) throws IOException { + if (depth > MAX_DEPTH) { + throw new TransactionAdmissionException(Reason.TOO_DEEP, "depth " + depth); + } + int tag; + while ((tag = in.readTag()) != 0) { + budget.consume(); + if (isGroup(tag)) { + throw new TransactionAdmissionException(Reason.MALFORMED, "group"); + } + Descriptor child = null; + if (WireFormat.getTagWireType(tag) == WireFormat.WIRETYPE_LENGTH_DELIMITED) { + if (descriptor == ANY && tag == ANY_VALUE_TAG) { + // Only the value the merged Any ends up with is unpacked. + if (effective != null && in.getTotalBytesRead() == effective.valueTagEnd) { + child = effective.payload; + } + } else { + child = children(descriptor).get(WireFormat.getTagFieldNumber(tag)); + } + } + if (child == null) { + if (!in.skipField(tag)) { + throw new TransactionAdmissionException(Reason.MALFORMED, "unexpected end group"); + } + continue; + } + int length = in.readRawVarint32(); + int start = in.getTotalBytesRead(); + int oldLimit = in.pushLimit(length); + try { + Effective next = null; + if (child == CONTRACT) { + next = resolveContract(data, start, length, budget); + } else if (descriptor == CONTRACT) { + next = effective; + } + scan(data, in, child, next, budget, depth + 1); + } finally { + in.popLimit(oldLimit); + } + } + } + + /** + * Finds the contract type and the {@code Any} value the merged {@code Contract} ends up with: + * the last occurrence of each, across all {@code parameter} occurrences. The caller must have + * pushed the limit for this slice. + */ + private static Effective resolveContract(byte[] data, int start, int length, Budget budget) + throws IOException { + CodedInputStream in = CodedInputStream.newInstance(data, start, length); + // Local count only: the second pass consumes the real budget for the same occurrences. + long left = budget.remaining(); + int type = 0; + int typeUrlOffset = -1; + int typeUrlLength = 0; + int valueTagEnd = -1; + int tag; + while ((tag = in.readTag()) != 0) { + left = consumeLocal(left); + if (isGroup(tag)) { + throw new TransactionAdmissionException(Reason.MALFORMED, "group"); + } + if (tag == CONTRACT_TYPE_TAG) { + type = in.readEnum(); + } else if (tag == CONTRACT_PARAMETER_TAG) { + int parameterLength = in.readRawVarint32(); + int oldLimit = in.pushLimit(parameterLength); + try { + int parameterTag; + while ((parameterTag = in.readTag()) != 0) { + left = consumeLocal(left); + if (isGroup(parameterTag)) { + throw new TransactionAdmissionException(Reason.MALFORMED, "group"); + } + if (parameterTag == ANY_TYPE_URL_TAG) { + int urlLength = in.readRawVarint32(); + typeUrlOffset = start + in.getTotalBytesRead(); + typeUrlLength = urlLength; + in.skipRawBytes(urlLength); + } else if (parameterTag == ANY_VALUE_TAG) { + valueTagEnd = start + in.getTotalBytesRead(); + in.skipField(parameterTag); + } else if (!in.skipField(parameterTag)) { + throw new TransactionAdmissionException(Reason.MALFORMED, "unexpected end group"); + } + } + } finally { + in.popLimit(oldLimit); + } + } else if (!in.skipField(tag)) { + throw new TransactionAdmissionException(Reason.MALFORMED, "unexpected end group"); + } + } + Payload payload = payload(ContractType.forNumber(type)); + if (payload == null || typeUrlOffset < 0 + || !typeNameMatches(data, typeUrlOffset, typeUrlLength, payload.fullName)) { + return new Effective(null, valueTagEnd); + } + return new Effective(payload.descriptor, valueTagEnd); + } + + private static long consumeLocal(long left) { + if (left <= 0) { + throw new TransactionAdmissionException(Reason.OVER_BUDGET, null); + } + return left - 1; + } + + private static boolean isGroup(int tag) { + int wireType = WireFormat.getTagWireType(tag); + return wireType == WireFormat.WIRETYPE_START_GROUP + || wireType == WireFormat.WIRETYPE_END_GROUP; + } + + // Mirrors Any.is: the part after the last '/' must equal the full name. + private static boolean typeNameMatches(byte[] data, int offset, int length, byte[] fullName) { + int slash = -1; + for (int i = offset + length - 1; i >= offset; i--) { + if (data[i] == '/') { + slash = i; + break; + } + } + if (slash < 0) { + return false; + } + int nameLength = offset + length - slash - 1; + if (nameLength != fullName.length) { + return false; + } + for (int i = 0; i < nameLength; i++) { + if (data[slash + 1 + i] != fullName[i]) { + return false; + } + } + return true; + } + + // The type getOwner unpacks. Only hits are cached: actuators may register later. + private static Payload payload(ContractType type) { + if (type == null) { + return null; + } + Payload cached = PAYLOADS.get(type); + if (cached != null) { + return cached; + } + Class clazz = type == ContractType.ShieldedTransferContract + ? ShieldedTransferContract.class : TransactionFactory.getContract(type); + if (clazz == null) { + return null; + } + Descriptor descriptor = Internal.getDefaultInstance(clazz).getDescriptorForType(); + Payload payload = new Payload(descriptor); + PAYLOADS.putIfAbsent(type, payload); + return payload; + } + + private static ChildTable children(Descriptor descriptor) { + ChildTable table = CHILDREN.get(descriptor); + if (table == null) { + table = ChildTable.of(descriptor); + ChildTable previous = CHILDREN.putIfAbsent(descriptor, table); + if (previous != null) { + table = previous; + } + } + return table; + } + + private static final class ChildTable { + + private final int[] numbers; + private final Descriptor[] types; + + private ChildTable(int[] numbers, Descriptor[] types) { + this.numbers = numbers; + this.types = types; + } + + static ChildTable of(Descriptor descriptor) { + List fields = descriptor.getFields(); + int size = 0; + for (FieldDescriptor field : fields) { + if (field.getJavaType() == FieldDescriptor.JavaType.MESSAGE) { + size++; + } + } + FieldDescriptor[] messageFields = new FieldDescriptor[size]; + int index = 0; + for (FieldDescriptor field : fields) { + if (field.getJavaType() == FieldDescriptor.JavaType.MESSAGE) { + messageFields[index++] = field; + } + } + Arrays.sort(messageFields, (a, b) -> Integer.compare(a.getNumber(), b.getNumber())); + int[] numbers = new int[size]; + Descriptor[] types = new Descriptor[size]; + for (int i = 0; i < size; i++) { + numbers[i] = messageFields[i].getNumber(); + types[i] = messageFields[i].getMessageType(); + } + return new ChildTable(numbers, types); + } + + Descriptor get(int fieldNumber) { + int i = Arrays.binarySearch(numbers, fieldNumber); + return i >= 0 ? types[i] : null; + } + } + + private static final class Payload { + + private final Descriptor descriptor; + private final byte[] fullName; + + private Payload(Descriptor descriptor) { + this.descriptor = descriptor; + this.fullName = descriptor.getFullName().getBytes(StandardCharsets.UTF_8); + } + } + + private static final class Effective { + + private final Descriptor payload; + private final int valueTagEnd; + + private Effective(Descriptor payload, int valueTagEnd) { + this.payload = payload; + this.valueTagEnd = valueTagEnd; + } + } + + private static final class Budget { + + private final long limit; + private long used; + + private Budget(long limit) { + this.limit = limit; + } + + void consume() { + if (used >= limit) { + throw new TransactionAdmissionException(Reason.OVER_BUDGET, null); + } + used++; + } + + long remaining() { + return limit - used; + } + } +} diff --git a/framework/src/main/java/org/tron/core/services/RpcApiService.java b/framework/src/main/java/org/tron/core/services/RpcApiService.java index 563df2c05b9..b85f44b9efc 100755 --- a/framework/src/main/java/org/tron/core/services/RpcApiService.java +++ b/framework/src/main/java/org/tron/core/services/RpcApiService.java @@ -193,16 +193,16 @@ public RpcApiService() { @Override protected void addService(NettyServerBuilder serverBuilder) { - serverBuilder.addService(databaseApi); + serverBuilder.addService(guardTransactionMethods(databaseApi.bindService())); CommonParameter parameter = Args.getInstance(); if (parameter.isSolidityNode()) { - serverBuilder.addService(walletSolidityApi); + serverBuilder.addService(guardTransactionMethods(walletSolidityApi.bindService())); } else { - serverBuilder.addService(walletApi); + serverBuilder.addService(guardTransactionMethods(walletApi.bindService())); } if (parameter.isNodeMetricsEnable()) { - serverBuilder.addService(monitorApi); + serverBuilder.addService(guardTransactionMethods(monitorApi.bindService())); } } diff --git a/framework/src/main/java/org/tron/core/services/http/servlets/BroadcastHexServlet.java b/framework/src/main/java/org/tron/core/services/http/servlets/BroadcastHexServlet.java index 42ed9d5a168..bbf71d1d197 100644 --- a/framework/src/main/java/org/tron/core/services/http/servlets/BroadcastHexServlet.java +++ b/framework/src/main/java/org/tron/core/services/http/servlets/BroadcastHexServlet.java @@ -9,6 +9,8 @@ import org.tron.api.GrpcAPI; import org.tron.common.utils.ByteArray; import org.tron.core.Wallet; +import org.tron.core.admission.TransactionAdmissionException; +import org.tron.core.admission.TransactionAdmissionGuard; import org.tron.core.capsule.TransactionCapsule; import org.tron.core.services.http.HttpApi; import org.tron.core.services.http.HttpApi.Access; @@ -30,7 +32,17 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) String input = request.getReader().lines() .collect(Collectors.joining(System.lineSeparator())); String trx = JSONObject.parseObject(input).getString("transaction"); - Transaction transaction = Transaction.parseFrom(ByteArray.fromHexString(trx)); + byte[] raw = ByteArray.fromHexString(trx); + try { + TransactionAdmissionGuard.checkTransaction(raw, + TransactionAdmissionGuard.MAX_TX_OCCURRENCES); + } catch (TransactionAdmissionException e) { + logger.debug("broadcasthex rejected by admission check: {}", e.getReason()); + // processError would render this client error as "internal server error". + Util.writeAuditedError(TransactionAdmissionException.REJECTION_MESSAGE, response); + return; + } + Transaction transaction = Transaction.parseFrom(raw); TransactionCapsule transactionCapsule = new TransactionCapsule(transaction); String transactionID = ByteArray .toHexString(transactionCapsule.getTransactionId().getBytes()); diff --git a/framework/src/main/java/org/tron/core/services/interfaceOnPBFT/RpcApiServiceOnPBFT.java b/framework/src/main/java/org/tron/core/services/interfaceOnPBFT/RpcApiServiceOnPBFT.java index 2e6d1bd59bd..dd268d8aa8e 100755 --- a/framework/src/main/java/org/tron/core/services/interfaceOnPBFT/RpcApiServiceOnPBFT.java +++ b/framework/src/main/java/org/tron/core/services/interfaceOnPBFT/RpcApiServiceOnPBFT.java @@ -27,10 +27,10 @@ public RpcApiServiceOnPBFT() { /** PBFT cursor bound at the service level, so it brackets only these two read services. */ @Override protected void addService(NettyServerBuilder serverBuilder) { - serverBuilder.addService( - ServerInterceptors.intercept(rpcApiService.getDatabaseApi(), pbftCursorInterceptor)); - serverBuilder.addService(ServerInterceptors.intercept( - rpcApiService.getWalletSolidityApi(), pbftCursorInterceptor)); + serverBuilder.addService(guardTransactionMethods(ServerInterceptors.intercept( + rpcApiService.getDatabaseApi(), pbftCursorInterceptor))); + serverBuilder.addService(guardTransactionMethods(ServerInterceptors.intercept( + rpcApiService.getWalletSolidityApi(), pbftCursorInterceptor))); } } diff --git a/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/RpcApiServiceOnSolidity.java b/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/RpcApiServiceOnSolidity.java index f0c7b1468d2..033b46be742 100755 --- a/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/RpcApiServiceOnSolidity.java +++ b/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/RpcApiServiceOnSolidity.java @@ -27,10 +27,10 @@ public RpcApiServiceOnSolidity() { /** SOLIDITY cursor bound at the service level, so it brackets only these two read services. */ @Override protected void addService(NettyServerBuilder serverBuilder) { - serverBuilder.addService( - ServerInterceptors.intercept(rpcApiService.getDatabaseApi(), solidityCursorInterceptor)); - serverBuilder.addService(ServerInterceptors.intercept( - rpcApiService.getWalletSolidityApi(), solidityCursorInterceptor)); + serverBuilder.addService(guardTransactionMethods(ServerInterceptors.intercept( + rpcApiService.getDatabaseApi(), solidityCursorInterceptor))); + serverBuilder.addService(guardTransactionMethods(ServerInterceptors.intercept( + rpcApiService.getWalletSolidityApi(), solidityCursorInterceptor))); } } diff --git a/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java b/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java new file mode 100644 index 00000000000..667a2ca0104 --- /dev/null +++ b/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java @@ -0,0 +1,619 @@ +package org.tron.common.application; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.protobuf.ByteString; +import com.google.protobuf.CodedOutputStream; +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import io.grpc.BindableService; +import io.grpc.CallOptions; +import io.grpc.ClientCall; +import io.grpc.Compressor; +import io.grpc.CompressorRegistry; +import io.grpc.ForwardingServerCall.SimpleForwardingServerCall; +import io.grpc.ForwardingServerCallListener.SimpleForwardingServerCallListener; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Server; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerMethodDefinition; +import io.grpc.ServerServiceDefinition; +import io.grpc.ServiceDescriptor; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.NettyServerBuilder; +import io.grpc.protobuf.ProtoMethodDescriptorSupplier; +import io.grpc.protobuf.ProtoUtils; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.StreamObserver; +import java.io.ByteArrayInputStream; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; +import org.tron.api.DatabaseGrpc; +import org.tron.api.GrpcAPI; +import org.tron.api.MonitorGrpc; +import org.tron.api.WalletGrpc; +import org.tron.api.WalletSolidityGrpc; +import org.tron.api.ZksnarkGrpcAPI.ZksnarkRequest; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.admission.AdmissionCheckedHandler; +import org.tron.core.admission.GuardedTransactionMarshaller; +import org.tron.core.services.ratelimiter.RpcApiAccessInterceptor; +import org.tron.protos.Protocol.Transaction; + +public class RpcServiceAdmissionTest { + + private static final int MAX_MESSAGE = 4 * 1024 * 1024; + private static final int BUDGET = 1000; + private static final long DEADLINE_SECONDS = 10; + // raw_data { contract { parameter { type_url: C3 28 } } } + private static final byte[] INVALID_UTF8_TYPE_URL = + {0x0a, 0x08, 0x5a, 0x06, 0x12, 0x04, 0x0a, 0x02, (byte) 0xc3, 0x28}; + + private static final AtomicInteger HANDLER_CALLS = new AtomicInteger(); + private static final AtomicInteger STARTED = new AtomicInteger(); + private static final AtomicInteger FINISHED = new AtomicInteger(); + private static final List CLOSED_WITH = new CopyOnWriteArrayList<>(); + private static final List GRPC_SEVERE = new CopyOnWriteArrayList<>(); + + private static Server server; + private static ManagedChannel channel; + private static Handler severeCapture; + private static List originalDisabledApiList; + + @Rule + public Timeout timeout = new Timeout(60, TimeUnit.SECONDS); + + @BeforeClass + public static void startServer() throws Exception { + CommonParameter parameter = CommonParameter.getInstance(); + originalDisabledApiList = parameter.disabledApiList; + parameter.disabledApiList = Collections.emptyList(); + severeCapture = new Handler() { + @Override + public void publish(LogRecord record) { + // Only the logger that reports exceptions from a call, not leftovers of other tests. + if (record.getLevel().intValue() >= Level.SEVERE.intValue() + && "io.grpc.internal.SerializingExecutor".equals(record.getLoggerName())) { + GRPC_SEVERE.add(record); + } + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + Logger.getLogger("io.grpc").addHandler(severeCapture); + + WalletGrpc.WalletImplBase wallet = new WalletGrpc.WalletImplBase() { + @Override + public void broadcastTransaction(Transaction request, + StreamObserver responseObserver) { + HANDLER_CALLS.incrementAndGet(); + responseObserver.onNext(GrpcAPI.Return.newBuilder().setResult(true).build()); + responseObserver.onCompleted(); + } + }; + // Real Netty transport bound to loopback only: the in-process transport skips the + // deframer and decompression. + server = NettyServerBuilder.forAddress(new InetSocketAddress("127.0.0.1", 0)) + .maxInboundMessageSize(MAX_MESSAGE) + .addService(RpcService.guardTransactionMethods(wallet.bindService(), MAX_MESSAGE, BUDGET)) + .intercept(new RpcApiAccessInterceptor()) + .intercept(new PermitLikeInterceptor()) + .build().start(); + channel = NettyChannelBuilder.forAddress("127.0.0.1", server.getPort()).usePlaintext() + .build(); + } + + @AfterClass + public static void stopServer() throws Exception { + try { + if (channel != null) { + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + if (server != null) { + server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + } finally { + if (severeCapture != null) { + Logger.getLogger("io.grpc").removeHandler(severeCapture); + } + CommonParameter.getInstance().disabledApiList = originalDisabledApiList; + } + } + + // ---------------------------------------------------------------- wrapping + + @Test + public void onlyTransactionRequestMethodsAreWrapped() { + ServerServiceDefinition original = new WalletGrpc.WalletImplBase() { }.bindService(); + ServerServiceDefinition guarded = + RpcService.guardTransactionMethods(original, MAX_MESSAGE, BUDGET); + + Set wrapped = new HashSet<>(); + for (ServerMethodDefinition method : guarded.getMethods()) { + MethodDescriptor descriptor = method.getMethodDescriptor(); + ServerMethodDefinition before = original.getMethod(descriptor.getFullMethodName()); + assertSame(before.getMethodDescriptor().getResponseMarshaller(), + descriptor.getResponseMarshaller()); + if (descriptor.getRequestMarshaller() instanceof GuardedTransactionMarshaller) { + wrapped.add(descriptor.getBareMethodName()); + assertTrue(method.getServerCallHandler() instanceof AdmissionCheckedHandler); + assertSame(before.getServerCallHandler(), + ((AdmissionCheckedHandler) method.getServerCallHandler()).getDelegate()); + } else { + assertSame(before.getMethodDescriptor(), descriptor); + assertSame(before.getServerCallHandler(), method.getServerCallHandler()); + } + } + assertEquals(new HashSet<>(Arrays.asList("BroadcastTransaction", + "GetTransactionSignWeight", "GetTransactionApprovedList", "GetShieldTransactionHash", + "CreateCommonTransaction")), wrapped); + assertEquals(original.getMethods().size(), guarded.getMethods().size()); + assertSame(original.getServiceDescriptor().getSchemaDescriptor(), + guarded.getServiceDescriptor().getSchemaDescriptor()); + } + + @Test + public void everyTransactionRequestMethodOfTheServiceTypesIsGuarded() { + BindableService[] services = { + new WalletGrpc.WalletImplBase() { }, + new WalletSolidityGrpc.WalletSolidityImplBase() { }, + new DatabaseGrpc.DatabaseImplBase() { }, + new MonitorGrpc.MonitorImplBase() { } + }; + for (BindableService service : services) { + assertAllTransactionMethodsGuarded( + RpcService.guardTransactionMethods(service.bindService(), MAX_MESSAGE, BUDGET), + BUDGET, MAX_MESSAGE); + } + } + + /** + * Shared with the production registration tests. A method is classified by its proto schema, + * so a request that only nests a Transaction, or a method without a schema, fails here instead + * of being skipped. Each guarded method must apply exactly {@code budget} and + * {@code maxMessageSize}. + */ + @SuppressWarnings("unchecked") + public static int assertAllTransactionMethodsGuarded(ServerServiceDefinition definition, + int budget, int maxMessageSize) { + int guarded = 0; + for (ServerMethodDefinition method : definition.getMethods()) { + MethodDescriptor descriptor = method.getMethodDescriptor(); + String name = descriptor.getFullMethodName(); + Object schema = descriptor.getSchemaDescriptor(); + assertTrue(name + " has no proto schema", schema instanceof ProtoMethodDescriptorSupplier); + Descriptor input = ((ProtoMethodDescriptorSupplier) schema).getMethodDescriptor() + .getInputType(); + MethodDescriptor.Marshaller request = descriptor.getRequestMarshaller(); + if (input.equals(Transaction.getDescriptor())) { + assertTrue(name, request instanceof GuardedTransactionMarshaller); + assertTrue(name, method.getServerCallHandler() instanceof AdmissionCheckedHandler); + assertLimits(name, (MethodDescriptor.Marshaller) request, budget, + maxMessageSize); + guarded++; + } else if (request instanceof GuardedTransactionMarshaller) { + fail(name + " is guarded although its request is not a Transaction"); + } else if (reachesTransaction(input, new HashSet<>())) { + fail(name + " nests a Transaction in its request, which the admission guard does not " + + "cover"); + } + } + return guarded; + } + + private static void assertLimits(String name, MethodDescriptor.Marshaller request, + int budget, int maxMessageSize) { + // A sentinel is an empty Transaction, a parsed request keeps its ret entries or signature. + assertEquals(name, budget, + request.parse(new ByteArrayInputStream(retFlood(budget))).getRetCount()); + assertEquals(name, 0, + request.parse(new ByteArrayInputStream(retFlood(budget + 1))).getRetCount()); + assertEquals(name, 1, request.parse(signatureOfSize(maxMessageSize)).getSignatureCount()); + assertEquals(name, 0, request.parse(signatureOfSize(maxMessageSize + 1)).getSignatureCount()); + } + + /** A Transaction of exactly {@code size} bytes holding one signature. */ + private static InputStream signatureOfSize(int size) { + int content = size - 2; + while (1 + CodedOutputStream.computeUInt32SizeNoTag(content) + content > size) { + content--; + } + byte[] bytes = Transaction.newBuilder().addSignature(ByteString.copyFrom(new byte[content])) + .build().toByteArray(); + assertEquals(size, bytes.length); + return new ByteArrayInputStream(bytes); + } + + private static boolean reachesTransaction(Descriptor type, Set seen) { + if (type.equals(Transaction.getDescriptor())) { + return true; + } + if (!seen.add(type.getFullName())) { + return false; + } + for (FieldDescriptor field : type.getFields()) { + if (field.getJavaType() == FieldDescriptor.JavaType.MESSAGE + && reachesTransaction(field.getMessageType(), seen)) { + return true; + } + } + return false; + } + + @Test + public void requestsNestingATransactionAreDetected() { + assertTrue(reachesTransaction(ZksnarkRequest.getDescriptor(), new HashSet<>())); + assertFalse(reachesTransaction(GrpcAPI.BytesMessage.getDescriptor(), new HashSet<>())); + } + + // ---------------------------------------------------------------- real Netty transport + + @Test + public void legitimateRequestsSucceedPlainAndGzip() { + int before = HANDLER_CALLS.get(); + assertTrue(stub().broadcastTransaction(legitimate()).getResult()); + assertTrue(stub().withCompression("gzip").broadcastTransaction(legitimate()).getResult()); + assertEquals(before + 2, HANDLER_CALLS.get()); + } + + @Test + public void overBudgetRequestsAreRejectedWithInvalidArgumentAndNoSevereLog() + throws Exception { + Transaction flood = Transaction.parseFrom(retFlood(BUDGET + 1)); + int before = HANDLER_CALLS.get(); + int severeBefore = GRPC_SEVERE.size(); + CLOSED_WITH.clear(); + + assertCode(Status.Code.INVALID_ARGUMENT, () -> stub().broadcastTransaction(flood)); + assertCode(Status.Code.INVALID_ARGUMENT, + () -> stub().withCompression("gzip").broadcastTransaction(flood)); + + assertEquals(before, HANDLER_CALLS.get()); + // Interceptors see the call close with the same status the client gets. + assertEquals(Arrays.asList(Status.Code.INVALID_ARGUMENT, Status.Code.INVALID_ARGUMENT), + CLOSED_WITH); + // Permit-style interceptors are released for every rejected call. + awaitTrue(() -> STARTED.get() == FINISHED.get()); + // No exception left the marshaller, so grpc logged nothing. Give its executor time to log. + Thread.sleep(500); + assertEquals(severeBefore, GRPC_SEVERE.size()); + } + + @Test + public void laterMessagesOnARejectedUnaryCallAreIgnored() throws Exception { + Transaction flood = Transaction.parseFrom(retFlood(BUDGET + 1)); + int before = HANDLER_CALLS.get(); + // A unary call that carries a rejected message followed by a valid one. + assertEquals(Status.Code.INVALID_ARGUMENT, + rawUnaryCall(Arrays.asList(flood, legitimate())).getCode()); + // A valid message followed by a rejected one. + assertEquals(Status.Code.INVALID_ARGUMENT, + rawUnaryCall(Arrays.asList(legitimate(), flood)).getCode()); + assertEquals(before, HANDLER_CALLS.get()); + awaitTrue(() -> STARTED.get() == FINISHED.get()); + } + + @Test + public void cancelledCallsReleaseInterceptorState() throws Exception { + int startedBefore = STARTED.get(); + int handlerCallsBefore = HANDLER_CALLS.get(); + ClientCall call = channel.newCall( + WalletGrpc.getBroadcastTransactionMethod(), + CallOptions.DEFAULT.withDeadlineAfter(DEADLINE_SECONDS, TimeUnit.SECONDS)); + CompletableFuture closed = new CompletableFuture<>(); + call.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + closed.complete(status); + } + }, new Metadata()); + call.request(1); + // Unary headers are only flushed with the message. Without a half-close the server keeps + // waiting, so the cancel is what ends the call. + call.sendMessage(legitimate()); + // Wait until the server has started the call, so its release is actually observed and + // nothing of this call is still in flight when the next test starts. + awaitTrue(() -> STARTED.get() > startedBefore); + call.cancel("client gives up", null); + closed.get(DEADLINE_SECONDS, TimeUnit.SECONDS); + awaitTrue(() -> STARTED.get() == FINISHED.get()); + assertEquals(handlerCallsBefore, HANDLER_CALLS.get()); + } + + @Test + public void accessInterceptorStillAppliesToGuardedMethods() { + CommonParameter parameter = CommonParameter.getInstance(); + List previous = parameter.disabledApiList; + parameter.disabledApiList = Collections.singletonList("broadcasttransaction"); + try { + int before = HANDLER_CALLS.get(); + assertCode(Status.Code.UNAVAILABLE, () -> stub().broadcastTransaction(legitimate())); + assertEquals(before, HANDLER_CALLS.get()); + } finally { + parameter.disabledApiList = previous; + } + } + + @Test + public void messageSizeLimitStillApplies() throws Exception { + Transaction big = Transaction.newBuilder() + .addSignature(ByteString.copyFrom(new byte[MAX_MESSAGE + 1])).build(); + int before = HANDLER_CALLS.get(); + int severeBefore = GRPC_SEVERE.size(); + // Uncompressed: rejected by the deframer before the marshaller. + assertCode(Status.Code.RESOURCE_EXHAUSTED, () -> stub().broadcastTransaction(big)); + // Compressed: the decompressed size check fires while the marshaller reads. This path is + // unchanged grpc behaviour (UNKNOWN plus a SEVERE log), which also shows that the SEVERE + // capture used above works. + assertCode(Status.Code.UNKNOWN, () -> stub().withCompression("gzip").broadcastTransaction(big)); + assertEquals(before, HANDLER_CALLS.get()); + awaitTrue(() -> GRPC_SEVERE.size() > severeBefore); + } + + @Test + public void malformedRequestsAreRejectedWithInvalidArgumentAndNoSevereLog() throws Exception { + CompressorRegistry registry = CompressorRegistry.newEmptyInstance(); + registry.register(new CorruptGzip()); + ManagedChannel corruptChannel = NettyChannelBuilder.forAddress("127.0.0.1", server.getPort()) + .usePlaintext().compressorRegistry(registry).build(); + try { + int before = HANDLER_CALLS.get(); + int severeBefore = GRPC_SEVERE.size(); + CLOSED_WITH.clear(); + // Passes the guard, then fails protobuf decoding of the original marshaller. + assertEquals(Status.Code.INVALID_ARGUMENT, + rawBytesCall(channel, INVALID_UTF8_TYPE_URL, null).getCode()); + assertEquals(Status.Code.INVALID_ARGUMENT, + rawBytesCall(channel, INVALID_UTF8_TYPE_URL, "gzip").getCode()); + // Valid gzip header, invalid deflate data. + assertEquals(Status.Code.INVALID_ARGUMENT, + rawBytesCall(corruptChannel, legitimate().toByteArray(), "gzip").getCode()); + + assertEquals(before, HANDLER_CALLS.get()); + assertEquals(Arrays.asList(Status.Code.INVALID_ARGUMENT, Status.Code.INVALID_ARGUMENT, + Status.Code.INVALID_ARGUMENT), CLOSED_WITH); + awaitTrue(() -> STARTED.get() == FINISHED.get()); + Thread.sleep(500); + assertEquals(severeBefore, GRPC_SEVERE.size()); + assertTrue(stub().broadcastTransaction(legitimate()).getResult()); + } finally { + corruptChannel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + } + + @Test + public void transactionMethodWithANonPrototypeMarshallerIsStillGuarded() { + MethodDescriptor generated = + WalletGrpc.getBroadcastTransactionMethod(); + MethodDescriptor.Marshaller plain = + new MethodDescriptor.Marshaller() { + @Override + public InputStream stream(Transaction value) { + return generated.getRequestMarshaller().stream(value); + } + + @Override + public Transaction parse(InputStream stream) { + return generated.getRequestMarshaller().parse(stream); + } + }; + MethodDescriptor method = + generated.toBuilder(plain, generated.getResponseMarshaller()).build(); + ServerServiceDefinition definition = ServerServiceDefinition.builder( + ServiceDescriptor.newBuilder(WalletGrpc.SERVICE_NAME).addMethod(method).build()) + .addMethod(method, (call, headers) -> new ServerCall.Listener() { }) + .build(); + assertEquals(1, assertAllTransactionMethodsGuarded( + RpcService.guardTransactionMethods(definition, MAX_MESSAGE, BUDGET), BUDGET, MAX_MESSAGE)); + } + + @Test + public void theSchemaDecidesWhenThereIsOne() { + MethodDescriptor broadcast = + WalletGrpc.getBroadcastTransactionMethod(); + MethodDescriptor byId = + WalletGrpc.getGetTransactionByIdMethod(); + MethodDescriptor.Marshaller transaction = + ProtoUtils.marshaller(Transaction.getDefaultInstance()); + MethodDescriptor.Marshaller bytes = + ProtoUtils.marshaller(GrpcAPI.BytesMessage.getDefaultInstance()); + + assertTrue(RpcService.isTransactionRequest(broadcast)); + assertFalse(RpcService.isTransactionRequest(byId)); + // The schema wins over a conflicting request prototype. + assertFalse(RpcService.isTransactionRequest( + byId.toBuilder(transaction, byId.getResponseMarshaller()).build())); + assertTrue(RpcService.isTransactionRequest( + broadcast.toBuilder(bytes, broadcast.getResponseMarshaller()).build())); + // Without a schema the request prototype decides. + assertTrue(RpcService.isTransactionRequest( + broadcast.toBuilder().setSchemaDescriptor(null).build())); + assertFalse(RpcService.isTransactionRequest( + byId.toBuilder().setSchemaDescriptor(null).build())); + } + + // ---------------------------------------------------------------- helpers + + private static WalletGrpc.WalletBlockingStub stub() { + return WalletGrpc.newBlockingStub(channel) + .withDeadlineAfter(DEADLINE_SECONDS, TimeUnit.SECONDS); + } + + private static Transaction legitimate() { + return Transaction.newBuilder().addSignature(ByteString.copyFrom(new byte[65])).build(); + } + + private static Status rawUnaryCall(List messages) throws Exception { + ClientCall call = channel.newCall( + WalletGrpc.getBroadcastTransactionMethod(), + CallOptions.DEFAULT.withDeadlineAfter(DEADLINE_SECONDS, TimeUnit.SECONDS)); + CompletableFuture closed = new CompletableFuture<>(); + call.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + closed.complete(status); + } + }, new Metadata()); + call.request(1); + for (Transaction message : messages) { + call.sendMessage(message); + } + call.halfClose(); + return closed.get(DEADLINE_SECONDS, TimeUnit.SECONDS); + } + + private static final MethodDescriptor.Marshaller RAW = + new MethodDescriptor.Marshaller() { + @Override + public InputStream stream(byte[] value) { + return new ByteArrayInputStream(value); + } + + @Override + public byte[] parse(InputStream stream) { + throw new UnsupportedOperationException(); + } + }; + + private static Status rawBytesCall(ManagedChannel target, byte[] request, String compression) { + MethodDescriptor generated = + WalletGrpc.getBroadcastTransactionMethod(); + MethodDescriptor method = + generated.toBuilder(RAW, generated.getResponseMarshaller()).build(); + CallOptions options = CallOptions.DEFAULT + .withDeadlineAfter(DEADLINE_SECONDS, TimeUnit.SECONDS).withCompression(compression); + try { + ClientCalls.blockingUnaryCall(target, method, options, request); + return Status.OK; + } catch (StatusRuntimeException e) { + return e.getStatus(); + } + } + + /** Writes a valid gzip header followed by deflate data with an invalid block type. */ + private static final class CorruptGzip implements Compressor { + + @Override + public String getMessageEncoding() { + return "gzip"; + } + + @Override + public OutputStream compress(OutputStream os) { + return new FilterOutputStream(os) { + @Override + public void write(int b) { + } + + @Override + public void write(byte[] b, int off, int len) { + } + + @Override + public void close() throws IOException { + out.write(new byte[] {0x1f, (byte) 0x8b, 8, 0, 0, 0, 0, 0, 0, (byte) 0xff, + (byte) 0xff, (byte) 0xff, 0, 0}); + super.close(); + } + }; + } + } + + private static void assertCode(Status.Code expected, Runnable call) { + try { + call.run(); + fail("expected " + expected); + } catch (StatusRuntimeException e) { + assertEquals(expected, e.getStatus().getCode()); + } + } + + private static void awaitTrue(BooleanSupplier condition) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(DEADLINE_SECONDS); + while (!condition.getAsBoolean()) { + if (System.nanoTime() > deadline) { + fail("condition not reached in time"); + } + Thread.sleep(10); + } + } + + private static byte[] retFlood(int entries) { + byte[] out = new byte[entries * 2]; + for (int i = 0; i < out.length; i += 2) { + out[i] = 0x2a; + } + return out; + } + + /** + * Mirrors how the rate limiter holds a permit from interceptCall until the call completes or + * is cancelled, and how the Prometheus interceptor observes the close status. + */ + private static final class PermitLikeInterceptor implements ServerInterceptor { + + @Override + public ServerCall.Listener interceptCall(ServerCall call, + Metadata headers, ServerCallHandler next) { + STARTED.incrementAndGet(); + ServerCall recording = new SimpleForwardingServerCall(call) { + @Override + public void close(Status status, Metadata trailers) { + CLOSED_WITH.add(status.getCode()); + super.close(status, trailers); + } + }; + return new SimpleForwardingServerCallListener(next.startCall(recording, headers)) { + @Override + public void onComplete() { + FINISHED.incrementAndGet(); + super.onComplete(); + } + + @Override + public void onCancel() { + FINISHED.incrementAndGet(); + super.onCancel(); + } + }; + } + } +} diff --git a/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java b/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java new file mode 100644 index 00000000000..6cdd7d4b81b --- /dev/null +++ b/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java @@ -0,0 +1,294 @@ +package org.tron.core.admission; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.fail; + +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.protobuf.ProtoUtils; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; +import org.junit.Test; +import org.tron.core.admission.TransactionAdmissionException.Reason; +import org.tron.protos.Protocol.Transaction; + +public class AdmissionSupportTest { + + // ---------------------------------------------------------------- readBounded + + @Test + public void readBoundedReturnsExactlyTheBytesRead() throws IOException { + byte[] data = new byte[1000]; + data[999] = 7; + assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + new HintStream(data, 0), 1000)); // no hint + assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + new HintStream(data, 1000), 1000)); // exact hint + assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + new HintStream(data, 1), 1000)); // decompressing stream + assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + new HintStream(data, Integer.MAX_VALUE), 4096)); // huge hint, short content + assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + new HintStream(data, 10), Integer.MAX_VALUE)); // no overflow at max int + assertArrayEquals(new byte[0], GuardedTransactionMarshaller.readBounded( + new HintStream(new byte[0], 0), 0)); + } + + @Test + public void readBoundedRejectsMoreThanMax() throws IOException { + byte[] data = new byte[1001]; + assertNull(GuardedTransactionMarshaller.readBounded(new HintStream(data, 0), 1000)); + assertNull(GuardedTransactionMarshaller.readBounded(new HintStream(data, 1001), 1000)); + assertNull(GuardedTransactionMarshaller.readBounded(new HintStream(new byte[1], 0), 0)); + } + + // ---------------------------------------------------------------- marshaller + + @Test + public void marshallerReturnsTheSentinelBeforeTheDelegateParses() throws Exception { + CountingMarshaller delegate = new CountingMarshaller(); + GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(delegate, 4096, 10); + + byte[] flood = new byte[2 * 11]; + for (int i = 0; i < flood.length; i += 2) { + flood[i] = 0x2a; + } + assertNotNull(GuardedTransactionMarshaller.rejectionReason( + marshaller.parse(new ByteArrayInputStream(flood)))); // over budget + assertNotNull(GuardedTransactionMarshaller.rejectionReason( + marshaller.parse(new ByteArrayInputStream(new byte[4097])))); // over max size + assertNotNull(GuardedTransactionMarshaller.rejectionReason( + marshaller.parse(new ByteArrayInputStream(new byte[] {0x0b, 0x0c})))); // malformed + assertEquals(0, delegate.parsed.get()); + + Transaction tx = Transaction.newBuilder().addSignature( + com.google.protobuf.ByteString.copyFrom(new byte[65])).build(); + Transaction parsed = marshaller.parse(new ByteArrayInputStream(tx.toByteArray())); + assertEquals(tx, parsed); + assertNull(GuardedTransactionMarshaller.rejectionReason(parsed)); + assertEquals(1, delegate.parsed.get()); + } + + @Test + public void sentinelIsNeverAParsedOrDefaultTransaction() throws Exception { + assertNull(GuardedTransactionMarshaller.rejectionReason(Transaction.getDefaultInstance())); + assertNull(GuardedTransactionMarshaller.rejectionReason(Transaction.parseFrom(new byte[0]))); + assertNull(GuardedTransactionMarshaller.rejectionReason(Transaction.newBuilder().build())); + CountingMarshaller delegate = new CountingMarshaller(); + GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(delegate, 4096, 10); + assertNull(GuardedTransactionMarshaller.rejectionReason( + marshaller.parse(new ByteArrayInputStream(new byte[0])))); + } + + @Test + public void eachRejectionMapsToItsReason() throws Exception { + CountingMarshaller delegate = new CountingMarshaller(); + GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(delegate, 4096, 10); + byte[] flood = new byte[2 * 11]; + for (int i = 0; i < flood.length; i += 2) { + flood[i] = 0x2a; + } + assertEquals(Reason.OVER_BUDGET, reason(marshaller, new ByteArrayInputStream(flood))); + assertEquals(Reason.TOO_LARGE, reason(marshaller, new ByteArrayInputStream(new byte[4097]))); + assertEquals(Reason.MALFORMED, + reason(marshaller, new ByteArrayInputStream(new byte[] {0x0b, 0x0c}))); + assertEquals(0, delegate.parsed.get()); + assertNull(GuardedTransactionMarshaller.rejectionReason(Transaction.getDefaultInstance())); + } + + @Test + public void decodingErrorOfTheDelegateIsAMalformedRejection() { + CountingMarshaller delegate = new CountingMarshaller(); + GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(delegate, 4096, 10); + // Well-formed wire within budget, but Any.type_url is not valid UTF-8. + assertEquals(Reason.MALFORMED, + reason(marshaller, new ByteArrayInputStream(INVALID_UTF8_TYPE_URL))); + assertEquals(1, delegate.parsed.get()); + } + + @Test + public void corruptOrTruncatedCompressedInputIsAMalformedRejection() throws Exception { + CountingMarshaller delegate = new CountingMarshaller(); + GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(delegate, 4096, 10); + byte[] corrupt = {0x1f, (byte) 0x8b, 8, 0, 0, 0, 0, 0, 0, (byte) 0xff, (byte) 0xff, 0, 0}; + assertEquals(Reason.MALFORMED, + reason(marshaller, new GZIPInputStream(new ByteArrayInputStream(corrupt)))); + ByteArrayOutputStream gzip = new ByteArrayOutputStream(); + try (GZIPOutputStream out = new GZIPOutputStream(gzip)) { + out.write(new byte[1000]); + } + byte[] truncated = Arrays.copyOf(gzip.toByteArray(), gzip.size() / 2); + assertEquals(Reason.MALFORMED, + reason(marshaller, new GZIPInputStream(new ByteArrayInputStream(truncated)))); + assertEquals(0, delegate.parsed.get()); + } + + @Test + public void otherReadFailuresAreThrownWithTheirCause() { + CountingMarshaller delegate = new CountingMarshaller(); + GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(delegate, 4096, 10); + IOException failure = new IOException("read failed"); + InputStream failsOnAvailable = new InputStream() { + @Override + public int read() { + return -1; + } + + @Override + public int available() throws IOException { + throw failure; + } + }; + InputStream failsAfterSomeBytes = new InputStream() { + private int left = 3; + + @Override + public int read() throws IOException { + if (left-- > 0) { + return 0x08; + } + throw failure; + } + }; + for (InputStream stream : Arrays.asList(failsOnAvailable, failsAfterSomeBytes)) { + try { + marshaller.parse(stream); + fail("expected a read failure"); + } catch (StatusRuntimeException e) { + assertEquals(Status.Code.INTERNAL, e.getStatus().getCode()); + assertSame(failure, e.getCause()); + } + } + assertEquals(0, delegate.parsed.get()); + } + + @Test + public void unexpectedDelegateFailuresAreNotTurnedIntoRejections() { + RuntimeException bug = new IllegalStateException("bug"); + StatusRuntimeException other = Status.INTERNAL.withCause(new IOException("x")) + .asRuntimeException(); + for (RuntimeException failure : Arrays.asList(bug, other)) { + MethodDescriptor.Marshaller failing = + new MethodDescriptor.Marshaller() { + @Override + public InputStream stream(Transaction value) { + throw new UnsupportedOperationException(); + } + + @Override + public Transaction parse(InputStream stream) { + throw failure; + } + }; + GuardedTransactionMarshaller marshaller = + new GuardedTransactionMarshaller(failing, 4096, 10); + try { + marshaller.parse(new ByteArrayInputStream(new byte[0])); + fail("expected the delegate failure"); + } catch (RuntimeException e) { + assertSame(failure, e); + } + } + } + + @Test + public void plainDelegateMarshallerIsSupported() { + MethodDescriptor.Marshaller plain = + new MethodDescriptor.Marshaller() { + @Override + public InputStream stream(Transaction value) { + return new ByteArrayInputStream(value.toByteArray()); + } + + @Override + public Transaction parse(InputStream stream) { + try { + return Transaction.parseFrom(stream); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + }; + GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(plain, 4096, 10); + Transaction tx = Transaction.newBuilder().addSignature( + com.google.protobuf.ByteString.copyFrom(new byte[65])).build(); + assertEquals(tx, marshaller.parse(new ByteArrayInputStream(tx.toByteArray()))); + assertSame(Transaction.getDefaultInstance(), marshaller.getMessagePrototype()); + } + + // raw_data { contract { parameter { type_url: C3 28 } } } + static final byte[] INVALID_UTF8_TYPE_URL = + {0x0a, 0x08, 0x5a, 0x06, 0x12, 0x04, 0x0a, 0x02, (byte) 0xc3, 0x28}; + + private static Reason reason(GuardedTransactionMarshaller marshaller, InputStream stream) { + return GuardedTransactionMarshaller.rejectionReason(marshaller.parse(stream)); + } + + private static final class CountingMarshaller + implements MethodDescriptor.PrototypeMarshaller { + + private final MethodDescriptor.PrototypeMarshaller real = + (MethodDescriptor.PrototypeMarshaller) + ProtoUtils.marshaller(Transaction.getDefaultInstance()); + private final AtomicInteger parsed = new AtomicInteger(); + + @Override + public InputStream stream(Transaction value) { + return real.stream(value); + } + + @Override + public Transaction parse(InputStream stream) { + parsed.incrementAndGet(); + return real.parse(stream); + } + + @Override + public Transaction getMessagePrototype() { + return real.getMessagePrototype(); + } + + @Override + public Class getMessageClass() { + return real.getMessageClass(); + } + } + + /** Stream whose available() returns a fixed hint regardless of the real content. */ + private static final class HintStream extends InputStream { + + private final ByteArrayInputStream in; + private final int hint; + + HintStream(byte[] data, int hint) { + this.in = new ByteArrayInputStream(data); + this.hint = hint; + } + + @Override + public int read() { + return in.read(); + } + + @Override + public int read(byte[] b, int off, int len) { + return in.read(b, off, len); + } + + @Override + public int available() { + return hint; + } + } +} diff --git a/framework/src/test/java/org/tron/core/admission/TransactionAdmissionGuardTest.java b/framework/src/test/java/org/tron/core/admission/TransactionAdmissionGuardTest.java new file mode 100644 index 00000000000..7eb546de45e --- /dev/null +++ b/framework/src/test/java/org/tron/core/admission/TransactionAdmissionGuardTest.java @@ -0,0 +1,727 @@ +package org.tron.core.admission; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.protobuf.Any; +import com.google.protobuf.ByteString; +import com.google.protobuf.DescriptorProtos.DescriptorProto; +import com.google.protobuf.DescriptorProtos.FileDescriptorProto; +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.GeneratedMessageV3; +import com.google.protobuf.Internal; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.WireFormat; +import java.io.ByteArrayOutputStream; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.BeforeClass; +import org.junit.Test; +import org.tron.core.actuator.TransactionFactory; +import org.tron.core.admission.TransactionAdmissionException.Reason; +import org.tron.core.utils.TransactionRegister; +import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.Permission; +import org.tron.protos.Protocol.Transaction; +import org.tron.protos.Protocol.Transaction.Contract.ContractType; +import org.tron.protos.contract.AccountContract.AccountCreateContract; +import org.tron.protos.contract.AccountContract.AccountPermissionUpdateContract; +import org.tron.protos.contract.BalanceContract.TransferContract; +import org.tron.protos.contract.ShieldContract.ShieldedTransferContract; +import org.tron.protos.contract.SmartContractOuterClass.CreateSmartContract; +import org.tron.protos.contract.SmartContractOuterClass.SmartContract; +import org.tron.protos.contract.SmartContractOuterClass.SmartContract.ABI; +import org.tron.protos.contract.SmartContractOuterClass.TriggerSmartContract; +import org.tron.protos.contract.WitnessContract.VoteWitnessContract; + +public class TransactionAdmissionGuardTest { + + private static final int LD = WireFormat.WIRETYPE_LENGTH_DELIMITED; + private static final int VARINT = WireFormat.WIRETYPE_VARINT; + private static final int VOTE = ContractType.VoteWitnessContract.getNumber(); + private static final int TRANSFER = ContractType.TransferContract.getNumber(); + + @BeforeClass + public static void registerActuators() { + TransactionRegister.registerActuator(); + } + + // ---------------------------------------------------------------- outer envelope floods + + @Test + public void everyOuterFloodShapeIsCountedPerOccurrence() { + assertEquals(1000, countTx(repeat(bytes(0x2a, 0x00), 1000))); // ret + assertEquals(1000, countTx(repeat(bytes(0x12, 0x00), 1000))); // empty signature + assertEquals(1001, countTx(ld(1, repeat(bytes(0x5a, 0x00), 1000)))); // raw.contract + assertEquals(1001, countTx(ld(1, repeat(bytes(0x4a, 0x00), 1000)))); // raw.auths + assertEquals(1000, countTx(repeat(bytes(0x30, 0xff, 0x01), 1000))); // same unknown field + assertEquals(1000, countTx(distinctUnknownFields(100, 1000))); // distinct unknown + } + + @Test + public void nestedMessagesAreRecursed() { + // Result.orderDetails (26) inside one ret + assertEquals(501, countTx(ld(5, repeat(bytes(0xd2, 0x01, 0x00), 500)))); + // Result.cancel_unfreezeV2_amount (28) map entries carrying unknown fields + byte[] entry = ld(28, repeat(bytes(0x30, 0xff, 0x01), 10)); + assertEquals(1 + 50 * 11, countTx(ld(5, repeat(entry, 50)))); + // raw.auths -> authority.account -> AccountId carrying unknown fields + assertEquals(103, countTx(ld(1, ld(9, ld(1, repeat(bytes(0x30, 0xff, 0x01), 100)))))); + // unknown fields inside Contract.parameter itself (outside value) + assertEquals(103, countTx(ld(1, ld(11, ld(2, repeat(bytes(0x30, 0xff, 0x01), 100)))))); + } + + @Test + public void budgetAllowsExactlyTheCount() { + assertExactBudget(repeat(bytes(0x2a, 0x00), 1000)); + assertExactBudget(ld(1, repeat(bytes(0x5a, 0x00), 1000))); + assertExactBudget(distinctUnknownFields(100, 1000)); + assertExactBudget(voteTx(VOTE, "protocol.VoteWitnessContract", 300)); + } + + // ---------------------------------------------------------------- Any.value payload + + @Test + public void shieldedPayloadIsRecursedLikeGetOwnerUnpacksIt() { + int shielded = ContractType.ShieldedTransferContract.getNumber(); + byte[] spends = repeat(bytes(0x1a, 0x00), 300); // spend_description + byte[] tx = txWithContract(contract(shielded, "protocol.ShieldedTransferContract", spends)); + assertEquals(countTx(voteTx(VOTE, "protocol.VoteWitnessContract", 300)), countTx(tx)); + assertExactBudget(tx); + } + + @Test + public void contractPayloadIsScannedWithItsContractType() throws Exception { + byte[] tx = voteTx(VOTE, "protocol.VoteWitnessContract", 1000); + // raw, contract, type, parameter, type_url, value, then 1000 votes + assertEquals(1006, countTx(tx)); + assertEquals(1000, unpack(tx, VoteWitnessContract.class).getVotesCount()); + + byte[] actives = repeat(bytes(0x22, 0x00), 500); + byte[] permissionTx = txWithContract(contract( + ContractType.AccountPermissionUpdateContract.getNumber(), + "protocol.AccountPermissionUpdateContract", actives)); + assertEquals(506, countTx(permissionTx)); + assertEquals(500, unpack(permissionTx, AccountPermissionUpdateContract.class) + .getActivesCount()); + + // CreateSmartContract.new_contract(2) -> SmartContract.abi(3) -> ABI.entrys(1) + byte[] create = ld(2, ld(3, repeat(bytes(0x0a, 0x00), 500))); + byte[] createTx = txWithContract(contract( + ContractType.CreateSmartContract.getNumber(), "protocol.CreateSmartContract", create)); + assertEquals(508, countTx(createTx)); + assertEquals(500, unpack(createTx, CreateSmartContract.class) + .getNewContract().getAbi().getEntrysCount()); + } + + @Test + public void mergedParameterUsesOnlyTheEffectiveValue() throws Exception { + // First parameter: type_url + malformed value; second parameter: only a valid value. + // The merged Any keeps the first type_url and the second value, and unpacks fine. + byte[] valid = TransferContract.newBuilder().setAmount(1).build().toByteArray(); + byte[] contract = cat( + varintField(1, TRANSFER), + ld(2, cat(ld(1, url("protocol.TransferContract")), ld(2, bytes(0x07)))), + ld(2, ld(2, valid))); + byte[] tx = txWithContract(contract); + TransactionAdmissionGuard.checkTransaction(tx, 1000); + assertEquals(1, unpack(tx, TransferContract.class).getAmount()); + + // A flood in an overwritten value is a leaf; only the effective value is recursed. + byte[] overwritten = cat( + varintField(1, VOTE), + ld(2, cat(ld(1, url("protocol.VoteWitnessContract")), + ld(2, repeat(bytes(0x12, 0x00), 1000)))), + ld(2, ld(2, repeat(bytes(0x12, 0x00), 2)))); + byte[] overwrittenTx = txWithContract(overwritten); + // raw, contract, type, p1, url, v1, p2, v2, then 2 votes + assertEquals(10, countTx(overwrittenTx)); + assertEquals(2, unpack(overwrittenTx, VoteWitnessContract.class).getVotesCount()); + } + + @Test + public void explicitEmptyOverridesFollowTheParser() throws Exception { + byte[] flood = repeat(bytes(0x12, 0x00), 100); + byte[] firstParameter = ld(2, cat(ld(1, url("protocol.VoteWitnessContract")), ld(2, flood))); + + byte[] emptyValue = txWithContract(cat(varintField(1, VOTE), firstParameter, + ld(2, bytes(0x12, 0x00)))); + assertEquals(8, countTx(emptyValue)); + assertTrue(Transaction.parseFrom(emptyValue).getRawData().getContract(0) + .getParameter().getValue().isEmpty()); + + byte[] emptyTypeUrl = txWithContract(cat(varintField(1, VOTE), firstParameter, + ld(2, bytes(0x0a, 0x00)))); + assertEquals(8, countTx(emptyTypeUrl)); + assertEquals("", Transaction.parseFrom(emptyTypeUrl).getRawData().getContract(0) + .getParameter().getTypeUrl()); + } + + @Test + public void missingContractTypeDefaultsToZero() throws Exception { + byte[] flood = repeat(bytes(0x30, 0xff, 0x01), 100); + byte[] tx = txWithContract(ld(2, cat( + ld(1, url("protocol.AccountCreateContract")), ld(2, flood)))); + assertEquals(ContractType.AccountCreateContract, + Transaction.parseFrom(tx).getRawData().getContract(0).getType()); + // raw, contract, parameter, type_url, value, then 100 unknown fields in the payload + assertEquals(105, countTx(tx)); + unpack(tx, AccountCreateContract.class); + + // Without '/', Any.is never matches, the payload is never unpacked and stays a leaf. + byte[] noSlash = txWithContract(ld(2, cat( + ld(1, "protocol.AccountCreateContract".getBytes()), ld(2, flood)))); + assertEquals(5, countTx(noSlash)); + try { + unpack(noSlash, AccountCreateContract.class); + fail("type url without '/' must not unpack"); + } catch (InvalidProtocolBufferException expected) { + // expected + } + } + + @Test + public void unknownContractTypeIsALeafAndDoesNotThrow() throws Exception { + byte[] tx = voteTx(9999, "protocol.VoteWitnessContract", 100); + assertEquals(ContractType.UNRECOGNIZED, + Transaction.parseFrom(tx).getRawData().getContract(0).getType()); + assertEquals(6, countTx(tx)); + } + + @Test + public void lastContractTypeWinsAndWrongWireTypeIsIgnored() throws Exception { + byte[] parameter = ld(2, cat(ld(1, url("protocol.VoteWitnessContract")), + ld(2, repeat(bytes(0x12, 0x00), 100)))); + + // Last type is TransferContract: type_url does not match, payload stays a leaf. + byte[] voteThenTransfer = txWithContract(cat( + varintField(1, VOTE), varintField(1, TRANSFER), parameter)); + assertEquals(7, countTx(voteThenTransfer)); + + byte[] transferThenVote = txWithContract(cat( + varintField(1, TRANSFER), varintField(1, VOTE), parameter)); + assertEquals(107, countTx(transferThenVote)); + + // Field 1 with the length-delimited wire type is an unknown field: type stays VOTE. + byte[] wrongWireType = txWithContract(cat(varintField(1, VOTE), ld(1, new byte[0]), + parameter)); + assertEquals(ContractType.VoteWitnessContract, + Transaction.parseFrom(wrongWireType).getRawData().getContract(0).getType()); + // raw, contract, type, field 1 as length-delimited, parameter, type_url, value, 100 votes + assertEquals(107, countTx(wrongWireType)); + + // Parameter written before type. + byte[] parameterFirst = txWithContract(cat(parameter, varintField(1, VOTE))); + assertEquals(106, countTx(parameterFirst)); + } + + @Test + public void contractsShareTheRootBudget() { + byte[] one = contract(VOTE, "protocol.VoteWitnessContract", repeat(bytes(0x12, 0x00), 100)); + byte[] tx = ld(1, repeat(ld(11, one), 3)); + assertEquals(1 + 3 * 105, countTx(tx)); + assertExactBudget(tx); + } + + @Test + public void firstPassStopsAtTheRemainingBudget() { + // 10000 unknown fields directly inside Contract are seen first by the first pass. + byte[] tx = txWithContract(repeat(bytes(0x30, 0xff, 0x01), 10000)); + assertEquals(Reason.OVER_BUDGET, + reason(() -> TransactionAdmissionGuard.checkTransaction(tx, 100))); + } + + @Test + public void contractsOfDifferentTypesAreCountedExactly() throws Exception { + byte[][] contracts = { + contract(VOTE, "protocol.VoteWitnessContract", repeat(bytes(0x12, 0x00), 3)), + contract(ContractType.AccountPermissionUpdateContract.getNumber(), + "protocol.AccountPermissionUpdateContract", repeat(bytes(0x22, 0x00), 7)), + contract(VOTE, "protocol.VoteWitnessContract", repeat(bytes(0x12, 0x00), 11)) + }; + long expected = 1 + 4; // raw_data and the four ret entries + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + for (byte[] contract : contracts) { + expected += countTx(txWithContract(contract)) - 1; + byte[] entry = ld(11, contract); + raw.write(entry, 0, entry.length); + } + byte[] tx = cat(ld(1, raw.toByteArray()), repeat(bytes(0x2a, 0x00), 4)); + // A wrong payload offset in a later contract would leave its payload uncounted. + assertEquals(expected, countTx(tx)); + assertExactBudget(tx); + assertEquals(contracts.length, Transaction.parseFrom(tx).getRawData().getContractCount()); + } + + @Test + public void malformedPayloadOfALaterContractIsFound() throws Exception { + byte[] tx = ld(1, cat( + ld(11, contract(VOTE, "protocol.VoteWitnessContract", repeat(bytes(0x12, 0x00), 10))), + ld(11, contract(VOTE, "protocol.VoteWitnessContract", bytes(0x07))))); + assertEquals(Reason.MALFORMED, + reason(() -> TransactionAdmissionGuard.checkTransaction(tx, 1000))); + Transaction.raw raw = Transaction.parseFrom(tx).getRawData(); + raw.getContract(0).getParameter().unpack(VoteWitnessContract.class); + try { + raw.getContract(1).getParameter().unpack(VoteWitnessContract.class); + fail("payload of the second contract must fail to unpack"); + } catch (InvalidProtocolBufferException expected) { + // expected + } + } + + @Test + public void protobufLookingBytesInsideBytesFieldsStayLeaves() throws Exception { + // Tags of ret, contract, auths and a nested message, as opaque content. + byte[] fake = repeat(bytes(0x2a, 0x00, 0x5a, 0x00, 0x4a, 0x00, 0x0a, 0x02, 0x2a, 0x00), 500); + // raw.data (10), two signatures (2), Contract.provider (3) and ContractName (4) + byte[] tx = cat( + ld(1, cat(ld(10, fake), ld(11, cat(varintField(1, TRANSFER), ld(3, fake), ld(4, fake))))), + ld(2, fake), ld(2, fake)); + // raw, data, contract, type, provider, ContractName, signature, signature + assertEquals(8, countTx(tx)); + Transaction parsed = Transaction.parseFrom(tx); + assertEquals(fake.length, parsed.getRawData().getData().size()); + assertEquals(0, parsed.getRetCount()); + } + + // ---------------------------------------------------------------- depth and recursive types + + @Test + public void depthLimitAppliesToRecursiveTypes() { + Descriptor recursive = DescriptorProto.getDescriptor(); // nested_type (3) is recursive + TransactionAdmissionGuard.check(nested(TransactionAdmissionGuard.MAX_DEPTH - 1), recursive, + 1000); + assertEquals(Reason.TOO_DEEP, reason(() -> TransactionAdmissionGuard.check( + nested(TransactionAdmissionGuard.MAX_DEPTH), recursive, 1000))); + } + + @Test(timeout = 60_000) + public void descriptorCacheIsConsistentUnderConcurrentFirstUse() throws Exception { + Descriptor fresh = FileDescriptorProto.getDescriptor(); + byte[] payload = FileDescriptorProto.newBuilder() + .addMessageType(DescriptorProto.newBuilder().setName("a") + .addNestedType(DescriptorProto.newBuilder().setName("b"))) + .build().toByteArray(); + ExecutorService pool = Executors.newFixedThreadPool(16); + try { + CountDownLatch start = new CountDownLatch(1); + List> results = new ArrayList<>(); + for (int i = 0; i < 64; i++) { + results.add(pool.submit((Callable) () -> { + start.await(); + return TransactionAdmissionGuard.count(payload, fresh); + })); + } + start.countDown(); + long expected = TransactionAdmissionGuard.count(payload, fresh); + for (Future result : results) { + assertEquals(expected, result.get(10, TimeUnit.SECONDS).longValue()); + } + } finally { + pool.shutdownNow(); + } + } + + // ---------------------------------------------------------------- boundaries and malformed + + @Test + public void invalidLengthsAreMalformedNotInternalErrors() { + // negative length on a nested message + assertMalformedLikeParser(cat(tag(1, LD), varint(-1))); + // length beyond the array + assertMalformedLikeParser(cat(tag(1, LD), varint(100), bytes(0x08, 0x01))); + // inside the array but beyond the parent: raw is 4 bytes, the contract claims 6 + assertMalformedLikeParser(bytes(0x0a, 0x04, 0x5a, 0x06, 0x08, 0x01, 0x2a, 0x00)); + // first pass, parameter with a negative length + assertMalformedLikeParser(txWithContract(cat(varintField(1, VOTE), tag(2, LD), + varint(-1)))); + // first pass, parameter beyond the contract: contract is 4 bytes, parameter claims 10 + assertMalformedLikeParser(cat(ld(1, cat(bytes(0x5a, 0x04), tag(2, LD), varint(10), + bytes(0x0a, 0x00))), bytes(0x2a, 0x00))); + } + + @Test + public void malformedOuterInputIsRejectedLikeTheParser() { + assertMalformedLikeParser(bytes(0x00)); // tag zero + assertMalformedLikeParser(bytes(0x08, 0x80)); // truncated varint + assertMalformedLikeParser(bytes(0x2a, 0x05, 0x00)); // truncated message + } + + @Test + public void malformedEffectivePayloadIsRejectedWhileOuterParseSucceeds() throws Exception { + byte[] tx = txWithContract(contract(VOTE, "protocol.VoteWitnessContract", bytes(0x07))); + assertEquals(Reason.MALFORMED, + reason(() -> TransactionAdmissionGuard.checkTransaction(tx, 1000))); + Transaction.parseFrom(tx); + try { + unpack(tx, VoteWitnessContract.class); + fail("payload must fail to unpack"); + } catch (InvalidProtocolBufferException expected) { + // expected + } + } + + @Test + public void groupsAreRejectedAlthoughTheParserAcceptsThem() throws Exception { + byte[] tx = bytes(0x0b, 0x0c); // field 1 start group, field 1 end group + Transaction.parseFrom(tx); + assertEquals(Reason.MALFORMED, + reason(() -> TransactionAdmissionGuard.checkTransaction(tx, 1000))); + } + + @Test + public void exceptionCarriesNoStackTrace() { + try { + TransactionAdmissionGuard.checkTransaction(repeat(bytes(0x2a, 0x00), 10), 5); + fail(); + } catch (TransactionAdmissionException e) { + assertEquals(0, e.getStackTrace().length); + } + } + + // ---------------------------------------------------------------- legitimate corpus + + @Test + public void legitimateTransactionsPassAndStillParse() throws Exception { + List corpus = new ArrayList<>(); + corpus.add(signed(ContractType.TransferContract, TransferContract.newBuilder() + .setOwnerAddress(address(1)).setToAddress(address(2)).setAmount(1_000_000).build(), 1) + .toBuilder().addRet(Transaction.Result.newBuilder() + .setContractRet(Transaction.Result.contractResult.SUCCESS)).build()); + + VoteWitnessContract.Builder votes = VoteWitnessContract.newBuilder() + .setOwnerAddress(address(1)); + for (int i = 0; i < 30; i++) { + votes.addVotes(VoteWitnessContract.Vote.newBuilder() + .setVoteAddress(address(10 + i)).setVoteCount(i + 1)); + } + corpus.add(signed(ContractType.VoteWitnessContract, votes.build(), 1)); + + AccountPermissionUpdateContract.Builder permissions = AccountPermissionUpdateContract + .newBuilder().setOwnerAddress(address(1)).setOwner(permission(5)); + for (int i = 0; i < 8; i++) { + permissions.addActives(permission(5)); + } + corpus.add(signed(ContractType.AccountPermissionUpdateContract, permissions.build(), 5)); + + ABI.Builder abi = ABI.newBuilder(); + for (int i = 0; i < 50; i++) { + abi.addEntrys(ABI.Entry.newBuilder().setName("f" + i).setType(ABI.Entry.EntryType.Function) + .addInputs(ABI.Entry.Param.newBuilder().setName("a").setType("uint256")) + .addOutputs(ABI.Entry.Param.newBuilder().setName("b").setType("address"))); + } + corpus.add(signed(ContractType.CreateSmartContract, CreateSmartContract.newBuilder() + .setOwnerAddress(address(1)).setNewContract(SmartContract.newBuilder() + .setName("c").setAbi(abi).setBytecode(ByteString.copyFrom(new byte[20_000]))) + .build(), 1)); + + corpus.add(signed(ContractType.TriggerSmartContract, TriggerSmartContract.newBuilder() + .setOwnerAddress(address(1)).setContractAddress(address(3)) + .setData(ByteString.copyFrom(new byte[10_000])).build(), 1)); + + for (Transaction tx : corpus) { + byte[] bytes = tx.toByteArray(); + TransactionAdmissionGuard.checkTransaction(bytes, 10_000); + Transaction.Contract contract = Transaction.parseFrom(bytes).getRawData().getContract(0); + contract.getParameter().unpack(TransactionFactory.getContract(contract.getType())); + } + } + + // A packed repeated scalar is one occurrence however many elements it holds. None is + // reachable today; a schema change that adds one must revisit the budget. + @Test + public void noPackedRepeatedScalarIsReachable() { + Deque pending = new ArrayDeque<>(); + pending.add(Transaction.getDescriptor()); + pending.add(ShieldedTransferContract.getDescriptor()); + for (ContractType type : ContractType.values()) { + if (type == ContractType.UNRECOGNIZED) { + continue; + } + Class clazz = TransactionFactory.getContract(type); + if (clazz != null) { + pending.add(Internal.getDefaultInstance(clazz).getDescriptorForType()); + } + } + Set seen = new HashSet<>(); + List packable = new ArrayList<>(); + while (!pending.isEmpty()) { + Descriptor descriptor = pending.poll(); + if (!seen.add(descriptor)) { + continue; + } + for (FieldDescriptor field : descriptor.getFields()) { + if (field.getJavaType() == FieldDescriptor.JavaType.MESSAGE) { + pending.add(field.getMessageType()); + } else if (field.isPackable()) { + packable.add(field.getFullName()); + } + } + } + assertTrue(seen.size() > 50); + assertEquals(new ArrayList(), packable); + } + + @Test + public void lastTypeUrlDecidesThePayload() throws Exception { + byte[] flood = repeat(bytes(0x12, 0x00), 1000); + byte[] contract = cat(varintField(1, VOTE), + ld(2, cat(ld(1, url("protocol.Bogus")), ld(1, url("protocol.VoteWitnessContract")), + ld(2, flood)))); + byte[] tx = txWithContract(contract); + assertEquals(1000, unpack(tx, VoteWitnessContract.class).getVotesCount()); + assertEquals(Reason.OVER_BUDGET, + reason(() -> TransactionAdmissionGuard.checkTransaction(tx, 500))); + } + + @Test + public void typeUrlIsMatchedLikeAnyIs() throws Exception { + String name = "protocol.VoteWitnessContract"; + String[] expanded = { + "type.googleapis.com/x/" + name, + "a/b/c/" + name, + "/" + name, + }; + String[] leaves = { + name, + "type.googleapis.com/" + name + "/x", + "type.googleapis.com/" + name + "/", + "type.googleapis.com/" + name.substring(0, name.length() - 1), + "type.googleapis.com/" + name + "X", + "type.googleapis.com/x" + name, + "type.googleapis.com/protocol.votewitnesscontract", + }; + for (String typeUrl : expanded) { + byte[] tx = voteTxWithTypeUrl(typeUrl, 100); + assertTrue(typeUrl, Transaction.parseFrom(tx).getRawData().getContract(0).getParameter() + .is(VoteWitnessContract.class)); + assertEquals(typeUrl, 106, countTx(tx)); + } + for (String typeUrl : leaves) { + byte[] tx = voteTxWithTypeUrl(typeUrl, 100); + assertFalse(typeUrl, Transaction.parseFrom(tx).getRawData().getContract(0).getParameter() + .is(VoteWitnessContract.class)); + assertEquals(typeUrl, 6, countTx(tx)); + } + } + + @Test + public void firstPassStopsAtTheBudgetBeforeLaterBytes() { + // The truncated varint at the end is only reached if the first pass ignores the budget. + byte[] tx = txWithContract(cat(repeat(bytes(0x30, 0xff, 0x01), 10000), bytes(0x30, 0x80))); + assertEquals(Reason.OVER_BUDGET, + reason(() -> TransactionAdmissionGuard.checkTransaction(tx, 100))); + } + + @Test + public void productionBudgetIsPinned() { + assertEquals(50_000, TransactionAdmissionGuard.MAX_TX_OCCURRENCES); + } + + @Test + public void largeValidAbiDeployIsAcceptedUpToTheBudgetAndRejectedBeyond() throws Exception { + int budget = TransactionAdmissionGuard.MAX_TX_OCCURRENCES; + // Largest ABI of ordinary entries that still fits the budget. + int low = 0; + int high = budget; + while (low < high) { + int mid = (low + high + 1) >>> 1; + if (countTx(deployWithAbi(mid).toByteArray()) <= budget) { + low = mid; + } else { + high = mid - 1; + } + } + byte[] within = deployWithAbi(low).toByteArray(); + byte[] beyond = deployWithAbi(low + 1).toByteArray(); + assertTrue(countTx(within) <= budget); + assertTrue(countTx(beyond) > budget); + + TransactionAdmissionGuard.checkTransaction(within, budget); + assertEquals(low, unpack(within, CreateSmartContract.class) + .getNewContract().getAbi().getEntrysCount()); + // Policy: a deploy whose ABI is beyond the budget is refused at the API. + assertEquals(Reason.OVER_BUDGET, + reason(() -> TransactionAdmissionGuard.checkTransaction(beyond, budget))); + } + + // ---------------------------------------------------------------- helpers + + private static long countTx(byte[] tx) { + return TransactionAdmissionGuard.countTransaction(tx); + } + + private static void assertExactBudget(byte[] tx) { + long count = countTx(tx); + TransactionAdmissionGuard.checkTransaction(tx, (int) count); + assertEquals(Reason.OVER_BUDGET, + reason(() -> TransactionAdmissionGuard.checkTransaction(tx, (int) count - 1))); + } + + private static void assertMalformedLikeParser(byte[] tx) { + assertEquals(Reason.MALFORMED, + reason(() -> TransactionAdmissionGuard.checkTransaction(tx, 1000))); + try { + Transaction.parseFrom(tx); + fail("parser must reject it too"); + } catch (InvalidProtocolBufferException expected) { + // expected + } + } + + private static Reason reason(Runnable action) { + try { + action.run(); + } catch (TransactionAdmissionException e) { + return e.getReason(); + } + fail("expected TransactionAdmissionException"); + return null; + } + + private static T unpack(byte[] tx, Class clazz) + throws InvalidProtocolBufferException { + return Transaction.parseFrom(tx).getRawData().getContract(0).getParameter().unpack(clazz); + } + + private static byte[] voteTx(int type, String typeName, int votes) { + return txWithContract(contract(type, typeName, repeat(bytes(0x12, 0x00), votes))); + } + + private static byte[] voteTxWithTypeUrl(String typeUrl, int votes) { + return txWithContract(cat(varintField(1, VOTE), ld(2, cat(ld(1, typeUrl.getBytes()), + ld(2, repeat(bytes(0x12, 0x00), votes)))))); + } + + private static byte[] contract(int type, String typeName, byte[] value) { + return cat(varintField(1, type), ld(2, cat(ld(1, url(typeName)), ld(2, value)))); + } + + private static byte[] txWithContract(byte[] contract) { + return ld(1, ld(11, contract)); + } + + private static byte[] url(String typeName) { + return ("type.googleapis.com/" + typeName).getBytes(); + } + + private static byte[] nested(int levels) { + byte[] body = new byte[0]; + for (int i = 0; i < levels; i++) { + body = ld(3, body); + } + return body; + } + + private static byte[] distinctUnknownFields(int firstNumber, int count) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (int i = 0; i < count; i++) { + byte[] field = varintField(firstNumber + i, 200); + out.write(field, 0, field.length); + } + return out.toByteArray(); + } + + private static Transaction signed(ContractType type, com.google.protobuf.Message contract, + int signatures) { + Transaction.Builder builder = Transaction.newBuilder().setRawData(Transaction.raw.newBuilder() + .setRefBlockBytes(ByteString.copyFrom(new byte[2])) + .setRefBlockHash(ByteString.copyFrom(new byte[8])) + .setExpiration(1_800_000_000_000L).setTimestamp(1_700_000_000_000L) + .addContract(Transaction.Contract.newBuilder().setType(type) + .setParameter(Any.pack(contract)))); + for (int i = 0; i < signatures; i++) { + builder.addSignature(ByteString.copyFrom(new byte[65])); + } + return builder.build(); + } + + private static Transaction deployWithAbi(int entries) { + ABI.Builder abi = ABI.newBuilder(); + for (int i = 0; i < entries; i++) { + abi.addEntrys(ABI.Entry.newBuilder().setName("f" + i).setType(ABI.Entry.EntryType.Function) + .addInputs(ABI.Entry.Param.newBuilder().setName("a").setType("uint256")) + .addOutputs(ABI.Entry.Param.newBuilder().setName("b").setType("bool"))); + } + return signed(ContractType.CreateSmartContract, CreateSmartContract.newBuilder() + .setOwnerAddress(address(1)).setNewContract(SmartContract.newBuilder() + .setName("c").setAbi(abi).setBytecode(ByteString.copyFrom(new byte[1_000]))) + .build(), 1); + } + + private static Permission permission(int keys) { + Permission.Builder permission = Permission.newBuilder().setPermissionName("p") + .setThreshold(keys); + for (int i = 0; i < keys; i++) { + permission.addKeys(Key.newBuilder().setAddress(address(100 + i)).setWeight(1)); + } + return permission.build(); + } + + private static ByteString address(int seed) { + byte[] address = new byte[21]; + address[0] = 0x41; + address[20] = (byte) seed; + return ByteString.copyFrom(address); + } + + private static byte[] varintField(int field, long value) { + return cat(tag(field, VARINT), varint(value)); + } + + private static byte[] ld(int field, byte[] body) { + return cat(tag(field, LD), varint(body.length), body); + } + + private static byte[] tag(int field, int wireType) { + return varint((((long) field) << 3) | wireType); + } + + private static byte[] varint(long value) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + while ((value & ~0x7FL) != 0) { + out.write((int) ((value & 0x7F) | 0x80)); + value >>>= 7; + } + out.write((int) value); + return out.toByteArray(); + } + + private static byte[] repeat(byte[] unit, int times) { + byte[] out = new byte[unit.length * times]; + for (int i = 0; i < times; i++) { + System.arraycopy(unit, 0, out, i * unit.length, unit.length); + } + return out; + } + + private static byte[] cat(byte[]... parts) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (byte[] part : parts) { + out.write(part, 0, part.length); + } + return out.toByteArray(); + } + + private static byte[] bytes(int... values) { + byte[] out = new byte[values.length]; + for (int i = 0; i < values.length; i++) { + out[i] = (byte) values[i]; + } + return out; + } +} diff --git a/framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java b/framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java new file mode 100644 index 00000000000..ea0f9dd8558 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java @@ -0,0 +1,157 @@ +package org.tron.core.services; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; +import io.grpc.netty.NettyServerBuilder; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.net.URL; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.reflections.Reflections; +import org.tron.common.application.RpcService; +import org.tron.common.application.RpcServiceAdmissionTest; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.admission.TransactionAdmissionGuard; +import org.tron.core.services.filter.PbftCursorInterceptor; +import org.tron.core.services.filter.SolidityCursorInterceptor; +import org.tron.core.services.interfaceOnPBFT.RpcApiServiceOnPBFT; +import org.tron.core.services.interfaceOnSolidity.RpcApiServiceOnSolidity; + +/** + * Captures what the production RpcService subclasses actually register, for every branch of + * their addService, and checks that each Transaction request method is guarded and that no + * service is registered unwrapped. + */ +public class RpcServiceRegistrationAdmissionTest { + + private static final int MAX_MESSAGE_SIZE = 256 * 1024; + + private boolean solidityNode; + private boolean nodeMetricsEnable; + private int maxMessageSize; + + @Before + public void saveFlags() { + CommonParameter parameter = CommonParameter.getInstance(); + solidityNode = parameter.isSolidityNode(); + nodeMetricsEnable = parameter.nodeMetricsEnable; + maxMessageSize = parameter.maxMessageSize; + parameter.maxMessageSize = MAX_MESSAGE_SIZE; + } + + @After + public void restoreFlags() { + CommonParameter parameter = CommonParameter.getInstance(); + parameter.setSolidityNode(solidityNode); + parameter.nodeMetricsEnable = nodeMetricsEnable; + parameter.maxMessageSize = maxMessageSize; + } + + @Test + public void fullNodeRegistersTheFiveTransactionMethodsGuarded() throws Exception { + CommonParameter.getInstance().setSolidityNode(false); + CommonParameter.getInstance().nodeMetricsEnable = true; + List registered = register(new RpcApiService()); + assertEquals(names(registered).toString(), 3, registered.size()); // database, wallet, monitor + assertEquals(5, guardedMethods(registered)); + } + + @Test + public void solidityNodeBranchRegistersEverythingThroughTheGuard() throws Exception { + CommonParameter.getInstance().setSolidityNode(true); + CommonParameter.getInstance().nodeMetricsEnable = true; + List registered = register(new RpcApiService()); + // database, walletSolidity, monitor + assertEquals(names(registered).toString(), 3, registered.size()); + assertEquals(0, guardedMethods(registered)); + } + + @Test + public void solidityAndPbftServicesRegisterEverythingThroughTheGuard() throws Exception { + for (RpcService service : Arrays.asList( + inject(new RpcApiServiceOnSolidity(), new SolidityCursorInterceptor()), + inject(new RpcApiServiceOnPBFT(), new PbftCursorInterceptor()))) { + List registered = register(service); + assertEquals(names(registered).toString(), 2, registered.size()); // database, wallet + assertEquals(0, guardedMethods(registered)); + } + } + + @Test + public void everyProductionRpcServiceIsCoveredByThisTest() { + URL testClasses = getClass().getProtectionDomain().getCodeSource().getLocation(); + Set found = new TreeSet<>(); + for (Class type : + new Reflections("org.tron").getSubTypesOf(RpcService.class)) { + if (!Modifier.isAbstract(type.getModifiers()) + && !testClasses.equals(type.getProtectionDomain().getCodeSource().getLocation())) { + found.add(type.getName()); + } + } + // A new subclass must get a registration test above before it is added here. + assertEquals(new TreeSet<>(Arrays.asList(RpcApiService.class.getName(), + RpcApiServiceOnSolidity.class.getName(), RpcApiServiceOnPBFT.class.getName())), found); + } + + private static RpcService inject(RpcService service, Object interceptor) throws Exception { + RpcApiService rpcApiService = new RpcApiService(); + for (Field field : service.getClass().getDeclaredFields()) { + for (Object value : Arrays.asList(rpcApiService, interceptor)) { + if (field.getType().isInstance(value)) { + field.setAccessible(true); + field.set(service, value); + } + } + } + return service; + } + + private static List register(RpcService service) throws Exception { + NettyServerBuilder builder = mock(NettyServerBuilder.class); + Method addService = service.getClass().getDeclaredMethod("addService", + NettyServerBuilder.class); + addService.setAccessible(true); + addService.invoke(service, builder); + // Every registration must go through guardTransactionMethods, which yields a definition. + verify(builder, never()).addService(any(BindableService.class)); + ArgumentCaptor captor = + ArgumentCaptor.forClass(ServerServiceDefinition.class); + verify(builder, atLeastOnce()).addService(captor.capture()); + List registered = captor.getAllValues(); + guardedMethods(registered); + return registered; + } + + private static int guardedMethods(List definitions) { + int guarded = 0; + for (ServerServiceDefinition definition : definitions) { + guarded += RpcServiceAdmissionTest.assertAllTransactionMethodsGuarded(definition, + TransactionAdmissionGuard.MAX_TX_OCCURRENCES, MAX_MESSAGE_SIZE); + } + return guarded; + } + + private static Set names(List definitions) { + Set names = new HashSet<>(); + for (ServerServiceDefinition definition : definitions) { + names.add(definition.getServiceDescriptor().getName()); + } + return names; + } +} diff --git a/framework/src/test/java/org/tron/core/services/http/servlets/BroadcastHexServletAdmissionTest.java b/framework/src/test/java/org/tron/core/services/http/servlets/BroadcastHexServletAdmissionTest.java new file mode 100644 index 00000000000..0106f347b7a --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/http/servlets/BroadcastHexServletAdmissionTest.java @@ -0,0 +1,116 @@ +package org.tron.core.services.http.servlets; + +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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.google.protobuf.ByteString; +import java.nio.charset.StandardCharsets; +import org.junit.Test; +import org.slf4j.LoggerFactory; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; +import org.tron.api.GrpcAPI; +import org.tron.common.utils.ByteArray; +import org.tron.core.Wallet; +import org.tron.core.admission.TransactionAdmissionException; +import org.tron.core.admission.TransactionAdmissionGuard; +import org.tron.protos.Protocol.Transaction; + +public class BroadcastHexServletAdmissionTest { + + @Test + public void overBudgetPayloadGetsTheFixedClientError() throws Exception { + byte[] flood = new byte[2 * (TransactionAdmissionGuard.MAX_TX_OCCURRENCES + 1)]; + for (int i = 0; i < flood.length; i += 2) { + flood[i] = 0x2a; + } + assertRejectedWithoutWallet(flood); + } + + @Test + public void malformedPayloadGetsTheFixedClientError() throws Exception { + assertRejectedWithoutWallet(new byte[] {0x08, (byte) 0x80}); // truncated varint + } + + @Test + public void groupEncodingGetsTheFixedClientError() throws Exception { + // Accepted by the generic protobuf parser, not by this admission path. + assertRejectedWithoutWallet(new byte[] {0x0b, 0x0c}); + } + + @Test + public void eachRejectionIsLoggedOnceAtDebugWithoutStack() throws Exception { + BroadcastHexServlet servlet = new BroadcastHexServlet(); + Logger logger = (Logger) LoggerFactory.getLogger("API"); + Level previous = logger.getLevel(); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.DEBUG); + try { + for (int i = 0; i < 3; i++) { + assertRejectedWithoutWallet(servlet, new byte[] {0x0b, 0x0c}); + } + } finally { + logger.detachAppender(appender); + logger.setLevel(previous); + } + assertEquals(3, appender.list.size()); + for (ILoggingEvent event : appender.list) { + assertEquals(Level.DEBUG, event.getLevel()); + assertNull(event.getThrowableProxy()); + assertEquals("broadcasthex rejected by admission check: MALFORMED", + event.getFormattedMessage()); + } + } + + @Test + public void legitimatePayloadIsBroadcast() throws Exception { + Wallet wallet = mock(Wallet.class); + when(wallet.broadcastTransaction(any())) + .thenReturn(GrpcAPI.Return.newBuilder().setResult(true).build()); + Transaction tx = Transaction.newBuilder() + .setRawData(Transaction.raw.newBuilder().setExpiration(1)) + .addSignature(ByteString.copyFrom(new byte[65])).build(); + String body = post(new BroadcastHexServlet(), wallet, + ByteArray.toHexString(tx.toByteArray())); + assertTrue(body, body.contains("\"result\":true")); + verify(wallet).broadcastTransaction(tx); + } + + private static void assertRejectedWithoutWallet(byte[] payload) throws Exception { + assertRejectedWithoutWallet(new BroadcastHexServlet(), payload); + } + + private static void assertRejectedWithoutWallet(BroadcastHexServlet servlet, byte[] payload) + throws Exception { + Wallet wallet = mock(Wallet.class); + String body = post(servlet, wallet, ByteArray.toHexString(payload)); + assertTrue(body, body.contains(TransactionAdmissionException.REJECTION_MESSAGE)); + assertFalse(body, body.contains("internal server error")); + verifyNoInteractions(wallet); + } + + private static String post(BroadcastHexServlet servlet, Wallet wallet, String hex) + throws Exception { + ReflectionTestUtils.setField(servlet, "wallet", wallet); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/wallet/broadcasthex"); + request.setContentType("application/json"); + request.setContent(("{\"transaction\":\"" + hex + "\"}").getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + servlet.doPost(request, response); + return response.getContentAsString(); + } +} From 6c0f6471a5ef61ebfbe0201b1e53b036e81b57ba Mon Sep 17 00:00:00 2001 From: waynercheung Date: Mon, 19 Oct 2026 15:13:43 +0800 Subject: [PATCH 2/9] perf(api): preserve KnownLength in transaction admission Use the exact size of KnownLength request streams to allocate the bounded read buffer once, including zero-length requests. Pass checked bytes to the original marshaller through a KnownLength stream to restore its array-backed protobuf parsing fast path. Keep admission budgets, size limits, rejection handling and the original delegate unchanged. Compressed requests retain the bounded growing read path and also use the KnownLength handoff after checking. --- .../GuardedTransactionMarshaller.java | 29 +++- .../core/admission/AdmissionSupportTest.java | 157 ++++++++++++++++++ 2 files changed, 180 insertions(+), 6 deletions(-) diff --git a/framework/src/main/java/org/tron/core/admission/GuardedTransactionMarshaller.java b/framework/src/main/java/org/tron/core/admission/GuardedTransactionMarshaller.java index f4a91c3ae56..75ff29e0171 100644 --- a/framework/src/main/java/org/tron/core/admission/GuardedTransactionMarshaller.java +++ b/framework/src/main/java/org/tron/core/admission/GuardedTransactionMarshaller.java @@ -1,6 +1,7 @@ package org.tron.core.admission; import com.google.protobuf.InvalidProtocolBufferException; +import io.grpc.KnownLength; import io.grpc.MethodDescriptor; import io.grpc.Status; import io.grpc.StatusRuntimeException; @@ -74,7 +75,7 @@ public Transaction parse(InputStream stream) { return REJECTED.get(e.getReason()); } try { - return delegate.parse(new ByteArrayInputStream(bytes)); + return delegate.parse(new KnownLengthInputStream(bytes)); } catch (StatusRuntimeException e) { if (e.getCause() instanceof InvalidProtocolBufferException) { return REJECTED.get(Reason.MALFORMED); @@ -109,10 +110,16 @@ static byte[] readBounded(InputStream stream, int max) throws IOException { } long limit = (long) max + 1; int hint = stream.available(); - // A decompressing stream reports 1 until it reaches the end, which says nothing about size. - long initial = hint > 1 ? hint : DEFAULT_INITIAL_BUFFER; - initial = StrictMathWrapper.min(initial, StrictMathWrapper.min(limit, MAX_INITIAL_BUFFER)); - byte[] buffer = new byte[(int) StrictMathWrapper.max(initial, 1)]; + long initial; + if (stream instanceof KnownLength) { + // The exact remaining size, so one allocation is enough. + initial = StrictMathWrapper.min(StrictMathWrapper.max(hint, 0), limit); + } else { + // A decompressing stream reports 1 until it reaches the end, which says nothing about size. + initial = hint > 1 ? hint : DEFAULT_INITIAL_BUFFER; + initial = StrictMathWrapper.min(initial, StrictMathWrapper.min(limit, MAX_INITIAL_BUFFER)); + } + byte[] buffer = new byte[(int) initial]; int size = 0; while (true) { if (size == buffer.length) { @@ -122,7 +129,8 @@ static byte[] readBounded(InputStream stream, int max) throws IOException { break; } long grown = StrictMathWrapper.min( - StrictMathWrapper.min((long) buffer.length * 2, limit), Integer.MAX_VALUE - 8); + StrictMathWrapper.min(StrictMathWrapper.max((long) buffer.length * 2, + DEFAULT_INITIAL_BUFFER), limit), Integer.MAX_VALUE - 8); if (grown <= buffer.length) { return null; // cannot hold more than this; the message is over the bound } @@ -144,4 +152,13 @@ static byte[] readBounded(InputStream stream, int max) throws IOException { } return size == buffer.length ? buffer : Arrays.copyOf(buffer, size); } + + // Lets the delegate read the bytes into an array instead of parsing a stream. + private static final class KnownLengthInputStream extends ByteArrayInputStream + implements KnownLength { + + KnownLengthInputStream(byte[] bytes) { + super(bytes); + } + } } diff --git a/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java b/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java index 6cdd7d4b81b..08874ef0a9d 100644 --- a/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java +++ b/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java @@ -5,8 +5,10 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import io.grpc.KnownLength; import io.grpc.MethodDescriptor; import io.grpc.Status; import io.grpc.StatusRuntimeException; @@ -15,10 +17,14 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; +import java.lang.management.ManagementFactory; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; +import org.junit.Assume; import org.junit.Test; import org.tron.core.admission.TransactionAdmissionException.Reason; import org.tron.protos.Protocol.Transaction; @@ -53,6 +59,50 @@ public void readBoundedRejectsMoreThanMax() throws IOException { assertNull(GuardedTransactionMarshaller.readBounded(new HintStream(new byte[1], 0), 0)); } + @Test + public void readBoundedReadsAKnownLengthStream() throws IOException { + int max = 4 << 20; + for (int size : new int[] {0, 1, GuardedTransactionMarshaller.MAX_INITIAL_BUFFER + 1, max}) { + byte[] data = new byte[size]; + if (size > 0) { + data[size - 1] = 7; + } + assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + new KnownStream(data, Integer.MAX_VALUE, -1), max)); + assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + new KnownStream(data, 1000, -1), max)); // short reads + } + byte[] data = new byte[1000]; + data[999] = 7; + assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + new KnownStream(data, Integer.MAX_VALUE, 0), 1000)); // size understated + assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + new KnownStream(data, Integer.MAX_VALUE, 5000), 4096)); // size overstated + assertNull(GuardedTransactionMarshaller.readBounded( + new KnownStream(new byte[1001], Integer.MAX_VALUE, -1), 1000)); + assertNull(GuardedTransactionMarshaller.readBounded( + new KnownStream(new byte[1001], Integer.MAX_VALUE, 0), 1000)); + } + + @Test + public void aKnownLengthStreamIsReadWithOneAllocation() throws IOException { + java.lang.management.ThreadMXBean bean = ManagementFactory.getThreadMXBean(); + Assume.assumeTrue(bean instanceof com.sun.management.ThreadMXBean); + com.sun.management.ThreadMXBean threads = (com.sun.management.ThreadMXBean) bean; + Assume.assumeTrue(threads.isThreadAllocatedMemorySupported() + && threads.isThreadAllocatedMemoryEnabled()); + long thread = Thread.currentThread().getId(); + byte[] data = new byte[3 << 20]; + GuardedTransactionMarshaller.readBounded(new KnownStream(data, Integer.MAX_VALUE, -1), 4 << 20); + KnownStream stream = new KnownStream(data, Integer.MAX_VALUE, -1); + long before = threads.getThreadAllocatedBytes(thread); + byte[] read = GuardedTransactionMarshaller.readBounded(stream, 4 << 20); + long allocated = threads.getThreadAllocatedBytes(thread) - before; + assertEquals(data.length, read.length); + // Growing from the 1 MiB initial buffer would allocate about 10 MiB here. + assertTrue("allocated " + allocated, allocated < data.length + (data.length >> 3)); + } + // ---------------------------------------------------------------- marshaller @Test @@ -227,6 +277,81 @@ public Transaction parse(InputStream stream) { assertSame(Transaction.getDefaultInstance(), marshaller.getMessagePrototype()); } + @Test + public void delegateReadsAKnownLengthStreamOfTheExactSize() throws Exception { + List streams = new ArrayList<>(); + List sizes = new ArrayList<>(); + MethodDescriptor.Marshaller recording = + new MethodDescriptor.Marshaller() { + @Override + public InputStream stream(Transaction value) { + return new ByteArrayInputStream(value.toByteArray()); + } + + @Override + public Transaction parse(InputStream stream) { + try { + streams.add(stream); + sizes.add(stream.available()); + return Transaction.parseFrom(stream); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + }; + GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(recording, 4096, 10); + Transaction tx = Transaction.newBuilder().addSignature( + com.google.protobuf.ByteString.copyFrom(new byte[65])).build(); + assertEquals(tx, marshaller.parse(new ByteArrayInputStream(tx.toByteArray()))); + assertEquals(tx, marshaller.parse(new GZIPInputStream( + new ByteArrayInputStream(gzip(tx.toByteArray()))))); + assertEquals(2, streams.size()); + for (int i = 0; i < streams.size(); i++) { + assertTrue(streams.get(i) instanceof KnownLength); + assertEquals(tx.getSerializedSize(), (int) sizes.get(i)); + } + } + + @Test + public void emptyRequestsAreParsedPlainAndGzip() throws Exception { + GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller( + ProtoUtils.marshaller(Transaction.getDefaultInstance()), 4096, 10); + for (InputStream empty : Arrays.asList( + new KnownStream(new byte[0], Integer.MAX_VALUE, -1), + new ByteArrayInputStream(new byte[0]), + new GZIPInputStream(new ByteArrayInputStream(gzip(new byte[0]))))) { + Transaction parsed = marshaller.parse(empty); + assertNull(GuardedTransactionMarshaller.rejectionReason(parsed)); + assertEquals(Transaction.getDefaultInstance(), parsed); + } + } + + @Test + public void protoMarshallerResultsAreUnchanged() throws Exception { + GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller( + ProtoUtils.marshaller(Transaction.getDefaultInstance()), 4 << 20, Integer.MAX_VALUE); + Transaction.Builder builder = Transaction.newBuilder(); + for (int i = 0; i < 20_000; i++) { + builder.addSignature(com.google.protobuf.ByteString.copyFrom(new byte[65])); + } + Transaction large = builder.build(); // above 1 MiB + assertTrue(large.getSerializedSize() > GuardedTransactionMarshaller.MAX_INITIAL_BUFFER); + assertEquals(large, marshaller.parse( + new KnownStream(large.toByteArray(), Integer.MAX_VALUE, -1))); + assertEquals(large, marshaller.parse(new GZIPInputStream( + new ByteArrayInputStream(gzip(large.toByteArray()))))); + assertEquals(Reason.MALFORMED, reason(marshaller, + new KnownStream(INVALID_UTF8_TYPE_URL, Integer.MAX_VALUE, -1))); + } + + private static byte[] gzip(byte[] data) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (GZIPOutputStream out = new GZIPOutputStream(bytes)) { + out.write(data); + } + return bytes.toByteArray(); + } + // raw_data { contract { parameter { type_url: C3 28 } } } static final byte[] INVALID_UTF8_TYPE_URL = {0x0a, 0x08, 0x5a, 0x06, 0x12, 0x04, 0x0a, 0x02, (byte) 0xc3, 0x28}; @@ -265,6 +390,38 @@ public Class getMessageClass() { } } + /** + * A {@link KnownLength} stream returning at most {@code chunk} bytes per read; available() is + * the real remaining size unless {@code hint} is not negative. + */ + private static final class KnownStream extends InputStream implements KnownLength { + + private final ByteArrayInputStream in; + private final int chunk; + private final int hint; + + KnownStream(byte[] data, int chunk, int hint) { + this.in = new ByteArrayInputStream(data); + this.chunk = chunk; + this.hint = hint; + } + + @Override + public int read() { + return in.read(); + } + + @Override + public int read(byte[] b, int off, int len) { + return in.read(b, off, len < chunk ? len : chunk); + } + + @Override + public int available() { + return hint >= 0 ? hint : in.available(); + } + } + /** Stream whose available() returns a fixed hint regardless of the real content. */ private static final class HintStream extends InputStream { From 3cb0b5a6d9aa120a5cdb05d3ecbccc34a3c7e1c0 Mon Sep 17 00:00:00 2001 From: waynercheung Date: Mon, 19 Oct 2026 16:55:38 +0800 Subject: [PATCH 3/9] feat(api): bound field occurrences across gRPC API requests Apply the 50,000-field-occurrence admission budget to requests of the Wallet, WalletSolidity, Database and Monitor services before the original request parser runs. Previously only Transaction requests had an occurrence budget. Resolve the original marshaller's protobuf type once when wrapping the method and use it as the scan root. Fall back to the proto schema when no protobuf instance is available. Normal rejections use per-method, per-reason sentinels; the schema-only fallback counts and throws the rejection before delegate parsing. Preserve the original marshaller and KnownLength handoff. Transaction requests keep their rejection message; other requests use "request rejected by admission check". Count admission rejections by method and reason through the existing metrics system. Newly guarded methods also reject group encoding and nesting deeper than 32, and report decoding errors as INVALID_ARGUMENT instead of UNKNOWN. Reflection and P2P are unchanged. --- .../tron/common/prometheus/MetricKeys.java | 1 + .../common/prometheus/MetricsCounter.java | 2 + .../tron/common/application/RpcService.java | 47 +- .../admission/AdmissionCheckedHandler.java | 38 +- .../admission/GuardedRequestMarshaller.java | 284 ++++++++++++ .../GuardedTransactionMarshaller.java | 164 ------- .../TransactionAdmissionException.java | 1 + .../admission/TransactionAdmissionGuard.java | 9 +- .../org/tron/core/services/RpcApiService.java | 8 +- .../interfaceOnPBFT/RpcApiServiceOnPBFT.java | 4 +- .../RpcApiServiceOnSolidity.java | 4 +- .../application/RpcServiceAdmissionTest.java | 435 ++++++++++++++---- ...cServiceInterceptorChainAdmissionTest.java | 216 +++++++++ .../core/admission/AdmissionSupportTest.java | 95 ++-- .../GuardedRequestMarshallerTest.java | 355 ++++++++++++++ .../RpcServiceRegistrationAdmissionTest.java | 17 +- 16 files changed, 1319 insertions(+), 361 deletions(-) create mode 100644 framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java delete mode 100644 framework/src/main/java/org/tron/core/admission/GuardedTransactionMarshaller.java create mode 100644 framework/src/test/java/org/tron/common/application/RpcServiceInterceptorChainAdmissionTest.java create mode 100644 framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java diff --git a/common/src/main/java/org/tron/common/prometheus/MetricKeys.java b/common/src/main/java/org/tron/common/prometheus/MetricKeys.java index 3fd940fda5e..0cca91c229f 100644 --- a/common/src/main/java/org/tron/common/prometheus/MetricKeys.java +++ b/common/src/main/java/org/tron/common/prometheus/MetricKeys.java @@ -26,6 +26,7 @@ public static class Counter { public static final String BLOCK_FETCH_ARMED = "tron:block_fetch_armed"; public static final String BLOCK_FETCH_SECONDARY = "tron:block_fetch_secondary"; public static final String BLOCK_ALREADY_KNOWN = "tron:block_already_known"; + public static final String GRPC_ADMISSION_REJECT = "tron:grpc_admission_reject"; private Counter() { throw new IllegalStateException("Counter"); diff --git a/common/src/main/java/org/tron/common/prometheus/MetricsCounter.java b/common/src/main/java/org/tron/common/prometheus/MetricsCounter.java index 5f30771e360..643bc76e844 100644 --- a/common/src/main/java/org/tron/common/prometheus/MetricsCounter.java +++ b/common/src/main/java/org/tron/common/prometheus/MetricsCounter.java @@ -30,6 +30,8 @@ class MetricsCounter { + "was already known when the response was handled (best-effort: concurrent " + "arrivals may be missed; a duplicate is not attributed to secondary " + "fetches)."); + init(MetricKeys.Counter.GRPC_ADMISSION_REJECT, "gRPC requests rejected by admission check.", + "method", "reason"); } private MetricsCounter() { 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 d47f5f8eab3..6edb1185718 100644 --- a/framework/src/main/java/org/tron/common/application/RpcService.java +++ b/framework/src/main/java/org/tron/common/application/RpcService.java @@ -15,6 +15,8 @@ package org.tron.common.application; +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Message; import io.grpc.MethodDescriptor; import io.grpc.Server; import io.grpc.ServerMethodDefinition; @@ -32,15 +34,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.tron.common.es.ExecutorServiceManager; import org.tron.common.parameter.CommonParameter; -import org.tron.core.admission.AdmissionCheckedHandler; -import org.tron.core.admission.GuardedTransactionMarshaller; +import org.tron.core.admission.GuardedRequestMarshaller; import org.tron.core.admission.TransactionAdmissionGuard; import org.tron.core.config.args.Args; import org.tron.core.services.filter.LiteFnQueryGrpcInterceptor; import org.tron.core.services.ratelimiter.PrometheusInterceptor; import org.tron.core.services.ratelimiter.RateLimiterInterceptor; import org.tron.core.services.ratelimiter.RpcApiAccessInterceptor; -import org.tron.protos.Protocol.Transaction; @Slf4j(topic = "rpc") public abstract class RpcService extends AbstractService { @@ -129,35 +129,23 @@ protected NettyServerBuilder initServerBuilder() { protected abstract void addService(NettyServerBuilder serverBuilder); - /** Guards every method whose request is a {@code Transaction}; other methods are kept. */ - protected static ServerServiceDefinition guardTransactionMethods( + /** Guards every method whose request type is known; other methods are kept as they are. */ + protected static ServerServiceDefinition guardRequestMethods( ServerServiceDefinition definition) { - return guardTransactionMethods(definition, Args.getInstance().getMaxMessageSize(), + return guardRequestMethods(definition, Args.getInstance().getMaxMessageSize(), TransactionAdmissionGuard.MAX_TX_OCCURRENCES); } - static ServerServiceDefinition guardTransactionMethods(ServerServiceDefinition definition, + static ServerServiceDefinition guardRequestMethods(ServerServiceDefinition definition, int maxMessageSize, int maxOccurrences) { ServiceDescriptor service = definition.getServiceDescriptor(); ServiceDescriptor.Builder serviceBuilder = ServiceDescriptor.newBuilder(service.getName()) .setSchemaDescriptor(service.getSchemaDescriptor()); List> methods = new ArrayList<>(); for (ServerMethodDefinition method : definition.getMethods()) { - MethodDescriptor descriptor = method.getMethodDescriptor(); - if (isTransactionRequest(descriptor)) { - @SuppressWarnings("unchecked") - ServerMethodDefinition original = - (ServerMethodDefinition) method; - MethodDescriptor guarded = original.getMethodDescriptor().toBuilder( - new GuardedTransactionMarshaller( - original.getMethodDescriptor().getRequestMarshaller(), - maxMessageSize, maxOccurrences), - original.getMethodDescriptor().getResponseMarshaller()).build(); - methods.add(ServerMethodDefinition.create(guarded, - new AdmissionCheckedHandler<>(original.getServerCallHandler()))); - } else { - methods.add(method); - } + Descriptor type = requestType(method.getMethodDescriptor()); + methods.add(type == null ? method + : GuardedRequestMarshaller.guard(method, type, maxMessageSize, maxOccurrences)); } // grpc requires the same MethodDescriptor instances in both descriptors. for (ServerMethodDefinition method : methods) { @@ -172,16 +160,19 @@ static ServerServiceDefinition guardTransactionMethods(ServerServiceDefinition d } // The schema decides when there is one; otherwise the request marshaller's prototype. - static boolean isTransactionRequest(MethodDescriptor descriptor) { + static Descriptor requestType(MethodDescriptor descriptor) { Object schema = descriptor.getSchemaDescriptor(); if (schema instanceof ProtoMethodDescriptorSupplier) { - return Transaction.getDescriptor().equals( - ((ProtoMethodDescriptorSupplier) schema).getMethodDescriptor().getInputType()); + return ((ProtoMethodDescriptorSupplier) schema).getMethodDescriptor().getInputType(); } MethodDescriptor.Marshaller request = descriptor.getRequestMarshaller(); - return request instanceof MethodDescriptor.PrototypeMarshaller - && ((MethodDescriptor.PrototypeMarshaller) request).getMessagePrototype() - instanceof Transaction; + if (request instanceof MethodDescriptor.PrototypeMarshaller) { + Object prototype = ((MethodDescriptor.PrototypeMarshaller) request).getMessagePrototype(); + if (prototype instanceof Message) { + return ((Message) prototype).getDescriptorForType(); + } + } + return null; } protected void addInterceptor(NettyServerBuilder serverBuilder) { diff --git a/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java b/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java index 39d5029b6cc..c2ed8ad5b95 100644 --- a/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java +++ b/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java @@ -8,48 +8,54 @@ import io.grpc.Status; import lombok.extern.slf4j.Slf4j; import org.tron.core.admission.TransactionAdmissionException.Reason; -import org.tron.protos.Protocol.Transaction; /** - * Closes the call with {@code INVALID_ARGUMENT} when {@link GuardedTransactionMarshaller} hands + * Closes the call with {@code INVALID_ARGUMENT} when {@link GuardedRequestMarshaller} hands * over a rejection sentinel; the service method is never invoked. */ @Slf4j(topic = "rpc") -public final class AdmissionCheckedHandler implements ServerCallHandler { +public final class AdmissionCheckedHandler implements ServerCallHandler { - private final ServerCallHandler delegate; + private final ServerCallHandler delegate; + private final GuardedRequestMarshaller marshaller; - public AdmissionCheckedHandler(ServerCallHandler delegate) { + AdmissionCheckedHandler(ServerCallHandler delegate, + GuardedRequestMarshaller marshaller) { this.delegate = delegate; + this.marshaller = marshaller; } - public ServerCallHandler getDelegate() { + public ServerCallHandler getDelegate() { return delegate; } + public GuardedRequestMarshaller getMarshaller() { + return marshaller; + } + @Override - public ServerCall.Listener startCall(ServerCall call, - Metadata headers) { - ServerCall.Listener next = delegate.startCall(call, headers); - return new SimpleForwardingServerCallListener(next) { + public ServerCall.Listener startCall(ServerCall call, Metadata headers) { + ServerCall.Listener next = delegate.startCall(call, headers); + return new SimpleForwardingServerCallListener(next) { // Listener callbacks of one call are serialized by grpc. private boolean rejected; @Override - public void onMessage(Transaction message) { + public void onMessage(ReqT message) { if (rejected) { return; } - Reason reason = GuardedTransactionMarshaller.rejectionReason(message); + Reason reason = marshaller.rejectionReason(message); if (reason != null) { rejected = true; + String method = call.getMethodDescriptor().getFullMethodName(); if (logger.isDebugEnabled()) { - logger.debug("gRPC {} from {} rejected by admission check: {}", - call.getMethodDescriptor().getFullMethodName(), + logger.debug("gRPC {} from {} rejected by admission check: {}", method, call.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR), reason); } - call.close(Status.INVALID_ARGUMENT - .withDescription(TransactionAdmissionException.REJECTION_MESSAGE), new Metadata()); + GuardedRequestMarshaller.countRejection(method, reason); + call.close(Status.INVALID_ARGUMENT.withDescription(marshaller.rejectionMessage()), + new Metadata()); return; } super.onMessage(message); diff --git a/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java b/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java new file mode 100644 index 00000000000..534cb8371e8 --- /dev/null +++ b/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java @@ -0,0 +1,284 @@ +package org.tron.core.admission; + +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import io.grpc.KnownLength; +import io.grpc.MethodDescriptor; +import io.grpc.ServerMethodDefinition; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import java.io.ByteArrayInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.Map; +import java.util.zip.ZipException; +import org.tron.common.math.StrictMathWrapper; +import org.tron.common.prometheus.MetricKeys; +import org.tron.common.prometheus.Metrics; +import org.tron.core.admission.TransactionAdmissionException.Reason; +import org.tron.protos.Protocol.Transaction; + +/** + * Reads the request with a size bound and runs {@link TransactionAdmissionGuard}, rooted at the + * type the original marshaller parses into, before that marshaller parses the same bytes. A + * rejection returns a per-{@link Reason} sentinel instead of throwing, because grpc logs every + * marshaller exception at SEVERE; the {@link AdmissionCheckedHandler} closes the call. + * Interceptors see a sentinel as an empty request. + */ +public final class GuardedRequestMarshaller implements MethodDescriptor.PrototypeMarshaller { + + static final int MAX_INITIAL_BUFFER = 1 << 20; + private static final int DEFAULT_INITIAL_BUFFER = 8 * 1024; + + private static final Reason[] REASONS = Reason.values(); + + private final MethodDescriptor.Marshaller delegate; + private final Descriptor root; + private final T prototype; + // Compared by identity; never the default instance, which an empty request parses to. + private final Map rejected = new EnumMap<>(Reason.class); + private final int maxMessageSize; + private final int maxOccurrences; + + @SuppressWarnings("unchecked") + GuardedRequestMarshaller(MethodDescriptor.Marshaller delegate, Message prototype, + int maxMessageSize, int maxOccurrences) { + this.delegate = delegate; + this.root = prototype.getDescriptorForType(); + this.prototype = (T) prototype.getDefaultInstanceForType(); + for (Reason reason : Reason.values()) { + rejected.put(reason, (T) prototype.newBuilderForType().build()); + } + this.maxMessageSize = maxMessageSize; + this.maxOccurrences = maxOccurrences; + } + + /** + * Wraps {@code method}. The scan root is the type its request marshaller parses into, taken + * once here and never from a real request; {@code schemaType} is used only when that + * marshaller gives no protobuf instance, see {@link Plain}. + */ + public static ServerMethodDefinition guard( + ServerMethodDefinition method, Descriptor schemaType, int maxMessageSize, + int maxOccurrences) { + MethodDescriptor descriptor = method.getMethodDescriptor(); + MethodDescriptor.Marshaller original = descriptor.getRequestMarshaller(); + Message prototype = prototypeOf(original); + if (prototype == null) { + Plain plain = new Plain<>(original, schemaType, descriptor.getFullMethodName(), + maxMessageSize, maxOccurrences); + return ServerMethodDefinition.create( + descriptor.toBuilder(plain, descriptor.getResponseMarshaller()).build(), + method.getServerCallHandler()); + } + GuardedRequestMarshaller guarded = + new GuardedRequestMarshaller<>(original, prototype, maxMessageSize, maxOccurrences); + return ServerMethodDefinition.create( + descriptor.toBuilder(guarded, descriptor.getResponseMarshaller()).build(), + new AdmissionCheckedHandler<>(method.getServerCallHandler(), guarded)); + } + + static Message prototypeOf(MethodDescriptor.Marshaller marshaller) { + Object prototype; + try { + prototype = marshaller instanceof MethodDescriptor.PrototypeMarshaller + ? ((MethodDescriptor.PrototypeMarshaller) marshaller).getMessagePrototype() + : marshaller.parse(new KnownLengthInputStream(new byte[0])); + } catch (RuntimeException e) { + return null; + } + return prototype instanceof Message ? (Message) prototype : null; + } + + @Override + public InputStream stream(T value) { + return delegate.stream(value); + } + + @Override + public T parse(InputStream stream) { + try { + return parseChecked(stream, delegate, root, maxMessageSize, maxOccurrences); + } catch (TransactionAdmissionException e) { + return rejected.get(e.getReason()); + } + } + + public Reason rejectionReason(T request) { + // Called for every accepted request; iterating the map would allocate entries. + for (Reason reason : REASONS) { + if (rejected.get(reason) == request) { + return reason; + } + } + return null; + } + + T rejected(Reason reason) { + return rejected.get(reason); + } + + public Descriptor getRoot() { + return root; + } + + String rejectionMessage() { + return rejectionMessage(root); + } + + static String rejectionMessage(Descriptor root) { + return Transaction.getDescriptor().equals(root) + ? TransactionAdmissionException.REJECTION_MESSAGE + : TransactionAdmissionException.REQUEST_REJECTION_MESSAGE; + } + + @Override + public T getMessagePrototype() { + return prototype; + } + + @Override + @SuppressWarnings("unchecked") + public Class getMessageClass() { + return (Class) prototype.getClass(); + } + + static void countRejection(String fullMethodName, Reason reason) { + Metrics.counterInc(MetricKeys.Counter.GRPC_ADMISSION_REJECT, 1, fullMethodName, + reason.name()); + } + + /** Throws {@link TransactionAdmissionException} for a rejection. */ + private static T parseChecked(InputStream stream, MethodDescriptor.Marshaller delegate, + Descriptor root, int maxMessageSize, int maxOccurrences) { + byte[] bytes; + try { + bytes = readBounded(stream, maxMessageSize); + } catch (ZipException | EOFException e) { + throw new TransactionAdmissionException(Reason.MALFORMED, null); + } catch (IOException e) { + throw Status.INTERNAL.withDescription("failed to read request").withCause(e) + .asRuntimeException(); + } + if (bytes == null) { + throw new TransactionAdmissionException(Reason.TOO_LARGE, null); + } + TransactionAdmissionGuard.check(bytes, root, maxOccurrences); + try { + return delegate.parse(new KnownLengthInputStream(bytes)); + } catch (StatusRuntimeException e) { + if (e.getCause() instanceof InvalidProtocolBufferException) { + throw new TransactionAdmissionException(Reason.MALFORMED, null); + } + throw e; + } + } + + /** + * For a request type known only from the schema, when the original marshaller gives no + * protobuf instance: there is no sentinel, so a rejection is thrown before the original + * marshaller parses. grpc then closes the call with UNKNOWN and logs the exception at SEVERE, + * and no handler sees it, so the rejection is counted here. + */ + static final class Plain implements MethodDescriptor.Marshaller { + + private final MethodDescriptor.Marshaller delegate; + private final Descriptor root; + private final String fullMethodName; + private final int maxMessageSize; + private final int maxOccurrences; + + Plain(MethodDescriptor.Marshaller delegate, Descriptor root, String fullMethodName, + int maxMessageSize, int maxOccurrences) { + this.delegate = delegate; + this.root = root; + this.fullMethodName = fullMethodName; + this.maxMessageSize = maxMessageSize; + this.maxOccurrences = maxOccurrences; + } + + @Override + public InputStream stream(T value) { + return delegate.stream(value); + } + + @Override + public T parse(InputStream stream) { + try { + return parseChecked(stream, delegate, root, maxMessageSize, maxOccurrences); + } catch (TransactionAdmissionException e) { + countRejection(fullMethodName, e.getReason()); + // Thrown from parse, so grpc reports it to the client as UNKNOWN. + throw Status.INVALID_ARGUMENT.withDescription(rejectionMessage(root)) + .asRuntimeException(); + } + } + + Descriptor getRoot() { + return root; + } + } + + /** Returns the whole stream, or {@code null} once more than {@code max} bytes are seen. */ + static byte[] readBounded(InputStream stream, int max) throws IOException { + if (max < 0) { + throw new IllegalArgumentException("max must not be negative"); + } + long limit = (long) max + 1; + int hint = stream.available(); + long initial; + if (stream instanceof KnownLength) { + // The exact remaining size, so one allocation is enough. + initial = StrictMathWrapper.min(StrictMathWrapper.max(hint, 0), limit); + } else { + // A decompressing stream reports 1 until it reaches the end, which says nothing about size. + initial = hint > 1 ? hint : DEFAULT_INITIAL_BUFFER; + initial = StrictMathWrapper.min(initial, StrictMathWrapper.min(limit, MAX_INITIAL_BUFFER)); + } + byte[] buffer = new byte[(int) initial]; + int size = 0; + while (true) { + if (size == buffer.length) { + // Probe for end of stream first, so an exact size hint never costs a copy. + int next = stream.read(); + if (next < 0) { + break; + } + long grown = StrictMathWrapper.min( + StrictMathWrapper.min(StrictMathWrapper.max((long) buffer.length * 2, + DEFAULT_INITIAL_BUFFER), limit), Integer.MAX_VALUE - 8); + if (grown <= buffer.length) { + return null; // cannot hold more than this; the message is over the bound + } + buffer = Arrays.copyOf(buffer, (int) grown); + buffer[size++] = (byte) next; + if (size > max) { + return null; + } + continue; + } + int read = stream.read(buffer, size, buffer.length - size); + if (read < 0) { + break; + } + size += read; + if (size > max) { + return null; + } + } + return size == buffer.length ? buffer : Arrays.copyOf(buffer, size); + } + + // Lets the delegate read the bytes into an array instead of parsing a stream. + private static final class KnownLengthInputStream extends ByteArrayInputStream + implements KnownLength { + + KnownLengthInputStream(byte[] bytes) { + super(bytes); + } + } +} diff --git a/framework/src/main/java/org/tron/core/admission/GuardedTransactionMarshaller.java b/framework/src/main/java/org/tron/core/admission/GuardedTransactionMarshaller.java deleted file mode 100644 index 75ff29e0171..00000000000 --- a/framework/src/main/java/org/tron/core/admission/GuardedTransactionMarshaller.java +++ /dev/null @@ -1,164 +0,0 @@ -package org.tron.core.admission; - -import com.google.protobuf.InvalidProtocolBufferException; -import io.grpc.KnownLength; -import io.grpc.MethodDescriptor; -import io.grpc.Status; -import io.grpc.StatusRuntimeException; -import java.io.ByteArrayInputStream; -import java.io.EOFException; -import java.io.IOException; -import java.io.InputStream; -import java.util.Arrays; -import java.util.EnumMap; -import java.util.Map; -import java.util.zip.ZipException; -import org.tron.common.math.StrictMathWrapper; -import org.tron.core.admission.TransactionAdmissionException.Reason; -import org.tron.protos.Protocol.Transaction; - -/** - * Reads the request with a size bound and runs {@link TransactionAdmissionGuard} before the - * original marshaller parses the same bytes. A rejection returns a per-{@link Reason} sentinel - * instead of throwing, because grpc logs every marshaller exception at SEVERE; the - * {@link AdmissionCheckedHandler} closes the call. Interceptors see a sentinel as an empty - * {@code Transaction}. - */ -public final class GuardedTransactionMarshaller - implements MethodDescriptor.PrototypeMarshaller { - - static final int MAX_INITIAL_BUFFER = 1 << 20; - private static final int DEFAULT_INITIAL_BUFFER = 8 * 1024; - - // Compared by identity; never the default instance. - private static final Map REJECTED = new EnumMap<>(Reason.class); - - static { - for (Reason reason : Reason.values()) { - REJECTED.put(reason, Transaction.newBuilder().build()); - } - } - - private final MethodDescriptor.Marshaller delegate; - private final int maxMessageSize; - private final int maxOccurrences; - - public GuardedTransactionMarshaller(MethodDescriptor.Marshaller delegate, - int maxMessageSize, int maxOccurrences) { - this.delegate = delegate; - this.maxMessageSize = maxMessageSize; - this.maxOccurrences = maxOccurrences; - } - - @Override - public InputStream stream(Transaction value) { - return delegate.stream(value); - } - - @Override - public Transaction parse(InputStream stream) { - byte[] bytes; - try { - bytes = readBounded(stream, maxMessageSize); - } catch (ZipException | EOFException e) { - return REJECTED.get(Reason.MALFORMED); - } catch (IOException e) { - throw Status.INTERNAL.withDescription("failed to read request").withCause(e) - .asRuntimeException(); - } - if (bytes == null) { - return REJECTED.get(Reason.TOO_LARGE); - } - try { - TransactionAdmissionGuard.checkTransaction(bytes, maxOccurrences); - } catch (TransactionAdmissionException e) { - return REJECTED.get(e.getReason()); - } - try { - return delegate.parse(new KnownLengthInputStream(bytes)); - } catch (StatusRuntimeException e) { - if (e.getCause() instanceof InvalidProtocolBufferException) { - return REJECTED.get(Reason.MALFORMED); - } - throw e; - } - } - - static Reason rejectionReason(Transaction request) { - for (Map.Entry entry : REJECTED.entrySet()) { - if (entry.getValue() == request) { - return entry.getKey(); - } - } - return null; - } - - @Override - public Transaction getMessagePrototype() { - return Transaction.getDefaultInstance(); - } - - @Override - public Class getMessageClass() { - return Transaction.class; - } - - /** Returns the whole stream, or {@code null} once more than {@code max} bytes are seen. */ - static byte[] readBounded(InputStream stream, int max) throws IOException { - if (max < 0) { - throw new IllegalArgumentException("max must not be negative"); - } - long limit = (long) max + 1; - int hint = stream.available(); - long initial; - if (stream instanceof KnownLength) { - // The exact remaining size, so one allocation is enough. - initial = StrictMathWrapper.min(StrictMathWrapper.max(hint, 0), limit); - } else { - // A decompressing stream reports 1 until it reaches the end, which says nothing about size. - initial = hint > 1 ? hint : DEFAULT_INITIAL_BUFFER; - initial = StrictMathWrapper.min(initial, StrictMathWrapper.min(limit, MAX_INITIAL_BUFFER)); - } - byte[] buffer = new byte[(int) initial]; - int size = 0; - while (true) { - if (size == buffer.length) { - // Probe for end of stream first, so an exact size hint never costs a copy. - int next = stream.read(); - if (next < 0) { - break; - } - long grown = StrictMathWrapper.min( - StrictMathWrapper.min(StrictMathWrapper.max((long) buffer.length * 2, - DEFAULT_INITIAL_BUFFER), limit), Integer.MAX_VALUE - 8); - if (grown <= buffer.length) { - return null; // cannot hold more than this; the message is over the bound - } - buffer = Arrays.copyOf(buffer, (int) grown); - buffer[size++] = (byte) next; - if (size > max) { - return null; - } - continue; - } - int read = stream.read(buffer, size, buffer.length - size); - if (read < 0) { - break; - } - size += read; - if (size > max) { - return null; - } - } - return size == buffer.length ? buffer : Arrays.copyOf(buffer, size); - } - - // Lets the delegate read the bytes into an array instead of parsing a stream. - private static final class KnownLengthInputStream extends ByteArrayInputStream - implements KnownLength { - - KnownLengthInputStream(byte[] bytes) { - super(bytes); - } - } -} diff --git a/framework/src/main/java/org/tron/core/admission/TransactionAdmissionException.java b/framework/src/main/java/org/tron/core/admission/TransactionAdmissionException.java index 54daa25170e..a162dc7284a 100644 --- a/framework/src/main/java/org/tron/core/admission/TransactionAdmissionException.java +++ b/framework/src/main/java/org/tron/core/admission/TransactionAdmissionException.java @@ -4,6 +4,7 @@ public class TransactionAdmissionException extends RuntimeException { public static final String REJECTION_MESSAGE = "transaction rejected by admission check"; + public static final String REQUEST_REJECTION_MESSAGE = "request rejected by admission check"; public enum Reason { MALFORMED, diff --git a/framework/src/main/java/org/tron/core/admission/TransactionAdmissionGuard.java b/framework/src/main/java/org/tron/core/admission/TransactionAdmissionGuard.java index dc63d11109f..b9002c353ab 100644 --- a/framework/src/main/java/org/tron/core/admission/TransactionAdmissionGuard.java +++ b/framework/src/main/java/org/tron/core/admission/TransactionAdmissionGuard.java @@ -20,16 +20,17 @@ import org.tron.protos.contract.ShieldContract.ShieldedTransferContract; /** - * Counts every field occurrence of an externally submitted {@code Transaction}, including the - * contract payload that {@code TransactionCapsule.getOwner} unpacks, and rejects it before parsing - * once the budget is exceeded. + * Counts every field occurrence (wire tag) of an externally submitted request, including the + * contract payload of a {@code Transaction} that {@code TransactionCapsule.getOwner} unpacks, and + * rejects it before parsing once the budget is exceeded. A packed repeated field is one occurrence. * *

Never use it for block, database or P2P parsing: a valid block must not fail a local, * non-consensus limit. */ public final class TransactionAdmissionGuard { - // About 12.7 times the largest count in mainnet history (3,926, a contract deployment). + // About 12.7 times the largest mainnet transaction (3,926, a contract deployment) and + // 7 times the largest mainnet account (7,065). public static final int MAX_TX_OCCURRENCES = 50_000; static final int MAX_DEPTH = 32; diff --git a/framework/src/main/java/org/tron/core/services/RpcApiService.java b/framework/src/main/java/org/tron/core/services/RpcApiService.java index b85f44b9efc..ce5296a1426 100755 --- a/framework/src/main/java/org/tron/core/services/RpcApiService.java +++ b/framework/src/main/java/org/tron/core/services/RpcApiService.java @@ -193,16 +193,16 @@ public RpcApiService() { @Override protected void addService(NettyServerBuilder serverBuilder) { - serverBuilder.addService(guardTransactionMethods(databaseApi.bindService())); + serverBuilder.addService(guardRequestMethods(databaseApi.bindService())); CommonParameter parameter = Args.getInstance(); if (parameter.isSolidityNode()) { - serverBuilder.addService(guardTransactionMethods(walletSolidityApi.bindService())); + serverBuilder.addService(guardRequestMethods(walletSolidityApi.bindService())); } else { - serverBuilder.addService(guardTransactionMethods(walletApi.bindService())); + serverBuilder.addService(guardRequestMethods(walletApi.bindService())); } if (parameter.isNodeMetricsEnable()) { - serverBuilder.addService(guardTransactionMethods(monitorApi.bindService())); + serverBuilder.addService(guardRequestMethods(monitorApi.bindService())); } } diff --git a/framework/src/main/java/org/tron/core/services/interfaceOnPBFT/RpcApiServiceOnPBFT.java b/framework/src/main/java/org/tron/core/services/interfaceOnPBFT/RpcApiServiceOnPBFT.java index dd268d8aa8e..da9bd6ef31c 100755 --- a/framework/src/main/java/org/tron/core/services/interfaceOnPBFT/RpcApiServiceOnPBFT.java +++ b/framework/src/main/java/org/tron/core/services/interfaceOnPBFT/RpcApiServiceOnPBFT.java @@ -27,9 +27,9 @@ public RpcApiServiceOnPBFT() { /** PBFT cursor bound at the service level, so it brackets only these two read services. */ @Override protected void addService(NettyServerBuilder serverBuilder) { - serverBuilder.addService(guardTransactionMethods(ServerInterceptors.intercept( + serverBuilder.addService(guardRequestMethods(ServerInterceptors.intercept( rpcApiService.getDatabaseApi(), pbftCursorInterceptor))); - serverBuilder.addService(guardTransactionMethods(ServerInterceptors.intercept( + serverBuilder.addService(guardRequestMethods(ServerInterceptors.intercept( rpcApiService.getWalletSolidityApi(), pbftCursorInterceptor))); } diff --git a/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/RpcApiServiceOnSolidity.java b/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/RpcApiServiceOnSolidity.java index 033b46be742..254b98855d5 100755 --- a/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/RpcApiServiceOnSolidity.java +++ b/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/RpcApiServiceOnSolidity.java @@ -27,9 +27,9 @@ public RpcApiServiceOnSolidity() { /** SOLIDITY cursor bound at the service level, so it brackets only these two read services. */ @Override protected void addService(NettyServerBuilder serverBuilder) { - serverBuilder.addService(guardTransactionMethods(ServerInterceptors.intercept( + serverBuilder.addService(guardRequestMethods(ServerInterceptors.intercept( rpcApiService.getDatabaseApi(), solidityCursorInterceptor))); - serverBuilder.addService(guardTransactionMethods(ServerInterceptors.intercept( + serverBuilder.addService(guardRequestMethods(ServerInterceptors.intercept( rpcApiService.getWalletSolidityApi(), solidityCursorInterceptor))); } diff --git a/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java b/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java index 667a2ca0104..8bcaa846b6b 100644 --- a/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java +++ b/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -10,6 +11,9 @@ import com.google.protobuf.CodedOutputStream; import com.google.protobuf.Descriptors.Descriptor; import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.GeneratedMessageV3; +import com.google.protobuf.Internal; +import com.google.protobuf.Message; import io.grpc.BindableService; import io.grpc.CallOptions; import io.grpc.ClientCall; @@ -34,7 +38,9 @@ import io.grpc.protobuf.ProtoMethodDescriptorSupplier; import io.grpc.protobuf.ProtoUtils; import io.grpc.stub.ClientCalls; +import io.grpc.stub.ServerCalls; import io.grpc.stub.StreamObserver; +import io.prometheus.client.CollectorRegistry; import java.io.ByteArrayInputStream; import java.io.FilterOutputStream; import java.io.IOException; @@ -46,6 +52,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.TreeSet; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; @@ -65,12 +72,18 @@ import org.tron.api.MonitorGrpc; import org.tron.api.WalletGrpc; import org.tron.api.WalletSolidityGrpc; -import org.tron.api.ZksnarkGrpcAPI.ZksnarkRequest; import org.tron.common.parameter.CommonParameter; +import org.tron.common.prometheus.MetricKeys; +import org.tron.core.actuator.TransactionFactory; import org.tron.core.admission.AdmissionCheckedHandler; -import org.tron.core.admission.GuardedTransactionMarshaller; +import org.tron.core.admission.GuardedRequestMarshaller; +import org.tron.core.admission.TransactionAdmissionException; +import org.tron.core.admission.TransactionAdmissionException.Reason; import org.tron.core.services.ratelimiter.RpcApiAccessInterceptor; +import org.tron.core.utils.TransactionRegister; +import org.tron.protos.Protocol.Block; import org.tron.protos.Protocol.Transaction; +import org.tron.protos.contract.ShieldContract.ShieldedTransferContract; public class RpcServiceAdmissionTest { @@ -82,6 +95,7 @@ public class RpcServiceAdmissionTest { {0x0a, 0x08, 0x5a, 0x06, 0x12, 0x04, 0x0a, 0x02, (byte) 0xc3, 0x28}; private static final AtomicInteger HANDLER_CALLS = new AtomicInteger(); + private static final AtomicInteger NOW_BLOCK_CALLS = new AtomicInteger(); private static final AtomicInteger STARTED = new AtomicInteger(); private static final AtomicInteger FINISHED = new AtomicInteger(); private static final List CLOSED_WITH = new CopyOnWriteArrayList<>(); @@ -128,12 +142,20 @@ public void broadcastTransaction(Transaction request, responseObserver.onNext(GrpcAPI.Return.newBuilder().setResult(true).build()); responseObserver.onCompleted(); } + + @Override + public void getNowBlock(GrpcAPI.EmptyMessage request, + StreamObserver responseObserver) { + NOW_BLOCK_CALLS.incrementAndGet(); + responseObserver.onNext(Block.getDefaultInstance()); + responseObserver.onCompleted(); + } }; // Real Netty transport bound to loopback only: the in-process transport skips the // deframer and decompression. server = NettyServerBuilder.forAddress(new InetSocketAddress("127.0.0.1", 0)) .maxInboundMessageSize(MAX_MESSAGE) - .addService(RpcService.guardTransactionMethods(wallet.bindService(), MAX_MESSAGE, BUDGET)) + .addService(RpcService.guardRequestMethods(wallet.bindService(), MAX_MESSAGE, BUDGET)) .intercept(new RpcApiAccessInterceptor()) .intercept(new PermitLikeInterceptor()) .build().start(); @@ -161,59 +183,60 @@ public static void stopServer() throws Exception { // ---------------------------------------------------------------- wrapping @Test - public void onlyTransactionRequestMethodsAreWrapped() { + public void everyMethodIsWrapped() { ServerServiceDefinition original = new WalletGrpc.WalletImplBase() { }.bindService(); ServerServiceDefinition guarded = - RpcService.guardTransactionMethods(original, MAX_MESSAGE, BUDGET); + RpcService.guardRequestMethods(original, MAX_MESSAGE, BUDGET); - Set wrapped = new HashSet<>(); for (ServerMethodDefinition method : guarded.getMethods()) { MethodDescriptor descriptor = method.getMethodDescriptor(); - ServerMethodDefinition before = original.getMethod(descriptor.getFullMethodName()); - assertSame(before.getMethodDescriptor().getResponseMarshaller(), + String name = descriptor.getFullMethodName(); + ServerMethodDefinition before = original.getMethod(name); + assertSame(name, before.getMethodDescriptor().getResponseMarshaller(), descriptor.getResponseMarshaller()); - if (descriptor.getRequestMarshaller() instanceof GuardedTransactionMarshaller) { - wrapped.add(descriptor.getBareMethodName()); - assertTrue(method.getServerCallHandler() instanceof AdmissionCheckedHandler); - assertSame(before.getServerCallHandler(), - ((AdmissionCheckedHandler) method.getServerCallHandler()).getDelegate()); - } else { - assertSame(before.getMethodDescriptor(), descriptor); - assertSame(before.getServerCallHandler(), method.getServerCallHandler()); - } + assertSame(name, before.getMethodDescriptor().getSchemaDescriptor(), + descriptor.getSchemaDescriptor()); + assertTrue(name, descriptor.getRequestMarshaller() instanceof GuardedRequestMarshaller); + assertTrue(name, method.getServerCallHandler() instanceof AdmissionCheckedHandler); + assertSame(name, before.getServerCallHandler(), + ((AdmissionCheckedHandler) method.getServerCallHandler()).getDelegate()); } - assertEquals(new HashSet<>(Arrays.asList("BroadcastTransaction", - "GetTransactionSignWeight", "GetTransactionApprovedList", "GetShieldTransactionHash", - "CreateCommonTransaction")), wrapped); assertEquals(original.getMethods().size(), guarded.getMethods().size()); + // The rpc count of service Wallet in api.proto; update it with the proto. + assertEquals(147, guarded.getMethods().size()); assertSame(original.getServiceDescriptor().getSchemaDescriptor(), guarded.getServiceDescriptor().getSchemaDescriptor()); } @Test - public void everyTransactionRequestMethodOfTheServiceTypesIsGuarded() { + public void everyMethodOfTheServiceTypesIsGuarded() { BindableService[] services = { new WalletGrpc.WalletImplBase() { }, new WalletSolidityGrpc.WalletSolidityImplBase() { }, new DatabaseGrpc.DatabaseImplBase() { }, new MonitorGrpc.MonitorImplBase() { } }; + int total = 0; for (BindableService service : services) { - assertAllTransactionMethodsGuarded( - RpcService.guardTransactionMethods(service.bindService(), MAX_MESSAGE, BUDGET), - BUDGET, MAX_MESSAGE); + ServerServiceDefinition definition = service.bindService(); + assertEquals(definition.getMethods().size(), assertAllMethodsGuarded( + RpcService.guardRequestMethods(definition, 64 * 1024, BUDGET), BUDGET, 64 * 1024)); + total += definition.getMethods().size(); } + // Wallet 147 + WalletSolidity 47 + Database 4 + Monitor 1 in api.proto. + assertEquals(199, total); } /** - * Shared with the production registration tests. A method is classified by its proto schema, - * so a request that only nests a Transaction, or a method without a schema, fails here instead - * of being skipped. Each guarded method must apply exactly {@code budget} and - * {@code maxMessageSize}. + * Shared with the production registration tests. Every method must have a proto schema, be + * wrapped with a {@link GuardedRequestMarshaller} whose scan root is the schema's request type + * (so schema and request marshaller agree), be handled by the matching + * {@link AdmissionCheckedHandler}, and apply exactly {@code budget} and + * {@code maxMessageSize}. Returns the number of methods checked. */ @SuppressWarnings("unchecked") - public static int assertAllTransactionMethodsGuarded(ServerServiceDefinition definition, - int budget, int maxMessageSize) { + public static int assertAllMethodsGuarded(ServerServiceDefinition definition, int budget, + int maxMessageSize) { int guarded = 0; for (ServerMethodDefinition method : definition.getMethods()) { MethodDescriptor descriptor = method.getMethodDescriptor(); @@ -223,65 +246,65 @@ public static int assertAllTransactionMethodsGuarded(ServerServiceDefinition def Descriptor input = ((ProtoMethodDescriptorSupplier) schema).getMethodDescriptor() .getInputType(); MethodDescriptor.Marshaller request = descriptor.getRequestMarshaller(); - if (input.equals(Transaction.getDescriptor())) { - assertTrue(name, request instanceof GuardedTransactionMarshaller); - assertTrue(name, method.getServerCallHandler() instanceof AdmissionCheckedHandler); - assertLimits(name, (MethodDescriptor.Marshaller) request, budget, - maxMessageSize); - guarded++; - } else if (request instanceof GuardedTransactionMarshaller) { - fail(name + " is guarded although its request is not a Transaction"); - } else if (reachesTransaction(input, new HashSet<>())) { - fail(name + " nests a Transaction in its request, which the admission guard does not " - + "cover"); - } + assertTrue(name + " is not guarded with a sentinel-returning marshaller: " + request, + request instanceof GuardedRequestMarshaller); + GuardedRequestMarshaller marshaller = (GuardedRequestMarshaller) request; + assertEquals(name + " schema and request marshaller disagree", input, + marshaller.getRoot()); + assertEquals(name, input, + ((Message) marshaller.getMessagePrototype()).getDescriptorForType()); + assertTrue(name, method.getServerCallHandler() instanceof AdmissionCheckedHandler); + assertSame(name, marshaller, + ((AdmissionCheckedHandler) method.getServerCallHandler()).getMarshaller()); + assertLimits(name, marshaller, budget, maxMessageSize); + guarded++; } return guarded; } - private static void assertLimits(String name, MethodDescriptor.Marshaller request, + private static void assertLimits(String name, GuardedRequestMarshaller request, int budget, int maxMessageSize) { - // A sentinel is an empty Transaction, a parsed request keeps its ret entries or signature. - assertEquals(name, budget, - request.parse(new ByteArrayInputStream(retFlood(budget))).getRetCount()); - assertEquals(name, 0, - request.parse(new ByteArrayInputStream(retFlood(budget + 1))).getRetCount()); - assertEquals(name, 1, request.parse(signatureOfSize(maxMessageSize)).getSignatureCount()); - assertEquals(name, 0, request.parse(signatureOfSize(maxMessageSize + 1)).getSignatureCount()); + assertNull(name, request.rejectionReason( + request.parse(new ByteArrayInputStream(distinctUnknownFields(budget))))); + assertEquals(name, Reason.OVER_BUDGET, request.rejectionReason( + request.parse(new ByteArrayInputStream(distinctUnknownFields(budget + 1))))); + assertNull(name, request.rejectionReason(request.parse(unknownFieldOfSize(maxMessageSize)))); + assertEquals(name, Reason.TOO_LARGE, + request.rejectionReason(request.parse(unknownFieldOfSize(maxMessageSize + 1)))); } - /** A Transaction of exactly {@code size} bytes holding one signature. */ - private static InputStream signatureOfSize(int size) { - int content = size - 2; - while (1 + CodedOutputStream.computeUInt32SizeNoTag(content) + content > size) { - content--; + /** {@code count} varint fields, each with a field number no request type uses. */ + private static byte[] distinctUnknownFields(int count) { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + for (int i = 0; i < count; i++) { + CodedOutputStream coded = CodedOutputStream.newInstance(out, 16); + try { + coded.writeUInt64(1000 + i, 0); + coded.flush(); + } catch (IOException e) { + throw new IllegalStateException(e); + } } - byte[] bytes = Transaction.newBuilder().addSignature(ByteString.copyFrom(new byte[content])) - .build().toByteArray(); - assertEquals(size, bytes.length); - return new ByteArrayInputStream(bytes); + return out.toByteArray(); } - private static boolean reachesTransaction(Descriptor type, Set seen) { - if (type.equals(Transaction.getDescriptor())) { - return true; - } - if (!seen.add(type.getFullName())) { - return false; + /** Exactly {@code size} bytes: one length-delimited field 999, unknown to every request. */ + private static InputStream unknownFieldOfSize(int size) { + int content = size - 2; + while (CodedOutputStream.computeTagSize(999) + CodedOutputStream.computeUInt32SizeNoTag( + content) + content > size) { + content--; } - for (FieldDescriptor field : type.getFields()) { - if (field.getJavaType() == FieldDescriptor.JavaType.MESSAGE - && reachesTransaction(field.getMessageType(), seen)) { - return true; - } + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + CodedOutputStream coded = CodedOutputStream.newInstance(out); + try { + coded.writeByteArray(999, new byte[content]); + coded.flush(); + } catch (IOException e) { + throw new IllegalStateException(e); } - return false; - } - - @Test - public void requestsNestingATransactionAreDetected() { - assertTrue(reachesTransaction(ZksnarkRequest.getDescriptor(), new HashSet<>())); - assertFalse(reachesTransaction(GrpcAPI.BytesMessage.getDescriptor(), new HashSet<>())); + assertEquals(size, out.size()); + return new ByteArrayInputStream(out.toByteArray()); } // ---------------------------------------------------------------- real Netty transport @@ -317,6 +340,138 @@ public void overBudgetRequestsAreRejectedWithInvalidArgumentAndNoSevereLog() assertEquals(severeBefore, GRPC_SEVERE.size()); } + @Test + public void anyMethodRejectsOverBudgetRequestsAndCountsEachRejectionOnce() throws Exception { + CommonParameter parameter = CommonParameter.getInstance(); + boolean metrics = parameter.metricsPrometheusEnable; + parameter.metricsPrometheusEnable = true; + try { + MethodDescriptor nowBlock = WalletGrpc.getGetNowBlockMethod(); + String name = nowBlock.getFullMethodName(); + double before = rejections(name, Reason.OVER_BUDGET); + double transactionBefore = rejections( + WalletGrpc.getBroadcastTransactionMethod().getFullMethodName(), Reason.OVER_BUDGET); + int calls = NOW_BLOCK_CALLS.get(); + int severeBefore = GRPC_SEVERE.size(); + for (String compression : Arrays.asList(null, "gzip")) { + Status status = rawCall(channel, nowBlock, distinctUnknownFields(BUDGET + 1), + compression); + assertEquals(Status.Code.INVALID_ARGUMENT, status.getCode()); + assertEquals(TransactionAdmissionException.REQUEST_REJECTION_MESSAGE, + status.getDescription()); + } + assertEquals(before + 2, rejections(name, Reason.OVER_BUDGET), 0); + assertEquals(calls, NOW_BLOCK_CALLS.get()); + // The Transaction methods keep their message. + Status transaction = rawBytesCall(channel, retFlood(BUDGET + 1), null); + assertEquals(Status.Code.INVALID_ARGUMENT, transaction.getCode()); + assertEquals(TransactionAdmissionException.REJECTION_MESSAGE, transaction.getDescription()); + assertEquals(transactionBefore + 1, rejections( + WalletGrpc.getBroadcastTransactionMethod().getFullMethodName(), Reason.OVER_BUDGET), 0); + + // Within the budget, and empty, the request reaches the service method. + for (String compression : Arrays.asList(null, "gzip")) { + assertEquals(Status.Code.OK, + rawCall(channel, nowBlock, distinctUnknownFields(BUDGET), compression).getCode()); + assertEquals(Status.Code.OK, rawCall(channel, nowBlock, new byte[0], compression) + .getCode()); + } + assertEquals(calls + 4, NOW_BLOCK_CALLS.get()); + assertEquals(before + 2, rejections(name, Reason.OVER_BUDGET), 0); + awaitTrue(() -> STARTED.get() == FINISHED.get()); + Thread.sleep(500); + assertEquals(severeBefore, GRPC_SEVERE.size()); + } finally { + parameter.metricsPrometheusEnable = metrics; + } + } + + @Test + public void withoutAPrototypeARejectionReachesTheClientAsUnknownAndIsCountedOnce() + throws Exception { + CommonParameter parameter = CommonParameter.getInstance(); + boolean metrics = parameter.metricsPrometheusEnable; + parameter.metricsPrometheusEnable = true; + try { + rejectionWithoutAPrototype(); + } finally { + parameter.metricsPrometheusEnable = metrics; + } + } + + private void rejectionWithoutAPrototype() throws Exception { + MethodDescriptor generated = WalletGrpc.getGetNowBlockMethod(); + AtomicInteger parsed = new AtomicInteger(); + AtomicInteger served = new AtomicInteger(); + // Not a PrototypeMarshaller, and it cannot parse empty bytes, so no instance is available + // when wrapping and only the schema type is known. + MethodDescriptor.Marshaller noPrototype = + new MethodDescriptor.Marshaller() { + @Override + public InputStream stream(GrpcAPI.EmptyMessage value) { + return generated.getRequestMarshaller().stream(value); + } + + @Override + public GrpcAPI.EmptyMessage parse(InputStream stream) { + parsed.incrementAndGet(); + try { + if (stream.available() == 0) { + throw new IllegalStateException("no instance"); + } + } catch (IOException e) { + throw new IllegalStateException(e); + } + return generated.getRequestMarshaller().parse(stream); + } + }; + MethodDescriptor method = + generated.toBuilder(noPrototype, generated.getResponseMarshaller()).build(); + ServerCallHandler handler = + ServerCalls.asyncUnaryCall((request, observer) -> { + served.incrementAndGet(); + observer.onNext(Block.getDefaultInstance()); + observer.onCompleted(); + }); + ServerServiceDefinition guarded = RpcService.guardRequestMethods( + ServerServiceDefinition.builder(ServiceDescriptor.newBuilder(WalletGrpc.SERVICE_NAME) + .addMethod(method).build()).addMethod(method, handler).build(), + MAX_MESSAGE, BUDGET); + String name = method.getFullMethodName(); + ServerMethodDefinition wrapped = guarded.getMethod(name); + assertFalse(wrapped.getMethodDescriptor().getRequestMarshaller() + instanceof MethodDescriptor.PrototypeMarshaller); + assertSame(handler, wrapped.getServerCallHandler()); + Server plainServer = NettyServerBuilder.forAddress(new InetSocketAddress("127.0.0.1", 0)) + .maxInboundMessageSize(MAX_MESSAGE).addService(guarded) + .intercept(new PermitLikeInterceptor()).build().start(); + ManagedChannel plainChannel = NettyChannelBuilder + .forAddress("127.0.0.1", plainServer.getPort()).usePlaintext().build(); + try { + parsed.set(0); // the empty-bytes attempt when wrapping + double before = rejections(name, Reason.OVER_BUDGET); + int severeBefore = GRPC_SEVERE.size(); + + Status status = rawCall(plainChannel, method, distinctUnknownFields(BUDGET + 1), null); + assertEquals(Status.Code.UNKNOWN, status.getCode()); + assertEquals(0, parsed.get()); + assertEquals(0, served.get()); + assertEquals(before + 1, rejections(name, Reason.OVER_BUDGET), 0); + awaitTrue(() -> STARTED.get() == FINISHED.get()); + awaitTrue(() -> GRPC_SEVERE.size() > severeBefore); + + assertEquals(Status.Code.OK, + rawCall(plainChannel, method, distinctUnknownFields(BUDGET), null).getCode()); + assertEquals(1, parsed.get()); + assertEquals(1, served.get()); + assertEquals(before + 1, rejections(name, Reason.OVER_BUDGET), 0); + awaitTrue(() -> STARTED.get() == FINISHED.get()); + } finally { + plainChannel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + plainServer.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + } + @Test public void laterMessagesOnARejectedUnaryCallAreIgnored() throws Exception { Transaction flood = Transaction.parseFrom(retFlood(BUDGET + 1)); @@ -327,6 +482,19 @@ public void laterMessagesOnARejectedUnaryCallAreIgnored() throws Exception { // A valid message followed by a rejected one. assertEquals(Status.Code.INVALID_ARGUMENT, rawUnaryCall(Arrays.asList(legitimate(), flood)).getCode()); + // Two rejected messages: one rejection, counted once. + CommonParameter parameter = CommonParameter.getInstance(); + boolean metrics = parameter.metricsPrometheusEnable; + parameter.metricsPrometheusEnable = true; + try { + String name = WalletGrpc.getBroadcastTransactionMethod().getFullMethodName(); + double rejected = rejections(name, Reason.OVER_BUDGET); + assertEquals(Status.Code.INVALID_ARGUMENT, + rawUnaryCall(Arrays.asList(flood, flood)).getCode()); + assertEquals(rejected + 1, rejections(name, Reason.OVER_BUDGET), 0); + } finally { + parameter.metricsPrometheusEnable = metrics; + } assertEquals(before, HANDLER_CALLS.get()); awaitTrue(() -> STARTED.get() == FINISHED.get()); } @@ -441,12 +609,62 @@ public Transaction parse(InputStream stream) { ServiceDescriptor.newBuilder(WalletGrpc.SERVICE_NAME).addMethod(method).build()) .addMethod(method, (call, headers) -> new ServerCall.Listener() { }) .build(); - assertEquals(1, assertAllTransactionMethodsGuarded( - RpcService.guardTransactionMethods(definition, MAX_MESSAGE, BUDGET), BUDGET, MAX_MESSAGE)); + assertEquals(1, assertAllMethodsGuarded( + RpcService.guardRequestMethods(definition, MAX_MESSAGE, BUDGET), BUDGET, MAX_MESSAGE)); + } + + /** + * The guard counts a packed repeated scalar field once and only unpacks the Any of + * Transaction.Contract, so no request type, including contract payloads, may have a packable + * repeated field or another Any. + */ + @Test + public void requestTypesHaveNoOtherPackedFieldOrAny() { + TransactionRegister.registerActuator(); + Set packable = new TreeSet<>(); + Set any = new TreeSet<>(); + Set seen = new HashSet<>(); + for (BindableService service : new BindableService[] { + new WalletGrpc.WalletImplBase() { }, new WalletSolidityGrpc.WalletSolidityImplBase() { }, + new DatabaseGrpc.DatabaseImplBase() { }, new MonitorGrpc.MonitorImplBase() { }}) { + for (ServerMethodDefinition method : service.bindService().getMethods()) { + walk(((ProtoMethodDescriptorSupplier) method.getMethodDescriptor().getSchemaDescriptor()) + .getMethodDescriptor().getInputType(), seen, packable, any); + } + } + for (Transaction.Contract.ContractType type : Transaction.Contract.ContractType.values()) { + Class payload = + type == Transaction.Contract.ContractType.ShieldedTransferContract + ? ShieldedTransferContract.class : TransactionFactory.getContract(type); + if (payload != null) { + walk(Internal.getDefaultInstance(payload).getDescriptorForType(), seen, packable, any); + } + } + assertEquals(Collections.emptySet(), packable); + assertEquals(Collections.singleton("protocol.Transaction.Contract.parameter"), any); + } + + private static void walk(Descriptor type, Set seen, Set packable, + Set any) { + if (!seen.add(type.getFullName())) { + return; + } + for (FieldDescriptor field : type.getFields()) { + if (field.isRepeated() && field.isPackable()) { + packable.add(field.getFullName()); + } + if (field.getJavaType() == FieldDescriptor.JavaType.MESSAGE) { + if (field.getMessageType().equals(com.google.protobuf.Any.getDescriptor())) { + any.add(field.getFullName()); + } else { + walk(field.getMessageType(), seen, packable, any); + } + } + } } @Test - public void theSchemaDecidesWhenThereIsOne() { + public void theSchemaDecidesWhetherToGuardWhenThereIsOne() { MethodDescriptor broadcast = WalletGrpc.getBroadcastTransactionMethod(); MethodDescriptor byId = @@ -456,20 +674,47 @@ public void theSchemaDecidesWhenThereIsOne() { MethodDescriptor.Marshaller bytes = ProtoUtils.marshaller(GrpcAPI.BytesMessage.getDefaultInstance()); - assertTrue(RpcService.isTransactionRequest(broadcast)); - assertFalse(RpcService.isTransactionRequest(byId)); - // The schema wins over a conflicting request prototype. - assertFalse(RpcService.isTransactionRequest( + assertEquals(Transaction.getDescriptor(), RpcService.requestType(broadcast)); + assertEquals(GrpcAPI.BytesMessage.getDescriptor(), RpcService.requestType(byId)); + // The schema wins over a conflicting request prototype for this decision. + assertEquals(GrpcAPI.BytesMessage.getDescriptor(), RpcService.requestType( byId.toBuilder(transaction, byId.getResponseMarshaller()).build())); - assertTrue(RpcService.isTransactionRequest( + assertEquals(Transaction.getDescriptor(), RpcService.requestType( broadcast.toBuilder(bytes, broadcast.getResponseMarshaller()).build())); // Without a schema the request prototype decides. - assertTrue(RpcService.isTransactionRequest( + assertEquals(Transaction.getDescriptor(), RpcService.requestType( broadcast.toBuilder().setSchemaDescriptor(null).build())); - assertFalse(RpcService.isTransactionRequest( + assertEquals(GrpcAPI.BytesMessage.getDescriptor(), RpcService.requestType( byId.toBuilder().setSchemaDescriptor(null).build())); } + @Test + public void aMethodOfUnknownRequestTypeIsKeptAndFailsTheRegistrationCheck() { + MethodDescriptor unknown = MethodDescriptor. + newBuilder().setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName(MethodDescriptor.generateFullMethodName("test.Unknown", "Call")) + .setRequestMarshaller(RAW) + .setResponseMarshaller(ProtoUtils.marshaller(GrpcAPI.Return.getDefaultInstance())) + .build(); + ServerCallHandler handler = + (call, headers) -> new ServerCall.Listener() { }; + ServerServiceDefinition definition = ServerServiceDefinition.builder( + ServiceDescriptor.newBuilder("test.Unknown").addMethod(unknown).build()) + .addMethod(unknown, handler).build(); + assertNull(RpcService.requestType(unknown)); + ServerServiceDefinition guarded = RpcService.guardRequestMethods(definition, MAX_MESSAGE, + BUDGET); + ServerMethodDefinition kept = guarded.getMethod(unknown.getFullMethodName()); + assertSame(unknown, kept.getMethodDescriptor()); + assertSame(handler, kept.getServerCallHandler()); + try { + assertAllMethodsGuarded(guarded, BUDGET, MAX_MESSAGE); + fail("an unguarded method must fail the registration check"); + } catch (AssertionError expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("test.Unknown/Call")); + } + } + // ---------------------------------------------------------------- helpers private static WalletGrpc.WalletBlockingStub stub() { @@ -514,9 +759,12 @@ public byte[] parse(InputStream stream) { }; private static Status rawBytesCall(ManagedChannel target, byte[] request, String compression) { - MethodDescriptor generated = - WalletGrpc.getBroadcastTransactionMethod(); - MethodDescriptor method = + return rawCall(target, WalletGrpc.getBroadcastTransactionMethod(), request, compression); + } + + private static Status rawCall(ManagedChannel target, + MethodDescriptor generated, byte[] request, String compression) { + MethodDescriptor method = generated.toBuilder(RAW, generated.getResponseMarshaller()).build(); CallOptions options = CallOptions.DEFAULT .withDeadlineAfter(DEADLINE_SECONDS, TimeUnit.SECONDS).withCompression(compression); @@ -576,6 +824,13 @@ private static void awaitTrue(BooleanSupplier condition) throws InterruptedExcep } } + private static double rejections(String method, Reason reason) { + Double value = CollectorRegistry.defaultRegistry.getSampleValue( + MetricKeys.Counter.GRPC_ADMISSION_REJECT + "_total", new String[] {"method", "reason"}, + new String[] {method, reason.name()}); + return value == null ? 0 : value; + } + private static byte[] retFlood(int entries) { byte[] out = new byte[entries * 2]; for (int i = 0; i < out.length; i += 2) { diff --git a/framework/src/test/java/org/tron/common/application/RpcServiceInterceptorChainAdmissionTest.java b/framework/src/test/java/org/tron/common/application/RpcServiceInterceptorChainAdmissionTest.java new file mode 100644 index 00000000000..c3b3a729ca5 --- /dev/null +++ b/framework/src/test/java/org/tron/common/application/RpcServiceInterceptorChainAdmissionTest.java @@ -0,0 +1,216 @@ +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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.protobuf.ByteString; +import com.google.protobuf.CodedOutputStream; +import io.grpc.CallOptions; +import io.grpc.ManagedChannel; +import io.grpc.MethodDescriptor; +import io.grpc.Server; +import io.grpc.ServerInterceptors; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.NettyServerBuilder; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.StreamObserver; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; +import org.springframework.test.util.ReflectionTestUtils; +import org.tron.api.WalletSolidityGrpc; +import org.tron.common.TestConstants; +import org.tron.core.ChainBaseManager; +import org.tron.core.admission.TransactionAdmissionException; +import org.tron.core.config.args.Args; +import org.tron.core.db.Manager; +import org.tron.core.services.filter.LiteFnQueryGrpcInterceptor; +import org.tron.core.services.filter.SolidityCursorInterceptor; +import org.tron.core.services.ratelimiter.PrometheusInterceptor; +import org.tron.core.services.ratelimiter.RateLimiterContainer; +import org.tron.core.services.ratelimiter.RateLimiterInterceptor; +import org.tron.core.services.ratelimiter.RpcApiAccessInterceptor; +import org.tron.core.services.ratelimiter.adapter.GlobalPreemptibleAdapter; +import org.tron.protos.Protocol.Account; + +/** + * Rejections through the interceptors that production registers: the rate limiter with a + * preemptible permit, access control, the lite fullnode filter and the SOLIDITY cursor. + */ +public class RpcServiceInterceptorChainAdmissionTest { + + private static final int MAX_MESSAGE = 4 * 1024 * 1024; + private static final int BUDGET = 100; + private static final String GET_ACCOUNT = "protocol.WalletSolidity/GetAccount"; + private static final MethodDescriptor RAW_GET_ACCOUNT = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName(GET_ACCOUNT) + .setRequestMarshaller(new RawMarshaller()) + .setResponseMarshaller(new RawMarshaller()) + .build(); + + private static final AtomicInteger SERVICE_CALLS = new AtomicInteger(); + + private static Server server; + private static ManagedChannel channel; + private static Manager manager; + private static GlobalPreemptibleAdapter permits; + + @Rule + public Timeout timeout = new Timeout(60, TimeUnit.SECONDS); + + @BeforeClass + public static void startServer() throws Exception { + Args.setParam(new String[0], TestConstants.TEST_CONF); + + RateLimiterContainer container = new RateLimiterContainer(); + permits = new GlobalPreemptibleAdapter("permit=1"); + container.add("rpc_", GET_ACCOUNT, permits); + RateLimiterInterceptor rateLimiter = new RateLimiterInterceptor(); + ReflectionTestUtils.setField(rateLimiter, "container", container); + LiteFnQueryGrpcInterceptor liteFn = new LiteFnQueryGrpcInterceptor(); + ChainBaseManager chainBaseManager = mock(ChainBaseManager.class); + when(chainBaseManager.isLiteNode()).thenReturn(false); + ReflectionTestUtils.setField(liteFn, "chainBaseManager", chainBaseManager); + manager = mock(Manager.class); + SolidityCursorInterceptor cursor = new SolidityCursorInterceptor(); + ReflectionTestUtils.setField(cursor, "dbManager", manager); + + RpcService service = new RpcService() { + @Override + protected void addService(NettyServerBuilder serverBuilder) { + } + }; + ReflectionTestUtils.setField(service, "rateLimiterInterceptor", rateLimiter); + ReflectionTestUtils.setField(service, "apiAccessInterceptor", new RpcApiAccessInterceptor()); + ReflectionTestUtils.setField(service, "liteFnQueryGrpcInterceptor", liteFn); + ReflectionTestUtils.setField(service, "prometheusInterceptor", new PrometheusInterceptor()); + + WalletSolidityGrpc.WalletSolidityImplBase walletSolidity = + new WalletSolidityGrpc.WalletSolidityImplBase() { + @Override + public void getAccount(Account request, StreamObserver responseObserver) { + SERVICE_CALLS.incrementAndGet(); + responseObserver.onNext(request); + responseObserver.onCompleted(); + } + }; + // As RpcApiServiceOnSolidity registers it. + NettyServerBuilder builder = NettyServerBuilder + .forAddress(new InetSocketAddress("127.0.0.1", 0)) + .maxInboundMessageSize(MAX_MESSAGE) + .addService(RpcService.guardRequestMethods( + ServerInterceptors.intercept(walletSolidity.bindService(), cursor), + MAX_MESSAGE, BUDGET)); + service.addInterceptor(builder); + server = builder.build().start(); + channel = NettyChannelBuilder.forAddress("127.0.0.1", server.getPort()).usePlaintext() + .build(); + } + + @AfterClass + public static void stopServer() throws Exception { + try { + if (channel != null) { + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + if (server != null) { + server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + } finally { + Args.clearParam(); + } + } + + @Test + public void rejectionsReleaseThePermitAndNeverSetTheCursor() throws Exception { + byte[] overBudget = unknownFields(BUDGET + 1); + // With one permit, a permit kept by a rejected call fails the next call. + for (int i = 0; i < 3; i++) { + try { + ClientCalls.blockingUnaryCall(channel, RAW_GET_ACCOUNT, + CallOptions.DEFAULT.withDeadlineAfter(10, TimeUnit.SECONDS), overBudget); + fail("expected a rejection"); + } catch (StatusRuntimeException e) { + assertEquals(Status.Code.INVALID_ARGUMENT, e.getStatus().getCode()); + assertEquals(TransactionAdmissionException.REQUEST_REJECTION_MESSAGE, + e.getStatus().getDescription()); + } + } + assertEquals(0, SERVICE_CALLS.get()); + verify(manager, never()).setCursor(any()); + + Account account = Account.newBuilder() + .setAddress(ByteString.copyFrom(new byte[21])).build(); + byte[] reply = ClientCalls.blockingUnaryCall(channel, RAW_GET_ACCOUNT, + CallOptions.DEFAULT.withDeadlineAfter(10, TimeUnit.SECONDS), account.toByteArray()); + assertEquals(account, Account.parseFrom(reply)); + assertEquals(1, SERVICE_CALLS.get()); + + // The permit comes back in onComplete, which runs after onHalfClose has reset the cursor. + long end = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + boolean acquired = false; + while (!acquired && System.nanoTime() < end) { + acquired = permits.tryAcquire(null); + if (!acquired) { + Thread.sleep(10); + } + } + assertTrue("permit leaked", acquired); + permits.release(); + verify(manager, times(1)).setCursor(any()); + verify(manager, times(1)).resetCursor(); + } + + private static byte[] unknownFields(int count) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + CodedOutputStream out = CodedOutputStream.newInstance(bytes); + for (int i = 0; i < count; i++) { + out.writeUInt64(1000 + i, 1); + } + out.flush(); + return bytes.toByteArray(); + } + + private static final class RawMarshaller implements MethodDescriptor.Marshaller { + + @Override + public InputStream stream(byte[] value) { + return new ByteArrayInputStream(value); + } + + @Override + public byte[] parse(InputStream stream) { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int read; + while ((read = stream.read(buffer)) > 0) { + out.write(buffer, 0, read); + } + return out.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + } +} diff --git a/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java b/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java index 08874ef0a9d..d4d0b37a073 100644 --- a/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java +++ b/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java @@ -37,50 +37,52 @@ public class AdmissionSupportTest { public void readBoundedReturnsExactlyTheBytesRead() throws IOException { byte[] data = new byte[1000]; data[999] = 7; - assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + assertArrayEquals(data, GuardedRequestMarshaller.readBounded( new HintStream(data, 0), 1000)); // no hint - assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + assertArrayEquals(data, GuardedRequestMarshaller.readBounded( new HintStream(data, 1000), 1000)); // exact hint - assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + assertArrayEquals(data, GuardedRequestMarshaller.readBounded( new HintStream(data, 1), 1000)); // decompressing stream - assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + assertArrayEquals(data, GuardedRequestMarshaller.readBounded( new HintStream(data, Integer.MAX_VALUE), 4096)); // huge hint, short content - assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + assertArrayEquals(data, GuardedRequestMarshaller.readBounded( new HintStream(data, 10), Integer.MAX_VALUE)); // no overflow at max int - assertArrayEquals(new byte[0], GuardedTransactionMarshaller.readBounded( + assertArrayEquals(new byte[0], GuardedRequestMarshaller.readBounded( new HintStream(new byte[0], 0), 0)); } @Test public void readBoundedRejectsMoreThanMax() throws IOException { byte[] data = new byte[1001]; - assertNull(GuardedTransactionMarshaller.readBounded(new HintStream(data, 0), 1000)); - assertNull(GuardedTransactionMarshaller.readBounded(new HintStream(data, 1001), 1000)); - assertNull(GuardedTransactionMarshaller.readBounded(new HintStream(new byte[1], 0), 0)); + assertNull(GuardedRequestMarshaller.readBounded(new HintStream(data, 0), 1000)); + assertNull(GuardedRequestMarshaller.readBounded(new HintStream(data, 1001), 1000)); + assertNull(GuardedRequestMarshaller.readBounded(new HintStream(new byte[1], 0), 0)); } @Test public void readBoundedReadsAKnownLengthStream() throws IOException { int max = 4 << 20; - for (int size : new int[] {0, 1, GuardedTransactionMarshaller.MAX_INITIAL_BUFFER + 1, max}) { + for (int size : new int[] {0, 1, GuardedRequestMarshaller.MAX_INITIAL_BUFFER + 1, max}) { byte[] data = new byte[size]; if (size > 0) { data[size - 1] = 7; } - assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + assertArrayEquals(data, GuardedRequestMarshaller.readBounded( new KnownStream(data, Integer.MAX_VALUE, -1), max)); - assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + assertArrayEquals(data, GuardedRequestMarshaller.readBounded( new KnownStream(data, 1000, -1), max)); // short reads } byte[] data = new byte[1000]; data[999] = 7; - assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + assertArrayEquals(data, GuardedRequestMarshaller.readBounded( new KnownStream(data, Integer.MAX_VALUE, 0), 1000)); // size understated - assertArrayEquals(data, GuardedTransactionMarshaller.readBounded( + assertArrayEquals(data, GuardedRequestMarshaller.readBounded( new KnownStream(data, Integer.MAX_VALUE, 5000), 4096)); // size overstated - assertNull(GuardedTransactionMarshaller.readBounded( + assertArrayEquals(data, GuardedRequestMarshaller.readBounded( + new KnownStream(data, Integer.MAX_VALUE, Integer.MAX_VALUE), 4096)); // capped at max + assertNull(GuardedRequestMarshaller.readBounded( new KnownStream(new byte[1001], Integer.MAX_VALUE, -1), 1000)); - assertNull(GuardedTransactionMarshaller.readBounded( + assertNull(GuardedRequestMarshaller.readBounded( new KnownStream(new byte[1001], Integer.MAX_VALUE, 0), 1000)); } @@ -93,10 +95,10 @@ public void aKnownLengthStreamIsReadWithOneAllocation() throws IOException { && threads.isThreadAllocatedMemoryEnabled()); long thread = Thread.currentThread().getId(); byte[] data = new byte[3 << 20]; - GuardedTransactionMarshaller.readBounded(new KnownStream(data, Integer.MAX_VALUE, -1), 4 << 20); + GuardedRequestMarshaller.readBounded(new KnownStream(data, Integer.MAX_VALUE, -1), 4 << 20); KnownStream stream = new KnownStream(data, Integer.MAX_VALUE, -1); long before = threads.getThreadAllocatedBytes(thread); - byte[] read = GuardedTransactionMarshaller.readBounded(stream, 4 << 20); + byte[] read = GuardedRequestMarshaller.readBounded(stream, 4 << 20); long allocated = threads.getThreadAllocatedBytes(thread) - before; assertEquals(data.length, read.length); // Growing from the 1 MiB initial buffer would allocate about 10 MiB here. @@ -108,17 +110,17 @@ public void aKnownLengthStreamIsReadWithOneAllocation() throws IOException { @Test public void marshallerReturnsTheSentinelBeforeTheDelegateParses() throws Exception { CountingMarshaller delegate = new CountingMarshaller(); - GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(delegate, 4096, 10); + GuardedRequestMarshaller marshaller = transactionMarshaller(delegate, 4096, 10); byte[] flood = new byte[2 * 11]; for (int i = 0; i < flood.length; i += 2) { flood[i] = 0x2a; } - assertNotNull(GuardedTransactionMarshaller.rejectionReason( + assertNotNull(marshaller.rejectionReason( marshaller.parse(new ByteArrayInputStream(flood)))); // over budget - assertNotNull(GuardedTransactionMarshaller.rejectionReason( + assertNotNull(marshaller.rejectionReason( marshaller.parse(new ByteArrayInputStream(new byte[4097])))); // over max size - assertNotNull(GuardedTransactionMarshaller.rejectionReason( + assertNotNull(marshaller.rejectionReason( marshaller.parse(new ByteArrayInputStream(new byte[] {0x0b, 0x0c})))); // malformed assertEquals(0, delegate.parsed.get()); @@ -126,25 +128,26 @@ public void marshallerReturnsTheSentinelBeforeTheDelegateParses() throws Excepti com.google.protobuf.ByteString.copyFrom(new byte[65])).build(); Transaction parsed = marshaller.parse(new ByteArrayInputStream(tx.toByteArray())); assertEquals(tx, parsed); - assertNull(GuardedTransactionMarshaller.rejectionReason(parsed)); + assertNull(marshaller.rejectionReason(parsed)); assertEquals(1, delegate.parsed.get()); } @Test public void sentinelIsNeverAParsedOrDefaultTransaction() throws Exception { - assertNull(GuardedTransactionMarshaller.rejectionReason(Transaction.getDefaultInstance())); - assertNull(GuardedTransactionMarshaller.rejectionReason(Transaction.parseFrom(new byte[0]))); - assertNull(GuardedTransactionMarshaller.rejectionReason(Transaction.newBuilder().build())); + assertNull(transactionMarshaller(new CountingMarshaller(), 4096, 10).rejectionReason( + Transaction.getDefaultInstance())); CountingMarshaller delegate = new CountingMarshaller(); - GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(delegate, 4096, 10); - assertNull(GuardedTransactionMarshaller.rejectionReason( + GuardedRequestMarshaller marshaller = transactionMarshaller(delegate, 4096, 10); + assertNull(marshaller.rejectionReason(Transaction.parseFrom(new byte[0]))); + assertNull(marshaller.rejectionReason(Transaction.newBuilder().build())); + assertNull(marshaller.rejectionReason( marshaller.parse(new ByteArrayInputStream(new byte[0])))); } @Test public void eachRejectionMapsToItsReason() throws Exception { CountingMarshaller delegate = new CountingMarshaller(); - GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(delegate, 4096, 10); + GuardedRequestMarshaller marshaller = transactionMarshaller(delegate, 4096, 10); byte[] flood = new byte[2 * 11]; for (int i = 0; i < flood.length; i += 2) { flood[i] = 0x2a; @@ -154,13 +157,13 @@ public void eachRejectionMapsToItsReason() throws Exception { assertEquals(Reason.MALFORMED, reason(marshaller, new ByteArrayInputStream(new byte[] {0x0b, 0x0c}))); assertEquals(0, delegate.parsed.get()); - assertNull(GuardedTransactionMarshaller.rejectionReason(Transaction.getDefaultInstance())); + assertNull(marshaller.rejectionReason(Transaction.getDefaultInstance())); } @Test public void decodingErrorOfTheDelegateIsAMalformedRejection() { CountingMarshaller delegate = new CountingMarshaller(); - GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(delegate, 4096, 10); + GuardedRequestMarshaller marshaller = transactionMarshaller(delegate, 4096, 10); // Well-formed wire within budget, but Any.type_url is not valid UTF-8. assertEquals(Reason.MALFORMED, reason(marshaller, new ByteArrayInputStream(INVALID_UTF8_TYPE_URL))); @@ -170,7 +173,7 @@ public void decodingErrorOfTheDelegateIsAMalformedRejection() { @Test public void corruptOrTruncatedCompressedInputIsAMalformedRejection() throws Exception { CountingMarshaller delegate = new CountingMarshaller(); - GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(delegate, 4096, 10); + GuardedRequestMarshaller marshaller = transactionMarshaller(delegate, 4096, 10); byte[] corrupt = {0x1f, (byte) 0x8b, 8, 0, 0, 0, 0, 0, 0, (byte) 0xff, (byte) 0xff, 0, 0}; assertEquals(Reason.MALFORMED, reason(marshaller, new GZIPInputStream(new ByteArrayInputStream(corrupt)))); @@ -187,7 +190,7 @@ public void corruptOrTruncatedCompressedInputIsAMalformedRejection() throws Exce @Test public void otherReadFailuresAreThrownWithTheirCause() { CountingMarshaller delegate = new CountingMarshaller(); - GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(delegate, 4096, 10); + GuardedRequestMarshaller marshaller = transactionMarshaller(delegate, 4096, 10); IOException failure = new IOException("read failed"); InputStream failsOnAvailable = new InputStream() { @Override @@ -241,8 +244,7 @@ public Transaction parse(InputStream stream) { throw failure; } }; - GuardedTransactionMarshaller marshaller = - new GuardedTransactionMarshaller(failing, 4096, 10); + GuardedRequestMarshaller marshaller = transactionMarshaller(failing, 4096, 10); try { marshaller.parse(new ByteArrayInputStream(new byte[0])); fail("expected the delegate failure"); @@ -270,7 +272,7 @@ public Transaction parse(InputStream stream) { } } }; - GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(plain, 4096, 10); + GuardedRequestMarshaller marshaller = transactionMarshaller(plain, 4096, 10); Transaction tx = Transaction.newBuilder().addSignature( com.google.protobuf.ByteString.copyFrom(new byte[65])).build(); assertEquals(tx, marshaller.parse(new ByteArrayInputStream(tx.toByteArray()))); @@ -299,7 +301,7 @@ public Transaction parse(InputStream stream) { } } }; - GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller(recording, 4096, 10); + GuardedRequestMarshaller marshaller = transactionMarshaller(recording, 4096, 10); Transaction tx = Transaction.newBuilder().addSignature( com.google.protobuf.ByteString.copyFrom(new byte[65])).build(); assertEquals(tx, marshaller.parse(new ByteArrayInputStream(tx.toByteArray()))); @@ -314,28 +316,28 @@ public Transaction parse(InputStream stream) { @Test public void emptyRequestsAreParsedPlainAndGzip() throws Exception { - GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller( + GuardedRequestMarshaller marshaller = transactionMarshaller( ProtoUtils.marshaller(Transaction.getDefaultInstance()), 4096, 10); for (InputStream empty : Arrays.asList( new KnownStream(new byte[0], Integer.MAX_VALUE, -1), new ByteArrayInputStream(new byte[0]), new GZIPInputStream(new ByteArrayInputStream(gzip(new byte[0]))))) { Transaction parsed = marshaller.parse(empty); - assertNull(GuardedTransactionMarshaller.rejectionReason(parsed)); + assertNull(marshaller.rejectionReason(parsed)); assertEquals(Transaction.getDefaultInstance(), parsed); } } @Test public void protoMarshallerResultsAreUnchanged() throws Exception { - GuardedTransactionMarshaller marshaller = new GuardedTransactionMarshaller( + GuardedRequestMarshaller marshaller = transactionMarshaller( ProtoUtils.marshaller(Transaction.getDefaultInstance()), 4 << 20, Integer.MAX_VALUE); Transaction.Builder builder = Transaction.newBuilder(); for (int i = 0; i < 20_000; i++) { builder.addSignature(com.google.protobuf.ByteString.copyFrom(new byte[65])); } Transaction large = builder.build(); // above 1 MiB - assertTrue(large.getSerializedSize() > GuardedTransactionMarshaller.MAX_INITIAL_BUFFER); + assertTrue(large.getSerializedSize() > GuardedRequestMarshaller.MAX_INITIAL_BUFFER); assertEquals(large, marshaller.parse( new KnownStream(large.toByteArray(), Integer.MAX_VALUE, -1))); assertEquals(large, marshaller.parse(new GZIPInputStream( @@ -356,8 +358,15 @@ private static byte[] gzip(byte[] data) throws IOException { static final byte[] INVALID_UTF8_TYPE_URL = {0x0a, 0x08, 0x5a, 0x06, 0x12, 0x04, 0x0a, 0x02, (byte) 0xc3, 0x28}; - private static Reason reason(GuardedTransactionMarshaller marshaller, InputStream stream) { - return GuardedTransactionMarshaller.rejectionReason(marshaller.parse(stream)); + private static GuardedRequestMarshaller transactionMarshaller( + MethodDescriptor.Marshaller delegate, int maxMessageSize, int maxOccurrences) { + return new GuardedRequestMarshaller<>(delegate, Transaction.getDefaultInstance(), + maxMessageSize, maxOccurrences); + } + + private static Reason reason(GuardedRequestMarshaller marshaller, + InputStream stream) { + return marshaller.rejectionReason(marshaller.parse(stream)); } private static final class CountingMarshaller diff --git a/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java b/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java new file mode 100644 index 00000000000..70105224258 --- /dev/null +++ b/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java @@ -0,0 +1,355 @@ +package org.tron.core.admission; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.protobuf.Any; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerMethodDefinition; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.protobuf.ProtoUtils; +import io.prometheus.client.CollectorRegistry; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.tron.api.GrpcAPI; +import org.tron.api.WalletGrpc; +import org.tron.api.ZksnarkGrpcAPI.ZksnarkRequest; +import org.tron.common.parameter.CommonParameter; +import org.tron.common.prometheus.MetricKeys; +import org.tron.core.admission.TransactionAdmissionException.Reason; +import org.tron.protos.Protocol.Transaction; +import org.tron.protos.contract.SmartContractOuterClass.CreateSmartContract; +import org.tron.protos.contract.SmartContractOuterClass.SmartContract; + +public class GuardedRequestMarshallerTest { + + private static final int MAX = 4096; + private static final int BUDGET = 10; + private static final ServerCallHandler HANDLER = + (call, headers) -> new ServerCall.Listener() { }; + + private boolean metricsEnabled; + + @Before + public void enableMetrics() { + metricsEnabled = CommonParameter.getInstance().metricsPrometheusEnable; + CommonParameter.getInstance().metricsPrometheusEnable = true; + } + + @After + public void restoreMetrics() { + CommonParameter.getInstance().metricsPrometheusEnable = metricsEnabled; + } + + @Test + public void rootAndSentinelsFollowTheRequestType() { + for (Message prototype : new Message[] {GrpcAPI.EmptyMessage.getDefaultInstance(), + GrpcAPI.BytesMessage.getDefaultInstance(), Transaction.getDefaultInstance()}) { + GuardedRequestMarshaller marshaller = guarded(proto(prototype), prototype); + assertEquals(prototype.getDescriptorForType(), marshaller.getRoot()); + assertSame(prototype, marshaller.getMessagePrototype()); + assertSame(prototype.getClass(), marshaller.getMessageClass()); + Map seen = new IdentityHashMap<>(); + for (Reason reason : Reason.values()) { + Message sentinel = marshaller.rejected(reason); + assertNotSame(prototype, sentinel); + assertEquals(prototype.getClass(), sentinel.getClass()); + assertEquals(reason, marshaller.rejectionReason(sentinel)); + assertNull(seen.put(sentinel, reason)); + } + // An empty request parses to the shared default instance, which is never a sentinel. + Message empty = marshaller.parse(new ByteArrayInputStream(new byte[0])); + assertNull(marshaller.rejectionReason(empty)); + assertNull(marshaller.rejectionReason(prototype)); + } + } + + @Test + public void unknownFieldsOfAnyRequestTypeCount() { + GuardedRequestMarshaller marshaller = + guarded(proto(GrpcAPI.EmptyMessage.getDefaultInstance()), + GrpcAPI.EmptyMessage.getDefaultInstance()); + assertNull(marshaller.rejectionReason( + marshaller.parse(new ByteArrayInputStream(distinctUnknownFields(BUDGET))))); + assertEquals(Reason.OVER_BUDGET, marshaller.rejectionReason( + marshaller.parse(new ByteArrayInputStream(distinctUnknownFields(BUDGET + 1))))); + assertEquals(TransactionAdmissionException.REQUEST_REJECTION_MESSAGE, + marshaller.rejectionMessage()); + assertEquals(TransactionAdmissionException.REJECTION_MESSAGE, + guarded(proto(Transaction.getDefaultInstance()), Transaction.getDefaultInstance()) + .rejectionMessage()); + } + + @Test + public void aNonPrototypeDelegateGivesItsTypeOnceWhenWrapped() { + Counting delegate = + new Counting<>(plain(GrpcAPI.EmptyMessage.getDefaultInstance())); + ServerMethodDefinition method = method(delegate, + GrpcAPI.EmptyMessage.getDefaultInstance()); + ServerMethodDefinition wrapped = GuardedRequestMarshaller.guard( + method, GrpcAPI.EmptyMessage.getDescriptor(), MAX, BUDGET); + assertEquals(1, delegate.parsed.get()); // the empty-bytes parse when wrapping + MethodDescriptor.Marshaller request = + wrapped.getMethodDescriptor().getRequestMarshaller(); + assertTrue(request instanceof GuardedRequestMarshaller); + assertTrue(wrapped.getServerCallHandler() instanceof AdmissionCheckedHandler); + assertSame(request, + ((AdmissionCheckedHandler) wrapped.getServerCallHandler()).getMarshaller()); + GuardedRequestMarshaller guarded = + (GuardedRequestMarshaller) request; + assertEquals(GrpcAPI.EmptyMessage.getDescriptor(), guarded.getRoot()); + assertEquals(Reason.OVER_BUDGET, guarded.rejectionReason( + guarded.parse(new ByteArrayInputStream(distinctUnknownFields(BUDGET + 1))))); + assertEquals(1, delegate.parsed.get()); + assertNull(guarded.rejectionReason(guarded.parse(new ByteArrayInputStream(new byte[0])))); + assertEquals(2, delegate.parsed.get()); + } + + @Test + public void theScanRootIsTheTypeTheDelegateParsesInto() { + // raw_data { auths x (BUDGET + 1) }: one field 1 of a BytesMessage, BUDGET + 2 occurrences + // of a Transaction. + Transaction.raw.Builder raw = Transaction.raw.newBuilder(); + for (int i = 0; i <= BUDGET; i++) { + raw.addAuths(org.tron.protos.Protocol.authority.getDefaultInstance()); + } + byte[] data = Transaction.newBuilder().setRawData(raw).build().toByteArray(); + + // Schema BytesMessage, delegate Transaction: scanned as the Transaction it becomes. + ServerMethodDefinition declaredBytes = GuardedRequestMarshaller.guard( + method(proto(Transaction.getDefaultInstance()), Transaction.getDefaultInstance()), + GrpcAPI.BytesMessage.getDescriptor(), MAX, BUDGET); + GuardedRequestMarshaller asTransaction = + (GuardedRequestMarshaller) + declaredBytes.getMethodDescriptor().getRequestMarshaller(); + assertEquals(Transaction.getDescriptor(), asTransaction.getRoot()); + assertEquals(Reason.OVER_BUDGET, + asTransaction.rejectionReason(asTransaction.parse(new ByteArrayInputStream(data)))); + + // Schema Transaction, delegate BytesMessage: one bytes field, nothing is materialized. + ServerMethodDefinition declaredTransaction = + GuardedRequestMarshaller.guard(method(proto(GrpcAPI.BytesMessage.getDefaultInstance()), + GrpcAPI.BytesMessage.getDefaultInstance()), Transaction.getDescriptor(), MAX, BUDGET); + GuardedRequestMarshaller asBytes = + (GuardedRequestMarshaller) + declaredTransaction.getMethodDescriptor().getRequestMarshaller(); + assertEquals(GrpcAPI.BytesMessage.getDescriptor(), asBytes.getRoot()); + GrpcAPI.BytesMessage parsed = asBytes.parse(new ByteArrayInputStream(data)); + assertNull(asBytes.rejectionReason(parsed)); + assertEquals(data.length - 2, parsed.getValue().size()); + } + + @Test + public void withoutAnInstanceTheSchemaTypeIsCheckedAndARejectionIsThrownAndCounted() { + Counting delegate = new Counting<>( + new MethodDescriptor.Marshaller() { + @Override + public InputStream stream(GrpcAPI.EmptyMessage value) { + return new ByteArrayInputStream(value.toByteArray()); + } + + @Override + public GrpcAPI.EmptyMessage parse(InputStream stream) { + try { + if (stream.available() == 0) { + throw new IllegalStateException("no empty requests"); + } + return GrpcAPI.EmptyMessage.parseFrom(stream); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + }); + ServerMethodDefinition method = method(delegate, + GrpcAPI.EmptyMessage.getDefaultInstance()); + String name = method.getMethodDescriptor().getFullMethodName(); + double before = rejections(name, Reason.OVER_BUDGET); + ServerMethodDefinition wrapped = GuardedRequestMarshaller.guard( + method, GrpcAPI.EmptyMessage.getDescriptor(), MAX, BUDGET); + // Failing to get an instance while wrapping is not a rejection. + assertEquals(before, rejections(name, Reason.OVER_BUDGET), 0); + MethodDescriptor.Marshaller request = + wrapped.getMethodDescriptor().getRequestMarshaller(); + assertTrue(request instanceof GuardedRequestMarshaller.Plain); + assertFalse(request instanceof MethodDescriptor.PrototypeMarshaller); + assertSame(method.getServerCallHandler(), wrapped.getServerCallHandler()); + assertEquals(GrpcAPI.EmptyMessage.getDescriptor(), + ((GuardedRequestMarshaller.Plain) request).getRoot()); + delegate.parsed.set(0); + try { + request.parse(new ByteArrayInputStream(distinctUnknownFields(BUDGET + 1))); + fail("expected a rejection"); + } catch (StatusRuntimeException e) { + assertEquals(Status.Code.INVALID_ARGUMENT, e.getStatus().getCode()); + assertEquals(TransactionAdmissionException.REQUEST_REJECTION_MESSAGE, + e.getStatus().getDescription()); + } + assertEquals(0, delegate.parsed.get()); + assertEquals(before + 1, rejections(name, Reason.OVER_BUDGET), 0); + assertNotNull(request.parse(new ByteArrayInputStream(distinctUnknownFields(BUDGET)))); + assertEquals(1, delegate.parsed.get()); + assertEquals(before + 1, rejections(name, Reason.OVER_BUDGET), 0); + } + + @Test + public void aTransactionNestedInAnotherRequestIsCountedWithItsContract() { + // The contract payload is reached through Any.value, which the schema sees as bytes. + CreateSmartContract.Builder contract = CreateSmartContract.newBuilder(); + SmartContract.ABI.Builder abi = SmartContract.ABI.newBuilder(); + for (int i = 0; i < BUDGET; i++) { + abi.addEntrys(SmartContract.ABI.Entry.getDefaultInstance()); + } + contract.setNewContract(SmartContract.newBuilder().setAbi(abi)); + Transaction tx = Transaction.newBuilder().setRawData(Transaction.raw.newBuilder() + .addContract(Transaction.Contract.newBuilder() + .setType(Transaction.Contract.ContractType.CreateSmartContract) + .setParameter(Any.pack(contract.build())))).build(); + ZksnarkRequest request = ZksnarkRequest.newBuilder().setTransaction(tx).build(); + GuardedRequestMarshaller marshaller = + guarded(proto(ZksnarkRequest.getDefaultInstance()), ZksnarkRequest.getDefaultInstance()); + assertEquals(Reason.OVER_BUDGET, marshaller.rejectionReason( + marshaller.parse(new ByteArrayInputStream(request.toByteArray())))); + // Without the contract payload the same request is far below the budget. + assertTrue(TransactionAdmissionGuard.count(request.toBuilder().setTransaction( + tx.toBuilder().setRawData(tx.getRawData().toBuilder().clearContract())).build() + .toByteArray(), ZksnarkRequest.getDescriptor()) < BUDGET); + } + + @Test + public void prototypeOfAcceptsOnlyAProtobufInstance() { + assertSame(Transaction.getDefaultInstance(), + GuardedRequestMarshaller.prototypeOf(proto(Transaction.getDefaultInstance()))); + assertEquals(GrpcAPI.EmptyMessage.getDefaultInstance(), + GuardedRequestMarshaller.prototypeOf(plain(GrpcAPI.EmptyMessage.getDefaultInstance()))); + assertNull(GuardedRequestMarshaller.prototypeOf(new MethodDescriptor.Marshaller() { + @Override + public InputStream stream(String value) { + throw new UnsupportedOperationException(); + } + + @Override + public String parse(InputStream stream) { + return "not a message"; + } + })); + assertNull(GuardedRequestMarshaller.prototypeOf(new MethodDescriptor.Marshaller() { + @Override + public InputStream stream(Message value) { + throw new UnsupportedOperationException(); + } + + @Override + public Message parse(InputStream stream) { + return null; + } + })); + } + + // ---------------------------------------------------------------- helpers + + @SuppressWarnings("unchecked") + private static GuardedRequestMarshaller guarded( + MethodDescriptor.Marshaller delegate, Message prototype) { + return new GuardedRequestMarshaller<>(delegate, prototype, MAX, BUDGET); + } + + @SuppressWarnings("unchecked") + private static MethodDescriptor.Marshaller proto(T prototype) { + return ProtoUtils.marshaller(prototype); + } + + private static MethodDescriptor.Marshaller plain(T prototype) { + return new MethodDescriptor.Marshaller() { + @Override + public InputStream stream(T value) { + return new ByteArrayInputStream(value.toByteArray()); + } + + @Override + @SuppressWarnings("unchecked") + public T parse(InputStream stream) { + try { + return (T) prototype.getParserForType().parseFrom(stream); + } catch (InvalidProtocolBufferException e) { + throw Status.INTERNAL.withCause(e).asRuntimeException(); + } + } + }; + } + + @SuppressWarnings("unchecked") + private static ServerMethodDefinition method( + MethodDescriptor.Marshaller request, Message prototype) { + MethodDescriptor descriptor = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName(MethodDescriptor.generateFullMethodName(WalletGrpc.SERVICE_NAME, + "Test" + prototype.getDescriptorForType().getName() + System.nanoTime())) + .setRequestMarshaller(request) + .setResponseMarshaller((MethodDescriptor.Marshaller) (Object) + ProtoUtils.marshaller(GrpcAPI.Return.getDefaultInstance())) + .build(); + return ServerMethodDefinition.create(descriptor, + (ServerCallHandler) (Object) HANDLER); + } + + /** {@code count} varint fields, each with its own field number unknown to every type. */ + static byte[] distinctUnknownFields(int count) { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + for (int i = 0; i < count; i++) { + long tag = (long) (1000 + i) << 3; + while ((tag & ~0x7FL) != 0) { + out.write((int) ((tag & 0x7F) | 0x80)); + tag >>>= 7; + } + out.write((int) tag); + out.write(0); + } + return out.toByteArray(); + } + + private static double rejections(String method, Reason reason) { + Double value = CollectorRegistry.defaultRegistry.getSampleValue( + MetricKeys.Counter.GRPC_ADMISSION_REJECT + "_total", new String[] {"method", "reason"}, + new String[] {method, reason.name()}); + return value == null ? 0 : value; + } + + private static final class Counting implements MethodDescriptor.Marshaller { + + private final MethodDescriptor.Marshaller real; + private final AtomicInteger parsed = new AtomicInteger(); + + Counting(MethodDescriptor.Marshaller real) { + this.real = real; + } + + @Override + public InputStream stream(T value) { + return real.stream(value); + } + + @Override + public T parse(InputStream stream) { + parsed.incrementAndGet(); + return real.parse(stream); + } + } +} diff --git a/framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java b/framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java index ea0f9dd8558..8a1e2d0f7ee 100644 --- a/framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java +++ b/framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java @@ -35,12 +35,13 @@ /** * Captures what the production RpcService subclasses actually register, for every branch of - * their addService, and checks that each Transaction request method is guarded and that no - * service is registered unwrapped. + * their addService, and checks that every method is guarded and that no service is registered + * unwrapped. */ public class RpcServiceRegistrationAdmissionTest { private static final int MAX_MESSAGE_SIZE = 256 * 1024; + // Methods per service in api.proto: Database 4, Wallet 147, WalletSolidity 47, Monitor 1. private boolean solidityNode; private boolean nodeMetricsEnable; @@ -64,12 +65,12 @@ public void restoreFlags() { } @Test - public void fullNodeRegistersTheFiveTransactionMethodsGuarded() throws Exception { + public void fullNodeRegistersEveryMethodGuarded() throws Exception { CommonParameter.getInstance().setSolidityNode(false); CommonParameter.getInstance().nodeMetricsEnable = true; List registered = register(new RpcApiService()); assertEquals(names(registered).toString(), 3, registered.size()); // database, wallet, monitor - assertEquals(5, guardedMethods(registered)); + assertEquals(4 + 147 + 1, guardedMethods(registered)); } @Test @@ -79,7 +80,7 @@ public void solidityNodeBranchRegistersEverythingThroughTheGuard() throws Except List registered = register(new RpcApiService()); // database, walletSolidity, monitor assertEquals(names(registered).toString(), 3, registered.size()); - assertEquals(0, guardedMethods(registered)); + assertEquals(4 + 47 + 1, guardedMethods(registered)); } @Test @@ -89,7 +90,7 @@ public void solidityAndPbftServicesRegisterEverythingThroughTheGuard() throws Ex inject(new RpcApiServiceOnPBFT(), new PbftCursorInterceptor()))) { List registered = register(service); assertEquals(names(registered).toString(), 2, registered.size()); // database, wallet - assertEquals(0, guardedMethods(registered)); + assertEquals(4 + 47, guardedMethods(registered)); } } @@ -128,7 +129,7 @@ private static List register(RpcService service) throws NettyServerBuilder.class); addService.setAccessible(true); addService.invoke(service, builder); - // Every registration must go through guardTransactionMethods, which yields a definition. + // Every registration must go through guardRequestMethods, which yields a definition. verify(builder, never()).addService(any(BindableService.class)); ArgumentCaptor captor = ArgumentCaptor.forClass(ServerServiceDefinition.class); @@ -141,7 +142,7 @@ private static List register(RpcService service) throws private static int guardedMethods(List definitions) { int guarded = 0; for (ServerServiceDefinition definition : definitions) { - guarded += RpcServiceAdmissionTest.assertAllTransactionMethodsGuarded(definition, + guarded += RpcServiceAdmissionTest.assertAllMethodsGuarded(definition, TransactionAdmissionGuard.MAX_TX_OCCURRENCES, MAX_MESSAGE_SIZE); } return guarded; From e36e8c9195e44c4b1d7123ece863818fdda2b8ae Mon Sep 17 00:00:00 2001 From: waynercheung Date: Mon, 19 Oct 2026 17:34:43 +0800 Subject: [PATCH 4/9] fix(api): reject oversized compressed requests without an error log Map the RESOURCE_EXHAUSTED that the grpc decompressing stream throws while the admission marshaller reads a request over the inbound size limit to the TOO_LARGE rejection, and close the call with RESOURCE_EXHAUSTED, the status grpc already gives an uncompressed message over the limit, instead of UNKNOWN. Other rejections keep INVALID_ARGUMENT. --- .../admission/AdmissionCheckedHandler.java | 11 +++++--- .../admission/GuardedRequestMarshaller.java | 6 +++++ .../application/RpcServiceAdmissionTest.java | 27 ++++++++++++++----- .../core/admission/AdmissionSupportTest.java | 23 ++++++++++++++++ 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java b/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java index c2ed8ad5b95..aeae7038c30 100644 --- a/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java +++ b/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java @@ -10,8 +10,9 @@ import org.tron.core.admission.TransactionAdmissionException.Reason; /** - * Closes the call with {@code INVALID_ARGUMENT} when {@link GuardedRequestMarshaller} hands - * over a rejection sentinel; the service method is never invoked. + * Closes the call with {@code INVALID_ARGUMENT}, or {@code RESOURCE_EXHAUSTED} for a request over + * the size limit, when {@link GuardedRequestMarshaller} hands over a rejection sentinel; the + * service method is never invoked. */ @Slf4j(topic = "rpc") public final class AdmissionCheckedHandler implements ServerCallHandler { @@ -54,8 +55,10 @@ public void onMessage(ReqT message) { call.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR), reason); } GuardedRequestMarshaller.countRejection(method, reason); - call.close(Status.INVALID_ARGUMENT.withDescription(marshaller.rejectionMessage()), - new Metadata()); + // The same status grpc gives an uncompressed message over the size limit. + Status status = reason == Reason.TOO_LARGE + ? Status.RESOURCE_EXHAUSTED : Status.INVALID_ARGUMENT; + call.close(status.withDescription(marshaller.rejectionMessage()), new Metadata()); return; } super.onMessage(message); diff --git a/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java b/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java index 534cb8371e8..a1a78dc5242 100644 --- a/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java +++ b/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java @@ -160,6 +160,12 @@ private static T parseChecked(InputStream stream, MethodDescriptor.Marshalle bytes = readBounded(stream, maxMessageSize); } catch (ZipException | EOFException e) { throw new TransactionAdmissionException(Reason.MALFORMED, null); + } catch (StatusRuntimeException e) { + // The decompressing stream of grpc enforces the inbound size limit while it is read. + if (e.getStatus().getCode() == Status.Code.RESOURCE_EXHAUSTED) { + throw new TransactionAdmissionException(Reason.TOO_LARGE, null); + } + throw e; } catch (IOException e) { throw Status.INTERNAL.withDescription("failed to read request").withCause(e) .asRuntimeException(); diff --git a/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java b/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java index 8bcaa846b6b..e93c1f3487a 100644 --- a/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java +++ b/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java @@ -546,14 +546,27 @@ public void messageSizeLimitStillApplies() throws Exception { .addSignature(ByteString.copyFrom(new byte[MAX_MESSAGE + 1])).build(); int before = HANDLER_CALLS.get(); int severeBefore = GRPC_SEVERE.size(); - // Uncompressed: rejected by the deframer before the marshaller. - assertCode(Status.Code.RESOURCE_EXHAUSTED, () -> stub().broadcastTransaction(big)); - // Compressed: the decompressed size check fires while the marshaller reads. This path is - // unchanged grpc behaviour (UNKNOWN plus a SEVERE log), which also shows that the SEVERE - // capture used above works. - assertCode(Status.Code.UNKNOWN, () -> stub().withCompression("gzip").broadcastTransaction(big)); + CommonParameter parameter = CommonParameter.getInstance(); + boolean metrics = parameter.metricsPrometheusEnable; + parameter.metricsPrometheusEnable = true; + try { + String name = WalletGrpc.getBroadcastTransactionMethod().getFullMethodName(); + double tooLarge = rejections(name, Reason.TOO_LARGE); + // Uncompressed: rejected by the deframer before the marshaller, not counted here. + assertCode(Status.Code.RESOURCE_EXHAUSTED, () -> stub().broadcastTransaction(big)); + assertEquals(tooLarge, rejections(name, Reason.TOO_LARGE), 0); + // Compressed: the decompressed size check of grpc fires while the marshaller reads; it is + // turned into the same status without an error log. + assertCode(Status.Code.RESOURCE_EXHAUSTED, + () -> stub().withCompression("gzip").broadcastTransaction(big)); + assertEquals(tooLarge + 1, rejections(name, Reason.TOO_LARGE), 0); + } finally { + parameter.metricsPrometheusEnable = metrics; + } assertEquals(before, HANDLER_CALLS.get()); - awaitTrue(() -> GRPC_SEVERE.size() > severeBefore); + awaitTrue(() -> STARTED.get() == FINISHED.get()); + Thread.sleep(500); + assertEquals(severeBefore, GRPC_SEVERE.size()); } @Test diff --git a/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java b/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java index d4d0b37a073..d320591868c 100644 --- a/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java +++ b/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java @@ -187,6 +187,29 @@ public void corruptOrTruncatedCompressedInputIsAMalformedRejection() throws Exce assertEquals(0, delegate.parsed.get()); } + @Test + public void theSizeLimitOfADecompressingStreamIsATooLargeRejection() { + GuardedRequestMarshaller marshaller = + transactionMarshaller(new CountingMarshaller(), 4096, 10); + StatusRuntimeException other = Status.INTERNAL.asRuntimeException(); + for (StatusRuntimeException failure : Arrays.asList( + Status.RESOURCE_EXHAUSTED.withDescription("decompressed message too large") + .asRuntimeException(), other)) { + InputStream stream = new InputStream() { + @Override + public int read() { + throw failure; + } + }; + try { + assertEquals(Reason.TOO_LARGE, reason(marshaller, stream)); + assertSame(Status.Code.RESOURCE_EXHAUSTED, failure.getStatus().getCode()); + } catch (StatusRuntimeException e) { + assertSame(other, e); + } + } + } + @Test public void otherReadFailuresAreThrownWithTheirCause() { CountingMarshaller delegate = new CountingMarshaller(); From 6aeaee09ece287f140b9ca582707bcdaf417d688 Mon Sep 17 00:00:00 2001 From: waynercheung Date: Wed, 21 Oct 2026 17:54:12 +0800 Subject: [PATCH 5/9] refactor(api): fail at wiring time without a request prototype Every guarded gRPC method must now have a request marshaller that gives a protobuf prototype. GuardedRequestMarshaller.guard takes the scan root from that prototype and throws IllegalStateException with the full method name otherwise, so such a method fails the registration instead of being kept unguarded or guarded through a separate path. Remove the schema-only Plain marshaller, the empty-bytes probe for non-prototype marshallers and the schema lookup in RpcService, and replace their tests with wiring failure tests. --- .../tron/common/application/RpcService.java | 26 +-- .../admission/GuardedRequestMarshaller.java | 85 ++----- .../application/RpcServiceAdmissionTest.java | 165 +------------- .../GuardedRequestMarshallerTest.java | 212 +++++------------- 4 files changed, 86 insertions(+), 402 deletions(-) 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 6edb1185718..cbb7fa64ea5 100644 --- a/framework/src/main/java/org/tron/common/application/RpcService.java +++ b/framework/src/main/java/org/tron/common/application/RpcService.java @@ -15,15 +15,11 @@ package org.tron.common.application; -import com.google.protobuf.Descriptors.Descriptor; -import com.google.protobuf.Message; -import io.grpc.MethodDescriptor; import io.grpc.Server; import io.grpc.ServerMethodDefinition; import io.grpc.ServerServiceDefinition; import io.grpc.ServiceDescriptor; import io.grpc.netty.NettyServerBuilder; -import io.grpc.protobuf.ProtoMethodDescriptorSupplier; import io.grpc.protobuf.services.ProtoReflectionService; import java.util.ArrayList; import java.util.List; @@ -129,7 +125,7 @@ protected NettyServerBuilder initServerBuilder() { protected abstract void addService(NettyServerBuilder serverBuilder); - /** Guards every method whose request type is known; other methods are kept as they are. */ + /** Guards every method; fails if a request marshaller gives no protobuf prototype. */ protected static ServerServiceDefinition guardRequestMethods( ServerServiceDefinition definition) { return guardRequestMethods(definition, Args.getInstance().getMaxMessageSize(), @@ -143,9 +139,7 @@ static ServerServiceDefinition guardRequestMethods(ServerServiceDefinition defin .setSchemaDescriptor(service.getSchemaDescriptor()); List> methods = new ArrayList<>(); for (ServerMethodDefinition method : definition.getMethods()) { - Descriptor type = requestType(method.getMethodDescriptor()); - methods.add(type == null ? method - : GuardedRequestMarshaller.guard(method, type, maxMessageSize, maxOccurrences)); + methods.add(GuardedRequestMarshaller.guard(method, maxMessageSize, maxOccurrences)); } // grpc requires the same MethodDescriptor instances in both descriptors. for (ServerMethodDefinition method : methods) { @@ -159,22 +153,6 @@ static ServerServiceDefinition guardRequestMethods(ServerServiceDefinition defin return builder.build(); } - // The schema decides when there is one; otherwise the request marshaller's prototype. - static Descriptor requestType(MethodDescriptor descriptor) { - Object schema = descriptor.getSchemaDescriptor(); - if (schema instanceof ProtoMethodDescriptorSupplier) { - return ((ProtoMethodDescriptorSupplier) schema).getMethodDescriptor().getInputType(); - } - MethodDescriptor.Marshaller request = descriptor.getRequestMarshaller(); - if (request instanceof MethodDescriptor.PrototypeMarshaller) { - Object prototype = ((MethodDescriptor.PrototypeMarshaller) request).getMessagePrototype(); - if (prototype instanceof Message) { - return ((Message) prototype).getDescriptorForType(); - } - } - return null; - } - protected void addInterceptor(NettyServerBuilder serverBuilder) { // add a ratelimiter interceptor serverBuilder.intercept(this.rateLimiterInterceptor); diff --git a/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java b/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java index a1a78dc5242..2070e54f5ba 100644 --- a/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java +++ b/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java @@ -59,39 +59,31 @@ public final class GuardedRequestMarshaller implements MethodDescriptor.Proto /** * Wraps {@code method}. The scan root is the type its request marshaller parses into, taken - * once here and never from a real request; {@code schemaType} is used only when that - * marshaller gives no protobuf instance, see {@link Plain}. + * once here from the marshaller's protobuf prototype and never from a real request. + * + * @throws IllegalStateException if the request marshaller gives no protobuf prototype */ public static ServerMethodDefinition guard( - ServerMethodDefinition method, Descriptor schemaType, int maxMessageSize, - int maxOccurrences) { + ServerMethodDefinition method, int maxMessageSize, int maxOccurrences) { MethodDescriptor descriptor = method.getMethodDescriptor(); - MethodDescriptor.Marshaller original = descriptor.getRequestMarshaller(); - Message prototype = prototypeOf(original); - if (prototype == null) { - Plain plain = new Plain<>(original, schemaType, descriptor.getFullMethodName(), - maxMessageSize, maxOccurrences); - return ServerMethodDefinition.create( - descriptor.toBuilder(plain, descriptor.getResponseMarshaller()).build(), - method.getServerCallHandler()); - } - GuardedRequestMarshaller guarded = - new GuardedRequestMarshaller<>(original, prototype, maxMessageSize, maxOccurrences); + GuardedRequestMarshaller guarded = new GuardedRequestMarshaller<>( + descriptor.getRequestMarshaller(), prototypeOf(descriptor), maxMessageSize, + maxOccurrences); return ServerMethodDefinition.create( descriptor.toBuilder(guarded, descriptor.getResponseMarshaller()).build(), new AdmissionCheckedHandler<>(method.getServerCallHandler(), guarded)); } - static Message prototypeOf(MethodDescriptor.Marshaller marshaller) { - Object prototype; - try { - prototype = marshaller instanceof MethodDescriptor.PrototypeMarshaller - ? ((MethodDescriptor.PrototypeMarshaller) marshaller).getMessagePrototype() - : marshaller.parse(new KnownLengthInputStream(new byte[0])); - } catch (RuntimeException e) { - return null; + static Message prototypeOf(MethodDescriptor descriptor) { + MethodDescriptor.Marshaller marshaller = descriptor.getRequestMarshaller(); + Object prototype = marshaller instanceof MethodDescriptor.PrototypeMarshaller + ? ((MethodDescriptor.PrototypeMarshaller) marshaller).getMessagePrototype() + : null; + if (!(prototype instanceof Message)) { + throw new IllegalStateException( + "no protobuf request prototype: " + descriptor.getFullMethodName()); } - return prototype instanceof Message ? (Message) prototype : null; + return (Message) prototype; } @Override @@ -184,51 +176,6 @@ private static T parseChecked(InputStream stream, MethodDescriptor.Marshalle } } - /** - * For a request type known only from the schema, when the original marshaller gives no - * protobuf instance: there is no sentinel, so a rejection is thrown before the original - * marshaller parses. grpc then closes the call with UNKNOWN and logs the exception at SEVERE, - * and no handler sees it, so the rejection is counted here. - */ - static final class Plain implements MethodDescriptor.Marshaller { - - private final MethodDescriptor.Marshaller delegate; - private final Descriptor root; - private final String fullMethodName; - private final int maxMessageSize; - private final int maxOccurrences; - - Plain(MethodDescriptor.Marshaller delegate, Descriptor root, String fullMethodName, - int maxMessageSize, int maxOccurrences) { - this.delegate = delegate; - this.root = root; - this.fullMethodName = fullMethodName; - this.maxMessageSize = maxMessageSize; - this.maxOccurrences = maxOccurrences; - } - - @Override - public InputStream stream(T value) { - return delegate.stream(value); - } - - @Override - public T parse(InputStream stream) { - try { - return parseChecked(stream, delegate, root, maxMessageSize, maxOccurrences); - } catch (TransactionAdmissionException e) { - countRejection(fullMethodName, e.getReason()); - // Thrown from parse, so grpc reports it to the client as UNKNOWN. - throw Status.INVALID_ARGUMENT.withDescription(rejectionMessage(root)) - .asRuntimeException(); - } - } - - Descriptor getRoot() { - return root; - } - } - /** Returns the whole stream, or {@code null} once more than {@code max} bytes are seen. */ static byte[] readBounded(InputStream stream, int max) throws IOException { if (max < 0) { diff --git a/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java b/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java index e93c1f3487a..b764beead1d 100644 --- a/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java +++ b/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java @@ -1,7 +1,6 @@ package org.tron.common.application; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; @@ -38,7 +37,6 @@ import io.grpc.protobuf.ProtoMethodDescriptorSupplier; import io.grpc.protobuf.ProtoUtils; import io.grpc.stub.ClientCalls; -import io.grpc.stub.ServerCalls; import io.grpc.stub.StreamObserver; import io.prometheus.client.CollectorRegistry; import java.io.ByteArrayInputStream; @@ -386,92 +384,6 @@ public void anyMethodRejectsOverBudgetRequestsAndCountsEachRejectionOnce() throw } } - @Test - public void withoutAPrototypeARejectionReachesTheClientAsUnknownAndIsCountedOnce() - throws Exception { - CommonParameter parameter = CommonParameter.getInstance(); - boolean metrics = parameter.metricsPrometheusEnable; - parameter.metricsPrometheusEnable = true; - try { - rejectionWithoutAPrototype(); - } finally { - parameter.metricsPrometheusEnable = metrics; - } - } - - private void rejectionWithoutAPrototype() throws Exception { - MethodDescriptor generated = WalletGrpc.getGetNowBlockMethod(); - AtomicInteger parsed = new AtomicInteger(); - AtomicInteger served = new AtomicInteger(); - // Not a PrototypeMarshaller, and it cannot parse empty bytes, so no instance is available - // when wrapping and only the schema type is known. - MethodDescriptor.Marshaller noPrototype = - new MethodDescriptor.Marshaller() { - @Override - public InputStream stream(GrpcAPI.EmptyMessage value) { - return generated.getRequestMarshaller().stream(value); - } - - @Override - public GrpcAPI.EmptyMessage parse(InputStream stream) { - parsed.incrementAndGet(); - try { - if (stream.available() == 0) { - throw new IllegalStateException("no instance"); - } - } catch (IOException e) { - throw new IllegalStateException(e); - } - return generated.getRequestMarshaller().parse(stream); - } - }; - MethodDescriptor method = - generated.toBuilder(noPrototype, generated.getResponseMarshaller()).build(); - ServerCallHandler handler = - ServerCalls.asyncUnaryCall((request, observer) -> { - served.incrementAndGet(); - observer.onNext(Block.getDefaultInstance()); - observer.onCompleted(); - }); - ServerServiceDefinition guarded = RpcService.guardRequestMethods( - ServerServiceDefinition.builder(ServiceDescriptor.newBuilder(WalletGrpc.SERVICE_NAME) - .addMethod(method).build()).addMethod(method, handler).build(), - MAX_MESSAGE, BUDGET); - String name = method.getFullMethodName(); - ServerMethodDefinition wrapped = guarded.getMethod(name); - assertFalse(wrapped.getMethodDescriptor().getRequestMarshaller() - instanceof MethodDescriptor.PrototypeMarshaller); - assertSame(handler, wrapped.getServerCallHandler()); - Server plainServer = NettyServerBuilder.forAddress(new InetSocketAddress("127.0.0.1", 0)) - .maxInboundMessageSize(MAX_MESSAGE).addService(guarded) - .intercept(new PermitLikeInterceptor()).build().start(); - ManagedChannel plainChannel = NettyChannelBuilder - .forAddress("127.0.0.1", plainServer.getPort()).usePlaintext().build(); - try { - parsed.set(0); // the empty-bytes attempt when wrapping - double before = rejections(name, Reason.OVER_BUDGET); - int severeBefore = GRPC_SEVERE.size(); - - Status status = rawCall(plainChannel, method, distinctUnknownFields(BUDGET + 1), null); - assertEquals(Status.Code.UNKNOWN, status.getCode()); - assertEquals(0, parsed.get()); - assertEquals(0, served.get()); - assertEquals(before + 1, rejections(name, Reason.OVER_BUDGET), 0); - awaitTrue(() -> STARTED.get() == FINISHED.get()); - awaitTrue(() -> GRPC_SEVERE.size() > severeBefore); - - assertEquals(Status.Code.OK, - rawCall(plainChannel, method, distinctUnknownFields(BUDGET), null).getCode()); - assertEquals(1, parsed.get()); - assertEquals(1, served.get()); - assertEquals(before + 1, rejections(name, Reason.OVER_BUDGET), 0); - awaitTrue(() -> STARTED.get() == FINISHED.get()); - } finally { - plainChannel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); - plainServer.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); - } - } - @Test public void laterMessagesOnARejectedUnaryCallAreIgnored() throws Exception { Transaction flood = Transaction.parseFrom(retFlood(BUDGET + 1)); @@ -600,32 +512,6 @@ public void malformedRequestsAreRejectedWithInvalidArgumentAndNoSevereLog() thro } } - @Test - public void transactionMethodWithANonPrototypeMarshallerIsStillGuarded() { - MethodDescriptor generated = - WalletGrpc.getBroadcastTransactionMethod(); - MethodDescriptor.Marshaller plain = - new MethodDescriptor.Marshaller() { - @Override - public InputStream stream(Transaction value) { - return generated.getRequestMarshaller().stream(value); - } - - @Override - public Transaction parse(InputStream stream) { - return generated.getRequestMarshaller().parse(stream); - } - }; - MethodDescriptor method = - generated.toBuilder(plain, generated.getResponseMarshaller()).build(); - ServerServiceDefinition definition = ServerServiceDefinition.builder( - ServiceDescriptor.newBuilder(WalletGrpc.SERVICE_NAME).addMethod(method).build()) - .addMethod(method, (call, headers) -> new ServerCall.Listener() { }) - .build(); - assertEquals(1, assertAllMethodsGuarded( - RpcService.guardRequestMethods(definition, MAX_MESSAGE, BUDGET), BUDGET, MAX_MESSAGE)); - } - /** * The guard counts a packed repeated scalar field once and only unpacks the Any of * Transaction.Contract, so no request type, including contract payloads, may have a packable @@ -677,54 +563,21 @@ private static void walk(Descriptor type, Set seen, Set packable } @Test - public void theSchemaDecidesWhetherToGuardWhenThereIsOne() { - MethodDescriptor broadcast = - WalletGrpc.getBroadcastTransactionMethod(); - MethodDescriptor byId = - WalletGrpc.getGetTransactionByIdMethod(); - MethodDescriptor.Marshaller transaction = - ProtoUtils.marshaller(Transaction.getDefaultInstance()); - MethodDescriptor.Marshaller bytes = - ProtoUtils.marshaller(GrpcAPI.BytesMessage.getDefaultInstance()); - - assertEquals(Transaction.getDescriptor(), RpcService.requestType(broadcast)); - assertEquals(GrpcAPI.BytesMessage.getDescriptor(), RpcService.requestType(byId)); - // The schema wins over a conflicting request prototype for this decision. - assertEquals(GrpcAPI.BytesMessage.getDescriptor(), RpcService.requestType( - byId.toBuilder(transaction, byId.getResponseMarshaller()).build())); - assertEquals(Transaction.getDescriptor(), RpcService.requestType( - broadcast.toBuilder(bytes, broadcast.getResponseMarshaller()).build())); - // Without a schema the request prototype decides. - assertEquals(Transaction.getDescriptor(), RpcService.requestType( - broadcast.toBuilder().setSchemaDescriptor(null).build())); - assertEquals(GrpcAPI.BytesMessage.getDescriptor(), RpcService.requestType( - byId.toBuilder().setSchemaDescriptor(null).build())); - } - - @Test - public void aMethodOfUnknownRequestTypeIsKeptAndFailsTheRegistrationCheck() { - MethodDescriptor unknown = MethodDescriptor. + public void aMethodWithoutAProtobufPrototypeFailsTheWiring() { + MethodDescriptor raw = MethodDescriptor. newBuilder().setType(MethodDescriptor.MethodType.UNARY) - .setFullMethodName(MethodDescriptor.generateFullMethodName("test.Unknown", "Call")) + .setFullMethodName(MethodDescriptor.generateFullMethodName("test.Raw", "Call")) .setRequestMarshaller(RAW) .setResponseMarshaller(ProtoUtils.marshaller(GrpcAPI.Return.getDefaultInstance())) .build(); - ServerCallHandler handler = - (call, headers) -> new ServerCall.Listener() { }; ServerServiceDefinition definition = ServerServiceDefinition.builder( - ServiceDescriptor.newBuilder("test.Unknown").addMethod(unknown).build()) - .addMethod(unknown, handler).build(); - assertNull(RpcService.requestType(unknown)); - ServerServiceDefinition guarded = RpcService.guardRequestMethods(definition, MAX_MESSAGE, - BUDGET); - ServerMethodDefinition kept = guarded.getMethod(unknown.getFullMethodName()); - assertSame(unknown, kept.getMethodDescriptor()); - assertSame(handler, kept.getServerCallHandler()); + ServiceDescriptor.newBuilder("test.Raw").addMethod(raw).build()) + .addMethod(raw, (call, headers) -> new ServerCall.Listener() { }).build(); try { - assertAllMethodsGuarded(guarded, BUDGET, MAX_MESSAGE); - fail("an unguarded method must fail the registration check"); - } catch (AssertionError expected) { - assertTrue(expected.getMessage(), expected.getMessage().contains("test.Unknown/Call")); + RpcService.guardRequestMethods(definition, MAX_MESSAGE, BUDGET); + fail("expected a wiring failure"); + } catch (IllegalStateException e) { + assertEquals("no protobuf request prototype: test.Raw/Call", e.getMessage()); } } diff --git a/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java b/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java index 70105224258..1d9cb4221c3 100644 --- a/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java +++ b/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java @@ -1,8 +1,6 @@ package org.tron.core.admission; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; @@ -17,23 +15,15 @@ import io.grpc.ServerCallHandler; import io.grpc.ServerMethodDefinition; import io.grpc.Status; -import io.grpc.StatusRuntimeException; import io.grpc.protobuf.ProtoUtils; -import io.prometheus.client.CollectorRegistry; import java.io.ByteArrayInputStream; -import java.io.IOException; import java.io.InputStream; import java.util.IdentityHashMap; import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.After; -import org.junit.Before; import org.junit.Test; import org.tron.api.GrpcAPI; import org.tron.api.WalletGrpc; import org.tron.api.ZksnarkGrpcAPI.ZksnarkRequest; -import org.tron.common.parameter.CommonParameter; -import org.tron.common.prometheus.MetricKeys; import org.tron.core.admission.TransactionAdmissionException.Reason; import org.tron.protos.Protocol.Transaction; import org.tron.protos.contract.SmartContractOuterClass.CreateSmartContract; @@ -46,19 +36,6 @@ public class GuardedRequestMarshallerTest { private static final ServerCallHandler HANDLER = (call, headers) -> new ServerCall.Listener() { }; - private boolean metricsEnabled; - - @Before - public void enableMetrics() { - metricsEnabled = CommonParameter.getInstance().metricsPrometheusEnable; - CommonParameter.getInstance().metricsPrometheusEnable = true; - } - - @After - public void restoreMetrics() { - CommonParameter.getInstance().metricsPrometheusEnable = metricsEnabled; - } - @Test public void rootAndSentinelsFollowTheRequestType() { for (Message prototype : new Message[] {GrpcAPI.EmptyMessage.getDefaultInstance(), @@ -99,28 +76,41 @@ public void unknownFieldsOfAnyRequestTypeCount() { } @Test - public void aNonPrototypeDelegateGivesItsTypeOnceWhenWrapped() { - Counting delegate = - new Counting<>(plain(GrpcAPI.EmptyMessage.getDefaultInstance())); - ServerMethodDefinition method = method(delegate, - GrpcAPI.EmptyMessage.getDefaultInstance()); - ServerMethodDefinition wrapped = GuardedRequestMarshaller.guard( - method, GrpcAPI.EmptyMessage.getDescriptor(), MAX, BUDGET); - assertEquals(1, delegate.parsed.get()); // the empty-bytes parse when wrapping + public void wrappingUsesTheMarshallerPrototype() { + ServerMethodDefinition method = + method(proto(GrpcAPI.EmptyMessage.getDefaultInstance()), + GrpcAPI.EmptyMessage.getDefaultInstance()); + ServerMethodDefinition wrapped = + GuardedRequestMarshaller.guard(method, MAX, BUDGET); MethodDescriptor.Marshaller request = wrapped.getMethodDescriptor().getRequestMarshaller(); assertTrue(request instanceof GuardedRequestMarshaller); assertTrue(wrapped.getServerCallHandler() instanceof AdmissionCheckedHandler); assertSame(request, ((AdmissionCheckedHandler) wrapped.getServerCallHandler()).getMarshaller()); - GuardedRequestMarshaller guarded = - (GuardedRequestMarshaller) request; - assertEquals(GrpcAPI.EmptyMessage.getDescriptor(), guarded.getRoot()); - assertEquals(Reason.OVER_BUDGET, guarded.rejectionReason( - guarded.parse(new ByteArrayInputStream(distinctUnknownFields(BUDGET + 1))))); - assertEquals(1, delegate.parsed.get()); - assertNull(guarded.rejectionReason(guarded.parse(new ByteArrayInputStream(new byte[0])))); - assertEquals(2, delegate.parsed.get()); + assertSame(method.getServerCallHandler(), + ((AdmissionCheckedHandler) wrapped.getServerCallHandler()).getDelegate()); + assertEquals(GrpcAPI.EmptyMessage.getDescriptor(), + ((GuardedRequestMarshaller) request).getRoot()); + } + + @Test + public void wrappingFailsWithoutAProtobufPrototype() { + assertWrappingFails(plain(GrpcAPI.EmptyMessage.getDefaultInstance())); + assertWrappingFails(prototypeMarshaller(null)); + assertWrappingFails(prototypeMarshaller("not a message")); + } + + private static void assertWrappingFails(MethodDescriptor.Marshaller request) { + ServerMethodDefinition method = + method(request, GrpcAPI.EmptyMessage.getDefaultInstance()); + String name = method.getMethodDescriptor().getFullMethodName(); + try { + GuardedRequestMarshaller.guard(method, MAX, BUDGET); + fail("expected a wiring failure for " + name); + } catch (IllegalStateException e) { + assertEquals("no protobuf request prototype: " + name, e.getMessage()); + } } @Test @@ -133,10 +123,10 @@ public void theScanRootIsTheTypeTheDelegateParsesInto() { } byte[] data = Transaction.newBuilder().setRawData(raw).build().toByteArray(); - // Schema BytesMessage, delegate Transaction: scanned as the Transaction it becomes. + // A Transaction delegate: scanned as the Transaction it becomes. ServerMethodDefinition declaredBytes = GuardedRequestMarshaller.guard( - method(proto(Transaction.getDefaultInstance()), Transaction.getDefaultInstance()), - GrpcAPI.BytesMessage.getDescriptor(), MAX, BUDGET); + method(proto(Transaction.getDefaultInstance()), GrpcAPI.BytesMessage.getDefaultInstance()), + MAX, BUDGET); GuardedRequestMarshaller asTransaction = (GuardedRequestMarshaller) declaredBytes.getMethodDescriptor().getRequestMarshaller(); @@ -144,10 +134,10 @@ public void theScanRootIsTheTypeTheDelegateParsesInto() { assertEquals(Reason.OVER_BUDGET, asTransaction.rejectionReason(asTransaction.parse(new ByteArrayInputStream(data)))); - // Schema Transaction, delegate BytesMessage: one bytes field, nothing is materialized. + // A BytesMessage delegate: one bytes field, nothing is materialized. ServerMethodDefinition declaredTransaction = GuardedRequestMarshaller.guard(method(proto(GrpcAPI.BytesMessage.getDefaultInstance()), - GrpcAPI.BytesMessage.getDefaultInstance()), Transaction.getDescriptor(), MAX, BUDGET); + Transaction.getDefaultInstance()), MAX, BUDGET); GuardedRequestMarshaller asBytes = (GuardedRequestMarshaller) declaredTransaction.getMethodDescriptor().getRequestMarshaller(); @@ -157,58 +147,6 @@ public void theScanRootIsTheTypeTheDelegateParsesInto() { assertEquals(data.length - 2, parsed.getValue().size()); } - @Test - public void withoutAnInstanceTheSchemaTypeIsCheckedAndARejectionIsThrownAndCounted() { - Counting delegate = new Counting<>( - new MethodDescriptor.Marshaller() { - @Override - public InputStream stream(GrpcAPI.EmptyMessage value) { - return new ByteArrayInputStream(value.toByteArray()); - } - - @Override - public GrpcAPI.EmptyMessage parse(InputStream stream) { - try { - if (stream.available() == 0) { - throw new IllegalStateException("no empty requests"); - } - return GrpcAPI.EmptyMessage.parseFrom(stream); - } catch (IOException e) { - throw new IllegalStateException(e); - } - } - }); - ServerMethodDefinition method = method(delegate, - GrpcAPI.EmptyMessage.getDefaultInstance()); - String name = method.getMethodDescriptor().getFullMethodName(); - double before = rejections(name, Reason.OVER_BUDGET); - ServerMethodDefinition wrapped = GuardedRequestMarshaller.guard( - method, GrpcAPI.EmptyMessage.getDescriptor(), MAX, BUDGET); - // Failing to get an instance while wrapping is not a rejection. - assertEquals(before, rejections(name, Reason.OVER_BUDGET), 0); - MethodDescriptor.Marshaller request = - wrapped.getMethodDescriptor().getRequestMarshaller(); - assertTrue(request instanceof GuardedRequestMarshaller.Plain); - assertFalse(request instanceof MethodDescriptor.PrototypeMarshaller); - assertSame(method.getServerCallHandler(), wrapped.getServerCallHandler()); - assertEquals(GrpcAPI.EmptyMessage.getDescriptor(), - ((GuardedRequestMarshaller.Plain) request).getRoot()); - delegate.parsed.set(0); - try { - request.parse(new ByteArrayInputStream(distinctUnknownFields(BUDGET + 1))); - fail("expected a rejection"); - } catch (StatusRuntimeException e) { - assertEquals(Status.Code.INVALID_ARGUMENT, e.getStatus().getCode()); - assertEquals(TransactionAdmissionException.REQUEST_REJECTION_MESSAGE, - e.getStatus().getDescription()); - } - assertEquals(0, delegate.parsed.get()); - assertEquals(before + 1, rejections(name, Reason.OVER_BUDGET), 0); - assertNotNull(request.parse(new ByteArrayInputStream(distinctUnknownFields(BUDGET)))); - assertEquals(1, delegate.parsed.get()); - assertEquals(before + 1, rejections(name, Reason.OVER_BUDGET), 0); - } - @Test public void aTransactionNestedInAnotherRequestIsCountedWithItsContract() { // The contract payload is reached through Any.value, which the schema sees as bytes. @@ -233,36 +171,6 @@ public void aTransactionNestedInAnotherRequestIsCountedWithItsContract() { .toByteArray(), ZksnarkRequest.getDescriptor()) < BUDGET); } - @Test - public void prototypeOfAcceptsOnlyAProtobufInstance() { - assertSame(Transaction.getDefaultInstance(), - GuardedRequestMarshaller.prototypeOf(proto(Transaction.getDefaultInstance()))); - assertEquals(GrpcAPI.EmptyMessage.getDefaultInstance(), - GuardedRequestMarshaller.prototypeOf(plain(GrpcAPI.EmptyMessage.getDefaultInstance()))); - assertNull(GuardedRequestMarshaller.prototypeOf(new MethodDescriptor.Marshaller() { - @Override - public InputStream stream(String value) { - throw new UnsupportedOperationException(); - } - - @Override - public String parse(InputStream stream) { - return "not a message"; - } - })); - assertNull(GuardedRequestMarshaller.prototypeOf(new MethodDescriptor.Marshaller() { - @Override - public InputStream stream(Message value) { - throw new UnsupportedOperationException(); - } - - @Override - public Message parse(InputStream stream) { - return null; - } - })); - } - // ---------------------------------------------------------------- helpers @SuppressWarnings("unchecked") @@ -295,6 +203,31 @@ public T parse(InputStream stream) { }; } + private static MethodDescriptor.Marshaller prototypeMarshaller(T prototype) { + return new MethodDescriptor.PrototypeMarshaller() { + @Override + public T getMessagePrototype() { + return prototype; + } + + @Override + @SuppressWarnings("unchecked") + public Class getMessageClass() { + return (Class) Object.class; + } + + @Override + public InputStream stream(T value) { + throw new UnsupportedOperationException(); + } + + @Override + public T parse(InputStream stream) { + throw new UnsupportedOperationException(); + } + }; + } + @SuppressWarnings("unchecked") private static ServerMethodDefinition method( MethodDescriptor.Marshaller request, Message prototype) { @@ -325,31 +258,4 @@ static byte[] distinctUnknownFields(int count) { return out.toByteArray(); } - private static double rejections(String method, Reason reason) { - Double value = CollectorRegistry.defaultRegistry.getSampleValue( - MetricKeys.Counter.GRPC_ADMISSION_REJECT + "_total", new String[] {"method", "reason"}, - new String[] {method, reason.name()}); - return value == null ? 0 : value; - } - - private static final class Counting implements MethodDescriptor.Marshaller { - - private final MethodDescriptor.Marshaller real; - private final AtomicInteger parsed = new AtomicInteger(); - - Counting(MethodDescriptor.Marshaller real) { - this.real = real; - } - - @Override - public InputStream stream(T value) { - return real.stream(value); - } - - @Override - public T parse(InputStream stream) { - parsed.incrementAndGet(); - return real.parse(stream); - } - } } From bd21475b76de2f355a50db26facf4b46d1bdaf8b Mon Sep 17 00:00:00 2001 From: waynercheung Date: Wed, 21 Oct 2026 17:58:11 +0800 Subject: [PATCH 6/9] refactor(api): register gRPC services through RpcService Add an abstract services() to RpcService. addServices guards every definition it returns and registers it, so the subclasses only list their services and no longer register them directly. Remove the one-argument guardRequestMethods. Keep the registration order, the per-port service sets and the cursor interceptor that wraps the SOLIDITY and PBFT services before the admission wrapper. The reflection service is still registered in initServerBuilder and is not wrapped. Make the registration and cursor wiring tests call addServices, and make the shared registration check also fail a method that is guarded twice. --- .../tron/common/application/RpcService.java | 17 +++++++++------- .../org/tron/core/services/RpcApiService.java | 20 +++++++++---------- .../interfaceOnPBFT/RpcApiServiceOnPBFT.java | 14 +++++++------ .../RpcApiServiceOnSolidity.java | 14 +++++++------ .../application/RpcServiceAdmissionTest.java | 10 +++++++--- .../RpcServiceHttp2SecurityTest.java | 5 ++++- ...cServiceInterceptorChainAdmissionTest.java | 6 +++++- ...pcApiServiceLegacyWalletExtensionTest.java | 2 +- .../RpcServiceRegistrationAdmissionTest.java | 8 ++++---- .../filter/CursorInterceptorWiringTest.java | 14 +++++++------ 10 files changed, 65 insertions(+), 45 deletions(-) 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 cbb7fa64ea5..00e47fe7443 100644 --- a/framework/src/main/java/org/tron/common/application/RpcService.java +++ b/framework/src/main/java/org/tron/common/application/RpcService.java @@ -91,7 +91,7 @@ public void innerStop() throws Exception { @Override public CompletableFuture start() { NettyServerBuilder serverBuilder = initServerBuilder(); - addService(serverBuilder); + addServices(serverBuilder); addInterceptor(serverBuilder); initServer(serverBuilder); this.rateLimiterInterceptor.init(this.apiServer); @@ -123,15 +123,18 @@ protected NettyServerBuilder initServerBuilder() { return serverBuilder; } - protected abstract void addService(NettyServerBuilder serverBuilder); + /** The services this port serves; {@link #addServices} guards and registers each of them. */ + protected abstract List services(); - /** Guards every method; fails if a request marshaller gives no protobuf prototype. */ - protected static ServerServiceDefinition guardRequestMethods( - ServerServiceDefinition definition) { - return guardRequestMethods(definition, Args.getInstance().getMaxMessageSize(), - TransactionAdmissionGuard.MAX_TX_OCCURRENCES); + private void addServices(NettyServerBuilder serverBuilder) { + int maxMessageSize = Args.getInstance().getMaxMessageSize(); + for (ServerServiceDefinition service : services()) { + serverBuilder.addService(guardRequestMethods(service, maxMessageSize, + TransactionAdmissionGuard.MAX_TX_OCCURRENCES)); + } } + /** Guards every method; fails if a request marshaller gives no protobuf prototype. */ static ServerServiceDefinition guardRequestMethods(ServerServiceDefinition definition, int maxMessageSize, int maxOccurrences) { ServiceDescriptor service = definition.getServiceDescriptor(); diff --git a/framework/src/main/java/org/tron/core/services/RpcApiService.java b/framework/src/main/java/org/tron/core/services/RpcApiService.java index ce5296a1426..b171ffb90a4 100755 --- a/framework/src/main/java/org/tron/core/services/RpcApiService.java +++ b/framework/src/main/java/org/tron/core/services/RpcApiService.java @@ -5,10 +5,12 @@ import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.Message; import com.google.protobuf.ProtocolStringList; +import io.grpc.ServerServiceDefinition; import io.grpc.Status; import io.grpc.StatusRuntimeException; -import io.grpc.netty.NettyServerBuilder; import io.grpc.stub.StreamObserver; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; import lombok.Getter; @@ -192,18 +194,16 @@ public RpcApiService() { } @Override - protected void addService(NettyServerBuilder serverBuilder) { - serverBuilder.addService(guardRequestMethods(databaseApi.bindService())); + protected List services() { CommonParameter parameter = Args.getInstance(); - if (parameter.isSolidityNode()) { - serverBuilder.addService(guardRequestMethods(walletSolidityApi.bindService())); - } else { - serverBuilder.addService(guardRequestMethods(walletApi.bindService())); - } - + List services = new ArrayList<>(); + services.add(databaseApi.bindService()); + services.add(parameter.isSolidityNode() + ? walletSolidityApi.bindService() : walletApi.bindService()); if (parameter.isNodeMetricsEnable()) { - serverBuilder.addService(guardRequestMethods(monitorApi.bindService())); + services.add(monitorApi.bindService()); } + return services; } diff --git a/framework/src/main/java/org/tron/core/services/interfaceOnPBFT/RpcApiServiceOnPBFT.java b/framework/src/main/java/org/tron/core/services/interfaceOnPBFT/RpcApiServiceOnPBFT.java index da9bd6ef31c..37eeefbd835 100755 --- a/framework/src/main/java/org/tron/core/services/interfaceOnPBFT/RpcApiServiceOnPBFT.java +++ b/framework/src/main/java/org/tron/core/services/interfaceOnPBFT/RpcApiServiceOnPBFT.java @@ -1,7 +1,9 @@ package org.tron.core.services.interfaceOnPBFT; import io.grpc.ServerInterceptors; -import io.grpc.netty.NettyServerBuilder; +import io.grpc.ServerServiceDefinition; +import java.util.Arrays; +import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.tron.common.application.RpcService; @@ -26,11 +28,11 @@ public RpcApiServiceOnPBFT() { /** PBFT cursor bound at the service level, so it brackets only these two read services. */ @Override - protected void addService(NettyServerBuilder serverBuilder) { - serverBuilder.addService(guardRequestMethods(ServerInterceptors.intercept( - rpcApiService.getDatabaseApi(), pbftCursorInterceptor))); - serverBuilder.addService(guardRequestMethods(ServerInterceptors.intercept( - rpcApiService.getWalletSolidityApi(), pbftCursorInterceptor))); + protected List services() { + return Arrays.asList( + ServerInterceptors.intercept(rpcApiService.getDatabaseApi(), pbftCursorInterceptor), + ServerInterceptors.intercept( + rpcApiService.getWalletSolidityApi(), pbftCursorInterceptor)); } } diff --git a/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/RpcApiServiceOnSolidity.java b/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/RpcApiServiceOnSolidity.java index 254b98855d5..4a6afeccf75 100755 --- a/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/RpcApiServiceOnSolidity.java +++ b/framework/src/main/java/org/tron/core/services/interfaceOnSolidity/RpcApiServiceOnSolidity.java @@ -1,7 +1,9 @@ package org.tron.core.services.interfaceOnSolidity; import io.grpc.ServerInterceptors; -import io.grpc.netty.NettyServerBuilder; +import io.grpc.ServerServiceDefinition; +import java.util.Arrays; +import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.tron.common.application.RpcService; @@ -26,11 +28,11 @@ public RpcApiServiceOnSolidity() { /** SOLIDITY cursor bound at the service level, so it brackets only these two read services. */ @Override - protected void addService(NettyServerBuilder serverBuilder) { - serverBuilder.addService(guardRequestMethods(ServerInterceptors.intercept( - rpcApiService.getDatabaseApi(), solidityCursorInterceptor))); - serverBuilder.addService(guardRequestMethods(ServerInterceptors.intercept( - rpcApiService.getWalletSolidityApi(), solidityCursorInterceptor))); + protected List services() { + return Arrays.asList( + ServerInterceptors.intercept(rpcApiService.getDatabaseApi(), solidityCursorInterceptor), + ServerInterceptors.intercept( + rpcApiService.getWalletSolidityApi(), solidityCursorInterceptor)); } } diff --git a/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java b/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java index b764beead1d..07633b512b4 100644 --- a/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java +++ b/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java @@ -1,6 +1,7 @@ package org.tron.common.application; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; @@ -229,7 +230,7 @@ public void everyMethodOfTheServiceTypesIsGuarded() { * Shared with the production registration tests. Every method must have a proto schema, be * wrapped with a {@link GuardedRequestMarshaller} whose scan root is the schema's request type * (so schema and request marshaller agree), be handled by the matching - * {@link AdmissionCheckedHandler}, and apply exactly {@code budget} and + * {@link AdmissionCheckedHandler} once, and apply exactly {@code budget} and * {@code maxMessageSize}. Returns the number of methods checked. */ @SuppressWarnings("unchecked") @@ -252,8 +253,11 @@ public static int assertAllMethodsGuarded(ServerServiceDefinition definition, in assertEquals(name, input, ((Message) marshaller.getMessagePrototype()).getDescriptorForType()); assertTrue(name, method.getServerCallHandler() instanceof AdmissionCheckedHandler); - assertSame(name, marshaller, - ((AdmissionCheckedHandler) method.getServerCallHandler()).getMarshaller()); + AdmissionCheckedHandler handler = + (AdmissionCheckedHandler) method.getServerCallHandler(); + assertSame(name, marshaller, handler.getMarshaller()); + assertFalse(name + " is guarded twice", + handler.getDelegate() instanceof AdmissionCheckedHandler); assertLimits(name, marshaller, budget, maxMessageSize); guarded++; } diff --git a/framework/src/test/java/org/tron/common/application/RpcServiceHttp2SecurityTest.java b/framework/src/test/java/org/tron/common/application/RpcServiceHttp2SecurityTest.java index 8ca1fb3be5b..db7599ec41b 100644 --- a/framework/src/test/java/org/tron/common/application/RpcServiceHttp2SecurityTest.java +++ b/framework/src/test/java/org/tron/common/application/RpcServiceHttp2SecurityTest.java @@ -46,6 +46,8 @@ import java.net.Socket; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; import java.util.concurrent.TimeUnit; import org.junit.After; import org.junit.Before; @@ -400,7 +402,8 @@ private NettyServerBuilder newServerBuilder() { } @Override - protected void addService(NettyServerBuilder serverBuilder) { + protected List services() { + return Collections.emptyList(); } } } diff --git a/framework/src/test/java/org/tron/common/application/RpcServiceInterceptorChainAdmissionTest.java b/framework/src/test/java/org/tron/common/application/RpcServiceInterceptorChainAdmissionTest.java index c3b3a729ca5..34ab9e8d1d2 100644 --- a/framework/src/test/java/org/tron/common/application/RpcServiceInterceptorChainAdmissionTest.java +++ b/framework/src/test/java/org/tron/common/application/RpcServiceInterceptorChainAdmissionTest.java @@ -17,6 +17,7 @@ import io.grpc.MethodDescriptor; import io.grpc.Server; import io.grpc.ServerInterceptors; +import io.grpc.ServerServiceDefinition; import io.grpc.Status; import io.grpc.StatusRuntimeException; import io.grpc.netty.NettyChannelBuilder; @@ -28,6 +29,8 @@ import java.io.IOException; import java.io.InputStream; import java.net.InetSocketAddress; +import java.util.Collections; +import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import org.junit.AfterClass; @@ -97,7 +100,8 @@ public static void startServer() throws Exception { RpcService service = new RpcService() { @Override - protected void addService(NettyServerBuilder serverBuilder) { + protected List services() { + return Collections.emptyList(); } }; ReflectionTestUtils.setField(service, "rateLimiterInterceptor", rateLimiter); diff --git a/framework/src/test/java/org/tron/core/services/RpcApiServiceLegacyWalletExtensionTest.java b/framework/src/test/java/org/tron/core/services/RpcApiServiceLegacyWalletExtensionTest.java index 0ac933ff3b6..c8dba4098ec 100644 --- a/framework/src/test/java/org/tron/core/services/RpcApiServiceLegacyWalletExtensionTest.java +++ b/framework/src/test/java/org/tron/core/services/RpcApiServiceLegacyWalletExtensionTest.java @@ -45,7 +45,7 @@ public void setUp() throws IOException { Args.getInstance().setSolidityNode(true); NettyServerBuilder builder = NettyServerBuilder.forPort(0); - new RpcApiService().addService(builder); + new RpcApiService().services().forEach(builder::addService); server = builder.build().start(); channel = ManagedChannelBuilder.forAddress("127.0.0.1", server.getPort()) .usePlaintext() diff --git a/framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java b/framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java index 8a1e2d0f7ee..72cb1e150ac 100644 --- a/framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java +++ b/framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java @@ -35,7 +35,7 @@ /** * Captures what the production RpcService subclasses actually register, for every branch of - * their addService, and checks that every method is guarded and that no service is registered + * their services, and checks that every method is guarded and that no service is registered * unwrapped. */ public class RpcServiceRegistrationAdmissionTest { @@ -125,10 +125,10 @@ private static RpcService inject(RpcService service, Object interceptor) throws private static List register(RpcService service) throws Exception { NettyServerBuilder builder = mock(NettyServerBuilder.class); - Method addService = service.getClass().getDeclaredMethod("addService", + Method addServices = RpcService.class.getDeclaredMethod("addServices", NettyServerBuilder.class); - addService.setAccessible(true); - addService.invoke(service, builder); + addServices.setAccessible(true); + addServices.invoke(service, builder); // Every registration must go through guardRequestMethods, which yields a definition. verify(builder, never()).addService(any(BindableService.class)); ArgumentCaptor captor = diff --git a/framework/src/test/java/org/tron/core/services/filter/CursorInterceptorWiringTest.java b/framework/src/test/java/org/tron/core/services/filter/CursorInterceptorWiringTest.java index f7dbb5ea5e6..1904dd476c9 100644 --- a/framework/src/test/java/org/tron/core/services/filter/CursorInterceptorWiringTest.java +++ b/framework/src/test/java/org/tron/core/services/filter/CursorInterceptorWiringTest.java @@ -28,6 +28,7 @@ import org.mockito.stubbing.Answer; import org.tron.api.DatabaseGrpc; import org.tron.api.WalletSolidityGrpc; +import org.tron.common.application.RpcService; import org.tron.core.ChainBaseManager; import org.tron.core.Wallet; import org.tron.core.db.Manager; @@ -42,7 +43,7 @@ * served and every response would still look well-formed, only resolved against HEAD instead of the * solidified or PBFT snapshot. * - *

The production {@code addService} runs against a mock builder, and one unary call is driven + *

The production {@code addServices} runs against a mock builder, and one unary call is driven * through each definition it registers the way gRPC does: {@code startCall}, the request, then * {@code onHalfClose}. The handler's first read is recorded alongside the cursor calls, so the * port's cursor has to be selected before that read and reset after the handler returns. A @@ -79,7 +80,7 @@ public void testPbftServiceReadsBothServicesUnderThePbftCursor() throws Exceptio } /** - * Registers the services through the production addService, drives one call through each + * Registers the services through the production addServices, drives one call through each * registered definition, and asserts every handler read ran between the port's setCursor and * resetCursor. */ @@ -113,7 +114,7 @@ private static void assertEveryServiceReadsUnder(Class serviceClass, } /** - * Runs the service's real addService against a mock builder and returns the definitions it + * Runs the service's real addServices against a mock builder and returns the definitions it * registered. A service added through the {@code BindableService} overload is not among them, so * the count check below fails for it. */ @@ -136,9 +137,10 @@ private static List register(Class serviceClass, inject(serviceClass, service, interceptor); NettyServerBuilder builder = mock(NettyServerBuilder.class); - Method addService = serviceClass.getDeclaredMethod("addService", NettyServerBuilder.class); - addService.setAccessible(true); - addService.invoke(service, builder); + Method addServices = RpcService.class.getDeclaredMethod("addServices", + NettyServerBuilder.class); + addServices.setAccessible(true); + addServices.invoke(service, builder); ArgumentCaptor registered = ArgumentCaptor.forClass(ServerServiceDefinition.class); From 0d81b2332fb231f3468d50843c59daf733175ebc Mon Sep 17 00:00:00 2001 From: waynercheung Date: Fri, 23 Oct 2026 18:40:28 +0800 Subject: [PATCH 7/9] refactor(api): tighten gRPC service wiring and admission helpers Make initServerBuilder, addInterceptor and initServer of RpcService protected final, so subclasses declare their services through services() instead of adding them in these steps. GuardedRequestMarshaller.guard now throws IllegalStateException when the request marshaller is already guarded. ServerInterceptors.intercept preserves the request marshaller, so this also detects a guarded definition intercepted before another guard. Turn parseChecked into an instance method, fold rejectionMessage, make prototypeOf private and move the rejection counter into AdmissionCheckedHandler. Remove the test of a plain delegate, which guard no longer accepts, and rename leftover test variables. --- .../tron/common/application/RpcService.java | 6 +- .../admission/AdmissionCheckedHandler.java | 4 +- .../admission/GuardedRequestMarshaller.java | 25 +++----- .../core/admission/AdmissionSupportTest.java | 25 -------- .../GuardedRequestMarshallerTest.java | 64 +++++++++++++++++-- 5 files changed, 75 insertions(+), 49 deletions(-) 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 00e47fe7443..287ec41e88d 100644 --- a/framework/src/main/java/org/tron/common/application/RpcService.java +++ b/framework/src/main/java/org/tron/common/application/RpcService.java @@ -98,7 +98,7 @@ public CompletableFuture start() { return super.start(); } - protected NettyServerBuilder initServerBuilder() { + protected final NettyServerBuilder initServerBuilder() { CommonParameter parameter = Args.getInstance(); NettyServerBuilder serverBuilder = NettyServerBuilder.forPort(this.port); if (parameter.getRpcThreadNum() > 0) { @@ -156,7 +156,7 @@ static ServerServiceDefinition guardRequestMethods(ServerServiceDefinition defin return builder.build(); } - protected void addInterceptor(NettyServerBuilder serverBuilder) { + protected final void addInterceptor(NettyServerBuilder serverBuilder) { // add a ratelimiter interceptor serverBuilder.intercept(this.rateLimiterInterceptor); @@ -172,7 +172,7 @@ protected void addInterceptor(NettyServerBuilder serverBuilder) { } } - protected void initServer(NettyServerBuilder serverBuilder) { + protected final void initServer(NettyServerBuilder serverBuilder) { this.apiServer = serverBuilder.build(); } diff --git a/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java b/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java index aeae7038c30..3056e594815 100644 --- a/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java +++ b/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java @@ -7,6 +7,8 @@ import io.grpc.ServerCallHandler; import io.grpc.Status; import lombok.extern.slf4j.Slf4j; +import org.tron.common.prometheus.MetricKeys; +import org.tron.common.prometheus.Metrics; import org.tron.core.admission.TransactionAdmissionException.Reason; /** @@ -54,7 +56,7 @@ public void onMessage(ReqT message) { logger.debug("gRPC {} from {} rejected by admission check: {}", method, call.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR), reason); } - GuardedRequestMarshaller.countRejection(method, reason); + Metrics.counterInc(MetricKeys.Counter.GRPC_ADMISSION_REJECT, 1, method, reason.name()); // The same status grpc gives an uncompressed message over the size limit. Status status = reason == Reason.TOO_LARGE ? Status.RESOURCE_EXHAUSTED : Status.INVALID_ARGUMENT; diff --git a/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java b/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java index 2070e54f5ba..dc07e49283e 100644 --- a/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java +++ b/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java @@ -17,8 +17,6 @@ import java.util.Map; import java.util.zip.ZipException; import org.tron.common.math.StrictMathWrapper; -import org.tron.common.prometheus.MetricKeys; -import org.tron.common.prometheus.Metrics; import org.tron.core.admission.TransactionAdmissionException.Reason; import org.tron.protos.Protocol.Transaction; @@ -61,11 +59,16 @@ public final class GuardedRequestMarshaller implements MethodDescriptor.Proto * Wraps {@code method}. The scan root is the type its request marshaller parses into, taken * once here from the marshaller's protobuf prototype and never from a real request. * - * @throws IllegalStateException if the request marshaller gives no protobuf prototype + * @throws IllegalStateException if the request marshaller gives no protobuf prototype or is + * already guarded */ public static ServerMethodDefinition guard( ServerMethodDefinition method, int maxMessageSize, int maxOccurrences) { MethodDescriptor descriptor = method.getMethodDescriptor(); + if (descriptor.getRequestMarshaller() instanceof GuardedRequestMarshaller) { + throw new IllegalStateException( + "request already guarded: " + descriptor.getFullMethodName()); + } GuardedRequestMarshaller guarded = new GuardedRequestMarshaller<>( descriptor.getRequestMarshaller(), prototypeOf(descriptor), maxMessageSize, maxOccurrences); @@ -74,7 +77,7 @@ public static ServerMethodDefinition guard( new AdmissionCheckedHandler<>(method.getServerCallHandler(), guarded)); } - static Message prototypeOf(MethodDescriptor descriptor) { + private static Message prototypeOf(MethodDescriptor descriptor) { MethodDescriptor.Marshaller marshaller = descriptor.getRequestMarshaller(); Object prototype = marshaller instanceof MethodDescriptor.PrototypeMarshaller ? ((MethodDescriptor.PrototypeMarshaller) marshaller).getMessagePrototype() @@ -94,7 +97,7 @@ public InputStream stream(T value) { @Override public T parse(InputStream stream) { try { - return parseChecked(stream, delegate, root, maxMessageSize, maxOccurrences); + return parseChecked(stream); } catch (TransactionAdmissionException e) { return rejected.get(e.getReason()); } @@ -119,10 +122,6 @@ public Descriptor getRoot() { } String rejectionMessage() { - return rejectionMessage(root); - } - - static String rejectionMessage(Descriptor root) { return Transaction.getDescriptor().equals(root) ? TransactionAdmissionException.REJECTION_MESSAGE : TransactionAdmissionException.REQUEST_REJECTION_MESSAGE; @@ -139,14 +138,8 @@ public Class getMessageClass() { return (Class) prototype.getClass(); } - static void countRejection(String fullMethodName, Reason reason) { - Metrics.counterInc(MetricKeys.Counter.GRPC_ADMISSION_REJECT, 1, fullMethodName, - reason.name()); - } - /** Throws {@link TransactionAdmissionException} for a rejection. */ - private static T parseChecked(InputStream stream, MethodDescriptor.Marshaller delegate, - Descriptor root, int maxMessageSize, int maxOccurrences) { + private T parseChecked(InputStream stream) { byte[] bytes; try { bytes = readBounded(stream, maxMessageSize); diff --git a/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java b/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java index d320591868c..2dd7984ef1f 100644 --- a/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java +++ b/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java @@ -277,31 +277,6 @@ public Transaction parse(InputStream stream) { } } - @Test - public void plainDelegateMarshallerIsSupported() { - MethodDescriptor.Marshaller plain = - new MethodDescriptor.Marshaller() { - @Override - public InputStream stream(Transaction value) { - return new ByteArrayInputStream(value.toByteArray()); - } - - @Override - public Transaction parse(InputStream stream) { - try { - return Transaction.parseFrom(stream); - } catch (IOException e) { - throw new IllegalStateException(e); - } - } - }; - GuardedRequestMarshaller marshaller = transactionMarshaller(plain, 4096, 10); - Transaction tx = Transaction.newBuilder().addSignature( - com.google.protobuf.ByteString.copyFrom(new byte[65])).build(); - assertEquals(tx, marshaller.parse(new ByteArrayInputStream(tx.toByteArray()))); - assertSame(Transaction.getDefaultInstance(), marshaller.getMessagePrototype()); - } - @Test public void delegateReadsAKnownLengthStreamOfTheExactSize() throws Exception { List streams = new ArrayList<>(); diff --git a/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java b/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java index 1d9cb4221c3..a32c70611c7 100644 --- a/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java +++ b/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java @@ -10,10 +10,14 @@ import com.google.protobuf.Any; import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.Message; +import io.grpc.Metadata; import io.grpc.MethodDescriptor; import io.grpc.ServerCall; import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; import io.grpc.ServerMethodDefinition; +import io.grpc.ServerServiceDefinition; import io.grpc.Status; import io.grpc.protobuf.ProtoUtils; import java.io.ByteArrayInputStream; @@ -101,6 +105,58 @@ public void wrappingFailsWithoutAProtobufPrototype() { assertWrappingFails(prototypeMarshaller("not a message")); } + @Test + public void wrappingFailsOnAnAlreadyGuardedRequest() { + ServerMethodDefinition method = + method(proto(GrpcAPI.EmptyMessage.getDefaultInstance()), + GrpcAPI.EmptyMessage.getDefaultInstance()); + String name = method.getMethodDescriptor().getFullMethodName(); + ServerMethodDefinition guarded = + GuardedRequestMarshaller.guard(method, MAX, BUDGET); + + assertAlreadyGuarded(guarded, name); + // An interceptor keeps the request marshaller, so a guard inside it is still found. + assertAlreadyGuarded(intercepted(guarded), name); + + ServerMethodDefinition interceptedFirst = intercepted(method); + ServerMethodDefinition wrapped = + GuardedRequestMarshaller.guard(interceptedFirst, MAX, BUDGET); + assertSame(interceptedFirst.getServerCallHandler(), + ((AdmissionCheckedHandler) wrapped.getServerCallHandler()).getDelegate()); + } + + private static void assertAlreadyGuarded(ServerMethodDefinition method, + String name) { + try { + GuardedRequestMarshaller.guard(method, MAX, BUDGET); + fail("expected a wiring failure for " + name); + } catch (IllegalStateException e) { + assertEquals("request already guarded: " + name, e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + private static ServerMethodDefinition intercepted( + ServerMethodDefinition method) { + String name = method.getMethodDescriptor().getFullMethodName(); + ServerServiceDefinition service = ServerInterceptors.intercept( + ServerServiceDefinition.builder(MethodDescriptor.extractFullServiceName(name)) + .addMethod(method).build(), + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + return next.startCall(call, headers); + } + }); + ServerMethodDefinition result = + (ServerMethodDefinition) service.getMethod(name); + assertSame(method.getMethodDescriptor().getRequestMarshaller(), + result.getMethodDescriptor().getRequestMarshaller()); + assertNotSame(method.getServerCallHandler(), result.getServerCallHandler()); + return result; + } + private static void assertWrappingFails(MethodDescriptor.Marshaller request) { ServerMethodDefinition method = method(request, GrpcAPI.EmptyMessage.getDefaultInstance()); @@ -124,23 +180,23 @@ public void theScanRootIsTheTypeTheDelegateParsesInto() { byte[] data = Transaction.newBuilder().setRawData(raw).build().toByteArray(); // A Transaction delegate: scanned as the Transaction it becomes. - ServerMethodDefinition declaredBytes = GuardedRequestMarshaller.guard( + ServerMethodDefinition transactionMethod = GuardedRequestMarshaller.guard( method(proto(Transaction.getDefaultInstance()), GrpcAPI.BytesMessage.getDefaultInstance()), MAX, BUDGET); GuardedRequestMarshaller asTransaction = (GuardedRequestMarshaller) - declaredBytes.getMethodDescriptor().getRequestMarshaller(); + transactionMethod.getMethodDescriptor().getRequestMarshaller(); assertEquals(Transaction.getDescriptor(), asTransaction.getRoot()); assertEquals(Reason.OVER_BUDGET, asTransaction.rejectionReason(asTransaction.parse(new ByteArrayInputStream(data)))); // A BytesMessage delegate: one bytes field, nothing is materialized. - ServerMethodDefinition declaredTransaction = + ServerMethodDefinition bytesMethod = GuardedRequestMarshaller.guard(method(proto(GrpcAPI.BytesMessage.getDefaultInstance()), Transaction.getDefaultInstance()), MAX, BUDGET); GuardedRequestMarshaller asBytes = (GuardedRequestMarshaller) - declaredTransaction.getMethodDescriptor().getRequestMarshaller(); + bytesMethod.getMethodDescriptor().getRequestMarshaller(); assertEquals(GrpcAPI.BytesMessage.getDescriptor(), asBytes.getRoot()); GrpcAPI.BytesMessage parsed = asBytes.parse(new ByteArrayInputStream(data)); assertNull(asBytes.rejectionReason(parsed)); From 90f150fedb78ff8c96891a97c38fe8ad1e354ba1 Mon Sep 17 00:00:00 2001 From: waynercheung Date: Fri, 23 Oct 2026 19:26:17 +0800 Subject: [PATCH 8/9] refactor(api): rename the admission guard to ProtobufAdmissionGuard The guard scans the requests of every guarded gRPC method and of /wallet/broadcasthex, not only transactions. Rename TransactionAdmissionGuard to ProtobufAdmissionGuard, TransactionAdmissionException to ProtobufAdmissionException and MAX_TX_OCCURRENCES to MAX_OCCURRENCES, with the test class. checkTransaction and countTransaction keep their names. Behavior, messages, status codes and metric labels are unchanged. --- .../tron/common/application/RpcService.java | 4 +- .../admission/AdmissionCheckedHandler.java | 2 +- .../admission/GuardedRequestMarshaller.java | 22 ++++---- ...n.java => ProtobufAdmissionException.java} | 4 +- ...Guard.java => ProtobufAdmissionGuard.java} | 30 +++++------ .../http/servlets/BroadcastHexServlet.java | 12 ++--- .../application/RpcServiceAdmissionTest.java | 8 +-- ...cServiceInterceptorChainAdmissionTest.java | 4 +- .../core/admission/AdmissionSupportTest.java | 2 +- .../GuardedRequestMarshallerTest.java | 8 +-- ...t.java => ProtobufAdmissionGuardTest.java} | 54 +++++++++---------- .../RpcServiceRegistrationAdmissionTest.java | 4 +- .../BroadcastHexServletAdmissionTest.java | 8 +-- 13 files changed, 81 insertions(+), 81 deletions(-) rename framework/src/main/java/org/tron/core/admission/{TransactionAdmissionException.java => ProtobufAdmissionException.java} (81%) rename framework/src/main/java/org/tron/core/admission/{TransactionAdmissionGuard.java => ProtobufAdmissionGuard.java} (90%) rename framework/src/test/java/org/tron/core/admission/{TransactionAdmissionGuardTest.java => ProtobufAdmissionGuardTest.java} (93%) 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 287ec41e88d..844a8350633 100644 --- a/framework/src/main/java/org/tron/common/application/RpcService.java +++ b/framework/src/main/java/org/tron/common/application/RpcService.java @@ -31,7 +31,7 @@ import org.tron.common.es.ExecutorServiceManager; import org.tron.common.parameter.CommonParameter; import org.tron.core.admission.GuardedRequestMarshaller; -import org.tron.core.admission.TransactionAdmissionGuard; +import org.tron.core.admission.ProtobufAdmissionGuard; import org.tron.core.config.args.Args; import org.tron.core.services.filter.LiteFnQueryGrpcInterceptor; import org.tron.core.services.ratelimiter.PrometheusInterceptor; @@ -130,7 +130,7 @@ private void addServices(NettyServerBuilder serverBuilder) { int maxMessageSize = Args.getInstance().getMaxMessageSize(); for (ServerServiceDefinition service : services()) { serverBuilder.addService(guardRequestMethods(service, maxMessageSize, - TransactionAdmissionGuard.MAX_TX_OCCURRENCES)); + ProtobufAdmissionGuard.MAX_OCCURRENCES)); } } diff --git a/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java b/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java index 3056e594815..2fbfb954841 100644 --- a/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java +++ b/framework/src/main/java/org/tron/core/admission/AdmissionCheckedHandler.java @@ -9,7 +9,7 @@ import lombok.extern.slf4j.Slf4j; import org.tron.common.prometheus.MetricKeys; import org.tron.common.prometheus.Metrics; -import org.tron.core.admission.TransactionAdmissionException.Reason; +import org.tron.core.admission.ProtobufAdmissionException.Reason; /** * Closes the call with {@code INVALID_ARGUMENT}, or {@code RESOURCE_EXHAUSTED} for a request over diff --git a/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java b/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java index dc07e49283e..e75b8e818f2 100644 --- a/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java +++ b/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java @@ -17,11 +17,11 @@ import java.util.Map; import java.util.zip.ZipException; import org.tron.common.math.StrictMathWrapper; -import org.tron.core.admission.TransactionAdmissionException.Reason; +import org.tron.core.admission.ProtobufAdmissionException.Reason; import org.tron.protos.Protocol.Transaction; /** - * Reads the request with a size bound and runs {@link TransactionAdmissionGuard}, rooted at the + * Reads the request with a size bound and runs {@link ProtobufAdmissionGuard}, rooted at the * type the original marshaller parses into, before that marshaller parses the same bytes. A * rejection returns a per-{@link Reason} sentinel instead of throwing, because grpc logs every * marshaller exception at SEVERE; the {@link AdmissionCheckedHandler} closes the call. @@ -98,7 +98,7 @@ public InputStream stream(T value) { public T parse(InputStream stream) { try { return parseChecked(stream); - } catch (TransactionAdmissionException e) { + } catch (ProtobufAdmissionException e) { return rejected.get(e.getReason()); } } @@ -123,8 +123,8 @@ public Descriptor getRoot() { String rejectionMessage() { return Transaction.getDescriptor().equals(root) - ? TransactionAdmissionException.REJECTION_MESSAGE - : TransactionAdmissionException.REQUEST_REJECTION_MESSAGE; + ? ProtobufAdmissionException.REJECTION_MESSAGE + : ProtobufAdmissionException.REQUEST_REJECTION_MESSAGE; } @Override @@ -138,17 +138,17 @@ public Class getMessageClass() { return (Class) prototype.getClass(); } - /** Throws {@link TransactionAdmissionException} for a rejection. */ + /** Throws {@link ProtobufAdmissionException} for a rejection. */ private T parseChecked(InputStream stream) { byte[] bytes; try { bytes = readBounded(stream, maxMessageSize); } catch (ZipException | EOFException e) { - throw new TransactionAdmissionException(Reason.MALFORMED, null); + throw new ProtobufAdmissionException(Reason.MALFORMED, null); } catch (StatusRuntimeException e) { // The decompressing stream of grpc enforces the inbound size limit while it is read. if (e.getStatus().getCode() == Status.Code.RESOURCE_EXHAUSTED) { - throw new TransactionAdmissionException(Reason.TOO_LARGE, null); + throw new ProtobufAdmissionException(Reason.TOO_LARGE, null); } throw e; } catch (IOException e) { @@ -156,14 +156,14 @@ private T parseChecked(InputStream stream) { .asRuntimeException(); } if (bytes == null) { - throw new TransactionAdmissionException(Reason.TOO_LARGE, null); + throw new ProtobufAdmissionException(Reason.TOO_LARGE, null); } - TransactionAdmissionGuard.check(bytes, root, maxOccurrences); + ProtobufAdmissionGuard.check(bytes, root, maxOccurrences); try { return delegate.parse(new KnownLengthInputStream(bytes)); } catch (StatusRuntimeException e) { if (e.getCause() instanceof InvalidProtocolBufferException) { - throw new TransactionAdmissionException(Reason.MALFORMED, null); + throw new ProtobufAdmissionException(Reason.MALFORMED, null); } throw e; } diff --git a/framework/src/main/java/org/tron/core/admission/TransactionAdmissionException.java b/framework/src/main/java/org/tron/core/admission/ProtobufAdmissionException.java similarity index 81% rename from framework/src/main/java/org/tron/core/admission/TransactionAdmissionException.java rename to framework/src/main/java/org/tron/core/admission/ProtobufAdmissionException.java index a162dc7284a..61e4211ebda 100644 --- a/framework/src/main/java/org/tron/core/admission/TransactionAdmissionException.java +++ b/framework/src/main/java/org/tron/core/admission/ProtobufAdmissionException.java @@ -1,7 +1,7 @@ package org.tron.core.admission; /** Admission rejection of untrusted input; carries no stack trace. */ -public class TransactionAdmissionException extends RuntimeException { +public class ProtobufAdmissionException extends RuntimeException { public static final String REJECTION_MESSAGE = "transaction rejected by admission check"; public static final String REQUEST_REJECTION_MESSAGE = "request rejected by admission check"; @@ -15,7 +15,7 @@ public enum Reason { private final Reason reason; - TransactionAdmissionException(Reason reason, String detail) { + ProtobufAdmissionException(Reason reason, String detail) { super(detail == null ? reason.name() : reason + ": " + detail, null, false, false); this.reason = reason; } diff --git a/framework/src/main/java/org/tron/core/admission/TransactionAdmissionGuard.java b/framework/src/main/java/org/tron/core/admission/ProtobufAdmissionGuard.java similarity index 90% rename from framework/src/main/java/org/tron/core/admission/TransactionAdmissionGuard.java rename to framework/src/main/java/org/tron/core/admission/ProtobufAdmissionGuard.java index b9002c353ab..8701bc57061 100644 --- a/framework/src/main/java/org/tron/core/admission/TransactionAdmissionGuard.java +++ b/framework/src/main/java/org/tron/core/admission/ProtobufAdmissionGuard.java @@ -14,7 +14,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import org.tron.core.actuator.TransactionFactory; -import org.tron.core.admission.TransactionAdmissionException.Reason; +import org.tron.core.admission.ProtobufAdmissionException.Reason; import org.tron.protos.Protocol.Transaction; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.contract.ShieldContract.ShieldedTransferContract; @@ -27,11 +27,11 @@ *

Never use it for block, database or P2P parsing: a valid block must not fail a local, * non-consensus limit. */ -public final class TransactionAdmissionGuard { +public final class ProtobufAdmissionGuard { // About 12.7 times the largest mainnet transaction (3,926, a contract deployment) and // 7 times the largest mainnet account (7,065). - public static final int MAX_TX_OCCURRENCES = 50_000; + public static final int MAX_OCCURRENCES = 50_000; static final int MAX_DEPTH = 32; @@ -51,7 +51,7 @@ public final class TransactionAdmissionGuard { private static final ConcurrentMap CHILDREN = new ConcurrentHashMap<>(); private static final ConcurrentMap PAYLOADS = new ConcurrentHashMap<>(); - private TransactionAdmissionGuard() { + private ProtobufAdmissionGuard() { } // WireFormat.makeTag is not public. @@ -82,25 +82,25 @@ static long count(byte[] data, Descriptor root) { private static void run(byte[] data, Descriptor root, Budget budget) { if (data == null) { - throw new TransactionAdmissionException(Reason.MALFORMED, "null payload"); + throw new ProtobufAdmissionException(Reason.MALFORMED, "null payload"); } try { scan(data, CodedInputStream.newInstance(data), root, null, budget, 1); } catch (IOException e) { - throw new TransactionAdmissionException(Reason.MALFORMED, e.getMessage()); + throw new ProtobufAdmissionException(Reason.MALFORMED, e.getMessage()); } } private static void scan(byte[] data, CodedInputStream in, Descriptor descriptor, Effective effective, Budget budget, int depth) throws IOException { if (depth > MAX_DEPTH) { - throw new TransactionAdmissionException(Reason.TOO_DEEP, "depth " + depth); + throw new ProtobufAdmissionException(Reason.TOO_DEEP, "depth " + depth); } int tag; while ((tag = in.readTag()) != 0) { budget.consume(); if (isGroup(tag)) { - throw new TransactionAdmissionException(Reason.MALFORMED, "group"); + throw new ProtobufAdmissionException(Reason.MALFORMED, "group"); } Descriptor child = null; if (WireFormat.getTagWireType(tag) == WireFormat.WIRETYPE_LENGTH_DELIMITED) { @@ -115,7 +115,7 @@ private static void scan(byte[] data, CodedInputStream in, Descriptor descriptor } if (child == null) { if (!in.skipField(tag)) { - throw new TransactionAdmissionException(Reason.MALFORMED, "unexpected end group"); + throw new ProtobufAdmissionException(Reason.MALFORMED, "unexpected end group"); } continue; } @@ -154,7 +154,7 @@ private static Effective resolveContract(byte[] data, int start, int length, Bud while ((tag = in.readTag()) != 0) { left = consumeLocal(left); if (isGroup(tag)) { - throw new TransactionAdmissionException(Reason.MALFORMED, "group"); + throw new ProtobufAdmissionException(Reason.MALFORMED, "group"); } if (tag == CONTRACT_TYPE_TAG) { type = in.readEnum(); @@ -166,7 +166,7 @@ private static Effective resolveContract(byte[] data, int start, int length, Bud while ((parameterTag = in.readTag()) != 0) { left = consumeLocal(left); if (isGroup(parameterTag)) { - throw new TransactionAdmissionException(Reason.MALFORMED, "group"); + throw new ProtobufAdmissionException(Reason.MALFORMED, "group"); } if (parameterTag == ANY_TYPE_URL_TAG) { int urlLength = in.readRawVarint32(); @@ -177,14 +177,14 @@ private static Effective resolveContract(byte[] data, int start, int length, Bud valueTagEnd = start + in.getTotalBytesRead(); in.skipField(parameterTag); } else if (!in.skipField(parameterTag)) { - throw new TransactionAdmissionException(Reason.MALFORMED, "unexpected end group"); + throw new ProtobufAdmissionException(Reason.MALFORMED, "unexpected end group"); } } } finally { in.popLimit(oldLimit); } } else if (!in.skipField(tag)) { - throw new TransactionAdmissionException(Reason.MALFORMED, "unexpected end group"); + throw new ProtobufAdmissionException(Reason.MALFORMED, "unexpected end group"); } } Payload payload = payload(ContractType.forNumber(type)); @@ -197,7 +197,7 @@ private static Effective resolveContract(byte[] data, int start, int length, Bud private static long consumeLocal(long left) { if (left <= 0) { - throw new TransactionAdmissionException(Reason.OVER_BUDGET, null); + throw new ProtobufAdmissionException(Reason.OVER_BUDGET, null); } return left - 1; } @@ -338,7 +338,7 @@ private Budget(long limit) { void consume() { if (used >= limit) { - throw new TransactionAdmissionException(Reason.OVER_BUDGET, null); + throw new ProtobufAdmissionException(Reason.OVER_BUDGET, null); } used++; } diff --git a/framework/src/main/java/org/tron/core/services/http/servlets/BroadcastHexServlet.java b/framework/src/main/java/org/tron/core/services/http/servlets/BroadcastHexServlet.java index bbf71d1d197..c90e30da6ed 100644 --- a/framework/src/main/java/org/tron/core/services/http/servlets/BroadcastHexServlet.java +++ b/framework/src/main/java/org/tron/core/services/http/servlets/BroadcastHexServlet.java @@ -9,8 +9,8 @@ import org.tron.api.GrpcAPI; import org.tron.common.utils.ByteArray; import org.tron.core.Wallet; -import org.tron.core.admission.TransactionAdmissionException; -import org.tron.core.admission.TransactionAdmissionGuard; +import org.tron.core.admission.ProtobufAdmissionException; +import org.tron.core.admission.ProtobufAdmissionGuard; import org.tron.core.capsule.TransactionCapsule; import org.tron.core.services.http.HttpApi; import org.tron.core.services.http.HttpApi.Access; @@ -34,12 +34,12 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) String trx = JSONObject.parseObject(input).getString("transaction"); byte[] raw = ByteArray.fromHexString(trx); try { - TransactionAdmissionGuard.checkTransaction(raw, - TransactionAdmissionGuard.MAX_TX_OCCURRENCES); - } catch (TransactionAdmissionException e) { + ProtobufAdmissionGuard.checkTransaction(raw, + ProtobufAdmissionGuard.MAX_OCCURRENCES); + } catch (ProtobufAdmissionException e) { logger.debug("broadcasthex rejected by admission check: {}", e.getReason()); // processError would render this client error as "internal server error". - Util.writeAuditedError(TransactionAdmissionException.REJECTION_MESSAGE, response); + Util.writeAuditedError(ProtobufAdmissionException.REJECTION_MESSAGE, response); return; } Transaction transaction = Transaction.parseFrom(raw); diff --git a/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java b/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java index 07633b512b4..b556a23d3c6 100644 --- a/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java +++ b/framework/src/test/java/org/tron/common/application/RpcServiceAdmissionTest.java @@ -76,8 +76,8 @@ import org.tron.core.actuator.TransactionFactory; import org.tron.core.admission.AdmissionCheckedHandler; import org.tron.core.admission.GuardedRequestMarshaller; -import org.tron.core.admission.TransactionAdmissionException; -import org.tron.core.admission.TransactionAdmissionException.Reason; +import org.tron.core.admission.ProtobufAdmissionException; +import org.tron.core.admission.ProtobufAdmissionException.Reason; import org.tron.core.services.ratelimiter.RpcApiAccessInterceptor; import org.tron.core.utils.TransactionRegister; import org.tron.protos.Protocol.Block; @@ -359,7 +359,7 @@ public void anyMethodRejectsOverBudgetRequestsAndCountsEachRejectionOnce() throw Status status = rawCall(channel, nowBlock, distinctUnknownFields(BUDGET + 1), compression); assertEquals(Status.Code.INVALID_ARGUMENT, status.getCode()); - assertEquals(TransactionAdmissionException.REQUEST_REJECTION_MESSAGE, + assertEquals(ProtobufAdmissionException.REQUEST_REJECTION_MESSAGE, status.getDescription()); } assertEquals(before + 2, rejections(name, Reason.OVER_BUDGET), 0); @@ -367,7 +367,7 @@ public void anyMethodRejectsOverBudgetRequestsAndCountsEachRejectionOnce() throw // The Transaction methods keep their message. Status transaction = rawBytesCall(channel, retFlood(BUDGET + 1), null); assertEquals(Status.Code.INVALID_ARGUMENT, transaction.getCode()); - assertEquals(TransactionAdmissionException.REJECTION_MESSAGE, transaction.getDescription()); + assertEquals(ProtobufAdmissionException.REJECTION_MESSAGE, transaction.getDescription()); assertEquals(transactionBefore + 1, rejections( WalletGrpc.getBroadcastTransactionMethod().getFullMethodName(), Reason.OVER_BUDGET), 0); diff --git a/framework/src/test/java/org/tron/common/application/RpcServiceInterceptorChainAdmissionTest.java b/framework/src/test/java/org/tron/common/application/RpcServiceInterceptorChainAdmissionTest.java index 34ab9e8d1d2..87fad19f490 100644 --- a/framework/src/test/java/org/tron/common/application/RpcServiceInterceptorChainAdmissionTest.java +++ b/framework/src/test/java/org/tron/common/application/RpcServiceInterceptorChainAdmissionTest.java @@ -42,7 +42,7 @@ import org.tron.api.WalletSolidityGrpc; import org.tron.common.TestConstants; import org.tron.core.ChainBaseManager; -import org.tron.core.admission.TransactionAdmissionException; +import org.tron.core.admission.ProtobufAdmissionException; import org.tron.core.config.args.Args; import org.tron.core.db.Manager; import org.tron.core.services.filter.LiteFnQueryGrpcInterceptor; @@ -156,7 +156,7 @@ public void rejectionsReleaseThePermitAndNeverSetTheCursor() throws Exception { fail("expected a rejection"); } catch (StatusRuntimeException e) { assertEquals(Status.Code.INVALID_ARGUMENT, e.getStatus().getCode()); - assertEquals(TransactionAdmissionException.REQUEST_REJECTION_MESSAGE, + assertEquals(ProtobufAdmissionException.REQUEST_REJECTION_MESSAGE, e.getStatus().getDescription()); } } diff --git a/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java b/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java index 2dd7984ef1f..4dd9b16a6ed 100644 --- a/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java +++ b/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java @@ -26,7 +26,7 @@ import java.util.zip.GZIPOutputStream; import org.junit.Assume; import org.junit.Test; -import org.tron.core.admission.TransactionAdmissionException.Reason; +import org.tron.core.admission.ProtobufAdmissionException.Reason; import org.tron.protos.Protocol.Transaction; public class AdmissionSupportTest { diff --git a/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java b/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java index a32c70611c7..3826a08489d 100644 --- a/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java +++ b/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java @@ -28,7 +28,7 @@ import org.tron.api.GrpcAPI; import org.tron.api.WalletGrpc; import org.tron.api.ZksnarkGrpcAPI.ZksnarkRequest; -import org.tron.core.admission.TransactionAdmissionException.Reason; +import org.tron.core.admission.ProtobufAdmissionException.Reason; import org.tron.protos.Protocol.Transaction; import org.tron.protos.contract.SmartContractOuterClass.CreateSmartContract; import org.tron.protos.contract.SmartContractOuterClass.SmartContract; @@ -72,9 +72,9 @@ public void unknownFieldsOfAnyRequestTypeCount() { marshaller.parse(new ByteArrayInputStream(distinctUnknownFields(BUDGET))))); assertEquals(Reason.OVER_BUDGET, marshaller.rejectionReason( marshaller.parse(new ByteArrayInputStream(distinctUnknownFields(BUDGET + 1))))); - assertEquals(TransactionAdmissionException.REQUEST_REJECTION_MESSAGE, + assertEquals(ProtobufAdmissionException.REQUEST_REJECTION_MESSAGE, marshaller.rejectionMessage()); - assertEquals(TransactionAdmissionException.REJECTION_MESSAGE, + assertEquals(ProtobufAdmissionException.REJECTION_MESSAGE, guarded(proto(Transaction.getDefaultInstance()), Transaction.getDefaultInstance()) .rejectionMessage()); } @@ -222,7 +222,7 @@ public void aTransactionNestedInAnotherRequestIsCountedWithItsContract() { assertEquals(Reason.OVER_BUDGET, marshaller.rejectionReason( marshaller.parse(new ByteArrayInputStream(request.toByteArray())))); // Without the contract payload the same request is far below the budget. - assertTrue(TransactionAdmissionGuard.count(request.toBuilder().setTransaction( + assertTrue(ProtobufAdmissionGuard.count(request.toBuilder().setTransaction( tx.toBuilder().setRawData(tx.getRawData().toBuilder().clearContract())).build() .toByteArray(), ZksnarkRequest.getDescriptor()) < BUDGET); } diff --git a/framework/src/test/java/org/tron/core/admission/TransactionAdmissionGuardTest.java b/framework/src/test/java/org/tron/core/admission/ProtobufAdmissionGuardTest.java similarity index 93% rename from framework/src/test/java/org/tron/core/admission/TransactionAdmissionGuardTest.java rename to framework/src/test/java/org/tron/core/admission/ProtobufAdmissionGuardTest.java index 7eb546de45e..b44aff2f78b 100644 --- a/framework/src/test/java/org/tron/core/admission/TransactionAdmissionGuardTest.java +++ b/framework/src/test/java/org/tron/core/admission/ProtobufAdmissionGuardTest.java @@ -31,7 +31,7 @@ import org.junit.BeforeClass; import org.junit.Test; import org.tron.core.actuator.TransactionFactory; -import org.tron.core.admission.TransactionAdmissionException.Reason; +import org.tron.core.admission.ProtobufAdmissionException.Reason; import org.tron.core.utils.TransactionRegister; import org.tron.protos.Protocol.Key; import org.tron.protos.Protocol.Permission; @@ -47,7 +47,7 @@ import org.tron.protos.contract.SmartContractOuterClass.TriggerSmartContract; import org.tron.protos.contract.WitnessContract.VoteWitnessContract; -public class TransactionAdmissionGuardTest { +public class ProtobufAdmissionGuardTest { private static final int LD = WireFormat.WIRETYPE_LENGTH_DELIMITED; private static final int VARINT = WireFormat.WIRETYPE_VARINT; @@ -137,7 +137,7 @@ public void mergedParameterUsesOnlyTheEffectiveValue() throws Exception { ld(2, cat(ld(1, url("protocol.TransferContract")), ld(2, bytes(0x07)))), ld(2, ld(2, valid))); byte[] tx = txWithContract(contract); - TransactionAdmissionGuard.checkTransaction(tx, 1000); + ProtobufAdmissionGuard.checkTransaction(tx, 1000); assertEquals(1, unpack(tx, TransferContract.class).getAmount()); // A flood in an overwritten value is a leaf; only the effective value is recursed. @@ -241,7 +241,7 @@ public void firstPassStopsAtTheRemainingBudget() { // 10000 unknown fields directly inside Contract are seen first by the first pass. byte[] tx = txWithContract(repeat(bytes(0x30, 0xff, 0x01), 10000)); assertEquals(Reason.OVER_BUDGET, - reason(() -> TransactionAdmissionGuard.checkTransaction(tx, 100))); + reason(() -> ProtobufAdmissionGuard.checkTransaction(tx, 100))); } @Test @@ -272,7 +272,7 @@ public void malformedPayloadOfALaterContractIsFound() throws Exception { ld(11, contract(VOTE, "protocol.VoteWitnessContract", repeat(bytes(0x12, 0x00), 10))), ld(11, contract(VOTE, "protocol.VoteWitnessContract", bytes(0x07))))); assertEquals(Reason.MALFORMED, - reason(() -> TransactionAdmissionGuard.checkTransaction(tx, 1000))); + reason(() -> ProtobufAdmissionGuard.checkTransaction(tx, 1000))); Transaction.raw raw = Transaction.parseFrom(tx).getRawData(); raw.getContract(0).getParameter().unpack(VoteWitnessContract.class); try { @@ -303,10 +303,10 @@ public void protobufLookingBytesInsideBytesFieldsStayLeaves() throws Exception { @Test public void depthLimitAppliesToRecursiveTypes() { Descriptor recursive = DescriptorProto.getDescriptor(); // nested_type (3) is recursive - TransactionAdmissionGuard.check(nested(TransactionAdmissionGuard.MAX_DEPTH - 1), recursive, + ProtobufAdmissionGuard.check(nested(ProtobufAdmissionGuard.MAX_DEPTH - 1), recursive, 1000); - assertEquals(Reason.TOO_DEEP, reason(() -> TransactionAdmissionGuard.check( - nested(TransactionAdmissionGuard.MAX_DEPTH), recursive, 1000))); + assertEquals(Reason.TOO_DEEP, reason(() -> ProtobufAdmissionGuard.check( + nested(ProtobufAdmissionGuard.MAX_DEPTH), recursive, 1000))); } @Test(timeout = 60_000) @@ -323,11 +323,11 @@ public void descriptorCacheIsConsistentUnderConcurrentFirstUse() throws Exceptio for (int i = 0; i < 64; i++) { results.add(pool.submit((Callable) () -> { start.await(); - return TransactionAdmissionGuard.count(payload, fresh); + return ProtobufAdmissionGuard.count(payload, fresh); })); } start.countDown(); - long expected = TransactionAdmissionGuard.count(payload, fresh); + long expected = ProtobufAdmissionGuard.count(payload, fresh); for (Future result : results) { assertEquals(expected, result.get(10, TimeUnit.SECONDS).longValue()); } @@ -365,7 +365,7 @@ public void malformedOuterInputIsRejectedLikeTheParser() { public void malformedEffectivePayloadIsRejectedWhileOuterParseSucceeds() throws Exception { byte[] tx = txWithContract(contract(VOTE, "protocol.VoteWitnessContract", bytes(0x07))); assertEquals(Reason.MALFORMED, - reason(() -> TransactionAdmissionGuard.checkTransaction(tx, 1000))); + reason(() -> ProtobufAdmissionGuard.checkTransaction(tx, 1000))); Transaction.parseFrom(tx); try { unpack(tx, VoteWitnessContract.class); @@ -380,15 +380,15 @@ public void groupsAreRejectedAlthoughTheParserAcceptsThem() throws Exception { byte[] tx = bytes(0x0b, 0x0c); // field 1 start group, field 1 end group Transaction.parseFrom(tx); assertEquals(Reason.MALFORMED, - reason(() -> TransactionAdmissionGuard.checkTransaction(tx, 1000))); + reason(() -> ProtobufAdmissionGuard.checkTransaction(tx, 1000))); } @Test public void exceptionCarriesNoStackTrace() { try { - TransactionAdmissionGuard.checkTransaction(repeat(bytes(0x2a, 0x00), 10), 5); + ProtobufAdmissionGuard.checkTransaction(repeat(bytes(0x2a, 0x00), 10), 5); fail(); - } catch (TransactionAdmissionException e) { + } catch (ProtobufAdmissionException e) { assertEquals(0, e.getStackTrace().length); } } @@ -435,7 +435,7 @@ public void legitimateTransactionsPassAndStillParse() throws Exception { for (Transaction tx : corpus) { byte[] bytes = tx.toByteArray(); - TransactionAdmissionGuard.checkTransaction(bytes, 10_000); + ProtobufAdmissionGuard.checkTransaction(bytes, 10_000); Transaction.Contract contract = Transaction.parseFrom(bytes).getRawData().getContract(0); contract.getParameter().unpack(TransactionFactory.getContract(contract.getType())); } @@ -485,7 +485,7 @@ public void lastTypeUrlDecidesThePayload() throws Exception { byte[] tx = txWithContract(contract); assertEquals(1000, unpack(tx, VoteWitnessContract.class).getVotesCount()); assertEquals(Reason.OVER_BUDGET, - reason(() -> TransactionAdmissionGuard.checkTransaction(tx, 500))); + reason(() -> ProtobufAdmissionGuard.checkTransaction(tx, 500))); } @Test @@ -524,17 +524,17 @@ public void firstPassStopsAtTheBudgetBeforeLaterBytes() { // The truncated varint at the end is only reached if the first pass ignores the budget. byte[] tx = txWithContract(cat(repeat(bytes(0x30, 0xff, 0x01), 10000), bytes(0x30, 0x80))); assertEquals(Reason.OVER_BUDGET, - reason(() -> TransactionAdmissionGuard.checkTransaction(tx, 100))); + reason(() -> ProtobufAdmissionGuard.checkTransaction(tx, 100))); } @Test public void productionBudgetIsPinned() { - assertEquals(50_000, TransactionAdmissionGuard.MAX_TX_OCCURRENCES); + assertEquals(50_000, ProtobufAdmissionGuard.MAX_OCCURRENCES); } @Test public void largeValidAbiDeployIsAcceptedUpToTheBudgetAndRejectedBeyond() throws Exception { - int budget = TransactionAdmissionGuard.MAX_TX_OCCURRENCES; + int budget = ProtobufAdmissionGuard.MAX_OCCURRENCES; // Largest ABI of ordinary entries that still fits the budget. int low = 0; int high = budget; @@ -551,30 +551,30 @@ public void largeValidAbiDeployIsAcceptedUpToTheBudgetAndRejectedBeyond() throws assertTrue(countTx(within) <= budget); assertTrue(countTx(beyond) > budget); - TransactionAdmissionGuard.checkTransaction(within, budget); + ProtobufAdmissionGuard.checkTransaction(within, budget); assertEquals(low, unpack(within, CreateSmartContract.class) .getNewContract().getAbi().getEntrysCount()); // Policy: a deploy whose ABI is beyond the budget is refused at the API. assertEquals(Reason.OVER_BUDGET, - reason(() -> TransactionAdmissionGuard.checkTransaction(beyond, budget))); + reason(() -> ProtobufAdmissionGuard.checkTransaction(beyond, budget))); } // ---------------------------------------------------------------- helpers private static long countTx(byte[] tx) { - return TransactionAdmissionGuard.countTransaction(tx); + return ProtobufAdmissionGuard.countTransaction(tx); } private static void assertExactBudget(byte[] tx) { long count = countTx(tx); - TransactionAdmissionGuard.checkTransaction(tx, (int) count); + ProtobufAdmissionGuard.checkTransaction(tx, (int) count); assertEquals(Reason.OVER_BUDGET, - reason(() -> TransactionAdmissionGuard.checkTransaction(tx, (int) count - 1))); + reason(() -> ProtobufAdmissionGuard.checkTransaction(tx, (int) count - 1))); } private static void assertMalformedLikeParser(byte[] tx) { assertEquals(Reason.MALFORMED, - reason(() -> TransactionAdmissionGuard.checkTransaction(tx, 1000))); + reason(() -> ProtobufAdmissionGuard.checkTransaction(tx, 1000))); try { Transaction.parseFrom(tx); fail("parser must reject it too"); @@ -586,10 +586,10 @@ private static void assertMalformedLikeParser(byte[] tx) { private static Reason reason(Runnable action) { try { action.run(); - } catch (TransactionAdmissionException e) { + } catch (ProtobufAdmissionException e) { return e.getReason(); } - fail("expected TransactionAdmissionException"); + fail("expected ProtobufAdmissionException"); return null; } diff --git a/framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java b/framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java index 72cb1e150ac..e145176748f 100644 --- a/framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java +++ b/framework/src/test/java/org/tron/core/services/RpcServiceRegistrationAdmissionTest.java @@ -27,7 +27,7 @@ import org.tron.common.application.RpcService; import org.tron.common.application.RpcServiceAdmissionTest; import org.tron.common.parameter.CommonParameter; -import org.tron.core.admission.TransactionAdmissionGuard; +import org.tron.core.admission.ProtobufAdmissionGuard; import org.tron.core.services.filter.PbftCursorInterceptor; import org.tron.core.services.filter.SolidityCursorInterceptor; import org.tron.core.services.interfaceOnPBFT.RpcApiServiceOnPBFT; @@ -143,7 +143,7 @@ private static int guardedMethods(List definitions) { int guarded = 0; for (ServerServiceDefinition definition : definitions) { guarded += RpcServiceAdmissionTest.assertAllMethodsGuarded(definition, - TransactionAdmissionGuard.MAX_TX_OCCURRENCES, MAX_MESSAGE_SIZE); + ProtobufAdmissionGuard.MAX_OCCURRENCES, MAX_MESSAGE_SIZE); } return guarded; } diff --git a/framework/src/test/java/org/tron/core/services/http/servlets/BroadcastHexServletAdmissionTest.java b/framework/src/test/java/org/tron/core/services/http/servlets/BroadcastHexServletAdmissionTest.java index 0106f347b7a..7d1460111c6 100644 --- a/framework/src/test/java/org/tron/core/services/http/servlets/BroadcastHexServletAdmissionTest.java +++ b/framework/src/test/java/org/tron/core/services/http/servlets/BroadcastHexServletAdmissionTest.java @@ -24,15 +24,15 @@ import org.tron.api.GrpcAPI; import org.tron.common.utils.ByteArray; import org.tron.core.Wallet; -import org.tron.core.admission.TransactionAdmissionException; -import org.tron.core.admission.TransactionAdmissionGuard; +import org.tron.core.admission.ProtobufAdmissionException; +import org.tron.core.admission.ProtobufAdmissionGuard; import org.tron.protos.Protocol.Transaction; public class BroadcastHexServletAdmissionTest { @Test public void overBudgetPayloadGetsTheFixedClientError() throws Exception { - byte[] flood = new byte[2 * (TransactionAdmissionGuard.MAX_TX_OCCURRENCES + 1)]; + byte[] flood = new byte[2 * (ProtobufAdmissionGuard.MAX_OCCURRENCES + 1)]; for (int i = 0; i < flood.length; i += 2) { flood[i] = 0x2a; } @@ -98,7 +98,7 @@ private static void assertRejectedWithoutWallet(BroadcastHexServlet servlet, byt throws Exception { Wallet wallet = mock(Wallet.class); String body = post(servlet, wallet, ByteArray.toHexString(payload)); - assertTrue(body, body.contains(TransactionAdmissionException.REJECTION_MESSAGE)); + assertTrue(body, body.contains(ProtobufAdmissionException.REJECTION_MESSAGE)); assertFalse(body, body.contains("internal server error")); verifyNoInteractions(wallet); } From 2c1cde444b47e630a5271b8a76b1cc315319fc75 Mon Sep 17 00:00:00 2001 From: waynercheung Date: Sun, 25 Oct 2026 13:10:46 +0800 Subject: [PATCH 9/9] refactor(api): clarify admission sentinel naming and buffer growth Rename the rejection map of GuardedRequestMarshaller to rejectionSentinels and its accessor to rejectionSentinel, to distinguish the per-reason sentinel objects from rejection state. Split the buffer growth in readBounded into a doubling step and a cap, and compute the cap once before the loop. The growth is unchanged. Add tests for a negative max and for reading at most one byte past max. --- .../admission/GuardedRequestMarshaller.java | 18 +++++----- .../core/admission/AdmissionSupportTest.java | 34 +++++++++++++++++++ .../GuardedRequestMarshallerTest.java | 2 +- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java b/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java index e75b8e818f2..ad75520a877 100644 --- a/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java +++ b/framework/src/main/java/org/tron/core/admission/GuardedRequestMarshaller.java @@ -38,7 +38,7 @@ public final class GuardedRequestMarshaller implements MethodDescriptor.Proto private final Descriptor root; private final T prototype; // Compared by identity; never the default instance, which an empty request parses to. - private final Map rejected = new EnumMap<>(Reason.class); + private final Map rejectionSentinels = new EnumMap<>(Reason.class); private final int maxMessageSize; private final int maxOccurrences; @@ -49,7 +49,7 @@ public final class GuardedRequestMarshaller implements MethodDescriptor.Proto this.root = prototype.getDescriptorForType(); this.prototype = (T) prototype.getDefaultInstanceForType(); for (Reason reason : Reason.values()) { - rejected.put(reason, (T) prototype.newBuilderForType().build()); + rejectionSentinels.put(reason, (T) prototype.newBuilderForType().build()); } this.maxMessageSize = maxMessageSize; this.maxOccurrences = maxOccurrences; @@ -99,22 +99,22 @@ public T parse(InputStream stream) { try { return parseChecked(stream); } catch (ProtobufAdmissionException e) { - return rejected.get(e.getReason()); + return rejectionSentinels.get(e.getReason()); } } public Reason rejectionReason(T request) { // Called for every accepted request; iterating the map would allocate entries. for (Reason reason : REASONS) { - if (rejected.get(reason) == request) { + if (rejectionSentinels.get(reason) == request) { return reason; } } return null; } - T rejected(Reason reason) { - return rejected.get(reason); + T rejectionSentinel(Reason reason) { + return rejectionSentinels.get(reason); } public Descriptor getRoot() { @@ -185,6 +185,7 @@ static byte[] readBounded(InputStream stream, int max) throws IOException { initial = hint > 1 ? hint : DEFAULT_INITIAL_BUFFER; initial = StrictMathWrapper.min(initial, StrictMathWrapper.min(limit, MAX_INITIAL_BUFFER)); } + long maxCapacity = StrictMathWrapper.min(limit, Integer.MAX_VALUE - 8L); byte[] buffer = new byte[(int) initial]; int size = 0; while (true) { @@ -194,9 +195,8 @@ static byte[] readBounded(InputStream stream, int max) throws IOException { if (next < 0) { break; } - long grown = StrictMathWrapper.min( - StrictMathWrapper.min(StrictMathWrapper.max((long) buffer.length * 2, - DEFAULT_INITIAL_BUFFER), limit), Integer.MAX_VALUE - 8); + long grown = StrictMathWrapper.max((long) buffer.length * 2, DEFAULT_INITIAL_BUFFER); + grown = StrictMathWrapper.min(grown, maxCapacity); if (grown <= buffer.length) { return null; // cannot hold more than this; the message is over the bound } diff --git a/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java b/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java index 4dd9b16a6ed..a936270fba6 100644 --- a/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java +++ b/framework/src/test/java/org/tron/core/admission/AdmissionSupportTest.java @@ -59,6 +59,34 @@ public void readBoundedRejectsMoreThanMax() throws IOException { assertNull(GuardedRequestMarshaller.readBounded(new HintStream(new byte[1], 0), 0)); } + @Test + public void readBoundedReadsAtMostOneBytePastMax() throws IOException { + HintStream stream = new HintStream(new byte[12], 2); + assertNull(GuardedRequestMarshaller.readBounded(stream, 10)); + assertEquals(11, stream.consumed()); + } + + @Test + public void readBoundedRejectsANegativeMaxBeforeTouchingTheStream() throws IOException { + InputStream untouchable = new InputStream() { + @Override + public int read() { + throw new AssertionError("stream read"); + } + + @Override + public int available() { + throw new AssertionError("stream queried"); + } + }; + try { + GuardedRequestMarshaller.readBounded(untouchable, -1); + fail(); + } catch (IllegalArgumentException expected) { + // expected + } + } + @Test public void readBoundedReadsAKnownLengthStream() throws IOException { int max = 4 << 20; @@ -433,13 +461,19 @@ public int available() { private static final class HintStream extends InputStream { private final ByteArrayInputStream in; + private final int length; private final int hint; HintStream(byte[] data, int hint) { this.in = new ByteArrayInputStream(data); + this.length = data.length; this.hint = hint; } + int consumed() { + return length - in.available(); + } + @Override public int read() { return in.read(); diff --git a/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java b/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java index 3826a08489d..e31007f6a28 100644 --- a/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java +++ b/framework/src/test/java/org/tron/core/admission/GuardedRequestMarshallerTest.java @@ -50,7 +50,7 @@ public void rootAndSentinelsFollowTheRequestType() { assertSame(prototype.getClass(), marshaller.getMessageClass()); Map seen = new IdentityHashMap<>(); for (Reason reason : Reason.values()) { - Message sentinel = marshaller.rejected(reason); + Message sentinel = marshaller.rejectionSentinel(reason); assertNotSame(prototype, sentinel); assertEquals(prototype.getClass(), sentinel.getClass()); assertEquals(reason, marshaller.rejectionReason(sentinel));