1
1
مراية لـ https://github.com/postalserver/postal.git تم المزامنة 2026-06-17 19:55:43 +00:00

Compare commits

...

16 الالتزامات

المؤلف SHA1 الرسالة التاريخ
dependabot[bot]
b16ab59a6d chore(deps): bump puma from 7.0.4 to 7.2.1
Bumps [puma](https://github.com/puma/puma) from 7.0.4 to 7.2.1.
- [Release notes](https://github.com/puma/puma/releases)
- [Changelog](https://github.com/puma/puma/blob/main/History.md)
- [Commits](https://github.com/puma/puma/compare/v7.0.4...v7.2.1)

---
updated-dependencies:
- dependency-name: puma
  dependency-version: 7.2.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-09 01:19:20 +00:00
github-actions[bot]
d038eaa8c7 chore(main): release 3.3.7 (#3577)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-03 16:24:45 +01:00
Adam Cooke
3b3defe271 doc: update config with new allowed request destinations option 2026-06-03 15:52:46 +01:00
Adam Cooke
029bfe098d fix(specs): stub IPv6 support in address guard IPv6 literal spec
The spec relied on the test machine having real IPv6 connectivity,
which GitHub Actions runners do not have.
2026-06-03 15:43:02 +01:00
Adam Cooke
0445e5c509 chore(deps): upgrade rack & rails 2026-06-03 15:40:29 +01:00
Adam Cooke
11c9814474 fix(http): prevent SSRF in outbound webhook and HTTP endpoint requests
Webhook and HTTP message endpoint deliveries both flow through
Postal::HTTP, which parsed the user-supplied URL and connected to its
host with no address validation. An authenticated user could point a
webhook or endpoint at a private, loopback or link-local address (e.g.
127.0.0.1, 169.254.169.254 cloud metadata, RFC1918 hosts) and make the
server issue requests into its own internal network.

Add Postal::HTTP::AddressGuard, which resolves the destination host and
rejects private/loopback/link-local/reserved/multicast IPv4 and IPv6
addresses, then pins the connection to the validated address so it cannot
be redirected via a DNS-rebinding race. Administrators can permit specific
destinations via the new postal.allowed_request_destinations config option
(hostnames or IP/CIDR ranges).

Address selection only uses families this server can actually reach so we
do not pin to an IPv6 address on a host without IPv6 connectivity; IPv4 is
preferred for predictability. HTTPEndpoint now validates that its URL is a
well-formed HTTP(S) URL with a host.
2026-06-03 15:09:18 +01:00
Adam Cooke
4314a6ec1e fix(message-db): prevent SQL injection via condition keys (GHSA-x2hq-rfpg-3xr5)
The Legacy API message lookup endpoints parsed the request body as JSON and
passed the `id` parameter straight through to the message database. A JSON
object supplied for `id` arrived as a Ruby Hash and was used as a raw set of
SQL `WHERE` conditions. `hash_to_sql` interpolated each Hash key directly
inside backtick identifier quoting while escaping only the value, so a key
containing a backtick could break out of the identifier and inject arbitrary
SQL into the SELECT (blind, time-based) against the message database.

Fixes:

- Escape all identifiers (columns, tables, database names) through a new
  `escape_identifier` helper that wraps in backticks and doubles embedded
  backticks. Applied across hash_to_sql, select, insert, insert_multi,
  update and delete so no caller can inject via an identifier.
- Validate the Legacy API `id` parameter at the controller boundary: reject
  any non-scalar value before it reaches the database and coerce it to an
  integer. Internal Hash-based lookups (e.g. tracking middleware) are
  unaffected.

Adds regression tests at the unit (hash_to_sql / escape_identifier) and
request (legacy messages/deliveries) levels.
2026-06-03 15:06:35 +01:00
Adam Cooke
8be1e27fec chore: update security vulnerability reporting instructions 2026-06-03 15:03:51 +01:00
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
32 ملفات معدلة مع 1221 إضافات و160 حذوفات

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

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

عرض الملف

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

عرض الملف

@@ -2,6 +2,67 @@
This file contains all the latest changes and updates to Postal. This file contains all the latest changes and updates to Postal.
## [3.3.7](https://github.com/postalserver/postal/compare/3.3.6...3.3.7) (2026-06-03)
### Bug Fixes
* **http:** prevent SSRF in outbound webhook and HTTP endpoint requests ([11c9814](https://github.com/postalserver/postal/commit/11c9814474f956619da35e8385ef7fab9f304de0))
* **message-db:** prevent SQL injection via condition keys (GHSA-x2hq-rfpg-3xr5) ([4314a6e](https://github.com/postalserver/postal/commit/4314a6ec1e2812daa67dd20effd1db1769c1f8e8))
* **specs:** stub IPv6 support in address guard IPv6 literal spec ([029bfe0](https://github.com/postalserver/postal/commit/029bfe098d9b8c0b5cafc49eac33e767f5748cd3))
### Miscellaneous Chores
* **deps:** upgrade rack & rails ([0445e5c](https://github.com/postalserver/postal/commit/0445e5c509870dfe9c16366c53dee3fc02ad3904))
* update security vulnerability reporting instructions ([8be1e27](https://github.com/postalserver/postal/commit/8be1e27fec489ab659ef5e909f705932028b1694))
## [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) ## [3.3.4](https://github.com/postalserver/postal/compare/3.3.3...3.3.4) (2024-06-20)

عرض الملف

@@ -29,7 +29,7 @@ gem "ostruct"
gem "prometheus-client" gem "prometheus-client"
gem "puma" gem "puma"
gem "rackup" gem "rackup"
gem "rails", "= 7.1.5.2" gem "rails", "= 7.1.6"
gem "resolv" gem "resolv"
gem "secure_headers" gem "secure_headers"
gem "sentry-rails" gem "sentry-rails"

عرض الملف

@@ -2,35 +2,36 @@ GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
abbrev (0.1.2) abbrev (0.1.2)
actioncable (7.1.5.2) actioncable (7.1.6)
actionpack (= 7.1.5.2) actionpack (= 7.1.6)
activesupport (= 7.1.5.2) activesupport (= 7.1.6)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (7.1.5.2) actionmailbox (7.1.6)
actionpack (= 7.1.5.2) actionpack (= 7.1.6)
activejob (= 7.1.5.2) activejob (= 7.1.6)
activerecord (= 7.1.5.2) activerecord (= 7.1.6)
activestorage (= 7.1.5.2) activestorage (= 7.1.6)
activesupport (= 7.1.5.2) activesupport (= 7.1.6)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.1.5.2) actionmailer (7.1.6)
actionpack (= 7.1.5.2) actionpack (= 7.1.6)
actionview (= 7.1.5.2) actionview (= 7.1.6)
activejob (= 7.1.5.2) activejob (= 7.1.6)
activesupport (= 7.1.5.2) activesupport (= 7.1.6)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (7.1.5.2) actionpack (7.1.6)
actionview (= 7.1.5.2) actionview (= 7.1.6)
activesupport (= 7.1.5.2) activesupport (= 7.1.6)
cgi
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc racc
rack (>= 2.2.4) rack (>= 2.2.4)
@@ -38,35 +39,36 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
actiontext (7.1.5.2) actiontext (7.1.6)
actionpack (= 7.1.5.2) actionpack (= 7.1.6)
activerecord (= 7.1.5.2) activerecord (= 7.1.6)
activestorage (= 7.1.5.2) activestorage (= 7.1.6)
activesupport (= 7.1.5.2) activesupport (= 7.1.6)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.1.5.2) actionview (7.1.6)
activesupport (= 7.1.5.2) activesupport (= 7.1.6)
builder (~> 3.1) builder (~> 3.1)
cgi
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (7.1.5.2) activejob (7.1.6)
activesupport (= 7.1.5.2) activesupport (= 7.1.6)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.1.5.2) activemodel (7.1.6)
activesupport (= 7.1.5.2) activesupport (= 7.1.6)
activerecord (7.1.5.2) activerecord (7.1.6)
activemodel (= 7.1.5.2) activemodel (= 7.1.6)
activesupport (= 7.1.5.2) activesupport (= 7.1.6)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (7.1.5.2) activestorage (7.1.6)
actionpack (= 7.1.5.2) actionpack (= 7.1.6)
activejob (= 7.1.5.2) activejob (= 7.1.6)
activerecord (= 7.1.5.2) activerecord (= 7.1.6)
activesupport (= 7.1.5.2) activesupport (= 7.1.6)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (7.1.5.2) activesupport (7.1.6)
base64 base64
benchmark (>= 0.3) benchmark (>= 0.3)
bigdecimal bigdecimal
@@ -93,10 +95,11 @@ GEM
execjs (~> 2) execjs (~> 2)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.20) bcrypt (3.1.20)
benchmark (0.4.1) benchmark (0.5.0)
bigdecimal (3.2.3) bigdecimal (4.1.2)
bindata (2.5.0) bindata (2.5.0)
builder (3.2.4) builder (3.3.0)
cgi (0.5.1)
chronic (0.10.2) chronic (0.10.2)
coffee-rails (5.0.0) coffee-rails (5.0.0)
coffee-script (>= 2.2.0) coffee-script (>= 2.2.0)
@@ -105,8 +108,8 @@ GEM
coffee-script-source coffee-script-source
execjs execjs
coffee-script-source (1.12.2) coffee-script-source (1.12.2)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.6)
connection_pool (2.5.4) connection_pool (3.0.2)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
rexml rexml
@@ -115,7 +118,7 @@ GEM
activerecord (>= 5.a) activerecord (>= 5.a)
database_cleaner-core (~> 2.0) database_cleaner-core (~> 2.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
date (3.4.1) date (3.5.1)
diff-lcs (1.6.2) diff-lcs (1.6.2)
domain_name (0.6.20240107) domain_name (0.6.20240107)
dotenv (3.0.2) dotenv (3.0.2)
@@ -123,8 +126,8 @@ GEM
dynamic_form (1.2.0) dynamic_form (1.2.0)
email_validator (2.2.4) email_validator (2.2.4)
activemodel activemodel
erb (5.0.2) erb (6.0.4)
erubi (1.12.0) erubi (1.13.1)
execjs (2.7.0) execjs (2.7.0)
factory_bot (6.4.6) factory_bot (6.4.6)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
@@ -149,11 +152,12 @@ GEM
hashdiff (1.1.0) hashdiff (1.1.0)
hashie (5.0.0) hashie (5.0.0)
highline (2.1.0) highline (2.1.0)
i18n (1.14.7) i18n (1.14.8)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
io-console (0.8.1) io-console (0.8.2)
irb (1.15.2) irb (1.18.0)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jquery-rails (4.5.1) jquery-rails (4.5.1)
@@ -189,42 +193,45 @@ GEM
konfig-config (3.0.0) konfig-config (3.0.0)
hashie hashie
logger (1.7.0) logger (1.7.0)
loofah (2.24.1) loofah (2.25.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.9.0)
logger
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.1.0) marcel (1.2.1)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.25.5) minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
mutex_m (0.3.0) mutex_m (0.3.0)
mysql2 (0.5.6) mysql2 (0.5.6)
net-http (0.4.1) net-http (0.4.1)
uri uri
net-imap (0.5.11) net-imap (0.6.4)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
net-protocol net-protocol
net-protocol (0.2.2) net-protocol (0.2.2)
timeout timeout
net-smtp (0.4.0.1) net-smtp (0.5.1)
net-protocol net-protocol
nifty-utils (1.1.7) nifty-utils (1.1.7)
nilify_blanks (1.4.0) nilify_blanks (1.4.0)
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
activesupport (>= 4.0.0) activesupport (>= 4.0.0)
nio4r (2.7.4) nio4r (2.7.5)
nokogiri (1.18.10-aarch64-linux-gnu) nokogiri (1.19.3-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin) nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-x86_64-darwin) nokogiri (1.19.3-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu) nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
omniauth (2.1.2) omniauth (2.1.2)
hashie (>= 3.4.6) hashie (>= 3.4.6)
@@ -253,18 +260,19 @@ GEM
parallel (1.22.1) parallel (1.22.1)
parser (3.2.1.1) parser (3.2.1.1)
ast (~> 2.4.1) ast (~> 2.4.1)
pp (0.6.2) pp (0.6.3)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.9.0)
prometheus-client (4.2.2) prometheus-client (4.2.2)
psych (5.2.6) psych (5.4.0)
date date
stringio stringio
public_suffix (5.0.4) public_suffix (5.0.4)
puma (7.0.4) puma (7.2.1)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.1) rack (3.2.6)
rack-oauth2 (2.2.1) rack-oauth2 (2.2.1)
activesupport activesupport
attr_required attr_required
@@ -276,49 +284,52 @@ GEM
base64 (>= 0.1.0) base64 (>= 0.1.0)
logger (>= 1.6.0) logger (>= 1.6.0)
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
rack-session (2.1.1) rack-session (2.1.2)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.1.0) rack-test (2.2.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.2.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (7.1.5.2) rails (7.1.6)
actioncable (= 7.1.5.2) actioncable (= 7.1.6)
actionmailbox (= 7.1.5.2) actionmailbox (= 7.1.6)
actionmailer (= 7.1.5.2) actionmailer (= 7.1.6)
actionpack (= 7.1.5.2) actionpack (= 7.1.6)
actiontext (= 7.1.5.2) actiontext (= 7.1.6)
actionview (= 7.1.5.2) actionview (= 7.1.6)
activejob (= 7.1.5.2) activejob (= 7.1.6)
activemodel (= 7.1.5.2) activemodel (= 7.1.6)
activerecord (= 7.1.5.2) activerecord (= 7.1.6)
activestorage (= 7.1.5.2) activestorage (= 7.1.6)
activesupport (= 7.1.5.2) activesupport (= 7.1.6)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.1.5.2) railties (= 7.1.6)
rails-dom-testing (2.2.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.7.0)
loofah (~> 2.21) loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.1.5.2) railties (7.1.6)
actionpack (= 7.1.5.2) actionpack (= 7.1.6)
activesupport (= 7.1.5.2) activesupport (= 7.1.6)
cgi
irb irb
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.1.0) rake (13.4.2)
rdoc (6.14.2) rdoc (7.2.0)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort
regexp_parser (2.7.0) regexp_parser (2.7.0)
reline (0.6.2) reline (0.6.3)
io-console (~> 0.5) io-console (~> 0.5)
resolv (0.6.2) resolv (0.6.2)
rexml (3.4.4) rexml (3.4.4)
@@ -387,17 +398,18 @@ GEM
actionpack (>= 5.2) actionpack (>= 5.2)
activesupport (>= 5.2) activesupport (>= 5.2)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
stringio (3.1.7) stringio (3.2.0)
swd (2.0.3) swd (2.0.3)
activesupport (>= 3) activesupport (>= 3)
attr_required (>= 0.0.5) attr_required (>= 0.0.5)
faraday (~> 2.0) faraday (~> 2.0)
faraday-follow_redirects faraday-follow_redirects
temple (0.10.3) temple (0.10.3)
thor (1.3.0) thor (1.5.0)
tilt (2.3.0) tilt (2.3.0)
timecop (0.9.8) timecop (0.9.8)
timeout (0.4.3) timeout (0.6.1)
tsort (0.2.0)
turbolinks (5.2.1) turbolinks (5.2.1)
turbolinks-source (~> 5.2) turbolinks-source (~> 5.2)
turbolinks-source (5.2.0) turbolinks-source (5.2.0)
@@ -423,7 +435,7 @@ GEM
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
zeitwerk (2.6.13) zeitwerk (2.8.2)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
@@ -467,7 +479,7 @@ DEPENDENCIES
prometheus-client prometheus-client
puma puma
rackup rackup
rails (= 7.1.5.2) rails (= 7.1.6)
resolv resolv
rspec rspec
rspec-rails rspec-rails

عرض الملف

@@ -11,5 +11,5 @@ We only support updates to the 3.x versions of Postal.
## Reporting a Vulnerability ## Reporting a Vulnerability
If you discover a vulnerability in Postal, please do not post an issue on GitHub. Instead you should send an If you discover a vulnerability in Postal, please do not post an issue on GitHub. Please,
e-mail to security@postalserver.io with details. We will get back to you directly. instead, [create a new security advisory through GitHub](https://github.com/postalserver/postal/security/advisories/new).

عرض الملف

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

عرض الملف

@@ -15,12 +15,9 @@ module LegacyAPI
# OR an error if the message does not exist. # OR an error if the message does not exist.
# #
def message def message
if api_params["id"].blank? message = find_message
render_parameter_error "`id` parameter is required but is missing" return if performed?
return
end
message = @current_credential.server.message(api_params["id"])
message_hash = { id: message.id, token: message.token } message_hash = { id: message.id, token: message.token }
expansions = api_params["_expansions"] expansions = api_params["_expansions"]
@@ -111,12 +108,9 @@ module LegacyAPI
# OR an error if the message does not exist. # OR an error if the message does not exist.
# #
def deliveries def deliveries
if api_params["id"].blank? message = find_message
render_parameter_error "`id` parameter is required but is missing" return if performed?
return
end
message = @current_credential.server.message(api_params["id"])
deliveries = message.deliveries.map do |d| deliveries = message.deliveries.map do |d|
{ {
id: d.id, id: d.id,
@@ -136,5 +130,37 @@ module LegacyAPI
id: api_params["id"] id: api_params["id"]
end end
private
# Look up the message referenced by the request's `id` parameter.
#
# The legacy API only ever identifies a message by its integer ID. The
# request body is parsed as JSON, so without validation a JSON object or
# array supplied for `id` would arrive as a Ruby Hash/Array and be passed
# straight through to the message database as a raw set of SQL conditions.
# We therefore reject anything that is not a simple scalar before it can
# reach the database and coerce the value to an integer ID.
#
# Renders an error and returns nil when the parameter is missing or is not
# a scalar; otherwise returns the matched message (raising NotFound when no
# message matches, which the actions rescue).
#
# @return [Postal::MessageDB::Message, nil]
def find_message
id = api_params["id"]
if id.blank?
render_parameter_error "`id` parameter is required but is missing"
return
end
unless id.is_a?(String) || id.is_a?(Integer)
render_parameter_error "`id` parameter must be a string or integer"
return
end
@current_credential.server.message(id.to_i)
end
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

عرض الملف

@@ -3,6 +3,7 @@
module ApplicationHelper module ApplicationHelper
def format_delivery_details(server, text) def format_delivery_details(server, text)
text = h(text)
text.gsub!(/<msg:(\d+)>/) do text.gsub!(/<msg:(\d+)>/) do
id = ::Regexp.last_match(1).to_i id = ::Regexp.last_match(1).to_i
link_to("message ##{id}", organization_server_message_path(server.organization, server, id), class: "u-link") 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'>" 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
@@ -42,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
@@ -59,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
@@ -70,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
@@ -81,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
@@ -93,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

عرض الملف

@@ -1,5 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require "uri"
# == Schema Information # == Schema Information
# #
# Table name: http_endpoints # Table name: http_endpoints
@@ -38,6 +40,7 @@ class HTTPEndpoint < ApplicationRecord
validates :name, presence: true validates :name, presence: true
validates :url, presence: true validates :url, presence: true
validate :url_must_be_http_or_https
validates :encoding, inclusion: { in: ENCODINGS } validates :encoding, inclusion: { in: ENCODINGS }
validates :format, inclusion: { in: FORMATS } validates :format, inclusion: { in: FORMATS }
validates :timeout, numericality: { greater_than_or_equal_to: 5, less_than_or_equal_to: 60 } validates :timeout, numericality: { greater_than_or_equal_to: 5, less_than_or_equal_to: 60 }
@@ -56,4 +59,17 @@ class HTTPEndpoint < ApplicationRecord
routes.each { |r| r.update(endpoint: nil, mode: "Reject") } routes.each { |r| r.update(endpoint: nil, mode: "Reject") }
end end
private
def url_must_be_http_or_https
return if url.blank?
uri = URI.parse(url)
return if uri.is_a?(URI::HTTP) && uri.host.present?
errors.add(:url, "must be an HTTP or HTTPS URL")
rescue URI::InvalidURIError
errors.add(:url, "must be a valid HTTP or HTTPS URL")
end
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)}

عرض الملف

@@ -18,6 +18,7 @@ This document contains all the environment variables which are available for thi
| `POSTAL_SIGNING_KEY_PATH` | String | Path to the private key used for signing | $config-file-root/signing.key | | `POSTAL_SIGNING_KEY_PATH` | String | Path to the private key used for signing | $config-file-root/signing.key |
| `POSTAL_SMTP_RELAYS` | Array of strings | An array of SMTP relays in the format of smtp://host:port | [] | | `POSTAL_SMTP_RELAYS` | Array of strings | An array of SMTP relays in the format of smtp://host:port | [] |
| `POSTAL_TRUSTED_PROXIES` | Array of strings | An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses) | [] | | `POSTAL_TRUSTED_PROXIES` | Array of strings | An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses) | [] |
| `POSTAL_ALLOWED_REQUEST_DESTINATIONS` | Array of strings | Hostnames or IP/CIDR ranges that outbound webhook and HTTP endpoint requests are permitted to reach even when they resolve to a private, loopback, link-local or otherwise reserved address. All other such destinations are blocked to prevent SSRF. | [] |
| `POSTAL_QUEUED_MESSAGE_LOCK_STALE_DAYS` | Integer | The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried. | 1 | | `POSTAL_QUEUED_MESSAGE_LOCK_STALE_DAYS` | Integer | The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried. | 1 |
| `POSTAL_BATCH_QUEUED_MESSAGES` | Boolean | When enabled queued messages will be de-queued in batches based on their destination | true | | `POSTAL_BATCH_QUEUED_MESSAGES` | Boolean | When enabled queued messages will be de-queued in batches based on their destination | true |
| `WEB_SERVER_DEFAULT_PORT` | Integer | The default port the web server should listen on unless overriden by the PORT environment variable | 5000 | | `WEB_SERVER_DEFAULT_PORT` | Integer | The default port the web server should listen on unless overriden by the PORT environment variable | 5000 |

عرض الملف

@@ -29,6 +29,8 @@ postal:
smtp_relays: [] smtp_relays: []
# An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses) # An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses)
trusted_proxies: [] trusted_proxies: []
# Hostnames or IP/CIDR ranges that outbound webhook and HTTP endpoint requests are permitted to reach even when they resolve to a private, loopback, link-local or otherwise reserved address. All other such destinations are blocked to prevent SSRF.
allowed_request_destinations: []
# The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried. # The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried.
queued_message_lock_stale_days: 1 queued_message_lock_stale_days: 1
# When enabled queued messages will be de-queued in batches based on their destination # When enabled queued messages will be de-queued in batches based on their destination

عرض الملف

@@ -92,6 +92,14 @@ module Postal
transform { |ip| IPAddr.new(ip) } transform { |ip| IPAddr.new(ip) }
end end
string :allowed_request_destinations do
array
description "Hostnames or IP/CIDR ranges that outbound webhook and HTTP " \
"endpoint requests are permitted to reach even when they resolve " \
"to a private, loopback, link-local or otherwise reserved address. " \
"All other such destinations are blocked to prevent SSRF."
end
integer :queued_message_lock_stale_days do integer :queued_message_lock_stale_days do
description "The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried." description "The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried."
default 1 default 1

عرض الملف

@@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require "net/https" require "net/https"
require "resolv"
require "uri" require "uri"
module Postal module Postal
@@ -47,19 +48,24 @@ module Postal
request["User-Agent"] = options[:user_agent] || "Postal/#{Postal.version}" request["User-Agent"] = options[:user_agent] || "Postal/#{Postal.version}"
connection = Net::HTTP.new(uri.host, uri.port) timeout = options[:timeout] || 60
ssl = uri.scheme == "https"
if uri.scheme == "https"
connection.use_ssl = true
connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
ssl = true
else
ssl = false
end
begin begin
timeout = options[:timeout] || 60
Timeout.timeout(timeout) do Timeout.timeout(timeout) do
connect_address = AddressGuard.safe_connect_address(uri.host)
connection = Net::HTTP.new(uri.host, uri.port)
# Pin the connection to the address we validated above so that the socket
# cannot be redirected to a different (e.g. internal) address via a DNS
# rebinding race between the check and the connection.
connection.ipaddr = connect_address
if uri.scheme == "https"
connection.use_ssl = true
connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
end
result = connection.request(request) result = connection.request(request)
{ {
code: result.code.to_i, code: result.code.to_i,
@@ -68,6 +74,13 @@ module Postal
secure: ssl secure: ssl
} }
end end
rescue BlockedDestinationError => e
{
code: -4,
body: e.message,
headers: {},
secure: ssl
}
rescue OpenSSL::SSL::SSLError rescue OpenSSL::SSL::SSLError
{ {
code: -3, code: -3,
@@ -75,7 +88,7 @@ module Postal
headers: {}, headers: {},
secure: ssl secure: ssl
} }
rescue SocketError, Errno::ECONNRESET, EOFError, Errno::EINVAL, Errno::ENETUNREACH, Errno::EHOSTUNREACH, Errno::ECONNREFUSED => e rescue Resolv::ResolvError, SocketError, SystemCallError, EOFError => e
{ {
code: -2, code: -2,
body: e.message, body: e.message,

عرض الملف

@@ -0,0 +1,202 @@
# frozen_string_literal: true
require "ipaddr"
require "resolv"
require "socket"
module Postal
module HTTP
# Guards outbound HTTP requests against SSRF by resolving the destination
# host and refusing to connect to private, loopback, link-local, multicast
# or otherwise reserved addresses (for example cloud metadata endpoints).
#
# Administrators can permit specific destinations by adding hostnames or
# IP/CIDR ranges to the `postal.allowed_request_destinations` config option.
class AddressGuard
# IP ranges that outbound requests are never allowed to reach unless the
# destination has been explicitly allowlisted.
BLOCKED_RANGES = [
# IPv4
"0.0.0.0/8", # "this host on this network"
"10.0.0.0/8", # RFC1918 private
"100.64.0.0/10", # RFC6598 carrier-grade NAT
"127.0.0.0/8", # loopback
"169.254.0.0/16", # link-local (incl. 169.254.169.254 metadata)
"172.16.0.0/12", # RFC1918 private
"192.0.0.0/24", # IETF protocol assignments
"192.168.0.0/16", # RFC1918 private
"198.18.0.0/15", # benchmarking
"224.0.0.0/4", # multicast
"240.0.0.0/4", # reserved
# IPv6
"::/128", # unspecified
"::1/128", # loopback
"::ffff:0:0/96", # IPv4-mapped (also re-checked against the v4 list)
"fc00::/7", # unique-local
"fe80::/10", # link-local
"ff00::/8", # multicast
].map { |range| IPAddr.new(range) }.freeze
class << self
# Resolve and validate the given host, returning the IP address the
# connection should be pinned to (as a string). Pinning the connection
# to the validated address prevents a DNS-rebinding race between the
# check here and the actual connection.
#
# @param [String] host the hostname or IP literal from the request URL
# @raise [Postal::HTTP::BlockedDestinationError] if the host cannot be
# resolved or any resolved address is not permitted
# @raise [SocketError] if the host only resolves to addresses whose
# family this server cannot reach (e.g. IPv6 with no IPv6 support)
# @return [String] the validated IP address to connect to
def safe_connect_address(host)
new(host).safe_connect_address
end
# Whether this server has IPv6 connectivity (a global IPv6 address on
# one of its interfaces). Memoized as it does not change at runtime.
def ipv6_supported?
return @ipv6_supported unless @ipv6_supported.nil?
@ipv6_supported = local_families.include?(:ipv6)
end
# Whether this server has IPv4 connectivity. Defaults to true unless the
# host clearly only has IPv6, so that a host reporting no global
# addresses at all (e.g. inside a minimal container) still attempts IPv4
# as it did before this guard existed.
def ipv4_supported?
return @ipv4_supported unless @ipv4_supported.nil?
families = local_families
@ipv4_supported = families.include?(:ipv4) || !families.include?(:ipv6)
end
private
def local_families
families = []
Socket.ip_address_list.each do |address|
families << :ipv4 if address.ipv4? && !address.ipv4_loopback?
families << :ipv6 if address.ipv6? && !address.ipv6_loopback? && !address.ipv6_linklocal?
end
families.uniq
end
end
# @param [String] host
def initialize(host)
@host = host.to_s
end
def safe_connect_address
if @host.empty?
raise BlockedDestinationError, "No host was given for the request"
end
addresses = resolve
if addresses.empty?
raise BlockedDestinationError, "Could not resolve '#{@host}' to any IP address"
end
# Reject the whole request if *any* resolved address is blocked. This is
# checked before the reachability filtering below so that a blocked
# destination is always reported as such, regardless of which address
# families this particular server can reach. It also defeats DNS
# responses that mix a public and a private address to slip past.
addresses.each do |address|
next unless blocked?(address)
raise BlockedDestinationError,
"Destination '#{@host}' (#{address}) is not permitted"
end
# Only connect to an address whose family this server can actually
# reach. Otherwise we might pin the connection to an IPv6 address on a
# host without IPv6 connectivity and fail to connect even when a usable
# IPv4 address was available.
usable = addresses.select { |address| family_reachable?(address) }
if usable.empty?
raise SocketError,
"'#{@host}' only resolves to addresses this server cannot reach " \
"(#{addresses.join(', ')})"
end
# Prefer IPv4 for predictability; only use IPv6 when it is the only
# reachable option.
(usable.find(&:ipv4?) || usable.first).to_s
end
private
# @return [Array<IPAddr>]
def resolve
return [IPAddr.new(@host)] if ip_literal?
Resolv.getaddresses(@host).filter_map do |address|
IPAddr.new(address)
rescue IPAddr::InvalidAddressError
nil
end
end
def ip_literal?
IPAddr.new(@host)
true
rescue IPAddr::InvalidAddressError
false
end
# @param [IPAddr] address
def family_reachable?(address)
if address.ipv6? && !address.ipv4_mapped?
self.class.ipv6_supported?
else
self.class.ipv4_supported?
end
end
# @param [IPAddr] address
def blocked?(address)
return false if allowlisted?(address)
# IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) must be checked against the
# IPv4 rules using the embedded address, otherwise they bypass the list.
if address.ipv6? && address.ipv4_mapped?
mapped = address.native
return true if mapped.ipv4? && BLOCKED_RANGES.any? { |range| range.include?(mapped) }
end
BLOCKED_RANGES.any? { |range| range.include?(address) }
end
# @param [IPAddr] address
def allowlisted?(address)
allowlist.any? do |entry|
if entry.is_a?(IPAddr)
entry.include?(address)
else
entry.casecmp?(@host)
end
end
end
# Allowlist entries are kept as strings in config. An entry that parses as
# an IP/CIDR is matched against the resolved address; anything else is
# matched against the request hostname (case-insensitively).
#
# @return [Array<IPAddr, String>]
def allowlist
@allowlist ||= Array(Postal::Config.postal.allowed_request_destinations).map do |entry|
IPAddr.new(entry.to_s)
rescue IPAddr::InvalidAddressError
entry.to_s
end
end
end
end
end

عرض الملف

@@ -0,0 +1,11 @@
# frozen_string_literal: true
module Postal
module HTTP
# Raised when an outbound request would be sent to an address that is not
# permitted (a private, loopback, link-local or otherwise reserved address
# that has not been explicitly allowlisted). Used as an SSRF guard.
class BlockedDestinationError < StandardError
end
end
end

عرض الملف

@@ -70,7 +70,7 @@ module Postal
# Return the total size of all stored messages # Return the total size of all stored messages
# #
def total_size def total_size
query("SELECT SUM(size) AS size FROM `#{database_name}`.`raw_message_sizes`").first["size"] || 0 query("SELECT SUM(size) AS size FROM #{escape_identifier(database_name)}.`raw_message_sizes`").first["size"] || 0
end end
# #
@@ -151,11 +151,11 @@ module Postal
if options[:count] if options[:count]
sql_query << " COUNT(id) AS count" sql_query << " COUNT(id) AS count"
elsif options[:fields] elsif options[:fields]
sql_query << (" " + options[:fields].map { |f| "`#{f}`" }.join(", ")) sql_query << (" " + options[:fields].map { |f| escape_identifier(f) }.join(", "))
else else
sql_query << " *" sql_query << " *"
end end
sql_query << " FROM `#{database_name}`.`#{table}`" sql_query << " FROM #{escape_identifier(database_name)}.#{escape_identifier(table)}"
if options[:where].present? if options[:where].present?
sql_query << (" " + build_where_string(options[:where], " AND ")) sql_query << (" " + build_where_string(options[:where], " AND "))
end end
@@ -163,7 +163,7 @@ module Postal
direction = (options[:direction] || "ASC").upcase direction = (options[:direction] || "ASC").upcase
raise Postal::Error, "Invalid direction #{options[:direction]}" unless %w[ASC DESC].include?(direction) raise Postal::Error, "Invalid direction #{options[:direction]}" unless %w[ASC DESC].include?(direction)
sql_query << " ORDER BY `#{options[:order]}` #{direction}" sql_query << " ORDER BY #{escape_identifier(options[:order])} #{direction}"
end end
if options[:limit] if options[:limit]
@@ -211,7 +211,7 @@ module Postal
# Will return the total number of affected rows. # Will return the total number of affected rows.
# #
def update(table, attributes, options = {}) def update(table, attributes, options = {})
sql_query = "UPDATE `#{database_name}`.`#{table}` SET" sql_query = "UPDATE #{escape_identifier(database_name)}.#{escape_identifier(table)} SET"
sql_query << " #{hash_to_sql(attributes)}" sql_query << " #{hash_to_sql(attributes)}"
if options[:where] if options[:where]
sql_query << (" " + build_where_string(options[:where])) sql_query << (" " + build_where_string(options[:where]))
@@ -227,8 +227,8 @@ module Postal
# Will return the ID of the new item. # Will return the ID of the new item.
# #
def insert(table, attributes) def insert(table, attributes)
sql_query = "INSERT INTO `#{database_name}`.`#{table}`" sql_query = "INSERT INTO #{escape_identifier(database_name)}.#{escape_identifier(table)}"
sql_query << (" (" + attributes.keys.map { |k| "`#{k}`" }.join(", ") + ")") sql_query << (" (" + attributes.keys.map { |k| escape_identifier(k) }.join(", ") + ")")
sql_query << (" VALUES (" + attributes.values.map { |v| escape(v) }.join(", ") + ")") sql_query << (" VALUES (" + attributes.values.map { |v| escape(v) }.join(", ") + ")")
with_mysql do |mysql| with_mysql do |mysql|
query_on_connection(mysql, sql_query) query_on_connection(mysql, sql_query)
@@ -243,8 +243,8 @@ module Postal
if values.empty? if values.empty?
nil nil
else else
sql_query = "INSERT INTO `#{database_name}`.`#{table}`" sql_query = "INSERT INTO #{escape_identifier(database_name)}.#{escape_identifier(table)}"
sql_query << (" (" + keys.map { |k| "`#{k}`" }.join(", ") + ")") sql_query << (" (" + keys.map { |k| escape_identifier(k) }.join(", ") + ")")
sql_query << " VALUES " sql_query << " VALUES "
sql_query << values.map { |v| "(" + v.map { |r| escape(r) }.join(", ") + ")" }.join(", ") sql_query << values.map { |v| "(" + v.map { |r| escape(r) }.join(", ") + ")" }.join(", ")
query(sql_query) query(sql_query)
@@ -260,7 +260,7 @@ module Postal
# Will return the total number of affected rows. # Will return the total number of affected rows.
# #
def delete(table, options = {}) def delete(table, options = {})
sql_query = "DELETE FROM `#{database_name}`.`#{table}`" sql_query = "DELETE FROM #{escape_identifier(database_name)}.#{escape_identifier(table)}"
sql_query << (" " + build_where_string(options[:where], " AND ")) sql_query << (" " + build_where_string(options[:where], " AND "))
with_mysql do |mysql| with_mysql do |mysql|
query_on_connection(mysql, sql_query) query_on_connection(mysql, sql_query)
@@ -351,32 +351,41 @@ module Postal
def hash_to_sql(hash, joiner = ", ") def hash_to_sql(hash, joiner = ", ")
hash.map do |key, value| hash.map do |key, value|
column = escape_identifier(key)
if value.is_a?(Array) && value.all? { |v| v.is_a?(Integer) } if value.is_a?(Array) && value.all? { |v| v.is_a?(Integer) }
"`#{key}` IN (#{value.join(', ')})" "#{column} IN (#{value.join(', ')})"
elsif value.is_a?(Array) elsif value.is_a?(Array)
escaped_values = value.map { |v| escape(v) }.join(", ") escaped_values = value.map { |v| escape(v) }.join(", ")
"`#{key}` IN (#{escaped_values})" "#{column} IN (#{escaped_values})"
elsif value.is_a?(Hash) elsif value.is_a?(Hash)
sql = [] sql = []
value.each do |operator, inner_value| value.each do |operator, inner_value|
case operator case operator
when :less_than when :less_than
sql << "`#{key}` < #{escape(inner_value)}" sql << "#{column} < #{escape(inner_value)}"
when :greater_than when :greater_than
sql << "`#{key}` > #{escape(inner_value)}" sql << "#{column} > #{escape(inner_value)}"
when :less_than_or_equal_to when :less_than_or_equal_to
sql << "`#{key}` <= #{escape(inner_value)}" sql << "#{column} <= #{escape(inner_value)}"
when :greater_than_or_equal_to when :greater_than_or_equal_to
sql << "`#{key}` >= #{escape(inner_value)}" sql << "#{column} >= #{escape(inner_value)}"
end end
end end
sql.empty? ? "1=1" : sql.join(joiner) sql.empty? ? "1=1" : sql.join(joiner)
else else
"`#{key}` = #{escape(value)}" "#{column} = #{escape(value)}"
end end
end.join(joiner) end.join(joiner)
end end
# Escape a value for safe use as a MySQL identifier (e.g. a column or
# table name). Identifiers are wrapped in backticks and any backtick
# within the identifier is doubled so it cannot break out of the quoting
# and inject arbitrary SQL.
def escape_identifier(identifier)
"`" + identifier.to_s.gsub("`", "``") + "`"
end
end end
end end
end end

عرض الملف

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

عرض الملف

@@ -64,6 +64,23 @@ RSpec.describe "Legacy Messages API", type: :request do
end end
end end
# Regression test for GHSA-x2hq-rfpg-3xr5 (see message_spec.rb). A JSON
# object supplied for `id` must be rejected before reaching the database
# rather than being interpreted as a raw set of SQL conditions.
context "when the message ID is a JSON object (SQL injection attempt)" do
it "rejects it with a parameter error and never reaches the database" do
expect_any_instance_of(Server).not_to receive(:message)
post "/api/v1/messages/deliveries",
headers: { "x-server-api-key" => credential.key,
"content-type" => "application/json" },
params: { id: { "id`=0 OR SLEEP(5)#" => "x" } }.to_json
expect(response.status).to eq 200
parsed_body = JSON.parse(response.body)
expect(parsed_body["status"]).to eq "parameter-error"
expect(parsed_body["data"]["message"]).to match(/must be a string or integer/)
end
end
context "when the message ID exists" do context "when the message ID exists" do
let(:server) { create(:server) } let(:server) { create(:server) }
let(:credential) { create(:credential, server: server) } let(:credential) { create(:credential, server: server) }

عرض الملف

@@ -63,6 +63,56 @@ RSpec.describe "Legacy Messages API", type: :request do
end end
end end
# Regression tests for GHSA-x2hq-rfpg-3xr5. The request body is parsed as
# JSON, so a JSON object/array supplied for `id` would otherwise arrive as
# a Ruby Hash/Array and be passed straight through to the message database
# as a raw set of SQL conditions (blind SQL injection). These must be
# rejected before reaching the database.
context "when the message ID is a JSON object (SQL injection attempt)" do
it "rejects it with a parameter error and never reaches the database" do
expect_any_instance_of(Server).not_to receive(:message)
post "/api/v1/messages/message",
headers: { "x-server-api-key" => credential.key,
"content-type" => "application/json" },
params: { id: { "id`=0 OR SLEEP(5)#" => "x" } }.to_json
expect(response.status).to eq 200
parsed_body = JSON.parse(response.body)
expect(parsed_body["status"]).to eq "parameter-error"
expect(parsed_body["data"]["message"]).to match(/must be a string or integer/)
end
end
context "when the message ID is a JSON array" do
it "rejects it with a parameter error" do
post "/api/v1/messages/message",
headers: { "x-server-api-key" => credential.key,
"content-type" => "application/json" },
params: { id: [1, 2, 3] }.to_json
expect(response.status).to eq 200
parsed_body = JSON.parse(response.body)
expect(parsed_body["status"]).to eq "parameter-error"
expect(parsed_body["data"]["message"]).to match(/must be a string or integer/)
end
end
context "when the message ID is provided as a numeric string" do
let(:message) { MessageFactory.outgoing(server) }
it "is coerced to an integer and looks the message up" do
post "/api/v1/messages/message",
headers: { "x-server-api-key" => credential.key,
"content-type" => "application/json" },
params: { id: message.id.to_s }.to_json
expect(response.status).to eq 200
parsed_body = JSON.parse(response.body)
expect(parsed_body["status"]).to eq "success"
expect(parsed_body["data"]).to match({
"id" => message.id,
"token" => message.token
})
end
end
context "when the message ID exists" do context "when the message ID exists" do
let(:server) { create(:server) } let(:server) { create(:server) }
let(:credential) { create(:credential, server: server) } let(:credential) { create(:credential, server: server) }

عرض الملف

@@ -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,192 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Postal::HTTP::AddressGuard do
describe ".safe_connect_address" do
subject(:call) { described_class.safe_connect_address(host) }
before do
allow(Postal::Config.postal).to receive(:allowed_request_destinations).and_return(allowlist)
end
let(:allowlist) { [] }
context "when given a public IP literal" do
let(:host) { "93.184.216.34" }
it "returns the address to connect to" do
expect(call).to eq "93.184.216.34"
end
end
context "when given a public IPv6 literal" do
let(:host) { "2606:2800:220:1:248:1893:25c8:1946" }
before { allow(described_class).to receive(:ipv6_supported?).and_return(true) }
it "returns the address to connect to" do
expect(call).to eq "2606:2800:220:1:248:1893:25c8:1946"
end
end
[
"127.0.0.1",
"10.0.0.1",
"172.16.5.4",
"192.168.1.1",
"169.254.169.254", # cloud metadata
"100.64.0.1", # carrier-grade NAT
"0.0.0.0",
"::1",
"fd00::1", # unique-local IPv6
"fe80::1", # link-local IPv6
"::ffff:127.0.0.1", # IPv4-mapped loopback
].each do |blocked|
context "when given the blocked address #{blocked}" do
let(:host) { blocked }
it "raises BlockedDestinationError" do
expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError)
end
end
end
context "when given a hostname that resolves to a public address" do
let(:host) { "example.com" }
before do
allow(Resolv).to receive(:getaddresses).with(host).and_return(["93.184.216.34"])
end
it "returns the resolved address" do
expect(call).to eq "93.184.216.34"
end
end
context "when given a hostname that resolves to a private address" do
let(:host) { "internal.example.com" }
before do
allow(Resolv).to receive(:getaddresses).with(host).and_return(["10.1.2.3"])
end
it "raises BlockedDestinationError" do
expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError)
end
end
context "when a hostname resolves to both a public and a private address" do
let(:host) { "rebind.example.com" }
before do
allow(Resolv).to receive(:getaddresses).with(host).and_return(["93.184.216.34", "127.0.0.1"])
end
it "raises BlockedDestinationError because one address is blocked" do
expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError)
end
end
context "when a hostname resolves to both IPv4 and IPv6 addresses" do
let(:host) { "dualstack.example.com" }
let(:ipv6) { "2606:2800:220:1:248:1893:25c8:1946" }
before do
allow(Resolv).to receive(:getaddresses).with(host).and_return([ipv6, "93.184.216.34"])
end
context "and the server does not support IPv6" do
before { allow(described_class).to receive(:ipv6_supported?).and_return(false) }
it "connects over IPv4" do
expect(call).to eq "93.184.216.34"
end
end
context "and the server supports IPv6" do
before { allow(described_class).to receive(:ipv6_supported?).and_return(true) }
it "still prefers IPv4 for predictability" do
expect(call).to eq "93.184.216.34"
end
end
end
context "when a hostname resolves only to an IPv6 address" do
let(:host) { "v6only.example.com" }
let(:ipv6) { "2606:2800:220:1:248:1893:25c8:1946" }
before do
allow(Resolv).to receive(:getaddresses).with(host).and_return([ipv6])
end
context "and the server does not support IPv6" do
before { allow(described_class).to receive(:ipv6_supported?).and_return(false) }
it "raises a SocketError because the address is unreachable" do
expect { call }.to raise_error(SocketError)
end
end
context "and the server supports IPv6" do
before { allow(described_class).to receive(:ipv6_supported?).and_return(true) }
it "connects over IPv6" do
expect(call).to eq ipv6
end
end
end
context "when a hostname cannot be resolved" do
let(:host) { "nope.example.com" }
before do
allow(Resolv).to receive(:getaddresses).with(host).and_return([])
end
it "raises BlockedDestinationError" do
expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError, /resolve/)
end
end
context "when the host is blank" do
let(:host) { "" }
it "raises BlockedDestinationError" do
expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError)
end
end
context "when a blocked address is allowlisted by CIDR" do
let(:host) { "10.0.0.5" }
let(:allowlist) { ["10.0.0.0/8"] }
it "returns the address" do
expect(call).to eq "10.0.0.5"
end
end
context "when a blocked address is allowlisted by exact IP" do
let(:host) { "127.0.0.1" }
let(:allowlist) { ["127.0.0.1"] }
it "returns the address" do
expect(call).to eq "127.0.0.1"
end
end
context "when a hostname resolving to a private address is allowlisted by name" do
let(:host) { "internal.example.com" }
let(:allowlist) { ["internal.example.com"] }
before do
allow(Resolv).to receive(:getaddresses).with(host).and_return(["10.1.2.3"])
end
it "returns the resolved address" do
expect(call).to eq "10.1.2.3"
end
end
end
end

