مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2026-06-13 09:45:43 +00:00
Compare commits
8 الالتزامات
3.3.6
...
dependabot
| المؤلف | SHA1 | التاريخ | |
|---|---|---|---|
|
|
b16ab59a6d | ||
|
|
d038eaa8c7 | ||
|
|
3b3defe271 | ||
|
|
029bfe098d | ||
|
|
0445e5c509 | ||
|
|
11c9814474 | ||
|
|
4314a6ec1e | ||
|
|
8be1e27fec |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "3.3.6"
|
".": "3.3.7"
|
||||||
}
|
}
|
||||||
|
|||||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
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)
|
## [3.3.6](https://github.com/postalserver/postal/compare/3.3.5...3.3.6) (2026-04-28)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2
Gemfile
2
Gemfile
@@ -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"
|
||||||
|
|||||||
198
Gemfile.lock
198
Gemfile.lock
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
timeout = options[:timeout] || 60
|
||||||
|
ssl = uri.scheme == "https"
|
||||||
|
|
||||||
|
begin
|
||||||
|
Timeout.timeout(timeout) do
|
||||||
|
connect_address = AddressGuard.safe_connect_address(uri.host)
|
||||||
|
|
||||||
connection = Net::HTTP.new(uri.host, uri.port)
|
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"
|
if uri.scheme == "https"
|
||||||
connection.use_ssl = true
|
connection.use_ssl = true
|
||||||
connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
||||||
ssl = true
|
|
||||||
else
|
|
||||||
ssl = false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
|
||||||
timeout = options[:timeout] || 60
|
|
||||||
Timeout.timeout(timeout) do
|
|
||||||
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,
|
||||||
|
|||||||
202
lib/postal/http/address_guard.rb
Normal file
202
lib/postal/http/address_guard.rb
Normal file
@@ -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
|
||||||
11
lib/postal/http/blocked_destination_error.rb
Normal file
11
lib/postal/http/blocked_destination_error.rb
Normal file
@@ -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
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
192
spec/lib/postal/http/address_guard_spec.rb
Normal file
192
spec/lib/postal/http/address_guard_spec.rb
Normal file
@@ -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
|
||||||
80
spec/lib/postal/http_spec.rb
Normal file
80
spec/lib/postal/http_spec.rb
Normal file
@@ -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
|
||||||
|
|||||||
38
spec/models/http_endpoint_spec.rb
Normal file
38
spec/models/http_endpoint_spec.rb
Normal file
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم