1
0
مراية لـ https://github.com/postalserver/postal.git تم المزامنة 2026-06-03 21:45:48 +00:00

Compare commits

...

8 الالتزامات

المؤلف SHA1 الرسالة التاريخ
github-actions[bot]
8ef89606bc chore(main): release 3.3.6 (#3558)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-28 17:19:33 +01:00
Adam Cooke
84f4e20f05 refactor(auth): tighten return_to validation
url_with_return_to only checked that return_to started with a forward
slash, which also allowed protocol-relative values like //host and
/\host. Rails 7.1 already refuses to follow those via redirect_to, so
the user just saw a 500. Reject the same shapes in the helper instead
so we fall back to the default URL cleanly.

Adds a sessions request spec covering the rejected shapes plus the
happy-path relative redirect.
2026-04-24 23:03:50 +01:00
Adam Cooke
9243524035 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.
2026-04-24 22:55:46 +01:00
Adam Cooke
dca7f90b90 refactor(tracking): remove unused src image proxy
The /img/<server>/<message> endpoint accepted a src=<url> 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.
2026-04-24 22:24:18 +01:00
Adam Cooke
cad2aa6808 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.
2026-04-24 22:12:27 +01:00
Adam Cooke
b611d577af chore: ignore node modules and yarn.lock 2026-04-24 21:34:57 +01:00
github-actions[bot]
d532922ff7 chore(main): release 3.3.5 (#3208)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-01 14:55:42 +00:00
Adam Cooke
11419f9914 fix(deliveries): escape delivery details to prevent HTML injection 2026-02-01 14:48:54 +00:00
13 ملفات معدلة مع 314 إضافات و26 حذوفات

3
.gitignore مباع
عرض الملف

@@ -34,3 +34,6 @@ BRANCH
.rubocop-https*
.env*
node_modules
yarn.lock

عرض الملف

@@ -1,3 +1,3 @@
{
".": "3.3.4"
".": "3.3.6"
}

عرض الملف

@@ -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)

عرض الملف

@@ -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

عرض الملف

@@ -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")
@@ -32,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
@@ -42,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
@@ -59,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
@@ -70,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
@@ -81,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
@@ -93,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

عرض الملف

@@ -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

عرض الملف

@@ -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

عرض الملف

@@ -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

عرض الملف

@@ -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