مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2026-06-03 13:45:42 +00:00
Compare commits
6 الالتزامات
dependabot
...
8ef89606bc
| المؤلف | SHA1 | التاريخ | |
|---|---|---|---|
|
|
8ef89606bc | ||
|
|
84f4e20f05 | ||
|
|
9243524035 | ||
|
|
dca7f90b90 | ||
|
|
cad2aa6808 | ||
|
|
b611d577af |
3
.gitignore
مباع
3
.gitignore
مباع
@@ -34,3 +34,6 @@ BRANCH
|
||||
.rubocop-https*
|
||||
.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.
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ GEM
|
||||
dynamic_form (1.2.0)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (6.0.1.1)
|
||||
erb (5.0.2)
|
||||
erubi (1.12.0)
|
||||
execjs (2.7.0)
|
||||
factory_bot (6.4.6)
|
||||
|
||||
@@ -62,10 +62,13 @@ class ApplicationController < ActionController::Base
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ module ApplicationHelper
|
||||
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
|
||||
@@ -43,7 +43,7 @@ module ApplicationHelper
|
||||
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
|
||||
@@ -60,7 +60,7 @@ module ApplicationHelper
|
||||
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
|
||||
@@ -71,7 +71,7 @@ module ApplicationHelper
|
||||
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
|
||||
@@ -82,7 +82,7 @@ module ApplicationHelper
|
||||
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
|
||||
@@ -94,7 +94,7 @@ module ApplicationHelper
|
||||
|
||||
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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -48,25 +48,11 @@ class TrackingMiddleware
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
المرجع في مشكلة جديدة
حظر مستخدم