Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion app/controllers/accounts/custom_styles_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Comment thread
jeremy marked this conversation as resolved.
render plain: Current.account&.custom_styles.to_s, content_type: "text/css"
end
end

def edit
end
Expand Down
6 changes: 0 additions & 6 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
<link rel="apple-touch-icon" href="/app-icon.png">

<%= 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 %>
</head>
Expand Down
2 changes: 1 addition & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions test/controllers/custom_styles_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "</style><script>alert(1)</script>"
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
Expand Down
41 changes: 41 additions & 0 deletions test/system/custom_styles_xss_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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 "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 <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
Loading