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:
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:
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:
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
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.