Skip to content
TomerAberbachPublic

About

đŸȘ„ Convert a JS value to JS source code, like eval in reverse.

Topics

Resources

Stars

8 stars

Watchers

0 watching

Forks

Repository files navigation

uneval

đŸȘ„ Convert a JS value to JS source code, like eval in reverse.
Features ‱ Priorities ‱ Install ‱ Usage ‱ Security guarantees ‱ Comparison

Features

  • Shared and circular references
  • Every built-in type, including sparse arrays, -0, invalid Dates, null-prototype objects, property descriptors, Temporal, and TypedArrays with non-canonical NaNs
  • Safe to run on untrusted input and to embed in a <script> tag
  • Custom types via a callback
  • Passes more roundtrip tests than any other package compared

See the full list of supported types.

Priorities

We prioritize these metrics in the following order:

  1. Security (see our guarantees)
  2. Correctness (i.e. (0, eval)(`(${uneval(value)})`) roundtrips)
  3. Generated source size (human-readable output is a non-goal)
  4. Generated source runtime performance
  5. uneval runtime performance
  6. uneval bundle size

Note that we do still care about metrics lower on the list. We just care about other metrics more.

Install

$ npm i uneval

Usage

import assert from 'node:assert'
import uneval from 'uneval'

const object = { message: `hello world` }

const source = uneval(object)
console.log(source)
//=> {message:"hello world"}

const roundtrippedObject = (0, eval)(`(${source})`)
assert.deepEqual(roundtrippedObject, object)

const circularObject = {}
circularObject.self = circularObject

const circularSource = uneval(circularObject)
console.log(circularSource)
//=> (a=>a.self=a)({})

const roundtrippedCircularObject = (0, eval)(`(${circularSource})`)
assert.deepEqual(roundtrippedCircularObject, circularObject)

Customization

Warning

We cannot ensure our security guarantees when the custom option is used.

uneval accepts a custom callback for unevaling values.

import assert from 'node:assert'
import uneval from 'uneval'

class Person {
  constructor(name) {
    this.name = name
  }
}

const people = {
  tomer: new Person(`Tomer`),
  amanda: new Person(`Amanda`),
}

const source = uneval(people, {
  custom: (value, uneval) => {
    if (value instanceof Person) {
      return `new Person(${uneval(value.name)})`
    }
  },
})
console.log(source)
//=> {tomer:new Person("Tomer"),amanda:new Person("Amanda")}

const roundtrippedPeople = (0, eval)(`(${source})`)
assert.deepEqual(roundtrippedPeople, people)

