From ff2c5bd1f006f1d8b36d744f82cc906cd1c203b1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 25 Aug 2026 21:56:50 -0700 Subject: [PATCH 1/8] Restrict iframe embeds to an allowlist of approved providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authored book content is rendered to unauthenticated readers, but the HtmlScrubber allowed ))) + + 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(%(
))) - assert_select "#test", html: %() + assert_select "#test", html: "" + assert_select "#test iframe", count: 0 end test "show with tables in the markdown" do diff --git a/test/helpers/pages_helper_test.rb b/test/helpers/pages_helper_test.rb new file mode 100644 index 00000000..2d52fb56 --- /dev/null +++ b/test/helpers/pages_helper_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class PagesHelperTest < ActionView::TestCase + test "sanitize_content keeps an approved-provider iframe" do + html = %() + result = sanitize_content(html) + + assert_includes result, ") + assert_not_includes sanitize_content(html), ") + assert_not_includes sanitize_content(html), ") + result = sanitize_content(html) + + assert_includes result, ") + assert_not_includes sanitize_content(html), " "https://www.youtube.com/embed/dQw4w9WgXcQ", + "YouTube (nocookie)" => "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ", + "Vimeo" => "https://player.vimeo.com/video/76979871", + "Loom" => "https://www.loom.com/embed/0123456789abcdef", + "Google Maps" => "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3" + }.each do |name, url| + test "#{name} embed is allowed" do + assert EmbedProvider.allows?(url), "expected #{url} to be allowed" + end + end + + test "bare and www hosts both allowed" do + assert EmbedProvider.allows?("https://youtube.com/embed/abc") + assert EmbedProvider.allows?("https://www.youtube.com/embed/abc") + end + + # --- disallowed origins ---------------------------------------------------- + + test "unknown origin is rejected" do + assert_not EmbedProvider.allows?("https://evil.com/embed/abc") + end + + test "valid host with wrong path shape is rejected" do + assert_not EmbedProvider.allows?("https://www.youtube.com/watch?v=dQw4w9WgXcQ") + assert_not EmbedProvider.allows?("https://player.vimeo.com/channels/staffpicks") + assert_not EmbedProvider.allows?("https://www.google.com/maps/place/foo") + end + + # --- allowlist bypass attempts -------------------------------------------- + + test "path-prefix boundary tricks are rejected" do + assert_not EmbedProvider.allows?("https://www.youtube.com/embedxyz/evil") + assert_not EmbedProvider.allows?("https://www.youtube.com/embedded") + end + + test "dot-segment and encoded traversal past the prefix are rejected" do + assert_not EmbedProvider.allows?("https://www.youtube.com/embed/../watch?v=x") + assert_not EmbedProvider.allows?("https://www.youtube.com/embed/%2e%2e/watch") + assert_not EmbedProvider.allows?("https://www.youtube.com/embed%2fx") + end + + test "explicit non-default port is rejected (CSP is host-only, implicit 443)" do + assert_not EmbedProvider.allows?("https://www.youtube.com:444/embed/x") + assert EmbedProvider.allows?("https://www.youtube.com:443/embed/x") + end + + test "protocol-relative url is rejected" do + assert_not EmbedProvider.allows?("//www.youtube.com/embed/abc") + end + + test "non-https schemes are rejected" do + assert_not EmbedProvider.allows?("http://www.youtube.com/embed/abc") + assert_not EmbedProvider.allows?("data:text/html,") + assert_not EmbedProvider.allows?("javascript:alert(1)") + end + + test "userinfo host confusion is rejected" do + assert_not EmbedProvider.allows?("https://www.youtube.com@evil.com/embed/abc") + assert_not EmbedProvider.allows?("https://evil.com@www.youtube.com/embed/abc") + end + + test "lookalike hostnames are rejected" do + assert_not EmbedProvider.allows?("https://notyoutube.com/embed/abc") + assert_not EmbedProvider.allows?("https://youtube.com.evil.com/embed/abc") + end + + test "host is matched case-insensitively" do + assert EmbedProvider.allows?("https://WWW.YOUTUBE.COM/embed/abc") + end + + test "trailing-dot host is rejected (CSP would not match it)" do + assert_not EmbedProvider.allows?("https://www.youtube.com./embed/abc") + end + + test "blank and malformed srcs are rejected" do + assert_not EmbedProvider.allows?(nil) + assert_not EmbedProvider.allows?("") + assert_not EmbedProvider.allows?("https://") + end + + # --- CSP frame-src derives from the same table ---------------------------- + + test "csp_frame_sources reflects exactly the default table" do + assert_equal %w[ + https://youtube.com https://www.youtube.com + https://youtube-nocookie.com https://www.youtube-nocookie.com + https://player.vimeo.com + https://loom.com https://www.loom.com + https://google.com https://www.google.com + ], EmbedProvider.csp_frame_sources + end + + # --- per-install config extends BOTH scrubber and CSP --------------------- + + test "operator-configured provider extends both the scrubber allowance and CSP" do + ENV["WRITEBOOK_EMBED_PROVIDERS"] = + %([{"name":"Wistia","hosts":["fast.wistia.net"],"path_prefix":"/embed/"}]) + + # scrubber allowance + assert EmbedProvider.allows?("https://fast.wistia.net/embed/iframe/abc123") + assert_not EmbedProvider.allows?("https://fast.wistia.net/other/abc123") + + # CSP directive — same table, so the host is now present too + assert_includes EmbedProvider.csp_frame_sources, "https://fast.wistia.net" + # defaults still present + assert_includes EmbedProvider.csp_frame_sources, "https://www.youtube.com" + end + + test "default providers carry only the vetted attribute set" do + provider = EmbedProvider.match("https://www.youtube.com/embed/abc") + assert_equal EmbedProvider::PERMITTED_ATTRIBUTES, provider.attributes + %w[srcdoc sandbox name onload style allow referrerpolicy].each do |forbidden| + assert_not_includes provider.attributes, forbidden + end + end + + test "operator config cannot reintroduce forbidden attributes" do + ENV["WRITEBOOK_EMBED_PROVIDERS"] = + %([{"name":"X","hosts":["x.example"],"path_prefix":"/e","attributes":["src","srcdoc","sandbox","onload","style","allow","referrerpolicy"]}]) + + provider = EmbedProvider.match("https://x.example/e/1") + assert_equal %w[src], provider.attributes + end + + test "invalid config json is ignored, defaults survive" do + ENV["WRITEBOOK_EMBED_PROVIDERS"] = "{not valid json" + assert EmbedProvider.allows?("https://www.youtube.com/embed/abc") + assert_not EmbedProvider.allows?("https://x.example/e/1") + end + + test "a single provider object (not wrapped in an array) is accepted" do + ENV["WRITEBOOK_EMBED_PROVIDERS"] = + %({"name":"Wistia","hosts":["fast.wistia.net"],"path_prefix":"/embed/"}) + assert EmbedProvider.allows?("https://fast.wistia.net/embed/iframe/abc") + assert_includes EmbedProvider.csp_frame_sources, "https://fast.wistia.net" + end + + test "wildcard, whitespace and over-broad config entries are rejected, defaults survive" do + [ + %([{"name":"a","hosts":["*"],"path_prefix":"/e"}]), + %([{"name":"b","hosts":["*.example.com"],"path_prefix":"/e"}]), + %([{"name":"c","hosts":["x.example bad"],"path_prefix":"/e"}]), + %([{"name":"d","hosts":["x.example"],"path_prefix":"/"}]), + %([{"name":"e","hosts":["127.1"],"path_prefix":"/e"}]), + %([{"name":"f","hosts":["0x7f.1"],"path_prefix":"/e"}]) + ].each do |config| + ENV.delete("WRITEBOOK_EMBED_PROVIDERS") + defaults = EmbedProvider.csp_frame_sources + ENV["WRITEBOOK_EMBED_PROVIDERS"] = config + # A fully-rejected entry leaves exactly the defaults — no wildcard, no + # whitespace, no IP-literal, and no path-prefix-of-"/" catch-all leaks in. + assert_equal defaults, EmbedProvider.csp_frame_sources, config + assert_not EmbedProvider.allows?("https://x.example/anything"), config + end + end +end diff --git a/test/models/html_scrubber_test.rb b/test/models/html_scrubber_test.rb index af8c8711..89ec495d 100644 --- a/test/models/html_scrubber_test.rb +++ b/test/models/html_scrubber_test.rb @@ -79,7 +79,7 @@ def render_and_scrub(markdown) end test "strips value-sensitive iframe attributes but keeps safe embed attributes" do - result = scrub(%()) + result = scrub(%()) assert_not_includes result, "allow=" assert_not_includes result, "referrerpolicy" assert_not_includes result, "sandbox" From 38d1df2c0ac78ecd383824c81e82d60af36fa35a Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 9 Sep 2026 13:28:24 -0700 Subject: [PATCH 2/8] Bust fragment caches when the embed policy changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The leaf and book fragments wrap scrubbed page content, so a fragment cached before a policy change keeps serving markup the scrubber would now strip: an iframe from a since-removed provider, or attributes the provider no longer permits. CSP allows the host, so nothing downstream catches it. Key both fragments on HtmlScrubber.cache_version — a scrubber policy version plus a digest of the effective provider table (hosts, path prefix, attributes) — so a WRITEBOOK_EMBED_PROVIDERS edit or a scrubber tightening re-renders through the scrubber instead of hitting the stale fragment. --- app/models/embed_provider.rb | 13 +++++++++ app/models/html_scrubber.rb | 9 +++++++ app/views/books/show.html.erb | 2 +- app/views/leaves/_leaf.html.erb | 2 +- test/controllers/books_controller_test.rb | 33 +++++++++++++++++++++++ test/models/embed_provider_test.rb | 27 +++++++++++++++++++ test/models/html_scrubber_test.rb | 10 +++++++ 7 files changed, 94 insertions(+), 2 deletions(-) diff --git a/app/models/embed_provider.rb b/app/models/embed_provider.rb index f257b1ee..b6eefbda 100644 --- a/app/models/embed_provider.rb +++ b/app/models/embed_provider.rb @@ -89,6 +89,15 @@ def csp_frame_sources all.flat_map(&:csp_sources).uniq end + # Digest of the effective table, for fragment cache keys wrapping scrubbed + # content: a cached fragment skips the scrubber, so it must be invalidated + # whenever the policy that produced it changes (a WRITEBOOK_EMBED_PROVIDERS + # edit, or a shipped default). Order-insensitive so reordering entries + # doesn't bust caches. + def cache_version + ActiveSupport::Digest.hexdigest all.map(&:signature).sort.join("\n") + 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 — @@ -163,6 +172,10 @@ 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 diff --git a/app/models/html_scrubber.rb b/app/models/html_scrubber.rb index 9aa510d9..5dcfec4a 100644 --- a/app/models/html_scrubber.rb +++ b/app/models/html_scrubber.rb @@ -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 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[ diff --git a/app/views/books/show.html.erb b/app/views/books/show.html.erb index b90644d1..1de6a27c 100644 --- a/app/views/books/show.html.erb +++ b/app/views/books/show.html.erb @@ -49,7 +49,7 @@ <% end %> -<% cache [ @book, @book.editable? ] do %> +<% cache [ @book, @book.editable?, HtmlScrubber.cache_version ] do %>