Skip to content

Commit 03a7c07

Browse files
authored
Fast fail array decoding (#132)
* fast fail when array length exceeds bytes left to decode
1 parent 391985f commit 03a7c07

File tree

6 files changed

+168
-2
lines changed

6 files changed

+168
-2
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ project adheres to [Semantic Versioning](http://semver.org/).
1111
### Fixed
1212
* Removed [the custom `Buffer.subarray` polyfill](https://github.com/stellar/js-xdr/pull/118) introduced in v3.1.1 to address the issue of it not being usable in the React Native Hermes engine. We recommend using [`@exodus/patch-broken-hermes-typed-arrays`](https://github.com/ExodusMovement/patch-broken-hermes-typed-arrays) as an alternative. If needed, please review and consider manually adding it to your project ([#128](https://github.com/stellar/js-xdr/pull/128)).
1313

14+
* Decoding Array and VarArray now fast fails when the array length exceeds remaining bytes to decode ([#132](https://github.com/stellar/js-xdr/pull/132))
15+
1416
## [v3.1.2](https://github.com/stellar/js-xdr/compare/v3.1.1...v3.1.2)
1517

1618
### Fixed

src/array.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NestedXdrType } from './xdr-type';
2-
import { XdrWriterError } from './errors';
2+
import { XdrWriterError, XdrReaderError } from './errors';
33

44
export class Array extends NestedXdrType {
55
constructor(childType, length, maxDepth = NestedXdrType.DEFAULT_MAX_DEPTH) {
@@ -12,6 +12,15 @@ export class Array extends NestedXdrType {
1212
* @inheritDoc
1313
*/
1414
read(reader, remainingDepth = this._maxDepth) {
15+
// Upper-bound fast-fail: remaining bytes is a loose capacity check since
16+
// each XDR element typically consumes more than 1 byte (e.g., 4+ bytes).
17+
if (this._length > reader.remainingBytes()) {
18+
throw new XdrReaderError(
19+
`Array length ${
20+
this._length
21+
} exceeds remaining ${reader.remainingBytes()} bytes`
22+
);
23+
}
1524
NestedXdrType.checkDepth(remainingDepth);
1625
// allocate array of specified length
1726
const result = new global.Array(this._length);

src/serialization/xdr-reader.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ export class XdrReader {
8888
this._index = 0;
8989
}
9090

91+
/**
92+
* Remaining unread bytes in the source buffer
93+
* @return {Number}
94+
*/
95+
remainingBytes() {
96+
return this._length - this._index;
97+
}
98+
9199
/**
92100
* Read byte array from the buffer
93101
* @param {Number} size - Bytes to read

src/var-array.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ export class VarArray extends NestedXdrType {
2424
`saw ${length} length VarArray, max allowed is ${this._maxLength}`
2525
);
2626

27+
// Upper-bound fast-fail: remaining bytes is a loose capacity check since
28+
// each XDR element typically consumes more than 1 byte (e.g., 4+ bytes)
29+
if (length > reader.remainingBytes()) {
30+
throw new XdrReaderError(
31+
`VarArray length ${length} exceeds remaining ${reader.remainingBytes()} bytes`
32+
);
33+
}
34+
2735
const result = new Array(length);
2836
for (let i = 0; i < length; i++) {
2937
result[i] = this._childType.read(reader, remainingDepth - 1);

test/unit/array_test.js

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,28 @@ let one = new XDR.Array(XDR.Int, 1);
66
let many = new XDR.Array(XDR.Int, 2);
77

88
describe('Array#read', function () {
9+
function createFixedSizeChild() {
10+
let calls = 0;
11+
12+
return {
13+
FixedSizeChild: class FixedSizeChild {
14+
static read(io) {
15+
calls += 1;
16+
return io.readInt32BE();
17+
}
18+
19+
static write() {}
20+
21+
static isValid() {
22+
return true;
23+
}
24+
},
25+
getCalls() {
26+
return calls;
27+
}
28+
};
29+
}
30+
931
it('decodes correctly', function () {
1032
expect(read(zero, [])).to.eql([]);
1133
expect(read(zero, [0x00, 0x00, 0x00, 0x00])).to.eql([]);
@@ -23,8 +45,54 @@ describe('Array#read', function () {
2345

2446
it("throws XdrReaderError when the byte stream isn't large enough", function () {
2547
expect(() => read(many, [0x00, 0x00, 0x00, 0x00])).to.throw(
26-
/read outside the boundary/i
48+
/(read outside the boundary|insufficient bytes)/i
49+
);
50+
});
51+
52+
it('fast-fails before decoding child elements when remaining bytes are insufficient', function () {
53+
const { FixedSizeChild, getCalls } = createFixedSizeChild();
54+
55+
const fixed = new XDR.Array(FixedSizeChild, 5);
56+
const reader = new XdrReader([0x00, 0x00, 0x00, 0x01]);
57+
expect(() => fixed.read(reader)).to.throw(
58+
new RegExp(`exceeds remaining ${reader.remainingBytes()} bytes`, 'i')
59+
);
60+
expect(getCalls()).to.eql(0);
61+
});
62+
63+
it('decodes on exact-fit byte length and reads each child exactly once', function () {
64+
const { FixedSizeChild, getCalls } = createFixedSizeChild();
65+
66+
const fixed = new XDR.Array(FixedSizeChild, 2);
67+
const reader = new XdrReader([
68+
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02
69+
]);
70+
71+
expect(fixed.read(reader)).to.eql([1, 2]);
72+
expect(getCalls()).to.eql(2);
73+
});
74+
75+
it('zero-length array does not decode child elements', function () {
76+
const { FixedSizeChild, getCalls } = createFixedSizeChild();
77+
78+
const fixed = new XDR.Array(FixedSizeChild, 0);
79+
const reader = new XdrReader([0x01, 0x02, 0x03, 0x04]);
80+
81+
expect(fixed.read(reader)).to.eql([]);
82+
expect(getCalls()).to.eql(0);
83+
});
84+
85+
it('keeps reader position unchanged on Array fast-fail', function () {
86+
const { FixedSizeChild } = createFixedSizeChild();
87+
88+
const fixed = new XDR.Array(FixedSizeChild, 3);
89+
const reader = new XdrReader([0x00, 0x00]);
90+
const before = reader.remainingBytes();
91+
92+
expect(() => fixed.read(reader)).to.throw(
93+
new RegExp(`exceeds remaining ${reader.remainingBytes()} bytes`, 'i')
2794
);
95+
expect(reader.remainingBytes()).to.eql(before);
2896
});
2997

3098
function read(arr, bytes) {

test/unit/var-array_test.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,28 @@ import { XdrWriter } from '../../src/serialization/xdr-writer';
44
const subject = new XDR.VarArray(XDR.Int, 2);
55

66
describe('VarArray#read', function () {
7+
function createFixedSizeChild() {
8+
let calls = 0;
9+
10+
return {
11+
FixedSizeChild: class FixedSizeChild {
12+
static read(io) {
13+
calls += 1;
14+
return io.readInt32BE();
15+
}
16+
17+
static write() {}
18+
19+
static isValid() {
20+
return true;
21+
}
22+
},
23+
getCalls() {
24+
return calls;
25+
}
26+
};
27+
}
28+
729
it('decodes correctly', function () {
830
expect(read([0x00, 0x00, 0x00, 0x00])).to.eql([]);
931

@@ -26,6 +48,55 @@ describe('VarArray#read', function () {
2648
expect(() => read([0x00, 0x00, 0x00, 0x03])).to.throw(/read error/i);
2749
});
2850

51+
it('fast-fails when declared length cannot fit remaining bytes for fixed-size child', function () {
52+
const { FixedSizeChild, getCalls } = createFixedSizeChild();
53+
54+
const arr = new XDR.VarArray(FixedSizeChild, 10);
55+
const io = new XdrReader([0x00, 0x00, 0x00, 0x02]);
56+
const remainingAfterLengthPrefix = io.remainingBytes() - 4;
57+
58+
expect(() => arr.read(io)).to.throw(
59+
new RegExp(`exceeds remaining ${remainingAfterLengthPrefix} bytes`, 'i')
60+
);
61+
expect(getCalls()).to.eql(0);
62+
});
63+
64+
it('decodes on exact-fit payload and reads each child once', function () {
65+
const { FixedSizeChild, getCalls } = createFixedSizeChild();
66+
67+
const arr = new XDR.VarArray(FixedSizeChild, 10);
68+
const io = new XdrReader([
69+
0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02
70+
]);
71+
72+
expect(arr.read(io)).to.eql([1, 2]);
73+
expect(getCalls()).to.eql(2);
74+
});
75+
76+
it('checks maxLength before insufficient-bytes fast-fail', function () {
77+
const { FixedSizeChild, getCalls } = createFixedSizeChild();
78+
79+
const arr = new XDR.VarArray(FixedSizeChild, 1);
80+
const io = new XdrReader([0x00, 0x00, 0x00, 0x02]);
81+
82+
expect(() => arr.read(io)).to.throw(/max allowed/i);
83+
expect(getCalls()).to.eql(0);
84+
});
85+
86+
it('consumes only the 4-byte length prefix on VarArray insufficient-bytes fast-fail', function () {
87+
const { FixedSizeChild } = createFixedSizeChild();
88+
89+
const arr = new XDR.VarArray(FixedSizeChild, 10);
90+
const io = new XdrReader([0x00, 0x00, 0x00, 0x02]);
91+
const before = io.remainingBytes();
92+
const remainingAfterLengthPrefix = io.remainingBytes() - 4;
93+
94+
expect(() => arr.read(io)).to.throw(
95+
new RegExp(`exceeds remaining ${remainingAfterLengthPrefix} bytes`, 'i')
96+
);
97+
expect(before - io.remainingBytes()).to.eql(4);
98+
});
99+
29100
function read(bytes) {
30101
let io = new XdrReader(bytes);
31102
return subject.read(io);

0 commit comments

Comments
 (0)