Skip to content
9 changes: 9 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,13 @@ class ApplicationController < ActionController::Base

# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern

# Render-time half of the iframe embed allowlist: once a provider table is
# configured, `frame-src` is derived from the same table HtmlScrubber reads so
# the two can't drift. Left unset while permissive, so nothing that renders
# today is blocked, and only `frame-src` is set — the rest of the policy is
# deliberately unrestricted.
content_security_policy if: -> { EmbedProvider.configured? } do |policy|
policy.frame_src(*EmbedProvider.csp_frame_sources)
end
end
5 changes: 5 additions & 0 deletions app/models/account.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
class Account < ApplicationRecord
include Joinable

# The iframe embed allowlist for this install, as EmbedProvider config entries.
# Nil leaves embeds permissive; FirstRun seeds the curated defaults for new
# installs, and an install upgraded from before the column keeps nil.
serialize :embed_providers, coder: JSON
end
254 changes: 254 additions & 0 deletions app/models/embed_provider.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
# Which third-party <iframe> embeds are permitted in authored book content.
#
# The allowlist is opt-in. With nothing configured, embeds stay permissive:
# HtmlScrubber keeps an <iframe> from any origin (attributes still scrubbed) and
# no Content-Security-Policy is sent. Once a provider table is configured, both
# enforcement points read from it so they can't drift:
#
# * author-time — HtmlScrubber keeps an <iframe> only when its src matches a
# provider's host *and* path shape, and strips every attribute
# the provider doesn't permit.
# * render-time — a `frame-src` directive derived from the same table (see
# ApplicationController).
#
# The table comes from, in order of precedence:
#
# 1. WRITEBOOK_EMBED_PROVIDERS — a JSON array of entries; the operator's
# per-install config, and the way an existing install opts in.
# 2. Account#embed_providers — the same entries, stored per install. FirstRun
# seeds DEFAULTS here, so a new install starts on the curated list while an
# install upgraded from before the setting keeps embeds as they were.
# 3. Neither — permissive.
#
# Whichever source applies is the whole table. There is no raw-iframe escape
# hatch once configured: an embed is permitted only if a provider vouches for it.
class EmbedProvider
# The widest set of attributes any provider may carry through the scrubber.
# Deliberately excludes srcdoc, sandbox, name and any on* handler (script /
# frame-busting), style (CSS exfil + overlay clickjacking), and allow /
# referrerpolicy (delegating powerful features or leaking the full URL to the
# embed) — so no configured provider can reintroduce them. Embeds are sized
# with width/height and go fullscreen with allowfullscreen; nothing here
# carries an author-controlled value that needs further sanitizing (src is
# validated by host + path below).
PERMITTED_ATTRIBUTES = %w[
src width height allowfullscreen frameborder title loading
].freeze
Comment thread
jeremy marked this conversation as resolved.

# A DNS hostname: one or more [a-z0-9-] labels joined by dots, ending in an
# alphabetic-initial TLD label — no wildcard, no whitespace, and no IP literal.
# Requiring an alphabetic final label rejects IPv4 spellings (127.1,
# 2130706433, 0x7f.1, 0177.0.0.1) that Ruby parses but browsers canonicalize
# differently than the CSP source this table emits — which would otherwise let
# the scrubber keep a frame the CSP blocks. Guards operator config so a bad
# host can't widen (or, with embedded whitespace, crash) the derived directive.
HOST_FORMAT = /\A(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z](?:[a-z0-9-]*[a-z0-9])?\z/

# The curated table a new install starts with — the common authoring cases,
# each pinned to its approved path shape. In config-entry form, so it is also
# the value an existing install sets to opt in to the same list.
DEFAULTS = [
{
"name" => "YouTube",
"hosts" => %w[youtube.com www.youtube.com youtube-nocookie.com www.youtube-nocookie.com],
"path_prefix" => "/embed"
},
{
"name" => "Vimeo",
"hosts" => %w[player.vimeo.com],
"path_prefix" => "/video"
},
{
"name" => "Loom",
"hosts" => %w[loom.com www.loom.com],
"path_prefix" => "/embed"
},
{
"name" => "Google Maps",
"hosts" => %w[google.com www.google.com],
"path_prefix" => "/maps/embed"
}
].freeze

Resolution = Struct.new(:source, :entries, :providers)

class << self
# True once a provider table is configured — by environment or by the
# account — and the allowlist is enforced. False leaves embeds permissive.
def configured?
!resolution.entries.nil?
end

def all
resolution.providers
end

# The provider vouching for +src+, or nil. Used by the scrubber both to decide
# whether to keep the <iframe> and to learn which attributes it may retain.
def match(src)
return if src.blank?

