Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions app/models/embed_provider.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# Single source of truth for which third-party <iframe> embeds are permitted in
# authored book content. Both enforcement points read from here 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 — the Content-Security-Policy `frame-src` directive is derived
# from the same table (see config/initializers/
# content_security_policy.rb).
#
# Writebook is self-hosted, so operators extend the table per install via the
# WRITEBOOK_EMBED_PROVIDERS environment variable (JSON). Extending it widens the
# scrubber allowance and the CSP directive together — there is no separate list
# to keep in sync, and there is no raw-iframe escape hatch: an embed is permitted
# only if a provider in this table 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 neither a default nor an operator-supplied 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 on lines +26 to +28

# 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/

# Shipped defaults — the common authoring cases, each pinned to its approved
# path shape. host(s) are matched exactly (case-insensitively); path is matched
# on a segment boundary against path_prefix.
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

class << self
def all
(DEFAULTS + configured).map { |attributes| new(**attributes) }
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. Host granularity here
# (implicit :443, matching the port the scrubber requires); path-shape
# enforcement lives in the scrubber. Always https.
def csp_frame_sources
all.flat_map(&:csp_sources).uniq
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
def configured
raw = ENV["WRITEBOOK_EMBED_PROVIDERS"]
return [] if raw.blank?

parsed = JSON.parse(raw)
entries = parsed.is_a?(Array) ? parsed : [ parsed ]
entries.filter_map { |entry| normalize_config(entry) }
rescue JSON::ParserError
Rails.logger.warn("[EmbedProvider] WRITEBOOK_EMBED_PROVIDERS is not valid JSON; ignoring")
[]
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 = entry["path_prefix"].to_s

if hosts.empty? || !valid_path_prefix?(path_prefix)
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

def valid_path_prefix?(prefix)
prefix.start_with?("/") && prefix.length > 1
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.chomp("/")
# Intersect with the master list so no provider — default or operator-supplied
# — 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 on lines +158 to +159

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep nondefault ports consistent between the scrubber and CSP

When an operator configures a provider reached on a nondefault HTTPS port, such as https://video.example:8443/embed/1, this check ignores uri.port and allows the iframe, while csp_sources emits only https://video.example, which CSP matches on the scheme's default port. The browser therefore blocks an embed that the shared provider table reports as allowed; either support ports in provider entries and include them in the CSP source or reject nondefault ports during matching.

Useful? React with 👍 / 👎.

end

def csp_sources
hosts.map { |host| "https://#{host}" }
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 on lines +178 to +181

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize paths before checking provider prefixes

When an approved host has another framable route, a source such as https://www.youtube.com/embed/../watch passes this raw-string prefix check, but browsers remove the dot segment and navigate to /watch. The CSP still permits the host, so this bypasses the provider's intended path-shape restriction; reject dot segments or canonicalize the URL path before applying the prefix check.

Useful? React with 👍 / 👎.

end

def traversal?(path)
path.split("/").include?("..") || path.match?(/%2e|%2f/i)
end
end
26 changes: 26 additions & 0 deletions app/models/html_scrubber.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,30 @@ def initialize
audio details summary iframe options table tbody td th thead tr video source mark
]
end

# An <iframe> survives only when an approved provider vouches for its src (host
# + path shape). Every other tag keeps the default PermitScrubber behavior.
def keep_node?(node)
if node.name == "iframe"
EmbedProvider.allows?(node["src"])
else
super
end
end

# For a kept <iframe>, 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"
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 on lines +28 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Sanitize retained iframe style values

When authored content supplies a style attribute on an approved iframe, this override retains it verbatim and bypasses the CSS-value sanitization performed by PermitScrubber#scrub_attributes. Because style is in PERMITTED_ATTRIBUTES, an author can inject declarations such as fixed positioning and a high z-index to turn the frame into a full-page overlay; filter the attribute names as intended, but still run the sanitizer's CSS scrubber on retained styles.

Useful? React with 👍 / 👎.

super
end
end
end
38 changes: 16 additions & 22 deletions config/initializers/content_security_policy.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,19 @@
# Be sure to restart your server when you modify this file.

# Define an application-wide content security policy.
# See the Securing Rails Applications Guide for more information:
# https://guides.rubyonrails.org/security.html#content-security-policy-header

# Rails.application.configure do
# config.content_security_policy do |policy|
# policy.default_src :self, :https
# policy.font_src :self, :https, :data
# policy.img_src :self, :https, :data
# policy.object_src :none
# policy.script_src :self, :https
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src style-src)
# Render-time half of the iframe embed allowlist. The `frame-src` directive is
# derived from the EmbedProvider table — the same source of truth the author-time
# HtmlScrubber reads — so the two enforcement points can't drift. Operators
# extend the allowlist per install via WRITEBOOK_EMBED_PROVIDERS (see
# app/models/embed_provider.rb); that widens both the scrubber and this directive
# at once.
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end
# The source is a lambda so it's resolved per request (in controller context),
# which keeps the header in lockstep with the provider table without referencing
# an autoloaded constant at boot. Only `frame-src` is set: the rest of the policy
# is intentionally left unrestricted so this hardening is limited to which origins
# may be framed.
Rails.application.configure do
config.content_security_policy do |policy|
policy.frame_src -> { EmbedProvider.csp_frame_sources.presence || [ :none ] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pass each provider as a separate CSP source

When any response builds this policy, Rails resolves the proc as one source expression rather than flattening its return value, so returning an array serializes a header like frame-src ["https://youtube.com", ...] instead of space-separated origins. Browsers reject those bracketed/quoted expressions, causing every approved iframe to remain blocked; expand the dynamic list into individual CSP sources rather than returning an array from a single source proc.

Useful? React with 👍 / 👎.

end
end
11 changes: 9 additions & 2 deletions test/controllers/pages_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,17 @@ class PagesControllerTest < ActionDispatch::IntegrationTest
assert_select "#test", html: %(<div style="text-align:center;">Hello</div>)
end

test "show with iframes" do
test "show keeps an approved-provider iframe" do
get leafable_path(sample_page_leaf(%(<div id="test"><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe></div>)))

assert_select "#test iframe[src=?]", "https://www.youtube.com/embed/dQw4w9WgXcQ"
end

test "show strips an off-allowlist iframe" do
get leafable_path(sample_page_leaf(%(<div id="test"><iframe src="http://example.com"></iframe></div>)))

assert_select "#test", html: %(<iframe src="http://example.com"></iframe>)
assert_select "#test", html: ""
assert_select "#test iframe", count: 0
end

test "show with tables in the markdown" do
Expand Down
42 changes: 42 additions & 0 deletions test/helpers/pages_helper_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
require "test_helper"

class PagesHelperTest < ActionView::TestCase
test "sanitize_content keeps an approved-provider iframe" do
html = %(<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe>)
result = sanitize_content(html)

assert_includes result, "<iframe"
assert_includes result, %(src="https://www.youtube.com/embed/dQw4w9WgXcQ")
end

test "sanitize_content strips a disallowed-origin iframe" do
html = %(<iframe src="https://evil.example/embed/x"></iframe>)
assert_not_includes sanitize_content(html), "<iframe"
end

test "sanitize_content strips a valid host used with the wrong path shape" do
html = %(<iframe src="https://www.youtube.com/watch?v=dQw4w9WgXcQ"></iframe>)
assert_not_includes sanitize_content(html), "<iframe"
end

test "sanitize_content strips forbidden attributes from an approved iframe" do
html = %(<iframe src="https://player.vimeo.com/video/76979871" ) +
%(srcdoc="<script>alert(1)</script>" sandbox="" onload="alert(1)" name="x" ) +
%(style="position:fixed" allow="camera *" referrerpolicy="unsafe-url" ) +
%(width="640" allowfullscreen></iframe>)
result = sanitize_content(html)

assert_includes result, "<iframe"
# Match on the attribute name= form so "allow" doesn't false-hit allowfullscreen.
%w[srcdoc= sandbox= onload= name= style= allow= referrerpolicy=].each do |forbidden|
assert_not_includes result, forbidden
end
assert_includes result, %(width="640")
assert_includes result, "allowfullscreen"
end

test "sanitize_content strips a bare srcdoc iframe with no src" do
html = %(<iframe srcdoc="<script>alert(1)</script>"></iframe>)
assert_not_includes sanitize_content(html), "<iframe"
end
end
17 changes: 17 additions & 0 deletions test/integration/content_security_policy_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
require "test_helper"

class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest
test "frame-src carries the approved embed providers" do
get new_session_url

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the path helper for the integration request

This request neither crosses hosts nor tests a full absolute URL, so use new_session_path rather than new_session_url. The root AGENTS.md specifically requires _path helpers in controller and integration tests unless the host or full URL is under test.

AGENTS.md reference: AGENTS.md:L8-L9

Useful? React with 👍 / 👎.

assert_response :success

csp = response.headers["Content-Security-Policy"]
assert csp.present?, "expected a Content-Security-Policy header"

frame_src = csp.split(";").map(&:strip).find { |directive| directive.start_with?("frame-src") }
assert frame_src.present?, "expected a frame-src directive, got: #{csp}"

tokens = frame_src.split(/\s+/).drop(1) # drop the "frame-src" keyword
assert_equal EmbedProvider.csp_frame_sources.sort, tokens.sort
end
end
Loading
Loading