عرض الملف

@@ -0,0 +1,80 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Postal::HTTP do
before do
allow(Postal::Config.postal).to receive(:allowed_request_destinations).and_return([])
end
describe ".post" do
context "when the host resolves to a blocked address" do
before do
allow(Resolv).to receive(:getaddresses).with("internal.example.com").and_return(["127.0.0.1"])
end
it "does not make a request and returns a blocked-destination result" do
result = described_class.post("http://internal.example.com/hook", json: "{}")
expect(result[:code]).to eq(-4)
expect(result[:body]).to match(/not permitted/)
expect(WebMock).not_to have_requested(:post, "http://internal.example.com/hook")
end
end
context "when resolving the host raises an error" do
before do
allow(Resolv).to receive(:getaddresses).with("example.com").and_raise(Resolv::ResolvError, "resolver failed")
end
it "returns a connection error result" do
result = described_class.post("http://example.com/hook", json: "{}")
expect(result[:code]).to eq(-2)
expect(result[:body]).to match(/resolver failed/)
expect(WebMock).not_to have_requested(:post, "http://example.com/hook")
end
end
context "when resolving the host exceeds the request timeout" do
before do
allow(Resolv).to receive(:getaddresses).with("example.com") do
sleep 0.2
["93.184.216.34"]
end
end
it "returns a timeout result before making a request" do
result = described_class.post("http://example.com/hook", json: "{}", timeout: 0.05)
expect(result[:code]).to eq(-1)
expect(WebMock).not_to have_requested(:post, "http://example.com/hook")
end
end
context "when the host resolves to a public address" do
before do
allow(Resolv).to receive(:getaddresses).with("example.com").and_return(["93.184.216.34"])
stub_request(:post, "http://example.com/hook").to_return(status: 200, body: "OK")
end
it "pins the connection to the validated address and performs the request" do
expect_any_instance_of(Net::HTTP).to receive(:ipaddr=).with("93.184.216.34").and_call_original
result = described_class.post("http://example.com/hook", json: "{}")
expect(result[:code]).to eq(200)
expect(WebMock).to have_requested(:post, "http://example.com/hook")
end
end
context "when the blocked host is allowlisted" do
before do
allow(Postal::Config.postal).to receive(:allowed_request_destinations).and_return(["internal.example.com"])
allow(Resolv).to receive(:getaddresses).with("internal.example.com").and_return(["10.0.0.5"])
stub_request(:post, "http://internal.example.com/hook").to_return(status: 200, body: "OK")
end
it "performs the request" do
result = described_class.post("http://internal.example.com/hook", json: "{}")
expect(result[:code]).to eq(200)
expect(WebMock).to have_requested(:post, "http://internal.example.com/hook")
end
end
end
end

