Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
56 changes: 56 additions & 0 deletions app/models/html_scrubber.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,64 @@
class HtmlScrubber < Rails::Html::PermitScrubber
# Attributes preserved on a surviving <iframe>. Everything else — srcdoc, on*
# handlers, name, sandbox overrides, arbitrary allow — is dropped so only the
# vetted embed shape survives. See EmbedAllowlist for the host allowlist.
IFRAME_ATTRIBUTES = %w[ src width height allowfullscreen loading referrerpolicy title frameborder allow ].freeze
Comment thread
jeremy marked this conversation as resolved.
Outdated
Comment thread
jeremy marked this conversation as resolved.
Outdated

# Feature-policy tokens permitted in a surviving iframe `allow` attribute. Any
# other requested capability (camera, microphone, geolocation, …) is dropped.
IFRAME_ALLOW_TOKENS = %w[
accelerometer autoplay clipboard-write encrypted-media fullscreen
gyroscope picture-in-picture web-share
].freeze

def initialize
super
self.tags = Rails::Html::WhiteListSanitizer.allowed_tags + %w[
audio details summary iframe options table tbody td th thead tr video source mark
]
end

# Keep an <iframe> only when its src host is on the embed allowlist; other tags
# fall through to the permitted-tag check.
def keep_node?(node)
if iframe?(node)
EmbedAllowlist.allows?(node["src"])
else
super
end
end

# Minimize a surviving <iframe> to the vetted attribute set; other tags keep
# the default attribute scrubbing.
def scrub_attributes(node)
if iframe?(node)
minimize_iframe(node)
else
super
end
end

private
def iframe?(node)
node.element? && node.name == "iframe"
end

def minimize_iframe(node)
node.attribute_nodes.each do |attr|
name = attr.name.downcase
if IFRAME_ATTRIBUTES.include?(name)
node[attr.name] = filtered_allow(attr.value) if name == "allow"
else
attr.remove
end
end
end

def filtered_allow(value)
value.to_s.split(";")
.map { |directive| directive.strip.split(/\s+/).first.to_s.downcase }
Comment thread
jeremy marked this conversation as resolved.
Outdated
.select { |token| IFRAME_ALLOW_TOKENS.include?(token) }
.uniq
.join("; ")
end
end
103 changes: 102 additions & 1 deletion config/initializers/content_security_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ def self.apply(policy)
# and get tuned against violation reports during the report-only window.
policy.img_src :self, :data, :blob, *extra(:img_src)
policy.connect_src :self, *extra(:connect_src)
policy.frame_src :self, *extra(:frame_src)
# frame-src is the render-time half of the iframe embed allowlist. It consumes
# EmbedAllowlist — the same source of truth HtmlScrubber uses at author-time —
# so a permitted-provider frame the scrubber keeps is one the browser will load,
# and the two enforcement points cannot drift. (Includes extra(:frame_src).)
policy.frame_src :self, *::EmbedAllowlist.frame_src_sources
policy.frame_ancestors :self
policy.base_uri :self
policy.form_action :self, *extra(:form_action)
Expand All @@ -103,6 +107,103 @@ def self.apply(policy)
end
end

# Single source of truth for which iframe embed providers Writebook permits.
#
# Consumed at two enforcement points that must never disagree:
# - author-time, by HtmlScrubber, which strips any <iframe> whose src host is
# not on this list before the page is stored/rendered; and
# - render-time, by the CSP frame-src directive below, which refuses to load a
# frame from an origin not on this list.
Comment thread
jeremy marked this conversation as resolved.
Outdated
# Both derive their host set from here, so an operator who adds a provider gets
# it honored in both places from one change.
#
# Defined here (not app/models) because the CSP policy is built at boot, before
# app autoloading can resolve an app/models constant; HtmlScrubber references it
# at request time, by which point it is defined.
#
# ── PRODUCT DECISION ──────────────────────────────────────────────────────────
# DEFAULT_PROVIDERS is the shipped default allowlist. Writebook is a ONCE product
# (each customer self-hosts on their own domain), so the *mechanism* is per-
# install configurable via the CSP_EXTRA_FRAME_SRC ENV — the same tokens CSP
# frame-src reads. But the shipped default list below, the per-provider attribute
# policy, and whether a raw-iframe escape hatch exists are product/authoring calls
# an owner must confirm before this enforces. Set DEFAULT_PROVIDERS to {} to ship
# with no built-in providers.
module EmbedAllowlist
# Provider name => host matcher(s). A leading "*." matches the apex and any
# subdomain (e.g. "*.vimeo.com" matches "vimeo.com" and "player.vimeo.com").
# Product-owned default list — curate before enforcing.
DEFAULT_PROVIDERS = {
"YouTube" => %w[ www.youtube.com youtube.com www.youtube-nocookie.com ],
"Vimeo" => %w[ player.vimeo.com ],
"Loom" => %w[ www.loom.com ],
"Google Maps" => %w[ www.google.com ]
}.freeze

ALLOWED_SCHEMES = %w[ https ].freeze

class << self
# Every host on the allowlist: the built-in defaults plus any hosts parsed
# out of the per-install CSP_EXTRA_FRAME_SRC ENV.
def hosts
(default_hosts + extra_hosts).uniq
end

# True when +src+ is an https URL whose host is on the allowlist.
def allows?(src)
uri = parse(src)
return false unless uri && ALLOWED_SCHEMES.include?(uri.scheme) && uri.host.present?

host = uri.host.downcase
hosts.any? { |pattern| host_matches?(host, pattern) }
Comment thread
jeremy marked this conversation as resolved.
end

# CSP frame-src source expressions for this same allowlist: the default
# provider origins as https:// URLs, plus the raw per-install extras (already
# CSP source expressions). Consumed by the CSP frame-src directive below so
# frame-src and the scrubber cannot drift.
def frame_src_sources
(default_hosts.map { |host| "https://#{host}" } + CSP.extra(:frame_src)).uniq
end

private
def default_hosts
DEFAULT_PROVIDERS.values.flatten
end

def extra_hosts
CSP.extra(:frame_src).filter_map { |source| host_from_source(source) }
end

def parse(src)
URI.parse(src.to_s.strip)
rescue URI::InvalidURIError
nil
end

def host_matches?(host, pattern)
pattern = pattern.downcase
if pattern.start_with?("*.")
apex = pattern.delete_prefix("*.")
host == apex || host.end_with?(".#{apex}")
Comment thread
jeremy marked this conversation as resolved.
Outdated
else
host == pattern
end
end

# Extract a bare host from a CSP source expression: "https://www.youtube.com"
# or "https://*.vimeo.com/embed" => "www.youtube.com" / "*.vimeo.com". Returns
# nil for scheme-only or keyword tokens (:self, https:, 'unsafe-inline', a
# host:port) that carry no plain host to match an iframe src against.
def host_from_source(source)
token = source.to_s.strip.delete_prefix("https://").delete_prefix("http://")
token = token.split("/").first.to_s
return nil if token.blank? || token.include?(":") || token.start_with?("'")
token
Comment thread
jeremy marked this conversation as resolved.
Outdated
end
end
end

Rails.application.configure do
config.content_security_policy { |policy| CSP.apply(policy) }

Expand Down
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 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 "iframe", false
end

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

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 Reuse the page fixture for the new iframe test

This new controller test calls sample_page_leaf, which persists another Page and Leaf via the exact books(...).press Page.new(...) pattern that the repository testing convention says to replace with an existing fixture. Reuse leaves(:welcome_page) and set up its body for this scenario instead of creating an additional record.

AGENTS.md reference: AGENTS.md:L5-L6

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not doing this — sample_page_leaf is this file's own pre-existing setup idiom (the neighboring tables test and others use it), and the two new tests follow it. Switching just these two to leaves(:welcome_page) plus a body rewrite would introduce a second setup pattern into the file while still mutating shared state per test, which is no closer to the convention's aim. Leaving unresolved for a human call.


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

test "show with tables in the markdown" do
Expand Down
9 changes: 7 additions & 2 deletions test/integration/csp_nonce_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,16 @@ class CspNonceTest < ActionDispatch::IntegrationTest
end
end

test "directives default to :self only when no ENV extras are set" do
test "directives default to :self plus the built-in embed providers when no ENV extras are set" do
with_env "CSP_EXTRA_FRAME_SRC" => nil, "CSP_EXTRA_IMG_SRC" => nil do
header = ActionDispatch::ContentSecurityPolicy.new { |p| CSP.apply(p) }.build

assert_match %r{frame-src 'self'(;|\z)}, header
# img-src has no built-in providers, so it stays at :self (+ data/blob).
assert_match %r{img-src 'self' data: blob:(;|\z)}, header
# frame-src consumes EmbedAllowlist: :self plus the shipped default provider
# origins, so the render-time policy agrees with the author-time scrubber.
assert_match %r{frame-src 'self'[^;]*\bhttps://www\.youtube\.com\b}, header
assert_match %r{frame-src 'self'[^;]*\bhttps://player\.vimeo\.com\b}, header
end
end

Expand Down
63 changes: 63 additions & 0 deletions test/models/embed_allowlist_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
require "test_helper"

class EmbedAllowlistTest < ActiveSupport::TestCase
test "default providers are allowed with no ENV set" do
with_env "CSP_EXTRA_FRAME_SRC" => nil do
assert EmbedAllowlist.allows?("https://www.youtube.com/embed/abc123")
assert EmbedAllowlist.allows?("https://player.vimeo.com/video/123")
assert EmbedAllowlist.allows?("https://www.loom.com/embed/xyz")
end
end

test "off-allowlist hosts are rejected" do
with_env "CSP_EXTRA_FRAME_SRC" => nil do
assert_not EmbedAllowlist.allows?("https://evil.example/embed")
assert_not EmbedAllowlist.allows?("https://notyoutube.com/embed")
end
end

test "non-https and non-absolute srcs are rejected" do
with_env "CSP_EXTRA_FRAME_SRC" => nil do
assert_not EmbedAllowlist.allows?("http://www.youtube.com/embed/x"), "http is rejected"
assert_not EmbedAllowlist.allows?("//www.youtube.com/embed/x"), "protocol-relative is rejected"
assert_not EmbedAllowlist.allows?("/local/page"), "relative is rejected"
assert_not EmbedAllowlist.allows?("javascript:alert(1)"), "javascript: is rejected"
assert_not EmbedAllowlist.allows?(nil)
assert_not EmbedAllowlist.allows?("")
end
end

test "a per-install ENV host is honored, from the same source CSP frame-src reads" do
with_env "CSP_EXTRA_FRAME_SRC" => "https://maps.example.test https://forms.example.test" do
assert EmbedAllowlist.allows?("https://maps.example.test/embed")
assert EmbedAllowlist.allows?("https://forms.example.test/f/1")
# and the same host appears in the CSP frame-src source list — no drift.
assert_includes EmbedAllowlist.frame_src_sources, "https://maps.example.test"
end
end

test "wildcard ENV hosts match the apex and any subdomain" do
with_env "CSP_EXTRA_FRAME_SRC" => "https://*.example.test" do
assert EmbedAllowlist.allows?("https://example.test/x")
assert EmbedAllowlist.allows?("https://deep.sub.example.test/x")
assert_not EmbedAllowlist.allows?("https://example.test.evil.com/x")
end
end

test "frame_src_sources always includes the default provider origins" do
with_env "CSP_EXTRA_FRAME_SRC" => nil do
assert_includes EmbedAllowlist.frame_src_sources, "https://www.youtube.com"
assert_includes EmbedAllowlist.frame_src_sources, "https://player.vimeo.com"
end
end

private
def with_env(vars)
original = {}
vars.each_key { |k| original[k] = ENV.key?(k) ? ENV[k] : :__unset__ }
vars.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
yield
ensure
original.each { |k, v| v == :__unset__ ? ENV.delete(k) : ENV[k] = v }
end
end
73 changes: 73 additions & 0 deletions test/models/html_scrubber_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
require "test_helper"

class HtmlScrubberTest < ActiveSupport::TestCase
test "keeps an allowlisted-provider iframe" do
html = %(<p>hi</p><iframe src="https://www.youtube.com/embed/abc"></iframe>)
out = scrub(html)

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

test "strips an off-allowlist iframe entirely" do
html = %(<p>before</p><iframe src="https://evil.example/x"></iframe><p>after</p>)
out = scrub(html)

assert_not_includes out, "<iframe"
assert_not_includes out, "evil.example"
assert_includes out, "before"
assert_includes out, "after"
end

test "strips an iframe with no src" do
assert_not_includes scrub(%(<iframe srcdoc="<b>x</b>"></iframe>)), "<iframe"
end

test "minimizes attributes on a surviving iframe" do
html = <<~HTML
<iframe src="https://player.vimeo.com/video/1"
width="640" height="360" allowfullscreen
srcdoc="<script>alert(1)</script>"
onload="steal()" name="x" sandbox=""></iframe>
HTML
out = scrub(html)

assert_includes out, %(src="https://player.vimeo.com/video/1")
assert_includes out, "width", "vetted attributes survive"
assert_not_includes out, "srcdoc", "srcdoc is dropped"
assert_not_includes out, "onload", "on* handlers are dropped"
assert_not_includes out, "sandbox", "author sandbox override is dropped"
assert_not_includes out, %(name="x"), "arbitrary attributes are dropped"
end

test "filters the iframe allow attribute to a safe token set" do
html = %(<iframe src="https://www.youtube.com/embed/x" allow="fullscreen; camera; microphone; autoplay"></iframe>)
out = scrub(html)

assert_match %r{allow="[^"]*fullscreen}, out
assert_match %r{allow="[^"]*autoplay}, out
assert_not_includes out, "camera"
assert_not_includes out, "microphone"
end

test "honors a per-install ENV provider" do
with_env "CSP_EXTRA_FRAME_SRC" => "https://maps.example.test" do
out = scrub(%(<iframe src="https://maps.example.test/embed"></iframe>))
assert_includes out, "maps.example.test"
end
end

private
def scrub(html)
Loofah.fragment(html).scrub!(HtmlScrubber.new).to_html
end

def with_env(vars)
original = {}
vars.each_key { |k| original[k] = ENV.key?(k) ? ENV[k] : :__unset__ }
vars.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
yield
ensure
original.each { |k, v| v == :__unset__ ? ENV.delete(k) : ENV[k] = v }
end
end
Loading