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
51 changes: 51 additions & 0 deletions app/util/user_creator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,60 @@

module UserCreator

ENV_PREFIX = "POSTAL_INITIAL_USER_"
REQUIRED_ENV_VARS = %w[EMAIL FIRST_NAME LAST_NAME PASSWORD].map { |s| "#{ENV_PREFIX}#{s}" }.freeze

class << self

# Create (or update) a user. If POSTAL_INITIAL_USER_EMAIL is set in the
# environment, runs non-interactively using POSTAL_INITIAL_USER_*
# variables and upserts by email. Otherwise prompts on STDIN.
def start(&block)
if non_interactive_env?
start_from_env(&block)
else
start_interactive(&block)
end
end

private

def non_interactive_env?
ENV["#{ENV_PREFIX}EMAIL"].to_s.strip != ""
end

def start_from_env(&block)
puts "\e[32mPostal User Creator\e[0m (non-interactive mode)"

missing = REQUIRED_ENV_VARS.reject { |k| ENV[k].to_s.strip != "" }
unless missing.empty?
warn "\e[31mFailed to create user\e[0m"
warn " * missing required environment variables: #{missing.join(', ')}"
exit 1
end

email = ENV.fetch("#{ENV_PREFIX}EMAIL")
user = User.find_by(email_address: email) || User.new
user.email_address = email
user.first_name = ENV.fetch("#{ENV_PREFIX}FIRST_NAME")
user.last_name = ENV.fetch("#{ENV_PREFIX}LAST_NAME")
user.password = ENV.fetch("#{ENV_PREFIX}PASSWORD")

block.call(user) if block_given?

action = user.new_record? ? "created" : "updated"
if user.save
puts "User \e[32m#{user.email_address}\e[0m has been #{action}"
else
warn "\e[31mFailed to create user\e[0m"
user.errors.full_messages.each do |error|
warn " * #{error}"
end
exit 1
end
end

def start_interactive(&block)
cli = HighLine.new
puts "\e[32mPostal User Creator\e[0m"
puts "Enter the information required to create a new Postal user."
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)}
4 changes: 4 additions & 0 deletions doc/config/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,7 @@ This document contains all the environment variables which are available for thi
| `OIDC_TOKEN_ENDPOINT` | String | The token endpoint on the authorization server (only used when discovery is false) | |
| `OIDC_USERINFO_ENDPOINT` | String | The user info endpoint on the authorization server (only used when discovery is false) | |
| `OIDC_JWKS_URI` | String | The JWKS endpoint on the authorization server (only used when discovery is false) | |
| `POSTAL_INITIAL_USER_EMAIL` | String | E-mail address for the user created or updated by `postal make-user`. When set, `make-user` runs non-interactively and upserts by e-mail (creates if absent, updates first/last name + password if present). All four `POSTAL_INITIAL_USER_*` variables must be set together. | |
| `POSTAL_INITIAL_USER_FIRST_NAME` | String | First name for the user created or updated by `postal make-user` (non-interactive mode; see `POSTAL_INITIAL_USER_EMAIL`). | |
| `POSTAL_INITIAL_USER_LAST_NAME` | String | Last name for the user created or updated by `postal make-user` (non-interactive mode; see `POSTAL_INITIAL_USER_EMAIL`). | |
| `POSTAL_INITIAL_USER_PASSWORD` | String | Password for the user created or updated by `postal make-user` (non-interactive mode; see `POSTAL_INITIAL_USER_EMAIL`). | |
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
Loading
Loading