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