Compare commits

...
Author SHA1 Message Date
Jeremy Daer 84ad886114 Navigate explicitly in custom_styles XSS system test
The test relied on the post-login landing page implicitly, so the
custom_styles <link> coverage could silently evaporate if that landing
changes. Visit root_url explicitly (matching the writebook sibling) to pin
the page under test. Assertions are unchanged and still non-vacuous.
2026-08-08 19:42:29 -07:00
Jeremy Daer 2fd1483e16 Harden custom-styles route: browser XSS proof and higher-resolution cache key
Add a headless-browser system test that stores a </style><script> payload
in custom_styles, loads a page, and asserts the payload arrives as an
external text/css stylesheet and never executes (no script breakout, no
window flag set). It fails against the old inline rendering.

Bust the stylesheet cache key with the repo's millisecond :epoch format
so two saves within the same second still produce distinct URLs — the
five-minute public cache would otherwise pin the first save.
2026-08-08 18:35:26 -07:00
Jeremy Daer 424219e485 Serve custom styles from a dedicated text/css route
Admin-authored custom styles were rendered inline into every page via
custom_styles_tag, marked html_safe inside a <style> element. A stored
payload containing </style><script>…</script> broke out of the style
context and executed as HTML — a stored XSS reaching everyone who
loads the app.

Deliver the styles as an external stylesheet instead, following the
account logo pattern: a new Accounts::CustomStyles#show renders the
raw CSS with a text/css content type (ETag on the account, 5-minute
public cache with stale-while-revalidate, fresh_custom_styles cache
buster on account updates), and the layout links it with
<link rel="stylesheet" data-turbo-track="reload">. In a CSS
document, markup is inert text — the HTML-context breakout is closed
by construction, and admins keep full custom-CSS capability.
2026-08-08 17:24:52 -07:00
6 changed files with 88 additions and 9 deletions

No files matched your search

@@ -1,5 +1,14 @@
class Accounts::CustomStylesController < ApplicationController class Accounts::CustomStylesController < ApplicationController
before_action :ensure_can_administer, :set_account allow_unauthenticated_access only: :show
before_action :ensure_can_administer, :set_account, except: :show
def show
if stale?(etag: Current.account)
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
render plain: Current.account&.custom_styles.to_s, content_type: "text/css"
end
end
def edit def edit
end end
-6
View File
@@ -12,12 +12,6 @@ module ApplicationHelper
end end
end end
def custom_styles_tag
if custom_styles = Current.account&.custom_styles
tag.style(custom_styles.to_s.html_safe, data: { turbo_track: "reload" })
end
end
def body_classes def body_classes
[ @body_class, admin_body_class, account_logo_body_class ].compact.join(" ") [ @body_class, admin_body_class, account_logo_body_class ].compact.join(" ")
end end
+1 -1
View File
@@ -22,7 +22,7 @@
<%= tag.link rel: "apple-touch-icon", href: fresh_account_logo_path %> <%= tag.link rel: "apple-touch-icon", href: fresh_account_logo_path %>
<%= stylesheet_link_tag :all, "data-turbo-track": "reload" %> <%= stylesheet_link_tag :all, "data-turbo-track": "reload" %>
<%= custom_styles_tag %> <%= tag.link rel: "stylesheet", href: fresh_custom_styles_path, data: { turbo_track: "reload" } %>
<%= javascript_importmap_tags %> <%= javascript_importmap_tags %>
+5 -1
View File
@@ -21,7 +21,7 @@ Rails.application.routes.draw do
resource :join_code, only: :create resource :join_code, only: :create
resource :logo, only: %i[ show destroy ] resource :logo, only: %i[ show destroy ]
resource :custom_styles, only: %i[ edit update ] resource :custom_styles, only: %i[ show edit update ]
end end
end end
@@ -29,6 +29,10 @@ Rails.application.routes.draw do
route_for :account_logo, v: Current.account&.updated_at&.to_fs(:number), size: options[:size] route_for :account_logo, v: Current.account&.updated_at&.to_fs(:number), size: options[:size]
end end
direct :fresh_custom_styles do |options|
route_for :account_custom_styles, v: Current.account&.updated_at&.to_fs(:epoch)
end
get "join/:join_code", to: "users#new", as: :join get "join/:join_code", to: "users#new", as: :join
post "join/:join_code", to: "users#create" post "join/:join_code", to: "users#create"
@@ -5,6 +5,34 @@ class Accounts::CustomStylesControllerTest < ActionDispatch::IntegrationTest
sign_in :david sign_in :david
end end
test "show serves custom styles as plain CSS" do
accounts(:signal).update! custom_styles: ":root { --color-text: red; }"
get account_custom_styles_url
assert_response :ok
assert_equal "text/css", @response.media_type
assert_equal ":root { --color-text: red; }", @response.body
end
test "show is accessible without authentication" do
accounts(:signal).update! custom_styles: ":root { --color-text: red; }"
reset!
get account_custom_styles_url
assert_response :ok
assert_equal "text/css", @response.media_type
end
test "show serves markup verbatim as inert CSS text, never HTML" do
payload = "</style><script>alert(1)</script>"
accounts(:signal).update! custom_styles: payload
get account_custom_styles_url
assert_response :ok
assert_equal "text/css", @response.media_type
assert_equal payload, @response.body
end
test "edit" do test "edit" do
get edit_account_custom_styles_url get edit_account_custom_styles_url
assert_response :ok assert_response :ok
+44
View File
@@ -0,0 +1,44 @@
require "application_system_test_case"
class CustomStylesXssTest < ApplicationSystemTestCase
# A stored payload that breaks out of an inline <style> context and executes
# as HTML. Rendered inline (the old behavior) this <script> runs and sets the
# flag; delivered as an external text/css stylesheet it is inert text.
PAYLOAD = %(</style><script>window.__xss_fired = true</script>)
setup do
accounts(:signal).update!(custom_styles: PAYLOAD)
sign_in "jz@37signals.com"
end
test "custom styles payload loads as a stylesheet and never executes" do
# Navigate to the page under test explicitly (matching the writebook
# sibling) rather than leaning on the post-login landing page — otherwise
# this coverage could silently evaporate if that landing changes.
visit root_url
# (a) custom styles arrive via an external stylesheet link, not inline markup
assert_selector "link[rel='stylesheet'][href*='custom_styles']", visible: false
# (b) the payload's <script> never executed — no breakout from CSS context
assert_nil evaluate_script("window.__xss_fired")
# (b') and it injected no live <script> element into the document
assert_no_selector "script", text: "__xss_fired", visible: false
# (c) the browser fetched the payload verbatim as text/css, so it is styled,
# never parsed as HTML
fetched = page.evaluate_async_script(<<~JS)
var done = arguments[arguments.length - 1];
var href = document.querySelector("link[rel='stylesheet'][href*='custom_styles']").href;
fetch(href).then(function(r) {
return r.text().then(function(body) {
done({ type: r.headers.get("content-type"), body: body });
});
});
JS
assert_includes fetched["type"], "text/css"
assert_includes fetched["body"], PAYLOAD
end
end