عرض الملف

@@ -14,5 +14,64 @@ describe Postal::MessageDB::Database do
it "should return the current schema version" do it "should return the current schema version" do
expect(database.schema_version).to be_a Integer expect(database.schema_version).to be_a Integer
end end
describe "#escape_identifier" do
it "wraps a plain identifier in backticks" do
expect(database.send(:escape_identifier, "id")).to eq "`id`"
end
it "doubles embedded backticks so the value cannot break out of the quoting" do
expect(database.send(:escape_identifier, "id`=0 OR SLEEP(5)#"))
.to eq "`id``=0 OR SLEEP(5)#`"
end
it "coerces non-string identifiers to a string" do
expect(database.send(:escape_identifier, :token)).to eq "`token`"
end
end
describe "#hash_to_sql" do
it "builds a simple equality condition" do
expect(database.send(:hash_to_sql, { "id" => 5 })).to eq "`id` = '5'"
end
it "builds an IN condition for an array of integers" do
expect(database.send(:hash_to_sql, { "id" => [1, 2] })).to eq "`id` IN (1, 2)"
end
it "builds operator conditions for a hash value" do
expect(database.send(:hash_to_sql, { "id" => { greater_than: 1 } }))
.to eq "`id` > '1'"
end
# Regression tests for GHSA-x2hq-rfpg-3xr5: a backtick in the condition
# key must be neutralised so it cannot close the identifier quoting and
# inject arbitrary SQL.
it "neutralises a backtick injection in an equality key" do
sql = database.send(:hash_to_sql, { "id`=0 OR SLEEP(5)#" => "x" })
expect(sql).to eq "`id``=0 OR SLEEP(5)#` = 'x'"
end
it "neutralises a backtick injection in an IN key" do
sql = database.send(:hash_to_sql, { "id`)#" => %w[a b] })
expect(sql).to eq "`id``)#` IN ('a', 'b')"
end
it "neutralises a backtick injection in an operator key" do
sql = database.send(:hash_to_sql, { "id`#" => { greater_than: 1 } })
expect(sql).to eq "`id``#` > '1'"
end
end
describe "#select with a hostile condition key" do
# End-to-end proof against the live test database: the injected key is
# treated as a single (non-existent) column identifier, so MySQL rejects
# the query instead of executing the injected SQL.
it "does not allow SQL injection via the condition key" do
expect do
database.select("messages", where: { "id`=0 OR 1=1#" => "x" }, limit: 1)
end.to raise_error(Mysql2::Error)
end
end
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