Return the following types depending on the desired behavior:

  • string to provide custom source for the input value
  • null to omit the input value from the output (e.g. in arrays, objects, Sets, Maps). Omitting the root value throws an Error.
  • undefined (or don't return anything, which is equivalent) to use the default behavior for the input value

The callback can be used to uneval already supported values differently or to uneval unsupported values such as functions. It also receives a second uneval param, which is the same uneval function the options were passed to. You can use it to delegate back to uneval for sub-values.

Note

custom is only called for each logical value. It is not called for sub-values that are incidentally unevaled as part of unevaling a value.

Some examples:

  • custom is called for each element of an Array because an Array is a container and its elements are its logical sub-values.
  • custom is called for each Date value, but not for a Date value's underlying numerical timestamp because the Date itself is the logical value while new Date(numericalTimestamp) is just one way a Date could be unevaled. Other ways are possible, which means the numerical timestamp in the source is just an implementation detail, not a logical sub-value.
  • custom is called for each ArrayBuffer value even when it's nested within a TypedArray or Node Buffer because the underlying ArrayBuffer is the logical sub-value backing these parent values. The fact that it can be shared between TypedArray and/or Node Buffer instances is proof of this.

This principle is a bit hand-wavy, but we use our best judgement. If you find a scenario where custom doesn't work the way you expect, then create an issue.

Supported types

Security guarantees

These guarantees assume the attacker controls the input value, but not the environment. uneval inspects values through the standard globals and prototype methods, so replaced globals and patched prototypes are out of scope. An attacker who can tamper with the environment already runs code in the process.

The following are safe UNLESS custom is used:

  1. Running uneval on untrusted input.

    uneval returns a string and never evaluates the input, so converting an untrusted value runs no attacker code through uneval itself.

    Converting a value reads it, though, so a coercion method the input defines on itself runs while uneval inspects it (e.g. its own valueOf). That runs the input's own code, but the returned string stays safe. Treat converting untrusted input the way you would treat JSON.stringify of the same input, which runs the input's own toJSON.

  2. Running (0, eval)(`(${uneval(value)})`).

    Call eval indirectly, as (0, eval)(...), so the evaluated source runs in the global scope instead of the surrounding one and cannot read local variables. new Function(...) runs in the global scope for the same reason.

  3. Embedding uneval(value) in JS source code, including inside an HTML <script> tag.

    We always escape </script> to avoid the following XSS attack:

    const value = {
      untrustedInput: `</script><script src='https://evil.com/hacked.js'>`,
    }
    
    const html = `
      <script>
        var preloaded = ${uneval(value)};
      </script>
    `

    Without escaping, we'd end up with this (after formatting):

    <script>
      var preloaded = {untrustedInput:"
    </script>
    <!-- Oh no! We've loaded an evil script :( -->
    <script src="https://evil.com/hacked.js">
      "}
    </script>

    But with escaping we get:

    <script>
      var preloaded = {
        untrustedInput:
          "\u003c/script>\u003cscript src='https://evil.com/hacked.js'>",
      }
    </script>
  4. Running uneval on a pooled Buffer.

    unevaling the underlying ArrayBuffer of a pooled Buffer and using it in the output source would be more correct from a roundtripping perspective, but wouldn't be safe because the underlying ArrayBuffer may contain sensitive data from other Buffers.

    Instead, a Buffer is unevaled with a sliced ArrayBuffer containing only the data in its view if the ArrayBuffer's size matches Buffer.poolSize. This results in false-positives for Buffers containing ArrayBuffers that coincidentally have a size of Buffer.poolSize.

    To force an ArrayBuffer to be unevaled as-is, include it elsewhere in the input to signal that it's expected to be in the output. For example:

    const poolSizeArrayBuffer = new ArrayBuffer(Buffer.poolSize)
    const source = uneval([
      poolSizeArrayBuffer,
      Buffer.from(poolSizeArrayBuffer, 5, 3),
    ])
    console.log(source)
    //=> (a=>[a,Buffer.from(a,5,3)])(new ArrayBuffer(8192))

Comparison

The tables are auto-generated. docs/comparison.md shows the result of each roundtrip test for each package.

The table below shows the results of unevaling the same 5,000 randomly generated values with each package. The numbers are from one machine and vary between runs. Each row also shows how many of the roundtrip tests the package passes, because a faster package may pass fewer of them.

Package Tests passing Ops/sec Mean Relative
uneval 🟱 505/515 134 7.51ms ±1.98% 2.07× slower
seroval@1⁠.⁠6⁠.⁠4 (sync) 🟡 365/515 124 8.15ms ±2.48% 2.24× slower
devalue@5⁠.⁠9⁠.⁠2 🟡 363/515 82.7 12.11ms ±1.08% 3.36× slower
javascript⁠-⁠stringify@2⁠.⁠1⁠.⁠0 🟠 213/515 85.0 11.85ms ±2.22% 3.27× slower
serialize⁠-⁠javascript@7⁠.⁠1⁠.⁠1 🟠 156/515 111 9.06ms ±1.08% 2.51× slower
jsesc@3⁠.⁠1⁠.⁠0 🟠 141/515 36.0 27.77ms ±0.88% 7.71× slower
tosource@2⁠.⁠0⁠.⁠0⁠-⁠alpha⁠.⁠3 🟠 141/515 134 7.50ms ±1.22% 2.08× slower
js⁠-⁠stringify@1⁠.⁠0⁠.⁠2 🔮 71/515 278 3.61ms ±0.75% fastest

Found an inaccuracy? Please create an issue or a pull request!

Contributing

Stars are always welcome!

For bugs and feature requests, please create an issue.

Special thanks to Chakrit Wichian for donating the package name!

License

MIT ©

About

đŸȘ„ Convert a JS value to JS source code, like eval in reverse.

Topics

Resources

Stars

8 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages