DEV Community

David Paluy
David Paluy

Posted on

Implement a Secure, Dynamic Domain Approval System for Embeddable Widgets in Ruby on Rails

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"
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • The set_content_security_policy method checks for a matching Account based on an api_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 sets frame-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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)