عرض الملف

@@ -0,0 +1,38 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe HTTPEndpoint do
describe "validations" do
subject(:endpoint) { build(:http_endpoint, url: url) }
[
"https://example.com/messages/~user;v=1?token=a+b#section",
"http://example.com:8080/path?x=1&y=2",
"https://[2606:2800:220:1:248:1893:25c8:1946]/hook",
].each do |valid_url|
context "with #{valid_url}" do
let(:url) { valid_url }
it "is valid" do
expect(endpoint).to be_valid
end
end
end
[
"ftp://example.com/hook",
"https:///missing-host",
"not a url",
].each do |invalid_url|
context "with #{invalid_url}" do
let(:url) { invalid_url }
it "is invalid" do
expect(endpoint).not_to be_valid
expect(endpoint.errors[:url]).to be_present
end
end
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

عرض الملف

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

عرض الملف

@@ -13,6 +13,7 @@ RSpec.describe WebhookDeliveryService do
let(:response_body) { "OK" } let(:response_body) { "OK" }
before do before do
allow(Resolv).to receive(:getaddresses).with("example.com").and_return(["93.184.216.34"])
stub_request(:post, webhook.url).to_return(status: response_status, body: response_body) stub_request(:post, webhook.url).to_return(status: response_status, body: response_body)
end end
@@ -116,5 +117,26 @@ RSpec.describe WebhookDeliveryService do
expect { webhook_request.reload }.to raise_error(ActiveRecord::RecordNotFound) expect { webhook_request.reload }.to raise_error(ActiveRecord::RecordNotFound)
end end
end end
context "when the webhook URL resolves to a blocked (private) address" do
let(:webhook_request) do
create(:webhook_request, :locked, webhook: webhook, url: "http://internal.example.com/hook")
end
before do
allow(Resolv).to receive(:getaddresses).with("internal.example.com").and_return(["127.0.0.1"])
end
it "does not make a request to the destination" do
service.call
expect(WebMock).not_to have_requested(:post, "http://internal.example.com/hook")
end
it "records the failure and schedules a retry" do
service.call
expect(webhook_request.reload.attempts).to eq(1)
expect(webhook_request.retry_after).to be_present
end
end
end end
end end