Automatic configuration reloads with Occson Webhooks

August 29, 2021

Just recently, we’ve released a webhooks feature for Occson. Let’s see how we can leverage it for automatic configuration reloads in a Ruby on Rails application.

We’ll extend the envie app from our previous post. If you haven’t read it, don’t worry - it’s very simple! It just takes a list of available ENV variables and shows it in the browser.

Quick prep: generalizing our configuration loader

Last time around, we were happy reading our configuration in the initializer. This time, we want to be a bit more dynamic with it, so we’ll refactor the loader into a service.

# app/services/ccs_config_loader.rb

require 'occson'

class OccsonConfigLoader
  ACCESS_TOKEN = ENV.fetch('OCCSON_ACCESS_TOKEN')
  PASSPHRASE = ENV.fetch('OCCSON_PASSPHRASE')

  def initialize(source)
    @source = source
  end

  def call
    document.split("\n").each do |line|
      key, value = line.split("=", 2)

      ENV.store(key, value)
    end
  end

  private

  def document
    @document ||= Occson::Document.new(@source, ENV.fetch('OCCSON_ACCESS_TOKEN'), ENV.fetch('OCCSON_PASSPHRASE')).download
  end
end

You’ll note we’ve also moved away from hard-coded paths. This makes our initializer look like so:

# config/initializers/001_occson.rb

OccsonConfigLoader.new('occson://0.1.0/.env').call

Accepting webhooks

Occson produces POST requests for webhooks, so let’s route them:

# config/routes.rb

Rails.application.routes.draw do
  # ...

  post :webhook, to: 'webhooks#create'
end

And then we can accept them easily with a controller looking like this:

# app/controllers/webhooks_controller.rb

# STOP! Before copying/pasting, read on.
# This minimal example is not secure enough for real-world use.

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    OccsonConfigLoader.new(document_uri).call

    render json: { message: :ok }, status: 200
  end

  private

  def payload
    request.raw_post
  end

  def body
    JSON.parse(payload)
  end

  def document_uri
    "occson://#{body['path']}"
  end
end

A webhook payload looks something like this:

{
  "id": "00000000-0000-0000-0000-000000000000",
  "path": "/test",
  "workspace_id": "00000000-0000-0000-0000-000000000000",
  "event": "someevent"
}

So we can just extract the path from it, load it and go, right?

Right, but HIGHLY unsecure. Let’s see how we can lock down this example.

Webhook security

Occson webhooks provide a time-dependent signature as a security measure. In practice, this means that along with every request, two headers are provided:

  • X-Occson-Signature, which is an HMAC-SHA256 signature of the payload + the timestamp,
  • X-Occson-Timestamp, which is the timestamp at which the requester alleges the request was generated.

I know the lawyerly language seems odd in an IT context - “requester alleges” and all that - but until proven otherwise, we don’t know that it’s Occson hitting our /webhook endpoint. So let’s see how we can prove it.

First of all, when setting up a webhook, you need to set up a secret:

Creating a webhook Fig 1. Creating a webhook

We’ll generate a reasonably secure one for you, but you can change it to anything you want. This secret will then be used to sign the payloads Occson sends to you.

Let’s see how we can verify the signature:

class WebhooksController < ApplicationController
  # ...
  def create
    return json_error('Wrong signature') unless signature_ok?

    OccsonConfigLoader.new(document_uri).call

    render json: { message: :ok }, status: 200
  end

  private

  def signature_ok?
    digest = OpenSSL::Digest.new('sha256')
    hmac = OpenSSL::HMAC.new(ENV['OCCSON_WEBHOOK_SECRET'], digest)
    hmac.update(payload)
    hmac.update(timestamp)

    ActiveSupport::SecurityUtils.fixed_length_secure_compare(signature, hmac.hexdigest)
  end

  def payload
    request.raw_post
  end

  def body
    JSON.parse(payload)
  end

  def signature
    request.headers['X-Occson-Signature']
  end

  def timestamp
    request.headers['X-Occson-Timestamp']
  end

  def document_uri
    "occson://#{body['path']}"
  end
