Dynamic json templates that interpolate ruby variables and objects

Viewed 186

We're building an outgoing webhook functionality into our app. We want to enable users who add a webhook to customize the json payload that's sent. To do this, we need a syntax that allows variables to be interpolated into the final payload.

Example: User managed template:

{:id=>"%{id}", :message=>"%{message}", :badge=>"%{badge}"}

Hopeful output:

{
  "id": "f1ih5g3",
  "message": "Thanks for your help",
  "badge": {
    "id": "M7nk8ojK",
    "name": "Thanks Badge",
    "points": 10,
  }
}

So, in the above example, there is a main webhook object and the basic variables of id and message are interpolated and returned as strings. But we also want to be able to support nested structures when a reference to a full object is included in the template. When a reference to a full object is included, it should return a json representation of that object.

We have tried using the String#% operator, which works for basic variables, but for nested objects, it results in a stringified version like this:

{
  "id": "f1ih5g3",
  "message": "Thanks for your help",
  "badge": "{:id=>\"M7nk8ojK\", :name=>\"Thanks Badge\", :points=>10}"
}

I've explored RABL and Jsonnet, they don't really seem to support dynamic templates (ie ones managed by users).

ERB syntax would work but seems unsafe as any arbitrary ruby could be included in the template.

2 Answers
require 'json'
template = {:my_id=>"%{id}", :my_message=>"%{message}", :my_badge=>"%{badge}"}
data = {
  id: 'f1ih5g3',
  badge: { id: "M7nk8ojK", name: "Thanks Badge", points: 10 }
}
output = template.each_with_object({}) { |(k, v), a|
  placeholder = /\A%\{(.*)\}\z/.match(v)&.captures.first
  a[k] = placeholder.nil? ? v : data[placeholder.to_sym]
}.to_json
JSON.parse(output)
# {"my_id"=>"f1ih5g3", "my_message"=>nil, "my_badge"=>{"id"=>"M7nk8ojK", "name"=>"Thanks Badge", "points"=>10}}

Concerns about XSS, invalid templates, or default values (e.g. message above) are left as an exercise to the reader.

Also not addressed: the compilation of the template from user input. A solution using eval (below) would likely be a RCE vulnerability unless the lexical context is strictly controlled.

customer_input = %q|{:my_id=>"%{id}", :my_badge=>"%{badge}"}|
template = eval customer_input

Consideration of alternative, safer, customer_input formats may be desirable.

If it were me, I would go to a solution like this. I would collect the variables that users can use under a class and write the following code. Maybe I misunderstood the code because I could not fully predict the problem.

require 'json'
require 'pry'
require 'securerandom'

class CustomerArea
  ALLOWED_METHODS = %i[id message badge]

  def self.id
    SecureRandom.uuid
  end

  def self.message
    "Lorem ipsum dolar sit amet"
  end

  def self.badge
    {
      id: SecureRandom.uuid,
      name: "Foo",
      points: 5
    }
  end
end


class Processor
  def process(input)
    json_object = JSON.parse(input, symbolize_names: true)
    result      = {}
    
    json_object.each do |key, value|
      extracts = value.match /\A%{insert_(?<field>\w+)}\z/
      if extracts.nil?
        result[key] = value
        next
      end

      field           = extracts[:field].to_sym
      allowed_methods = CustomerArea.const_get(:ALLOWED_METHODS)
      next unless allowed_methods.include? field

      result[key] = CustomerArea.send(field)
    end

    result.to_json
  end
end

customer_input = '{ "id": "%{insert_id}", "message": "%{insert_message}", "badge": "%{insert_badge}"}'
processor = Processor.new
puts processor.process(customer_input)
Related