uri = parse(src)
return unless uri

all.find { |provider| provider.allows?(uri) }
end

def allows?(src)
!match(src).nil?
end

# CSP `frame-src` sources derived from the same table, or nil when
# permissive (no directive is sent). Host granularity here (implicit :443,
# matching the port the scrubber requires); path-shape enforcement lives in
# the scrubber. Always https. A configured table with no valid entry fails
# closed.
def csp_frame_sources
if configured?
all.flat_map(&:csp_sources).uniq.presence || [ :none ]
end
end

# Fragment cache keys wrapping scrubbed content include this: a cached
# fragment skips the scrubber, so it must be invalidated whenever the policy
# that produced it changes — permissive to configured, or an edit to the
# table. Digested in resolution order because match is first-match-wins:
# reordering overlapping entries changes the policy.
def cache_version
if configured?
ActiveSupport::Digest.hexdigest all.map(&:signature).join("\n")
else
"permissive"
end
end

# Parses +src+ into a URI only when it is a fetchable https URL, on the
# default port, with a host and no embedded userinfo (which would let
# "https://youtube.com@evil.com/…" read as trusted). Anything else —
# protocol-relative, data:, javascript:, http:, an explicit non-443 port,
# malformed — yields nil and is therefore never matched.
def parse(src)
uri = URI.parse(src.to_s.strip)
return unless uri.is_a?(URI::HTTPS)
return if uri.host.blank? || uri.userinfo.present?
return if uri.port != uri.default_port

uri
rescue URI::InvalidURIError
nil
end

private
# Resolved once per distinct configuration: the scrubber, the CSP directive
# and the fragment cache key all consult the table several times per
# request, and a parse failure or a rejected entry should be logged once,
# not per call. A changed environment value or account row is a new
# source, so it is picked up on the next read without a restart.
def resolution
source = [ ENV["WRITEBOOK_EMBED_PROVIDERS"].presence, Account.first&.embed_providers ]
Comment thread
jeremy marked this conversation as resolved.
resolved = @resolution
resolved = @resolution = resolve(source) unless resolved&.source == source
resolved
end

def resolve(source)
raw, account_entries = source
entries = parse_environment(raw) || account_entries
Resolution.new(source, entries, build(entries)).freeze
end

def parse_environment(raw)
if raw
parsed = JSON.parse(raw)
parsed.is_a?(Array) ? parsed : [ parsed ]
end
rescue JSON::ParserError
Rails.logger.warn("[EmbedProvider] WRITEBOOK_EMBED_PROVIDERS is not valid JSON; ignoring")
nil
end

def build(entries)
Array(entries).filter_map { |entry| normalize_config(entry) }.map { |attributes| new(**attributes) }.freeze
end

def normalize_config(entry)
return unless entry.is_a?(Hash)

hosts = Array(entry["hosts"] || entry["host"]).map { |host| host.to_s.strip.downcase }
hosts = hosts.select { |host| host.match?(HOST_FORMAT) }
path_prefix = canonical_path_prefix(entry["path_prefix"])

if hosts.empty? || path_prefix.nil?
Rails.logger.warn("[EmbedProvider] ignoring invalid provider entry: #{entry.inspect}")
return
end

attributes = entry["attributes"]
{
name: entry["name"].to_s.presence || hosts.first,
hosts: hosts,
path_prefix: path_prefix,
attributes: attributes.nil? ? nil : Array(attributes).map(&:to_s)
}
end

# Canonical form of a configured prefix: leading slash, duplicate slashes
# collapsed, no trailing slash. Nil — the entry is dropped — for a dot
# segment or anything that reduces to the root: a browser resolves "/.",
# "/./" and "//" to "/", so storing them verbatim would turn the entry
# into a whole-host allowance.
def canonical_path_prefix(prefix)
prefix = prefix.to_s
segments = prefix.split("/").reject(&:empty?)

if prefix.start_with?("/") && segments.any? && (segments & %w[. ..]).empty?
"/#{segments.join("/")}"
end
end
end

attr_reader :name, :hosts, :path_prefix, :attributes

def initialize(name:, hosts:, path_prefix:, attributes: nil)
@name = name
@hosts = Array(hosts).map { |host| normalize_host(host) }
@path_prefix = path_prefix
# Intersect with the master list so no configured provider can widen the
# attribute surface beyond what the scrubber vets.
@attributes = (attributes || PERMITTED_ATTRIBUTES) & PERMITTED_ATTRIBUTES
end

def allows?(uri)
hosts.include?(normalize_host(uri.host)) && path_allowed?(uri.path)
Comment thread
jeremy marked this conversation as resolved.
end