end

The most interesting method here is signature_ok?, of course. We create a new SHA-256 digest, then run an HMAC with the shared secret. We then push both the payload, and the timestamp header values into it. Finally, we perform a fixed-length secure compare between our result and what Occson returned. It’s quite important to use secure-comparison functions, as otherwise we open ourselves up to timing attacks.

You’ll note we’re taking the secret from an ENV variable. We’re in fact depending on Occson itself for that - the config we initialize off of contains this variable:

The secret is in Occson Fig 2. The secret is in Occson

So we’ve secured ourselves from one type of attack - “just sending us random stuff”. There is, however, another type of attack we should defend against: replay attacks.

Protecting against replay attacks

Imagine the following turn of events:

  • You pushed configuration v0.1.0, which contained an API URL for an external provider
  • The provider suffered a massive outage because their domain name was suspended
  • You pushed a new version with a temporary domain name from them, which stopped your software from crashing.

But while you were doing step 1, someone was listening in on the network - let’s call that someone Eve. Eve is unfortunately malicious. They know that if they send the exact same request they recorded - the v0.1.0 webhook - your site will crash again.

And so they do. Every other second, too, because they’re just awful people. Let’s see what we can do to prevent this.

Remember that Eve cannot change the X-Occson-Timestamp header, otherwise the entire signature would be invalid. We can check, then, if that timestamp is reasonably recent:

  def create
    return json_error('Wrong timestamp') unless timestamp_ok?
    # ...
  end

  private

  def timestamp_ok?
    Time.current.to_i - timestamp.to_i < 300
  end

  def timestamp
    request.headers['X-Occson-Timestamp']
  end

  # ...

And there we go. Now we’ll only accept each request for five minutes after it was originated. In practice, this should be a safe value - using only a few seconds may be too little if one accounts for network latency and async jobs on either side, and five minutes should be a slim enough window for any attackers to make a replay attack impractical. However, we encourage you to think through your use case and decide what’s safe and practical for you.

Putting it all together

Here’s the final version of the controller:

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    return json_error('Wrong timestamp') unless timestamp_ok?
    return json_error('Wrong signature') unless signature_ok?

    OccsonConfigLoader.new(document_uri).call if path_ok?

    render json: { message: :ok }, status: 200
  end

  private

  def signature_ok?
    digest = OpenSSL::Digest.new('sha256')
    hmac = OpenSSL::HMAC.new(ENV['OCCSON_WEBHOOK_SECRET'], digest)
    hmac.update(payload)
    hmac.update(timestamp)

    ActiveSupport::SecurityUtils.fixed_length_secure_compare(signature, hmac.hexdigest)
  end

  def timestamp_ok?
    Time.current.to_i - timestamp.to_i < 300
  end

  def path_ok?
    body['path'].ends_with?('.env')
  end

  def payload
    request.raw_post
  end

  def body
    JSON.parse(payload)
  end

  def signature
    request.headers['X-Occson-Signature']
  end

  def timestamp
    request.headers['X-Occson-Timestamp']
  end

  def document_uri
    "occson://#{body['path']}"
  end

  def json_error(message)
    render json: { message: message, } status: 422
  end
end

Let’s update our configuration file then:

Adding a new configuration variable Fig 3. Adding a new configuration variable

We save, navigate to envie’s main page, and…

FOO=bar
BAZ=quux
OCCSON_WEBHOOK_SECRET=5a2aae07-c109-4aec-8e65-51bc457efccc
RACK_ENV=development
HELLO=world

Fig 4. New variable is there!

Ta-dah!

Final considerations

Similar to how we encourage you to think about security implications for your system, we encourage you to think about what Occson webhooks can do for your system. The example application glances over several decisions you need to make for yourself, including how to approach config versioning, rollbacks and removing old variables.