From 11419f99140e13688a9613cab3ee03f8d3cbae45 Mon Sep 17 00:00:00 2001 From: Adam Cooke Date: Sun, 1 Feb 2026 14:48:54 +0000 Subject: [PATCH 01/10] fix(deliveries): escape delivery details to prevent HTML injection --- app/helpers/application_helper.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c6acf4563..52106ad60 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -3,6 +3,7 @@ module ApplicationHelper def format_delivery_details(server, text) + text = h(text) text.gsub!(//) do id = ::Regexp.last_match(1).to_i link_to("message ##{id}", organization_server_message_path(server.organization, server, id), class: "u-link") From d532922ff7f51e096a69fd97a2f0a37386e60243 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 14:55:42 +0000 Subject: [PATCH 02/10] chore(main): release 3.3.5 (#3208) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 931d27504..63d74733e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.3.4" + ".": "3.3.5" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b78a9257..9b74885d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ This file contains all the latest changes and updates to Postal. +## [3.3.5](https://github.com/postalserver/postal/compare/3.3.4...3.3.5) (2026-02-01) + + +### Bug Fixes + +* **deliveries:** escape delivery details to prevent HTML injection ([11419f9](https://github.com/postalserver/postal/commit/11419f99140e13688a9613cab3ee03f8d3cbae45)) +* **health_server:** use rackup handler instead of rack handler ([7c47422](https://github.com/postalserver/postal/commit/7c47422c865e738c4d6af0fed1cca4405288341f)) +* oidc scopes are invalid when concatenated ([#3332](https://github.com/postalserver/postal/issues/3332)) ([9c5f96a](https://github.com/postalserver/postal/commit/9c5f96ae90cf06dcd5db776806865752f667bd95)) +* typo in process logging ([#3212](https://github.com/postalserver/postal/issues/3212)) ([b7e5232](https://github.com/postalserver/postal/commit/b7e5232e077b3c9b7a999dcb6676fba0ec61458e)) +* typo in the credentials page ([fd3c7cc](https://github.com/postalserver/postal/commit/fd3c7ccdf6dc4ee0a76c9523cbd735159e4b8000)) +* update url for v2 config ([#3225](https://github.com/postalserver/postal/issues/3225)) ([e00098b](https://github.com/postalserver/postal/commit/e00098b8003cf37f2708f536871b3ade377aed2d)) + + +### Documentation + +* **process.rb:** add help about time unit used by metric ([#3339](https://github.com/postalserver/postal/issues/3339)) ([f5325c4](https://github.com/postalserver/postal/commit/f5325c49ff1152ad53eaaec98717ad3412d379ae)) + + +### Miscellaneous Chores + +* **deps:** upgrade puma, net-imap and other deps ([c03c44b](https://github.com/postalserver/postal/commit/c03c44b442a29aa9881c1e1aae60bead9776a6b6)) +* **dockerfile:** reduce container size ([86de372](https://github.com/postalserver/postal/commit/86de372382bd62bdd5d1372254f8817b0360bd56)) +* remove version from docker-compose.yml ([c78000c](https://github.com/postalserver/postal/commit/c78000ca8f2998aa04648f465060768db6467de6)) +* upgrade resolv to 0.6.2 ([d00d978](https://github.com/postalserver/postal/commit/d00d978872a96369544303d08f6a9d11cdf56b62)) +* upgrade to rails 7.1 and ruby 3.4 ([#3457](https://github.com/postalserver/postal/issues/3457)) ([ab6d443](https://github.com/postalserver/postal/commit/ab6d4430baa33a05f1aa66e776cc2a5bcaa0ede8)) +* upgrade uri gem to 1.0.3 ([f193b8e](https://github.com/postalserver/postal/commit/f193b8e77fc096382ab7aaa6a2c29641b4cb12df)) + ## [3.3.4](https://github.com/postalserver/postal/compare/3.3.3...3.3.4) (2024-06-20) From b611d577af79b8e1e75b6d47fa04d1ba03e34eec Mon Sep 17 00:00:00 2001 From: Adam Cooke Date: Fri, 24 Apr 2026 21:34:57 +0100 Subject: [PATCH 03/10] chore: ignore node modules and yarn.lock --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b89b11b4f..96a88d934 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ BRANCH .rubocop-https* .env* +node_modules +yarn.lock + From cad2aa6808519a3ff25215f09f4966d9fa3bb372 Mon Sep 17 00:00:00 2001 From: Adam Cooke Date: Fri, 24 Apr 2026 22:12:27 +0100 Subject: [PATCH 04/10] 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 ea0e940ce..9d59f08e4 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -89,6 +89,18 @@ def deliveries 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 357f2cb50..826f7e176 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 000000000..524d32b98 --- /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 From dca7f90b9046247c0d953567be35921167e79d87 Mon Sep 17 00:00:00 2001 From: Adam Cooke Date: Fri, 24 Apr 2026 22:24:18 +0100 Subject: [PATCH 05/10] refactor(tracking): remove unused src image proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /img// endpoint accepted a src= query parameter and proxied the body of that URL back to the caller. Nothing in the codebase ever produces a src= parameter — the parser only inserts a plain tracking pixel and rewrites href links — so this branch is dead code inherited from the original AppMail import. Drop the src branch: requests with src now return 400. The no-src path that serves the tracking pixel and records loads is unchanged, and a spec covers both the pixel-serving path and the removed branch. --- lib/tracking_middleware.rb | 16 +------ spec/lib/tracking_middleware_spec.rb | 71 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 spec/lib/tracking_middleware_spec.rb diff --git a/lib/tracking_middleware.rb b/lib/tracking_middleware.rb index bee3a6378..d66473637 100644 --- a/lib/tracking_middleware.rb +++ b/lib/tracking_middleware.rb @@ -48,25 +48,11 @@ def dispatch_image_request(request, server_token, message_token) Sentry.capture_exception(e) if defined?(Sentry) end - source_image = request.params["src"] - case source_image - when nil + if request.params["src"].nil? headers = {} headers["Content-Type"] = "image/png" headers["Content-Length"] = TRACKING_PIXEL.bytesize.to_s [200, headers, [TRACKING_PIXEL]] - when /\Ahttps?:\/\// - response = Postal::HTTP.get(source_image, timeout: 3) - return [404, {}, ["Not found"]] unless response[:code] == 200 - - headers = {} - headers["Content-Type"] = response[:headers]["content-type"]&.first - headers["Last-Modified"] = response[:headers]["last-modified"]&.first - headers["Cache-Control"] = response[:headers]["cache-control"]&.first - headers["Etag"] = response[:headers]["etag"]&.first - headers["Content-Length"] = response[:body].bytesize.to_s - [200, headers, [response[:body]]] - else [400, {}, ["Invalid/missing source image"]] end diff --git a/spec/lib/tracking_middleware_spec.rb b/spec/lib/tracking_middleware_spec.rb new file mode 100644 index 000000000..0335892f3 --- /dev/null +++ b/spec/lib/tracking_middleware_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" +require "rack/test" + +RSpec.describe TrackingMiddleware do + include Rack::Test::Methods + + let(:inner_app) { ->(_env) { [200, {}, ["inner"]] } } + let(:app) { described_class.new(inner_app) } + + let(:server) { create(:server) } + let(:message) do + MessageFactory.incoming(server) do |_msg, mail| + mail.html_part = Mail::Part.new do + content_type "text/html; charset=UTF-8" + body "hi" + end + end + end + + def track_headers + { "HTTP_X_POSTAL_TRACK_HOST" => "1" } + end + + describe "GET /img/:server_token/:message_token (open tracking pixel)" do + before do + get "/img/#{server.token}/#{message.token}", {}, track_headers + end + + it "returns the tracking pixel PNG" do + expect(last_response.status).to eq 200 + expect(last_response.headers["Content-Type"]).to eq "image/png" + expect(last_response.body.bytesize).to be > 0 + end + + it "records a load for the message" do + # Re-fetch the message so loads are read fresh from the DB. + reloaded = server.message_db.message(message.id) + expect(reloaded.loads.size).to eq 1 + end + end + + describe "GET /img/:server_token/:message_token?src= (image proxy)" do + let(:attacker_url) { "http://internal.example.com/secret" } + + before do + stub_request(:get, attacker_url).to_return(status: 200, body: "internal-secret") + end + + it "does not fetch the URL and returns 400" do + get "/img/#{server.token}/#{message.token}", { src: attacker_url }, track_headers + + expect(last_response.status).to eq 400 + expect(WebMock).not_to have_requested(:get, attacker_url) + end + + it "does not fetch the URL even when the message token is invalid" do + get "/img/#{server.token}/nonexistent", { src: attacker_url }, track_headers + + expect(WebMock).not_to have_requested(:get, attacker_url) + end + end + + describe "when the track-host header is missing" do + it "passes the request through to the inner app untouched" do + get "/img/#{server.token}/#{message.token}" + expect(last_response.body).to eq "inner" + end + end +end From 924352403553dcfcc569876ca76c219493fac9d6 Mon Sep 17 00:00:00 2001 From: Adam Cooke Date: Fri, 24 Apr 2026 22:55:46 +0100 Subject: [PATCH 06/10] refactor(helpers): escape interpolated values in select options The endpoint and domain option helpers interpolated model attributes straight into an HTML string before marking the whole buffer html_safe. Wrap the interpolations in h() so untrusted attributes can't break out of the surrounding tag. Also stop the helpers glob in rails_helper from eagerly requiring _spec.rb files so helper specs can live under spec/helpers/, and add a small application helper spec covering the escape behaviour. --- app/helpers/application_helper.rb | 12 ++++---- spec/helpers/application_helper_spec.rb | 37 +++++++++++++++++++++++++ spec/rails_helper.rb | 2 +- 3 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 spec/helpers/application_helper_spec.rb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 52106ad60..17d124217 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -33,7 +33,7 @@ def domain_options_for_select(server, selected_domain = nil, options = {}) s << "" server_domains.each do |domain| selected = domain == selected_domain ? "selected='selected'" : "" - s << "" + s << "" end s << "" end @@ -43,7 +43,7 @@ def domain_options_for_select(server, selected_domain = nil, options = {}) s << "" organization_domains.each do |domain| selected = domain == selected_domain ? "selected='selected'" : "" - s << "" + s << "" end s << "" end @@ -60,7 +60,7 @@ def endpoint_options_for_select(server, selected_value = nil, options = {}) http_endpoints.each do |endpoint| value = "#{endpoint.class}##{endpoint.uuid}" selected = value == selected_value ? "selected='selected'" : "" - s << "" + s << "" end s << "" end @@ -71,7 +71,7 @@ def endpoint_options_for_select(server, selected_value = nil, options = {}) smtp_endpoints.each do |endpoint| value = "#{endpoint.class}##{endpoint.uuid}" selected = value == selected_value ? "selected='selected'" : "" - s << "" + s << "" end s << "" end @@ -82,7 +82,7 @@ def endpoint_options_for_select(server, selected_value = nil, options = {}) address_endpoints.each do |endpoint| value = "#{endpoint.class}##{endpoint.uuid}" selected = value == selected_value ? "selected='selected'" : "" - s << "" + s << "" end s << "" end @@ -94,7 +94,7 @@ def endpoint_options_for_select(server, selected_value = nil, options = {}) selected = (selected_value == mode ? "selected='selected'" : "") text = t("route_modes.#{mode.underscore}") - s << "" + s << "" end s << "" end diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb new file mode 100644 index 000000000..4beb3de98 --- /dev/null +++ b/spec/helpers/application_helper_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ApplicationHelper, type: :helper do + describe "#endpoint_options_for_select" do + let(:server) { create(:server) } + + context "when an endpoint has HTML characters in its description" do + let(:payload) { %q(x'">) } + + before do + create(:http_endpoint, server: server, name: payload) + end + + it "HTML-escapes the endpoint description in the option text" do + html = helper.endpoint_options_for_select(server) + + # The raw payload must not appear verbatim — if it does, the browser + # will execute the ") + + # Escaped form should appear instead. + expect(html).to include("<script>alert(1)</script>") + end + + it "does not allow the payload to break out of the option tag" do + html = helper.endpoint_options_for_select(server) + + # The ' and > characters in the payload must be escaped so they + # cannot close the opening