مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2026-06-03 21:45:48 +00:00
Compare commits
6 الالتزامات
3.3.5
...
8ef89606bc
| المؤلف | SHA1 | التاريخ | |
|---|---|---|---|
|
|
8ef89606bc | ||
|
|
84f4e20f05 | ||
|
|
9243524035 | ||
|
|
dca7f90b90 | ||
|
|
cad2aa6808 | ||
|
|
b611d577af |
3
.gitignore
مباع
3
.gitignore
مباع
@@ -34,3 +34,6 @@ BRANCH
|
|||||||
.rubocop-https*
|
.rubocop-https*
|
||||||
.env*
|
.env*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "3.3.5"
|
".": "3.3.6"
|
||||||
}
|
}
|
||||||
|
|||||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -2,6 +2,25 @@
|
|||||||
|
|
||||||
This file contains all the latest changes and updates to Postal.
|
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)
|
## [3.3.5](https://github.com/postalserver/postal/compare/3.3.4...3.3.5) (2026-02-01)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,10 +62,13 @@ class ApplicationController < ActionController::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def url_with_return_to(url)
|
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)
|
url_for(url)
|
||||||
else
|
else
|
||||||
params[:return_to]
|
return_to
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,18 @@ class MessagesController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def html_raw
|
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
|
render html: @message.html_body_without_tracking_image.html_safe
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ module ApplicationHelper
|
|||||||
s << "<optgroup label='Server Domains'>"
|
s << "<optgroup label='Server Domains'>"
|
||||||
server_domains.each do |domain|
|
server_domains.each do |domain|
|
||||||
selected = domain == selected_domain ? "selected='selected'" : ""
|
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
|
end
|
||||||
s << "</optgroup>"
|
s << "</optgroup>"
|
||||||
end
|
end
|
||||||
@@ -43,7 +43,7 @@ module ApplicationHelper
|
|||||||
s << "<optgroup label='Organization Domains'>"
|
s << "<optgroup label='Organization Domains'>"
|
||||||
organization_domains.each do |domain|
|
organization_domains.each do |domain|
|
||||||
selected = domain == selected_domain ? "selected='selected'" : ""
|
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
|
end
|
||||||
s << "</optgroup>"
|
s << "</optgroup>"
|
||||||
end
|
end
|
||||||
@@ -60,7 +60,7 @@ module ApplicationHelper
|
|||||||
http_endpoints.each do |endpoint|
|
http_endpoints.each do |endpoint|
|
||||||
value = "#{endpoint.class}##{endpoint.uuid}"
|
value = "#{endpoint.class}##{endpoint.uuid}"
|
||||||
selected = value == selected_value ? "selected='selected'" : ""
|
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
|
end
|
||||||
s << "</optgroup>"
|
s << "</optgroup>"
|
||||||
end
|
end
|
||||||
@@ -71,7 +71,7 @@ module ApplicationHelper
|
|||||||
smtp_endpoints.each do |endpoint|
|
smtp_endpoints.each do |endpoint|
|
||||||
value = "#{endpoint.class}##{endpoint.uuid}"
|
value = "#{endpoint.class}##{endpoint.uuid}"
|
||||||
selected = value == selected_value ? "selected='selected'" : ""
|
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
|
end
|
||||||
s << "</optgroup>"
|
s << "</optgroup>"
|
||||||
end
|
end
|
||||||
@@ -82,7 +82,7 @@ module ApplicationHelper
|
|||||||
address_endpoints.each do |endpoint|
|
address_endpoints.each do |endpoint|
|
||||||
value = "#{endpoint.class}##{endpoint.uuid}"
|
value = "#{endpoint.class}##{endpoint.uuid}"
|
||||||
selected = value == selected_value ? "selected='selected'" : ""
|
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
|
end
|
||||||
s << "</optgroup>"
|
s << "</optgroup>"
|
||||||
end
|
end
|
||||||
@@ -94,7 +94,7 @@ module ApplicationHelper
|
|||||||
|
|
||||||
selected = (selected_value == mode ? "selected='selected'" : "")
|
selected = (selected_value == mode ? "selected='selected'" : "")
|
||||||
text = t("route_modes.#{mode.underscore}")
|
text = t("route_modes.#{mode.underscore}")
|
||||||
s << "<option value='#{mode}' #{selected}>#{text}</option>"
|
s << "<option value='#{h(mode)}' #{selected}>#{h(text)}</option>"
|
||||||
end
|
end
|
||||||
s << "</optgroup>"
|
s << "</optgroup>"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,4 +14,4 @@
|
|||||||
This means that we no longer store the raw data for this e-mail
|
This means that we no longer store the raw data for this e-mail
|
||||||
or the e-mail didn't include a HTML part.
|
or the e-mail didn't include a HTML part.
|
||||||
- else
|
- 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)}
|
||||||
|
|||||||
@@ -48,25 +48,11 @@ class TrackingMiddleware
|
|||||||
Sentry.capture_exception(e) if defined?(Sentry)
|
Sentry.capture_exception(e) if defined?(Sentry)
|
||||||
end
|
end
|
||||||
|
|
||||||
source_image = request.params["src"]
|
if request.params["src"].nil?
|
||||||
case source_image
|
|
||||||
when nil
|
|
||||||
headers = {}
|
headers = {}
|
||||||
headers["Content-Type"] = "image/png"
|
headers["Content-Type"] = "image/png"
|
||||||
headers["Content-Length"] = TRACKING_PIXEL.bytesize.to_s
|
headers["Content-Length"] = TRACKING_PIXEL.bytesize.to_s
|
||||||
[200, headers, [TRACKING_PIXEL]]
|
[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
|
else
|
||||||
[400, {}, ["Invalid/missing source image"]]
|
[400, {}, ["Invalid/missing source image"]]
|
||||||
end
|
end
|
||||||
|
|||||||
37
spec/helpers/application_helper_spec.rb
Normal file
37
spec/helpers/application_helper_spec.rb
Normal file
@@ -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("<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 <option value='...'> or terminate the
|
||||||
|
# element early.
|
||||||
|
expect(html).not_to match(/<option[^>]*>[^<]*<script/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
71
spec/lib/tracking_middleware_spec.rb
Normal file
71
spec/lib/tracking_middleware_spec.rb
Normal file
@@ -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
|
||||||
@@ -16,7 +16,7 @@ require "shoulda-matchers"
|
|||||||
DatabaseCleaner.allow_remote_database_url = true
|
DatabaseCleaner.allow_remote_database_url = true
|
||||||
ActiveRecord::Base.logger = Logger.new("/dev/null")
|
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
|
ActionMailer::Base.delivery_method = :test
|
||||||
|
|
||||||
|
|||||||
58
spec/requests/messages_controller_spec.rb
Normal file
58
spec/requests/messages_controller_spec.rb
Normal file
@@ -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
|
||||||
71
spec/requests/sessions_controller_spec.rb
Normal file
71
spec/requests/sessions_controller_spec.rb
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "SessionsController", type: :request do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
|
||||||
|
describe "POST /login with return_to" do
|
||||||
|
def login_with(return_to:)
|
||||||
|
post "/login", params: {
|
||||||
|
email_address: user.email_address,
|
||||||
|
password: "passw0rd",
|
||||||
|
return_to: return_to
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
shared_examples "rejects unsafe return_to" do
|
||||||
|
it "does not redirect to the attacker-controlled location" do
|
||||||
|
login_with(return_to: unsafe_path)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:found)
|
||||||
|
# Whatever the fallback is, it must be same-origin: a Location that
|
||||||
|
# either omits a host or points at our own host. A browser must not
|
||||||
|
# end up at attacker.example.
|
||||||
|
location = response.location
|
||||||
|
expect(location).not_to include("attacker.example")
|
||||||
|
# Reject protocol-relative and absolute redirects entirely.
|
||||||
|
expect(location).not_to match(%r{\A//})
|
||||||
|
expect(location).not_to match(%r{\Ahttps?://attacker})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with a protocol-relative URL (//host)" do
|
||||||
|
let(:unsafe_path) { "//attacker.example/phish" }
|
||||||
|
include_examples "rejects unsafe return_to"
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with a backslash-prefixed URL (/\\host)" do
|
||||||
|
let(:unsafe_path) { "/\\attacker.example/phish" }
|
||||||
|
include_examples "rejects unsafe return_to"
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with an absolute http(s) URL" do
|
||||||
|
let(:unsafe_path) { "https://attacker.example/phish" }
|
||||||
|
include_examples "rejects unsafe return_to"
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with a javascript: URL" do
|
||||||
|
let(:unsafe_path) { "javascript:alert(1)" }
|
||||||
|
include_examples "rejects unsafe return_to"
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with a safe relative path" do
|
||||||
|
it "honours the return_to" do
|
||||||
|
login_with(return_to: "/org/acme/settings")
|
||||||
|
expect(response).to redirect_to("/org/acme/settings")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with no return_to" do
|
||||||
|
it "redirects to the default root" do
|
||||||
|
post "/login", params: {
|
||||||
|
email_address: user.email_address,
|
||||||
|
password: "passw0rd"
|
||||||
|
}
|
||||||
|
expect(response).to have_http_status(:found)
|
||||||
|
expect(response.location).not_to match(%r{\A//})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
المرجع في مشكلة جديدة
حظر مستخدم