0

Sometimes it's useful to store function names and parameters in a JSON-like object structure, to allow inclusion of values in that vary, or are otherwise not known until run-time. For example, AWS Cloudformation allows functions prefixed with Fn:: to be included, such as Fn::Select, an example of which might be:

const sourceObject = {
  "fruit": { "Fn::Select" : [ "1", [ "apples", "grapes", "oranges", "mangoes" ] ] },
  "somethingElse": [
    {"Fn::Join" : [ " ", [ "a", "b", "c" ] ]},
    "some value"
  ]
}

const substituted = substituteFunctionsInObject(sourceObject) 
// {"fruit": "grapes", "somethingElse":["a b c", "some value"]}

This form of substitution gives some balance between expressive freedom, allowing data to be stored and passed around in pure JSON, and preventing arbitrary code injection. (See for example EVO's answer to How to store a javascript function in JSON for another example, which uses "$" as a function tag instead of "Fn::")

Can anybody suggest a good version of the function that does such a substitution (substituteFunctionsInObject in the example above)? Perhaps one in an existing library, or one more elegant or less problematic that what I might have thought of.

Such a function should allow specification of arbitrary functions to substitute, and should work recursively (deeply), perhaps allowing function arguments to be generated by additional function calls.

I've written my own implementation based on lodash's transform function, and allowing additional data to be supplied via curried arguments. This works, but I can't help thinking this problem must have been solved already in a more rigorous way (since AWS, for example, uses it), perhaps using a more elegant functional programming pattern.

const {transform} = require('lodash')   // fp.transform is broken
const fp = require('lodash/fp')

const substituteFunctionsInObject = ({
  validFunctions = {
    "date": x=>new Date(x),
    "get": fp.get
  },
  getFunctionTag = fnName => "$" + fnName
}={}) => function applyFunctionsInObjectInner (obj){
  return (...data) => transform(    
    obj,
    (acc, item, key) => {
      const firstKey = Object.keys(item)?.[0];
      const functionIndex = Object.keys(validFunctions).map(getFunctionTag).indexOf(firstKey);
      const newItem = (fp.isPlainObject(item) || fp.isArray(item) ) 
        // only allow where one key, which is valid function name
        ? (firstKey && Object.keys(item).length===1 && functionIndex >-1 )  
          // call the function 
          ? Object.values(validFunctions)[functionIndex](     
            // allow substitution on arguments
            ...applyFunctionsInObjectInner(fp.castArray(item[firstKey]))(...data),
            // optional data 
            ...fp.castArray(data)       
          )  
          // recurse objects (deepness)
          : applyFunctionsInObjectInner(item)(...data) 
        : item ;        // pass non-object/array values directly
      acc[key]=newItem;
    }
  ) 
}

const source = {
  dateExample: {"$date":["01-01-2020"]},
  getExample: {"$get":"test"},
  getWithRecursedArg: {"$get":{"$get":"test2"}},
};
const data = {"test":99,"test2":"test"};
const result = substituteFunctionsInObject({
  get: fp.get,
  date: x=>new Date(x)
})(source)(data);
console.log(JSON.stringify(result))  
// {"dateExample":"2020-01-01T00:00:00.000Z","getExample":99,"getWithRecursedArg":99}

phhu
  • 1,462
  • 13
  • 33

2 Answers2

2

JSON.parse(jsonString[, reviver]) accepts an optional second argument, reviver. It's not possible for a malicious JSON string to inject harmful code as you do not run arbitrary functions. Instead you specifically defined which keys are callable and define the behaviour for each. Note fn:: prefix is completely optional -

const callable = {
  "fn::console.log": console.log,
  "fn::times10": x => x * 10
}

function reviver(key, value) {
  if (callable[key] == null)
    return value
  else
    return callable[key](value)
}
 
const j = `{"a": 1, "b": [{ "fn::times10": 5 }], "c": { "fn::console.log": "hi" }}`

const o = JSON.parse(j, reviver)

console.log(o)
hi
{
  "a": 1,
  "b": [
    {
      "fn::times10": 5
    }
  ],
  "c": {}  // empty object because `console.log` returns undefined!
}

Note there is not requirement to pull lodash in for this. This maybe isn't the exact form you are looking for but it should be enough to get you started.

user3297291
  • 22,592
  • 4
  • 29
  • 45
Mulan
  • 129,518
  • 31
  • 228
  • 259
  • Thanks, very useful. MDN docs for JSON.parse reviver are at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse . Worth noting that this is bound to the object of which key is part (if a non-arrow function is used), so it's possible to add (and in effect change) key names using a reviver. The AWS model of replacing an object with the function output can be done by checking if `value` is an object (with an appropriate key, etc). – phhu Apr 20 '22 at 08:43
1

Based on Mulan's very helpful answer, suggesting using JSON.parse with a reviver function as a second argument (docs), a pure Javascript (no lodash) version of the function I was looking for might look like this (with a curried argument for a data object, needed in my use case):

const substituteFunctionsInObject = ({
  functions = {}, /* { add: (x,y)=>x+y, get: prop=>data=>data[prop] } */ 
  addFunctionTag = f => "$"+f,
} = {}) => jsonLikeObj => data => {

  function reviveFunctions(key, item) {
    if (typeof item === "object" && item != null && !Array.isArray(item)) {
      const [firstKey, args] = Object.entries(item)?.[0] ?? [];
      const func = Object.entries(functions)
        .find(([key, f]) => addFunctionTag(key) === firstKey)
        ?.[1];
      if (firstKey && func && Object.keys(item).length === 1) {
        const funcRes = func(  // call the function
          ...(
            [].concat(args)  // allow single arg without array
              .map(arg => reviveFunctions(null, arg))  // argument recursion 
          ),
          //...[data]    // could also apply data here?
        );
        // optional data as curried last argument:
        return typeof (funcRes) === "function" ? funcRes(data) : funcRes;
      }
    };
    return item;  // default to unmodified
  }

  return JSON.parse(JSON.stringify(jsonLikeObj), reviveFunctions);
}

I haven't tested this extensively, so there may be faults, especially around type checks. A basic test case and output are below:

// BASIC TEST CASE
const obj = {
  someGet: { $get: ["test", { test: 1 }] },
  getCurriedFromData: [
    { $getCurried: "test" },
    { $getCurried: ["test"] },
  ],
  someDates: { fixed: { $date: ["01-01-2020"] }, now: { "$date": [] } },
  recursiveArgs: {
    $get: [
      { "$always": "test" }, { "$always": [{ "test": { "$always": 3 } }] }
    ]
  },
  always: { "$always": "unchanged" },
  added: { $add: [1, { $add: [2, 3.1] }] },
  invalidFuncName: { $invalid: "fakearg" },
  literals: [
    1, -1.23, "string", "$string", 
    null, undefined, false, true, 
    [], , [[]], {}, { p: "val" }
  ]
};
const data = { "test": 2 };

console.log(
  substituteFunctionsInObject({
    functions: {
      date: x => new Date(x === undefined ? Date.now() : x),
      get: (label, x) => x[label],
      getCurried: label => x => x[label],
      always: val => x => val,
      add: (x, y) => x + y,
    }
  })(obj)(data)
);

/*
{
  someGet: 1,
  getCurriedFromData: [ 2, 2 ],
  someDates: { 
    fixed: 2020-01-01T00:00:00.000Z,
    now: 2022-04-20T16:05:27.536Z
  },
  recursiveArgs: 3,
  always: 'unchanged',
  added: 6.1,
  invalidFuncName: { '$invalid': 'fakearg' },
  literalsDontChange: [
    1,            -1.23,
    'string',     '$string',
    null,         null,
    false,        true,
    [],           null,
    [ [] ],       {},
    { p: 'val' }
  ]
}
*/
phhu
  • 1,462
  • 13
  • 33