In the previous post I explained how to implement Embed JS Widgets with Ruby on Rails.
But you have to define the approved domain explicitly:
Rails.application.config.action_dispatch.default_headers.merge!({
'Content-Security-Policy' => "frame-ancestors 'self' https://trusted-domain.com"
})
A common, naive approach is to set frame-ancestors to allow embedding on any domain using a wildcard (*)
. While this enables maximum flexibility, it also opens the widget to misuse and unauthorized access.
# app/controllers/widgets_controller.rb
class WidgetsController < ApplicationController
def show
response.headers['Content-Type'] = 'application/javascript'
response.headers['Content-Security-Policy'] = "frame-ancestors *"
render layout: false
end
end
While this approach is convenient, it lacks domain restriction, meaning any website can use the widget, which could lead to potential misuse. For a secure, client-specific widget, let’s set up a dynamic domain approval system.
Secure Approach: Dynamic Domain Approval
To ensure that only authorized clients can embed the widget, we’ll implement a system where each Account
in the application has approved domains. When the widget is served, it dynamically checks the account’s approved domains and sets a restrictive frame-ancestors
header accordingly.
Step 1: Set Up the Account Model with Approved Domains
Update your Account model to include a domain attribute representing the domain where the client’s widget can be embedded.
Example: Account.create!(name: "Example Client 1", domain: "example1.com")
Step 2: Set Up a Secure Widget Endpoint
To serve the widget securely, we’ll modify the WidgetsController
to check the Account model for the requesting client’s approved domain and set the frame-ancestors
directive based on that domain.
Define the Widget Controller Action
In the WidgetsController
, add logic to look up the client account based on a unique identifier, such as an API key. This API key can be passed securely as part of the script request to identify the client.
# app/controllers/widgets_controller.rb
class WidgetsController < ApplicationController
before_action :set_content_security_policy
def show
response.headers['Content-Type'] = 'application/javascript'
render layout: false
end
private
def set_content_security_policy
# Look up the account based on a unique identifier, such as an API key
account = Account.find_by(api_key: params[:api_key])
if account && account.domain.present?
# Restrict embedding to the approved domain
response.headers["Content-Security-Policy"] = "frame-ancestors #{account.domain}"
else
# Deny embedding if no approved domain is found
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
head :forbidden # Block access if the account or domain is not valid
end
end
end
- The
set_content_security_policy
method checks for a matchingAccount
based on anapi_key
parameter. - If the account has a valid approved domain, it sets the
frame-ancestors
directive to allow embedding only on that domain. If no approved domain is found, it setsframe-ancestors
to'none'
, denying access and returning a 403 Forbidden status.
Generate API Keys for Accounts
Each Account
should have a unique API key to secure access. Generate these keys and store them in the Account
model.
# db/migrate/xxxxxx_add_api_key_to_accounts.rb
class AddApiKeyToAccounts < ActiveRecord::Migration[7.0]
def change
add_column :accounts, :api_key, :string
add_index :accounts, :api_key, unique: true
end
end
# app/models/account.rb
class Account < ApplicationRecord
before_create :generate_api_key
private
def generate_api_key
self.api_key = SecureRandom.hex(20) # Generates a 40-character API key
end
end
Use this API key in the widget request URL to identify the account.
Step 3: Update the Embed Code for Client Sites
Provide each client with an embed code that includes their unique API key. This ensures only authorized clients can load the widget on their approved domain.
Client-Specific Embed Code
The API key is embedded in the script URL to securely identify the client account:
<!-- Embed Code for Client Site -->
<script type="text/javascript">
(function() {
const script = document.createElement('script');
script.src = "https://yourapp.com/widget.js?api_key=CLIENT_API_KEY";
script.async = true;
document.head.appendChild(script);
})();
</script>
Replace CLIENT_API_KEY
with the actual API key for each client. This key allows Rails to dynamically set the frame-ancestors
header according to the client’s approved domain.
Step 4: Test and Monitor Access
Test the widget on both approved and unapproved domains to ensure this solution works as expected.
1. Approved Domain Test: Embed the widget code on a page hosted on the approved domain (e.g., example1.com). Confirm the widget loads and displays properly.
2. Unapproved Domain Test: Try embedding the widget on an unapproved domain. Confirm that the widget does not load and the network request returns a 403 Forbidden status.
Happy Hacking!