Skip to content

Commit 6a24625

Browse files
ai-agent-kxrpc[bot]claudeMr3zee
authored
KRPC-564: Reject >10-byte varint tags in protobuf parser (#672)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Alexander Sysoev <Alexander.Sysoev@jetbrains.com>
1 parent 484c612 commit 6a24625

7 files changed

Lines changed: 107 additions & 48 deletions

File tree

native-deps/shims/protobuf/src/cpp/protowire.cpp

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -235,47 +235,75 @@ extern "C" {
235235
}
236236

237237
int pw_decoder_read_validated_tag(pw_decoder_t *self, uint32_t *tag_out) {
238-
int pos_before = self->codedInputStream.CurrentPosition();
239-
240-
uint64_t raw64;
241-
if (!self->codedInputStream.ReadVarint64(&raw64)) {
242-
// Use ConsumedEntireMessage() to distinguish
243-
// legitimate end-of-stream from actual errors (like >10-byte varints).
244-
// Note: CurrentPosition() alone is insufficient because ReadVarint64's
245-
// fast-path array reader does not advance the buffer pointer on failure.
246-
if (self->codedInputStream.ConsumedEntireMessage()) {
247-
return 0; // legitimate end of stream
248-
}
249-
return -1; // error (>10-byte varint, truncated varint, etc.)
238+
// Zero-initialize so the Kotlin caller always has a meaningful value
239+
// for error diagnostics, even on early-return error paths.
240+
*tag_out = 0;
241+
242+
// Sub-message boundary: BytesUntilLimit() == 0 when a PushLimit scope
243+
// is active and all bytes within it have been consumed. This mirrors
244+
// ReadTag()'s internal limit check.
245+
if (self->codedInputStream.BytesUntilLimit() == 0) {
246+
return 0;
250247
}
251248

252-
int pos_after = self->codedInputStream.CurrentPosition();
253-
int bytes_used = pos_after - pos_before;
254-
255-
// A zero tag value read from actual bytes is invalid (field number 0).
256-
if (raw64 == 0) {
257-
return -1;
249+
// Read the first byte via ReadRaw to reliably distinguish top-level
250+
// EOF from a varint start. BytesUntilLimit() is either -1 (no limit,
251+
// top-level context where current_limit_ is INT_MAX) or positive
252+
// (inside a PushLimit scope with remaining data). In either case,
253+
// ReadRaw(1) failure means the underlying stream is exhausted.
254+
//
255+
// This avoids ReadTag()'s ambiguous return-0-for-both-EOF-and-errors
256+
// and ConsumedEntireMessage()'s broken behavior at the top level (no
257+
// limit set → legitimate_message_end_ is never set).
258+
uint8_t b;
259+
if (!self->codedInputStream.ReadRaw(&b, 1)) {
260+
// BytesUntilLimit == -1: top-level EOF (no limit active, stream
261+
// exhausted). BytesUntilLimit > 0: the limit says data should be
262+
// available but the stream is exhausted — I/O error or truncation.
263+
if (self->codedInputStream.BytesUntilLimit() > 0) {
264+
return -1; // truncated stream
265+
}
266+
return 0; // top-level EOF
258267
}
259268

260-
// Tag must fit in 32 bits (29-bit field number + 3-bit wire type).
261-
if (raw64 > UINT32_MAX) {
262-
return -1;
269+
// Parse the varint manually, tracking byte count for overlong detection.
270+
// Tags are uint32 (max 5 varint bytes), but we must read up to 10 bytes
271+
// to detect the >10-byte varint conformance case.
272+
uint64_t result = b & 0x7F;
273+
int bytes_used = 1;
274+
275+
while (b >= 0x80) {
276+
if (bytes_used >= 10) {
277+
// Write partial result for diagnostics before returning error.
278+
*tag_out = static_cast<uint32_t>(result & UINT32_MAX);
279+
return -1; // varint exceeds 10 bytes
280+
}
281+
if (!self->codedInputStream.ReadRaw(&b, 1)) {
282+
*tag_out = static_cast<uint32_t>(result & UINT32_MAX);
283+
return -1; // truncated varint
284+
}
285+
result |= static_cast<uint64_t>(b & 0x7F) << (7 * bytes_used);
286+
bytes_used++;
263287
}
264288

289+
// Write the decoded value for diagnostics on all remaining error paths.
290+
*tag_out = static_cast<uint32_t>(result & UINT32_MAX);
291+
292+
if (result == 0) return -1; // zero tag (invalid field number 0)
293+
if (result > UINT32_MAX) return -1; // exceeds 32-bit tag range
294+
265295
// Reject overlong varint encoding: the varint used more bytes than the
266296
// minimum required for its value. Each varint byte carries 7 payload bits.
267297
int min_bytes;
268-
if (raw64 < (1ULL << 7)) min_bytes = 1;
269-
else if (raw64 < (1ULL << 14)) min_bytes = 2;
270-
else if (raw64 < (1ULL << 21)) min_bytes = 3;
271-
else if (raw64 < (1ULL << 28)) min_bytes = 4;
272-
else min_bytes = 5;
273-
274-
if (bytes_used > min_bytes) {
275-
return -1;
276-
}
298+
if (result < (1ULL << 7)) min_bytes = 1;
299+
else if (result < (1ULL << 14)) min_bytes = 2;
300+
else if (result < (1ULL << 21)) min_bytes = 3;
301+
else if (result < (1ULL << 28)) min_bytes = 4;
302+
else min_bytes = 5;
303+
304+
if (bytes_used > min_bytes) return -1;
277305

278-
*tag_out = static_cast<uint32_t>(raw64);
306+
// Success — *tag_out already set above.
279307
return 1;
280308
}
281309

protobuf/protobuf-api/src/commonMain/kotlin/kotlinx/rpc/protobuf/ProtobufException.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ public class ProtobufDecodingException : ProtobufException {
3232
"Decoder encountered an embedded string or message which claimed to have negative size."
3333
)
3434

35-
internal fun invalidTag() = ProtobufDecodingException(
36-
"Protocol message contained an invalid tag (zero)."
35+
internal fun invalidTag(tag: UInt = 0u) = ProtobufDecodingException(
36+
"Protocol message contained an invalid tag ($tag)."
3737
)
3838

3939
internal fun truncatedMessage() = ProtobufDecodingException(

protobuf/protobuf-api/src/commonTest/kotlin/kotlinx/rpc/protobuf/test/WireMarshallerTest.kt

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -829,7 +829,7 @@ class WireMarshallerTest {
829829

830830

831831
@Test
832-
fun testInvalidTag() {
832+
fun testInvalidTagZero() {
833833
val buffer = Buffer()
834834
buffer.writeByte(0)
835835

@@ -840,6 +840,37 @@ class WireMarshallerTest {
840840
}
841841
}
842842

843+
@Test
844+
fun testInvalidTagVarintMoreThanTenBytes() {
845+
// 11 bytes: 10 continuation bytes (0x80) + 1 terminator (0x01)
846+
// This exceeds the protobuf varint maximum of 10 bytes.
847+
val buffer = Buffer()
848+
repeat(10) { buffer.writeByte(0x80.toByte()) }
849+
buffer.writeByte(0x01)
850+
851+
assertFailsWith<ProtobufDecodingException> {
852+
checkForPlatformDecodeException {
853+
WireDecoder(buffer).readTag()
854+
}
855+
}
856+
}
857+
858+
@Test
859+
fun testInvalidTagOverlongEncoding() {
860+
// Field number 1, wire type 0 (VARINT) = tag value 8.
861+
// Minimal encoding: single byte 0x08.
862+
// Overlong encoding: two bytes 0x88 0x00 (value 8 in 2 varint bytes).
863+
val buffer = Buffer()
864+
buffer.writeByte(0x88.toByte())
865+
buffer.writeByte(0x00)
866+
867+
assertFailsWith<ProtobufDecodingException> {
868+
checkForPlatformDecodeException {
869+
WireDecoder(buffer).readTag()
870+
}
871+
}
872+
}
873+
843874
/**
844875
* Writes a raw protobuf LENGTH_DELIMITED field with the given [data] bytes
845876
* at field number [fieldNr] into the buffer.

protobuf/protobuf-api/src/jvmMain/kotlin/kotlinx/rpc/protobuf/internal/WireDecoder.jvm.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,13 @@ internal class WireDecoderJvm(source: Source) : WireDecoder {
2424
if (codedInputStream.isAtEnd) return null
2525

2626
val posBefore = codedInputStream.totalBytesRead
27-
val raw64 = codedInputStream.readRawVarint64()
27+
val raw64 = try {
28+
codedInputStream.readRawVarint64()
29+
} catch (e: InvalidProtocolBufferException) {
30+
// readRawVarint64() throws for varints exceeding 10 bytes.
31+
// Convert to ProtobufDecodingException so callers only need to handle one type.
32+
throw ProtobufDecodingException(e.message ?: "Malformed varint", e)
33+
}
2834
val bytesUsed = codedInputStream.totalBytesRead - posBefore
2935

3036
// A valid tag must fit in 32 bits (29-bit field number + 3-bit wire type).
@@ -49,7 +55,7 @@ internal class WireDecoderJvm(source: Source) : WireDecoder {
4955

5056
val tag = raw64.toUInt()
5157
if (tag == 0u) {
52-
throw ProtobufDecodingException.invalidTag()
58+
throw ProtobufDecodingException.invalidTag(tag)
5359
}
5460

5561
return KTag.from(tag)

protobuf/protobuf-api/src/nativeMain/kotlin/kotlinx/rpc/protobuf/internal/WireDecoder.native.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,11 @@ internal class WireDecoderNative(private val source: Buffer) : WireDecoder {
6767
}
6868

6969
override fun readTag(): KTag? = memScoped {
70-
val tagOut = alloc<UIntVar>()
71-
when (pw_decoder_read_validated_tag(raw, tagOut.ptr)) {
72-
0 -> null // end of stream
73-
1 -> KTag.from(tagOut.value)
74-
else -> throw ProtobufDecodingException.invalidTag()
70+
val tag = alloc<UIntVar>()
71+
when (pw_decoder_read_validated_tag(raw, tag.ptr)) {
72+
0 -> null // end of stream or sub-message boundary
73+
1 -> KTag.from(tag.value)
74+
else -> throw ProtobufDecodingException.invalidTag(tag.value)
7575
}
7676
}
7777

tests/protobuf-conformance/src/jvmTest/resources/native_known_failures.txt

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,3 @@
22
# Tests listed here are excluded from native conformance JUnit assertions.
33
# Each line is a test name (text after '#' is a comment).
44

5-
# >10-byte varint tag rejection not yet handled by C++ ReadVarint64 (pre-existing, also fails on JVM)
6-
Required.Proto2.ProtobufInput.BadTag_VarintMoreThanTenBytes
7-
Required.Proto3.ProtobufInput.BadTag_VarintMoreThanTenBytes
8-
Required.Editions_Proto2.ProtobufInput.BadTag_VarintMoreThanTenBytes
9-
Required.Editions_Proto3.ProtobufInput.BadTag_VarintMoreThanTenBytes
10-

versions-root/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ kotlin-compiler = "0.0.0" # default to kotlin-lang or env.KOTLIN_COMPILER_VERSIO
1111
# the version scheme is "<upstream-grpc-version>-<shim-version>"
1212
internal-native-grpc-shim = "1.74.1-2"
1313
# the version scheme is "<upstream-protobuf-version>-<shim-version>"
14-
internal-native-protobuf-shim = "31.1-2"
14+
internal-native-protobuf-shim = "31.1-5"
1515
# the version numbers for the shim annotation
1616
internal-native-shim-annotation = "0.1.0"
1717

0 commit comments

Comments
 (0)