diff --git a/app/controllers/accounts/custom_styles_controller.rb b/app/controllers/accounts/custom_styles_controller.rb
index 25231973..bf208b00 100644
--- a/app/controllers/accounts/custom_styles_controller.rb
+++ b/app/controllers/accounts/custom_styles_controller.rb
@@ -1,5 +1,13 @@
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? Current.account
+ expires_in 1.hour, public: true
+ render plain: Current.account&.custom_styles.to_s, content_type: "text/css"
+ end
+ end
def edit
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index f86f19d2..163d5a77 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -6,10 +6,4 @@ def hide_from_user_style_tag
}
CSS
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
end
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 7a24e3e1..610839b6 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -26,7 +26,7 @@
<%= stylesheet_link_tag :all, "data-turbo-track": "reload" %>
- <%= custom_styles_tag %>
+ <%= tag.link rel: "stylesheet", href: account_custom_styles_path(v: Current.account&.updated_at&.to_fs(:usec)), data: { turbo_track: "reload" } %>
<%= javascript_importmap_tags %>
diff --git a/config/routes.rb b/config/routes.rb
index 6a759da6..29843eaf 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -15,7 +15,7 @@
resource :account do
scope module: "accounts" do
resource :join_code, only: :create
- resource :custom_styles, only: %i[ edit update ]
+ resource :custom_styles, only: %i[ show edit update ]
end
end
diff --git a/test/controllers/custom_styles_controller_test.rb b/test/controllers/custom_styles_controller_test.rb
index c7dc926c..52687367 100644
--- a/test/controllers/custom_styles_controller_test.rb
+++ b/test/controllers/custom_styles_controller_test.rb
@@ -5,6 +5,46 @@ class Accounts::CustomStylesControllerTest < ActionDispatch::IntegrationTest
sign_in :david
end
+ test "show serves custom styles as plain CSS" do
+ accounts(:signal).update! custom_styles: ":root { --color-text: red; }"
+
+ get account_custom_styles_path
+ 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
+ sign_out
+
+ get account_custom_styles_path
+ assert_response :ok
+ assert_equal "text/css", @response.media_type
+ end
+
+ test "show serves markup verbatim as inert CSS text, never HTML" do
+ payload = ""
+ accounts(:signal).update! custom_styles: payload
+
+ get account_custom_styles_path
+ assert_response :ok
+ assert_equal "text/css", @response.media_type
+ assert_equal payload, @response.body
+ end
+
+ test "show is publicly cacheable and honors conditional requests" do
+ accounts(:signal).update! custom_styles: ":root { --color-text: red; }"
+
+ get account_custom_styles_path
+ assert_response :ok
+ assert_includes @response.headers["Cache-Control"], "public"
+ assert_includes @response.headers["Cache-Control"], "max-age=3600"
+ assert_not_nil @response.headers["ETag"]
+
+ get account_custom_styles_path, headers: { "If-None-Match" => @response.headers["ETag"] }
+ assert_response :not_modified
+ end
+
test "edit" do
get edit_account_custom_styles_url
assert_response :ok
diff --git a/test/system/custom_styles_xss_test.rb b/test/system/custom_styles_xss_test.rb
new file mode 100644
index 00000000..838f9495
--- /dev/null
+++ b/test/system/custom_styles_xss_test.rb
@@ -0,0 +1,41 @@
+require "application_system_test_case"
+
+class CustomStylesXssTest < ApplicationSystemTestCase
+ # A stored payload that breaks out of an inline )
+
+ setup do
+ accounts(:signal).update!(custom_styles: PAYLOAD)
+ sign_in "kevin@example.com"
+ end
+
+ test "custom styles payload loads as a stylesheet and never executes" do
+ 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