From 424219e4851cb4d92303ba19a18fbdf5ae727576 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 8 Aug 2026 17:24:52 -0700 Subject: [PATCH] Serve custom styles from a dedicated text/css route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin-authored custom styles were rendered inline into every page via custom_styles_tag, marked html_safe inside a 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 . In a CSS document, markup is inert text — the HTML-context breakout is closed by construction, and admins keep full custom-CSS capability. --- .../accounts/custom_styles_controller.rb | 11 +++++++- app/helpers/application_helper.rb | 6 ---- app/views/layouts/application.html.erb | 2 +- config/routes.rb | 6 +++- .../accounts/custom_styles_controller_test.rb | 28 +++++++++++++++++++ 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/app/controllers/accounts/custom_styles_controller.rb b/app/controllers/accounts/custom_styles_controller.rb index ad59317..11b7fd3 100644 --- a/app/controllers/accounts/custom_styles_controller.rb +++ b/app/controllers/accounts/custom_styles_controller.rb @@ -1,5 +1,14 @@ 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 end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index db43497..eafb2f2 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -12,12 +12,6 @@ module ApplicationHelper 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 [ @body_class, admin_body_class, account_logo_body_class ].compact.join(" ") end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 00f7e5b..c9f7915 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -22,7 +22,7 @@ <%= tag.link rel: "apple-touch-icon", href: fresh_account_logo_path %> <%= 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 %> diff --git a/config/routes.rb b/config/routes.rb index e55fd7e..ff693c7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,7 +21,7 @@ Rails.application.routes.draw do resource :join_code, only: :create resource :logo, only: %i[ show destroy ] - resource :custom_styles, only: %i[ edit update ] + resource :custom_styles, only: %i[ show edit update ] 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] end + direct :fresh_custom_styles do |options| + route_for :account_custom_styles, v: Current.account&.updated_at&.to_fs(:number) + end + get "join/:join_code", to: "users#new", as: :join post "join/:join_code", to: "users#create" diff --git a/test/controllers/accounts/custom_styles_controller_test.rb b/test/controllers/accounts/custom_styles_controller_test.rb index c7dc926..2cd8f7e 100644 --- a/test/controllers/accounts/custom_styles_controller_test.rb +++ b/test/controllers/accounts/custom_styles_controller_test.rb @@ -5,6 +5,34 @@ 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_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 = "" + 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 get edit_account_custom_styles_url assert_response :ok