Skip to content

Commit a737d05

Browse files
authored
feat: runtime size limits for arrays and maps (#128)
It's possible to limit the size of arrays and maps at compile time: ```protobuf message MyMessage { repeated uint32 repeatedField = 1 [(protons.options).limit = 10]; map<string, string> stringMap = 2 [(protons.options).limit = 10]; } ``` This PR adds the ability to do it at runtime too: ```TypeScript const message = MyMessage.decode(buf, { limits: { repeatedField: 10, stringMap: 10 } }) ```
1 parent 3234bb6 commit a737d05

27 files changed

+625
-195
lines changed

packages/protons-runtime/src/codec.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,15 @@ export interface EncodeFunction<T> {
1919
(value: Partial<T>, writer: Writer, opts?: EncodeOptions): void
2020
}
2121

22+
export interface DecodeOptions<T> {
23+
/**
24+
* Runtime-specified limits for lengths of repeated/map fields
25+
*/
26+
limits?: Partial<Record<keyof T, number>>
27+
}
28+
2229
export interface DecodeFunction<T> {
23-
(reader: Reader, length?: number): T
30+
(reader: Reader, length?: number, opts?: DecodeOptions<T>): T
2431
}
2532

2633
export interface Codec<T> {
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { createCodec, CODEC_TYPES, type EncodeOptions, type Codec } from '../codec.js'
2-
import type { Reader, Writer } from '../index.js'
1+
import { createCodec, CODEC_TYPES, type EncodeFunction, type DecodeFunction, type Codec } from '../codec.js'
32

43
export interface Factory<A, T> {
54
new (obj: A): T
65
}
76

8-
export function message <T> (encode: (obj: Partial<T>, writer: Writer, opts?: EncodeOptions) => void, decode: (reader: Reader, length?: number) => T): Codec<T> {
7+
export function message <T> (encode: EncodeFunction<T>, decode: DecodeFunction<T>): Codec<T> {
98
return createCodec('message', CODEC_TYPES.LENGTH_DELIMITED, encode, decode)
109
}
+3-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { createReader } from './utils/reader.js'
2-
import type { Codec } from './codec.js'
2+
import type { Codec, DecodeOptions } from './codec.js'
33
import type { Uint8ArrayList } from 'uint8arraylist'
44

5-
export function decodeMessage <T> (buf: Uint8Array | Uint8ArrayList, codec: Codec<T>): T {
5+
export function decodeMessage <T> (buf: Uint8Array | Uint8ArrayList, codec: Codec<T>, opts?: DecodeOptions<T>): T {
66
const reader = createReader(buf)
77

8-
return codec.decode(reader)
8+
return codec.decode(reader, undefined, opts)
99
}

packages/protons-runtime/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export { enumeration } from './codecs/enum.js'
2020
export { message } from './codecs/message.js'
2121
export { createReader as reader } from './utils/reader.js'
2222
export { createWriter as writer } from './utils/writer.js'
23-
export type { Codec, EncodeOptions } from './codec.js'
23+
export type { Codec, EncodeOptions, DecodeOptions } from './codec.js'
2424

2525
export interface Writer {
2626
/**

packages/protons/README.md

+56
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
- [Install](#install)
1313
- [Usage](#usage)
1414
- [Differences from protobuf.js](#differences-from-protobufjs)
15+
- [Extra features](#extra-features)
16+
- [Limiting the size of repeated/map elements](#limiting-the-size-of-repeatedmap-elements)
17+
- [Overriding 64 bit types](#overriding-64-bit-types)
1518
- [Missing features](#missing-features)
1619
- [API Docs](#api-docs)
1720
- [License](#license)
@@ -73,6 +76,59 @@ It does have one or two differences:
7376
5. `map` fields can have keys of any type - protobufs.js [only supports strings](https://github.com/protobufjs/protobuf.js/issues/1203#issuecomment-488637338)
7477
6. `map` fields are deserialized as ES6 `Map`s - protobuf.js uses `Object`s
7578

79+
## Extra features
80+
81+
### Limiting the size of repeated/map elements
82+
83+
To protect decoders from malicious payloads, it's possible to limit the maximum size of repeated/map elements.
84+
85+
You can either do this at compile time by using the [protons.options](https://github.com/protocolbuffers/protobuf/blob/6f1d88107f268b8ebdad6690d116e74c403e366e/docs/options.md?plain=1#L490-L493) extension:
86+
87+
```protobuf
88+
message MyMessage {
89+
// repeatedField cannot have more than 10 entries
90+
repeated uint32 repeatedField = 1 [(protons.options).limit = 10];
91+
92+
// stringMap cannot have more than 10 keys
93+
map<string, string> stringMap = 2 [(protons.options).limit = 10];
94+
}
95+
```
96+
97+
Or at runtime by passing objects to the `.decode` function of your message:
98+
99+
```TypeScript
100+
const message = MyMessage.decode(buf, {
101+
limits: {
102+
repeatedField: 10,
103+
stringMap: 10
104+
}
105+
})
106+
```
107+
108+
### Overriding 64 bit types
109+
110+
By default 64 bit types are implemented as [BigInt](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt)s.
111+
112+
Sometimes this is undesirable due to [performance issues](https://betterprogramming.pub/the-downsides-of-bigints-in-javascript-6350fd807d) or code legibility.
113+
114+
It's possible to override the JavaScript type 64 bit fields will deserialize to:
115+
116+
```protobuf
117+
message MyMessage {
118+
repeated int64 bigintField = 1;
119+
repeated int64 numberField = 2 [jstype = JS_NUMBER];
120+
repeated int64 stringField = 3 [jstype = JS_STRING];
121+
}
122+
```
123+
124+
```TypeScript
125+
const message = MyMessage.decode(buf)
126+
127+
console.info(typeof message.bigintField) // bigint
128+
console.info(typeof message.numberField) // number
129+
console.info(typeof message.stringField) // string
130+
```
131+
76132
## Missing features
77133

78134
Some features are missing `OneOf`s, etc due to them not being needed so far in ipfs/libp2p. If these features are important to you, please open PRs implementing them along with tests comparing the generated bytes to `protobuf.js` and `pbjs`.

packages/protons/bin/protons.ts

-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ async function main (): Promise<void> {
1616
Examples
1717
$ protons ./path/to/file.proto ./path/to/other/file.proto
1818
`, {
19-
// @ts-expect-error importMeta is missing from the types
2019
importMeta: import.meta,
2120
flags: {
2221
output: {

packages/protons/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@
130130
"release": "aegir release"
131131
},
132132
"dependencies": {
133-
"meow": "^13.0.0",
133+
"meow": "^13.1.0",
134134
"protobufjs-cli": "^1.0.0"
135135
},
136136
"devDependencies": {

packages/protons/src/index.ts

+20-11
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,7 @@ export namespace ${messageDef.name} {
518518
moduleDef.addImport('protons-runtime', 'decodeMessage')
519519
moduleDef.addImport('protons-runtime', 'message')
520520
moduleDef.addTypeImport('protons-runtime', 'Codec')
521+
moduleDef.addTypeImport('protons-runtime', 'DecodeOptions')
521522
moduleDef.addTypeImport('uint8arraylist', 'Uint8ArrayList')
522523

523524
const interfaceFields = defineFields(fields, messageDef, moduleDef)
@@ -691,12 +692,16 @@ export interface ${messageDef.name} {
691692
const parseValue = `${decoderGenerators[type] == null ? `${codec}.decode(reader${type === 'message' ? ', reader.uint32()' : ''})` : decoderGenerators[type](jsTypeOverride)}`
692693

693694
if (fieldDef.map) {
694-
let limit = ''
695+
moduleDef.addImport('protons-runtime', 'CodeError')
695696

696-
if (fieldDef.lengthLimit != null) {
697-
moduleDef.addImport('protons-runtime', 'CodeError')
697+
let limit = `
698+
if (opts.limits?.${fieldName} != null && obj.${fieldName}.size === opts.limits.${fieldName}) {
699+
throw new CodeError('decode error - map field "${fieldName}" had too many elements', 'ERR_MAX_SIZE')
700+
}
701+
`
698702

699-
limit = `
703+
if (fieldDef.lengthLimit != null) {
704+
limit += `
700705
if (obj.${fieldName}.size === ${fieldDef.lengthLimit}) {
701706
throw new CodeError('decode error - map field "${fieldName}" had too many elements', 'ERR_MAX_SIZE')
702707
}
@@ -709,12 +714,16 @@ export interface ${messageDef.name} {
709714
break
710715
}`
711716
} else if (fieldDef.repeated) {
712-
let limit = ''
717+
moduleDef.addImport('protons-runtime', 'CodeError')
713718

714-
if (fieldDef.lengthLimit != null) {
715-
moduleDef.addImport('protons-runtime', 'CodeError')
719+
let limit = `
720+
if (opts.limits?.${fieldName} != null && obj.${fieldName}.length === opts.limits.${fieldName}) {
721+
throw new CodeError('decode error - map field "${fieldName}" had too many elements', 'ERR_MAX_LENGTH')
722+
}
723+
`
716724

717-
limit = `
725+
if (fieldDef.lengthLimit != null) {
726+
limit += `
718727
if (obj.${fieldName}.length === ${fieldDef.lengthLimit}) {
719728
throw new CodeError('decode error - repeated field "${fieldName}" had too many elements', 'ERR_MAX_LENGTH')
720729
}
@@ -750,7 +759,7 @@ ${encodeFields === '' ? '' : `${encodeFields}\n`}
750759
if (opts.lengthDelimited !== false) {
751760
w.ldelim()
752761
}
753-
}, (reader, length) => {
762+
}, (reader, length, opts = {}) => {
754763
const obj: any = {${createDefaultObject(fields, messageDef, moduleDef)}}
755764
756765
const end = length == null ? reader.len : reader.pos + length
@@ -777,8 +786,8 @@ ${encodeFields === '' ? '' : `${encodeFields}\n`}
777786
return encodeMessage(obj, ${messageDef.name}.codec())
778787
}
779788
780-
export const decode = (buf: Uint8Array | Uint8ArrayList): ${messageDef.name} => {
781-
return decodeMessage(buf, ${messageDef.name}.codec())
789+
export const decode = (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions<${messageDef.name}>): ${messageDef.name} => {
790+
return decodeMessage(buf, ${messageDef.name}.codec(), opts)
782791
}`
783792

784793
return `

packages/protons/test/fixtures/basic.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
/* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare */
55
/* eslint-disable @typescript-eslint/no-empty-interface */
66

7-
import { type Codec, decodeMessage, encodeMessage, message } from 'protons-runtime'
7+
import { type Codec, decodeMessage, type DecodeOptions, encodeMessage, message } from 'protons-runtime'
88
import type { Uint8ArrayList } from 'uint8arraylist'
99

1010
export interface Basic {
@@ -35,7 +35,7 @@ export namespace Basic {
3535
if (opts.lengthDelimited !== false) {
3636
w.ldelim()
3737
}
38-
}, (reader, length) => {
38+
}, (reader, length, opts = {}) => {
3939
const obj: any = {
4040
num: 0
4141
}
@@ -72,8 +72,8 @@ export namespace Basic {
7272
return encodeMessage(obj, Basic.codec())
7373
}
7474

75-
export const decode = (buf: Uint8Array | Uint8ArrayList): Basic => {
76-
return decodeMessage(buf, Basic.codec())
75+
export const decode = (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions<Basic>): Basic => {
76+
return decodeMessage(buf, Basic.codec(), opts)
7777
}
7878
}
7979

@@ -92,7 +92,7 @@ export namespace Empty {
9292
if (opts.lengthDelimited !== false) {
9393
w.ldelim()
9494
}
95-
}, (reader, length) => {
95+
}, (reader, length, opts = {}) => {
9696
const obj: any = {}
9797

9898
const end = length == null ? reader.len : reader.pos + length
@@ -119,7 +119,7 @@ export namespace Empty {
119119
return encodeMessage(obj, Empty.codec())
120120
}
121121

122-
export const decode = (buf: Uint8Array | Uint8ArrayList): Empty => {
123-
return decodeMessage(buf, Empty.codec())
122+
export const decode = (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions<Empty>): Empty => {
123+
return decodeMessage(buf, Empty.codec(), opts)
124124
}
125125
}

0 commit comments

Comments
 (0)