diff --git a/src/core/lib/Protobuf.mjs b/src/core/lib/Protobuf.mjs index e131d3a527..68b38282a0 100644 --- a/src/core/lib/Protobuf.mjs +++ b/src/core/lib/Protobuf.mjs @@ -180,7 +180,17 @@ class Protobuf { try { message = this.parsedProto.root.nested[this.mainMessageName]; - const packageDecode = message.toObject(message.decode(input), { + const reader = protobuf.Reader.create(input); + let decoded; + try { + decoded = message.decode(reader); + } catch (error) { + if (error && !Number.isInteger(error.byteOffset)) { + error.byteOffset = reader.pos; + } + throw error; + } + const packageDecode = message.toObject(decoded, { bytes: String, longs: Number, enums: String, @@ -198,7 +208,11 @@ class Protobuf { } catch (error) { if (message) { - throw new Error("Input " + error); + const wrapped = new Error("Input " + error); + if (Number.isInteger(error?.byteOffset)) { + wrapped.byteOffset = error.byteOffset; + } + throw wrapped; } else { return rawDecode; } @@ -372,7 +386,7 @@ class Protobuf { } // Throw an error if we have gone beyond the end of the data if (this.offset > this.LENGTH) { - throw new Error("Exhausted Buffer"); + throw this._parseError("Exhausted Buffer", this.LENGTH); } return object; } @@ -400,6 +414,20 @@ class Protobuf { return object; } + /** + * Create a parse error annotated with the byte offset. + * + * @private + * @param {string} message + * @param {number} byteOffset + * @returns {Error} + */ + _parseError(message, byteOffset) { + const error = new Error(message); + error.byteOffset = byteOffset; + return error; + } + /** * Parse a field and return the Object read from the record * @@ -408,6 +436,7 @@ class Protobuf { */ _parseField() { // Get the field headers + const fieldOffset = this.offset; const header = this._fieldHeader(); const type = header.type; const key = header.key; @@ -430,8 +459,9 @@ class Protobuf { case 5: return { "key": key, "value": this._uint32() }; // unknown type - default: - throw new Error("Unknown type 0x" + type.toString(16)); + default: { + throw this._parseError("Unknown type 0x" + type.toString(16), fieldOffset); + } } } @@ -455,6 +485,9 @@ class Protobuf { * @returns {number} */ _fieldType() { + if (this.offset >= this.LENGTH) { + throw this._parseError("Exhausted Buffer", this.LENGTH); + } // Field type stored in lower 3 bits of tag byte return this.data[this.offset] & this.TYPE; } @@ -472,6 +505,9 @@ class Protobuf { let shift = -3; let fieldNumber = 0; do { + if (this.offset >= this.LENGTH) { + throw this._parseError("Exhausted Buffer", this.LENGTH); + } fieldNumber += shift < 28 ? shift === -3 ? (this.data[this.offset] & this.NUMBER) >> -shift : @@ -495,6 +531,9 @@ class Protobuf { let shift = 0; // Keep reading while upper bit set do { + if (this.offset >= this.LENGTH) { + throw this._parseError("Exhausted Buffer", this.LENGTH); + } value += shift < 28 ? (this.data[this.offset] & this.VALUE) << shift : (this.data[this.offset] & this.VALUE) * Math.pow(2, shift); @@ -510,6 +549,9 @@ class Protobuf { * @returns {number} */ _uint64() { + if (this.offset + 8 > this.LENGTH) { + throw this._parseError("Exhausted Buffer", this.LENGTH); + } // Read off a Uint64 with little-endian const lowerHalf = this.data[this.offset++] + (this.data[this.offset++] * 0x100) + (this.data[this.offset++] * 0x10000) + this.data[this.offset++] * 0x1000000; const upperHalf = this.data[this.offset++] + (this.data[this.offset++] * 0x100) + (this.data[this.offset++] * 0x10000) + this.data[this.offset++] * 0x1000000; @@ -525,6 +567,9 @@ class Protobuf { _lenDelim(fieldNum) { // Read off the field length const length = this._varInt(); + if (this.offset + length > this.LENGTH) { + throw this._parseError("Exhausted Buffer", this.LENGTH); + } const fieldBytes = this.data.slice(this.offset, this.offset + length); let field; try { @@ -551,6 +596,9 @@ class Protobuf { * @returns {number} */ _uint32() { + if (this.offset + 4 > this.LENGTH) { + throw this._parseError("Exhausted Buffer", this.LENGTH); + } // Use a dataview to read off the integer const dataview = new DataView(new Uint8Array(this.data.slice(this.offset, this.offset + 4)).buffer); const value = dataview.getUint32(0, true); diff --git a/src/core/operations/ProtobufDecode.mjs b/src/core/operations/ProtobufDecode.mjs index fbc16dc408..e625e94b2c 100644 --- a/src/core/operations/ProtobufDecode.mjs +++ b/src/core/operations/ProtobufDecode.mjs @@ -56,7 +56,9 @@ class ProtobufDecode extends Operation { try { return Protobuf.decode(input, args); } catch (err) { - throw new OperationError(err); + const message = err instanceof Error ? err.message : String(err); + const offset = Number.isInteger(err?.byteOffset) ? ` at byte offset ${err.byteOffset}` : ""; + throw new OperationError(message + offset); } } diff --git a/tests/operations/tests/Protobuf.mjs b/tests/operations/tests/Protobuf.mjs index 2131e72340..43ee6e115d 100644 --- a/tests/operations/tests/Protobuf.mjs +++ b/tests/operations/tests/Protobuf.mjs @@ -9,6 +9,88 @@ import TestRegister from "../../lib/TestRegister.mjs"; TestRegister.addTests([ + { + name: "Protobuf Decode: malformed input reports byte offset", + input: "08", + expectedOutput: "Exhausted Buffer at byte offset 1", + recipeConfig: [ + { + "op": "From Hex", + "args": ["Auto"] + }, + { + "op": "Protobuf Decode", + "args": ["", false, false] + } + ] + }, + { + name: "Protobuf Decode: invalid wire type reports byte offset", + input: "0f", + expectedOutput: "Unknown type 0x7 at byte offset 0", + recipeConfig: [ + { + "op": "From Hex", + "args": ["Auto"] + }, + { + "op": "Protobuf Decode", + "args": ["", false, false] + } + ] + }, + { + name: "Protobuf Decode: continued field header reports byte offset", + input: "80", + expectedOutput: "Exhausted Buffer at byte offset 1", + recipeConfig: [ + { "op": "From Hex", "args": ["Auto"] }, + { "op": "Protobuf Decode", "args": ["", false, false] } + ] + }, + { + name: "Protobuf Decode: truncated fixed32 reports byte offset", + input: "05", + expectedOutput: "Exhausted Buffer at byte offset 1", + recipeConfig: [ + { "op": "From Hex", "args": ["Auto"] }, + { "op": "Protobuf Decode", "args": ["", false, false] } + ] + }, + { + name: "Protobuf Decode: truncated fixed64 reports byte offset", + input: "0901020304050607", + expectedOutput: "Exhausted Buffer at byte offset 8", + recipeConfig: [ + { "op": "From Hex", "args": ["Auto"] }, + { "op": "Protobuf Decode", "args": ["", false, false] } + ] + }, + { + name: "Protobuf Decode: truncated length-delimited payload reports byte offset", + input: "0a030102", + expectedOutput: "Exhausted Buffer at byte offset 4", + recipeConfig: [ + { "op": "From Hex", "args": ["Auto"] }, + { "op": "Protobuf Decode", "args": ["", false, false] } + ] + }, + { + name: "Protobuf Decode: schema-backed nested failure reports absolute byte offset", + input: "0a0108", + expectedOutput: "Input RangeError: index out of range: 3 + 1 > 3 at byte offset 3", + recipeConfig: [ + { "op": "From Hex", "args": ["Auto"] }, + { + "op": "Protobuf Decode", + "args": [ + "message Outer { optional Inner inner = 1; } message Inner { optional int32 value = 1; }", + false, + false + ] + } + ] + }, { name: "Protobuf Decode: no schema", input: "0d1c0000001203596f751a024d65202b2a0a0a066162633132331200",