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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ BRANCH
.rubocop-https*
.env*

node_modules
yarn.lock

2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "3.3.4"
".": "3.3.6"
}
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,52 @@

This file contains all the latest changes and updates to Postal.

## [3.3.6](https://github.com/postalserver/postal/compare/3.3.5...3.3.6) (2026-04-28)


### Bug Fixes

* **messages:** sandbox rendered email HTML as extra XSS defence ([cad2aa6](https://github.com/postalserver/postal/commit/cad2aa6808519a3ff25215f09f4966d9fa3bb372))


### Miscellaneous Chores

* ignore node modules and yarn.lock ([b611d57](https://github.com/postalserver/postal/commit/b611d577af79b8e1e75b6d47fa04d1ba03e34eec))


### Code Refactoring

* **auth:** tighten return_to validation ([84f4e20](https://github.com/postalserver/postal/commit/84f4e20f05db2d11b0144f95960c956f8221e657))
* **helpers:** escape interpolated values in select options ([9243524](https://github.com/postalserver/postal/commit/924352403553dcfcc569876ca76c219493fac9d6))
* **tracking:** remove unused src image proxy ([dca7f90](https://github.com/postalserver/postal/commit/dca7f90b9046247c0d953567be35921167e79d87))

## [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)


Expand Down
7 changes: 5 additions & 2 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,13 @@ def append_info_to_payload(payload)
end

def url_with_return_to(url)
if params[:return_to].blank? || !params[:return_to].starts_with?("/")
return_to = params[:return_to]
if return_to.blank? ||
!return_to.start_with?("/") ||
return_to.start_with?("//", "/\\")
url_for(url)
else
params[:return_to]
return_to
end
end

Expand Down
12 changes: 12 additions & 0 deletions app/controllers/messages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 7 additions & 6 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
module ApplicationHelper

def format_delivery_details(server, text)
text = h(text)
text.gsub!(/<msg:(\d+)>/) do
id = ::Regexp.last_match(1).to_i
link_to("message ##{id}", organization_server_message_path(server.organization, server, id), class: "u-link")
Expand Down Expand Up @@ -32,7 +33,7 @@ def domain_options_for_select(server, selected_domain = nil, options = {})
s << "<optgroup label='Server Domains'>"
server_domains.each do |domain|
selected = domain == selected_domain ? "selected='selected'" : ""
s << "<option value='#{domain.id}' #{selected}>#{domain.name}</option>"
s << "<option value='#{h(domain.id)}' #{selected}>#{h(domain.name)}</option>"
end
s << "</optgroup>"
end
Expand All @@ -42,7 +43,7 @@ def domain_options_for_select(server, selected_domain = nil, options = {})
s << "<optgroup label='Organization Domains'>"
organization_domains.each do |domain|
selected = domain == selected_domain ? "selected='selected'" : ""
s << "<option value='#{domain.id}' #{selected}>#{domain.name}</option>"
s << "<option value='#{h(domain.id)}' #{selected}>#{h(domain.name)}</option>"
end
s << "</optgroup>"
end
Expand All @@ -59,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 << "<option value='#{value}' #{selected}>#{endpoint.description}</option>"
s << "<option value='#{h(value)}' #{selected}>#{h(endpoint.description)}</option>"
end
s << "</optgroup>"
end
Expand All @@ -70,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 << "<option value='#{value}' #{selected}>#{endpoint.description}</option>"
s << "<option value='#{h(value)}' #{selected}>#{h(endpoint.description)}</option>"
end
s << "</optgroup>"
end
Expand All @@ -81,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 << "<option value='#{value}' #{selected}>#{endpoint.address}</option>"
s << "<option value='#{h(value)}' #{selected}>#{h(endpoint.address)}</option>"
end
s << "</optgroup>"
end
Expand All @@ -93,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 << "<option value='#{mode}' #{selected}>#{text}</option>"
s << "<option value='#{h(mode)}' #{selected}>#{h(text)}</option>"
end
s << "</optgroup>"
end
Expand Down
2 changes: 1 addition & 1 deletion app/views/ip_pool_rules/_form.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
.fieldSet__field
= f.label :ip_pool_id, "IP Pool", :class => 'fieldSet__label'
.fieldSet__input
= f.collection_select :ip_pool_id, organization.ip_pools.includes(:ip_addresses).order("`default` desc, name asc"), :id, :name, {}, :class => 'input input--select'
= f.collection_select :ip_pool_id, organization.ip_pools.includes(:ip_addresses).order("`default` desc, name asc"), :id, :name, { prompt: "Select an IP pool" }, :class => 'input input--select'
%p.fieldSet__text
This is the IP pool that this message should be delivered from.

Expand Down
2 changes: 1 addition & 1 deletion app/views/messages/html.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
2 changes: 1 addition & 1 deletion app/views/servers/_form.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
.fieldSet__field
= f.label :ip_pool_id, :class => 'fieldSet__label'
.fieldSet__input
= f.collection_select :ip_pool_id, organization.ip_pools.includes(:ip_addresses).order("`default` desc, name asc"), :id, :name, {}, :class => 'input input--select'
= f.collection_select :ip_pool_id, organization.ip_pools.includes(:ip_addresses).order("`default` desc, name asc"), :id, :name, { include_blank: "No specific pool (use rules / default)" }, :class => 'input input--select'
%p.fieldSet__text
This is the set of IP addresses which outbound e-mails will be delivered from.

Expand Down
16 changes: 1 addition & 15 deletions lib/tracking_middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions spec/helpers/application_helper_spec.rb
Original file line number Diff line number Diff line change
@@ -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'"><script>alert(1)</script>) }

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 <script> tag.
expect(html).not_to include("<script>alert(1)</script>")

# Escaped form should appear instead.
expect(html).to include("&lt;script&gt;alert(1)&lt;/script&gt;")
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 <option value='...'> or terminate the
# element early.
expect(html).not_to match(/<option[^>]*>[^<]*<script/)
end
end
end
end
71 changes: 71 additions & 0 deletions spec/lib/tracking_middleware_spec.rb
Original file line number Diff line number Diff line change
@@ -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 "<html><body>hi</body></html>"
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=<url> (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
2 changes: 1 addition & 1 deletion spec/rails_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
DatabaseCleaner.allow_remote_database_url = true
ActiveRecord::Base.logger = Logger.new("/dev/null")

Dir[File.expand_path("helpers/**/*.rb", __dir__)].each { |f| require f }
Dir[File.expand_path("helpers/**/*.rb", __dir__)].reject { |f| f.end_with?("_spec.rb") }.each { |f| require f }

ActionMailer::Base.delivery_method = :test

Expand Down
58 changes: 58 additions & 0 deletions spec/requests/messages_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -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) { %(<script>alert("XSS")</script>) }
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 %(<html><body><p>hello</p>#{payload}</body></html>)
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
Loading
Loading