From cad2aa6808519a3ff25215f09f4966d9fa3bb372 Mon Sep 17 00:00:00 2001 From: Adam Cooke Date: Fri, 24 Apr 2026 22:12:27 +0100 Subject: [PATCH] fix(messages): sandbox rendered email HTML as extra XSS defence The app-wide CSP already blocks inline script execution, but the HTML preview iframe for a stored email was same-origin and un-sandboxed, and the html_raw response had no per-action hardening. Add a sandbox on the iframe and tighten the CSP on html_raw to script-src 'none' with nosniff and no-referrer so the preview has defence in depth against a future CSP bypass or regression. Relates to GHSA-f6g9-8555-cw28. --- app/controllers/messages_controller.rb | 12 +++++ app/views/messages/html.html.haml | 2 +- spec/requests/messages_controller_spec.rb | 58 +++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 spec/requests/messages_controller_spec.rb diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index ea0e940..9d59f08 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -89,6 +89,18 @@ class MessagesController < ApplicationController end def html_raw + override_content_security_policy_directives( + default_src: %w('none'), + script_src: %w('none'), + style_src: %w('unsafe-inline'), + img_src: %w(* data:), + font_src: %w(*), + frame_ancestors: %w('self'), + form_action: %w('none'), + base_uri: %w('none') + ) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "no-referrer" render html: @message.html_body_without_tracking_image.html_safe end diff --git a/app/views/messages/html.html.haml b/app/views/messages/html.html.haml index 357f2cb..826f7e1 100644 --- a/app/views/messages/html.html.haml +++ b/app/views/messages/html.html.haml @@ -14,4 +14,4 @@ This means that we no longer store the raw data for this e-mail or the e-mail didn't include a HTML part. - else - %iframe{:width => "100%", :height => "100%", :src => html_raw_organization_server_message_path(organization, @server, @message.id)} + %iframe{:width => "100%", :height => "100%", :sandbox => "allow-popups allow-popups-to-escape-sandbox", :referrerpolicy => "no-referrer", :src => html_raw_organization_server_message_path(organization, @server, @message.id)} diff --git a/spec/requests/messages_controller_spec.rb b/spec/requests/messages_controller_spec.rb new file mode 100644 index 0000000..524d32b --- /dev/null +++ b/spec/requests/messages_controller_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "MessagesController", type: :request do + let(:user) { create(:user, admin: true) } + let(:organization) { create(:organization, owner: user) } + let(:server) { create(:server, organization: organization) } + + before do + post "/login", params: { email_address: user.email_address, password: "passw0rd" } + end + + describe "GET /org/:org/servers/:server/messages/:id/html_raw" do + let(:xss_payload) { %() } + let(:message) do + payload = xss_payload + MessageFactory.incoming(server) do |_msg, mail| + mail.html_part = Mail::Part.new do + content_type "text/html; charset=UTF-8" + body %(

hello

#{payload}) + end + end + end + + before do + get "/org/#{organization.permalink}/servers/#{server.permalink}/messages/#{message.id}/html_raw" + end + + it "returns the stored email HTML" do + expect(response).to have_http_status(:ok) + expect(response.body).to include("hello") + end + + it "serves a restrictive Content-Security-Policy that blocks scripts" do + csp = response.headers["Content-Security-Policy"] + expect(csp).to include("script-src 'none'") + expect(csp).to include("default-src 'none'") + expect(csp).to include("form-action 'none'") + expect(csp).to include("base-uri 'none'") + end + + it "sets X-Content-Type-Options and Referrer-Policy on the response" do + expect(response.headers["X-Content-Type-Options"]).to eq "nosniff" + expect(response.headers["Referrer-Policy"]).to eq "no-referrer" + end + end + + describe "messages/html view template" do + # We assert against the template source rather than rendering it in a + # request spec because the full application layout depends on the asset + # pipeline which is not configured in this test environment. + it "embeds the html_raw view inside a sandboxed iframe" do + template = Rails.root.join("app/views/messages/html.html.haml").read + expect(template).to match(/%iframe\{[^}]*:sandbox\s*=>/) + end + end +end