diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..eae6dbee --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,23 @@ +# https://gitlab.com/gitlab-org/security-products/analyzers/fuzzers/jsfuzz + +name: Fuzz + +on: + push: + branches: + - main + pull_request: + +jobs: + fuzzing: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Setup Node.js + uses: actions/setup-node@v1 + with: + node-version: "16" + + - run: npm ci + - run: npm run test:fuzz diff --git a/.gitignore b/.gitignore index c67b64cb..a2b46560 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ isolate-*.log # flamebearer flamegraph.html + +# jsfuzz +corpus/ diff --git a/package.json b/package.json index f4a31df8..3d208202 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:cover:purejs": "npx nyc --no-clean npm run test:purejs", "test:cover:te": "npx nyc --no-clean npm run test:te", "test:deno": "deno test test/deno_test.ts", + "test:fuzz": "npm exec -- jsfuzz@git+https://gitlab.com/gitlab-org/security-products/analyzers/fuzzers/jsfuzz.git --fuzzTime 60 --no-versifier test/decode.jsfuzz.js corpus", "cover:clean": "rimraf .nyc_output coverage/", "cover:report": "npx nyc report --reporter=text-summary --reporter=html --reporter=json", "test:browser": "karma start --single-run", diff --git a/src/DecodeError.ts b/src/DecodeError.ts new file mode 100644 index 00000000..39d8e08f --- /dev/null +++ b/src/DecodeError.ts @@ -0,0 +1,16 @@ + +export class DecodeError extends Error { + constructor(message: string) { + super(message); + + // fix the prototype chain in a cross-platform way + const proto: typeof DecodeError.prototype = Object.create(DecodeError.prototype); + Object.setPrototypeOf(this, proto); + + Object.defineProperty(this, "name", { + configurable: true, + enumerable: false, + value: DecodeError.name, + }); + } +} diff --git a/src/Decoder.ts b/src/Decoder.ts index 71f5104a..39ca7a25 100644 --- a/src/Decoder.ts +++ b/src/Decoder.ts @@ -4,6 +4,7 @@ import { getInt64, getUint64 } from "./utils/int"; import { utf8DecodeJs, TEXT_DECODER_THRESHOLD, utf8DecodeTD } from "./utils/utf8"; import { createDataView, ensureUint8Array } from "./utils/typedArrays"; import { CachedKeyDecoder, KeyDecoder } from "./CachedKeyDecoder"; +import { DecodeError } from "./DecodeError"; const enum State { ARRAY, @@ -60,22 +61,6 @@ const DEFAULT_MAX_LENGTH = 0xffff_ffff; // uint32_max const sharedCachedKeyDecoder = new CachedKeyDecoder(); -export class DecodeError extends Error { - constructor(message: string) { - super(message); - - // fix the prototype chain in a cross-platform way - const proto: typeof DecodeError.prototype = Object.create(DecodeError.prototype); - Object.setPrototypeOf(this, proto); - - Object.defineProperty(this, "name", { - configurable: true, - enumerable: false, - value: DecodeError.name, - }); - } -} - export class Decoder { private totalPos = 0; private pos = 0; @@ -133,6 +118,10 @@ export class Decoder { return new RangeError(`Extra ${view.byteLength - pos} of ${view.byteLength} byte(s) found at buffer[${posToShow}]`); } + /** + * @throws {DecodeError} + * @throws {RangeError} + */ public decode(buffer: ArrayLike | BufferSource): unknown { this.reinitializeState(); this.setBuffer(buffer); diff --git a/src/index.ts b/src/index.ts index 0c71804e..28560f1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,8 +13,9 @@ export { DecodeOptions }; import { decodeAsync, decodeArrayStream, decodeMultiStream, decodeStream } from "./decodeAsync"; export { decodeAsync, decodeArrayStream, decodeMultiStream, decodeStream }; -import { Decoder, DecodeError } from "./Decoder"; -export { Decoder, DecodeError }; +import { Decoder, DataViewIndexOutOfBoundsError } from "./Decoder"; +import { DecodeError } from "./DecodeError"; +export { Decoder, DecodeError, DataViewIndexOutOfBoundsError }; import { Encoder } from "./Encoder"; export { Encoder }; diff --git a/src/timestamp.ts b/src/timestamp.ts index 79ee1b96..e3fe0155 100644 --- a/src/timestamp.ts +++ b/src/timestamp.ts @@ -1,4 +1,5 @@ // https://github.com/msgpack/msgpack/blob/master/spec.md#timestamp-extension-type +import { DecodeError } from "./DecodeError"; import { getInt64, setInt64 } from "./utils/int"; export const EXT_TIMESTAMP = -1; @@ -91,7 +92,7 @@ export function decodeTimestampToTimeSpec(data: Uint8Array): TimeSpec { return { sec, nsec }; } default: - throw new Error(`Unrecognized data size for timestamp: ${data.length}`); + throw new DecodeError(`Unrecognized data size for timestamp (expected 4, 8, or 12): ${data.length}`); } } diff --git a/test/decode.jsfuzz.js b/test/decode.jsfuzz.js new file mode 100644 index 00000000..8dbe634d --- /dev/null +++ b/test/decode.jsfuzz.js @@ -0,0 +1,30 @@ +/* eslint-disable */ +const assert = require("assert"); +const { Decoder, encode, DecodeError } = require("../dist/index.js"); + +/** + * @param {Buffer} bytes + * @returns {void} + */ +module.exports.fuzz = function fuzz(bytes) { + const decoder = new Decoder(); + try { + decoder.decode(bytes); + } catch (e) { + if (e instanceof DecodeError) { + // ok + } else if (e instanceof RangeError) { + // ok + } else { + throw e; + } + } + + // make sure the decoder instance is not broken + const object = { + foo: 1, + bar: 2, + baz: ["one", "two", "three"], + }; + assert.deepStrictEqual(decoder.decode(encode(object)), object); +}