def csp_sources
hosts.map { |host| "https://#{host}" }
end

def signature
[ hosts, path_prefix, attributes ].to_json
end

private
# Case-insensitive only. A trailing dot is *not* stripped: "youtube.com." is a
# distinct hostname to a CSP `frame-src` source, so tolerating it here would let
# the scrubber keep a frame the CSP blocks. Left unmatched, it is rejected.
def normalize_host(host)
host.to_s.downcase
end

# Segment-boundary prefix match: "/embed" permits "/embed" and "/embed/<id>"
# but not "/embedded" or "/watch". Dot-segments and percent-encoded dot/slash
# are rejected outright so a path the browser would canonicalize past the
# prefix (e.g. "/embed/../watch") can't slip through.
def path_allowed?(path)
return false if path.blank? || traversal?(path)

path == path_prefix || path.start_with?("#{path_prefix}/")
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.
end

def traversal?(path)
path.split("/").include?("..") || path.match?(/%2e|%2f/i)
end
end
2 changes: 1 addition & 1 deletion app/models/first_run.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ class FirstRun
ACCOUNT_NAME = "Writebook"

def self.create!(user_params)
account = Account.create!(name: ACCOUNT_NAME)
account = Account.create!(name: ACCOUNT_NAME, embed_providers: EmbedProvider::DEFAULTS)

User.create!(user_params.merge(role: :administrator)).tap do |user|
DemoContent.create_manual(user)
Expand Down
36 changes: 36 additions & 0 deletions app/models/html_scrubber.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ class HtmlScrubber < Rails::Html::PermitScrubber
controls autoplay muted playsinline allowfullscreen frameborder loading open reversed
].freeze

# Bump whenever the scrubbing rules tighten. Fragment caches wrapping scrubbed
# content key on cache_version, so a fragment rendered under the old rules —
# or under a previous embed policy — is re-scrubbed rather than served verbatim.
POLICY_VERSION = 1

def self.cache_version
"#{POLICY_VERSION}-#{EmbedProvider.cache_version}"
end

def initialize
super
self.tags = Rails::Html::WhiteListSanitizer.allowed_tags + %w[
Expand All @@ -33,6 +42,33 @@ def scrub(node)
end
end

# Once an embed allowlist is configured, an <iframe> survives only when an
# approved provider vouches for its src (host + path shape). Otherwise, and
# for every other tag, the default PermitScrubber behavior applies.
def keep_node?(node)
if node.name == "iframe" && EmbedProvider.configured?
EmbedProvider.allows?(node["src"])
else
super
end
end

# For a kept <iframe> under the allowlist, strip every attribute the matching
# provider doesn't permit — so srcdoc, sandbox, name, on* handlers, style, and
# allow/referrer policies can't ride along on an otherwise-approved embed. The
# surviving attributes carry no author-controlled URI or CSS value (src itself
# is validated by EmbedProvider), so no further per-value sanitizing is needed.
def scrub_attributes(node)
if node.name == "iframe" && EmbedProvider.configured?
permitted = EmbedProvider.match(node["src"])&.attributes || []
node.attribute_nodes.each do |attr|
node.remove_attribute(attr.name) unless permitted.include?(attr.name)
end
else
Comment thread
jeremy marked this conversation as resolved.
super
end
end

# ARIA is a non-scriptable namespace Loofah allows by wildcard; keep it.
def scrub_attribute?(name)
return false if name.start_with?("aria-") || name == "role"
Expand Down
2 changes: 1 addition & 1 deletion app/views/books/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
</nav>
<% end %>

<% cache [ @book, @book.editable? ] do %>
<% cache [ @book, @book.editable?, HtmlScrubber.cache_version ] do %>
<aside class="txt-align-center margin-block">
<div class="book__sidebar <%= "theme--#{@book&.theme}" unless @book.cover.attached? %>">
<% if @book.cover.attached? %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/leaves/_leaf.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<% cache [ leaf, leaf.book, leaf.book.editable? ] do %>
<% cache [ leaf, leaf.book, leaf.book.editable?, HtmlScrubber.cache_version ] do %>
<%= leaf_item_tag(leaf) do %>
<span class="btn btn--link arrangement__handle txt-small">
<%= image_tag "handle.svg", aria: { hidden: true }, size: 24 %>
Expand Down
5 changes: 5 additions & 0 deletions db/migrate/20260909222120_add_embed_providers_to_accounts.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddEmbedProvidersToAccounts < ActiveRecord::Migration[8.2]
def change
add_column :accounts, :embed_providers, :text
end
end
3 changes: 2 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading