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

Compare commits

..

7 الالتزامات

المؤلف SHA1 الرسالة التاريخ
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
21 ملفات معدلة مع 907 إضافات و134 حذوفات

عرض الملف

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

عرض الملف

@@ -2,6 +2,21 @@
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)

عرض الملف

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

عرض الملف

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

عرض الملف

@@ -11,5 +11,5 @@ We only support updates to the 3.x versions of Postal.
## Reporting a Vulnerability
If you discover a vulnerability in Postal, please do not post an issue on GitHub. Instead you should send an
e-mail to security@postalserver.io with details. We will get back to you directly.
If you discover a vulnerability in Postal, please do not post an issue on GitHub. Please,
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.
#
def message
if api_params["id"].blank?
render_parameter_error "`id` parameter is required but is missing"
return
end
message = find_message
return if performed?
message = @current_credential.server.message(api_params["id"])
message_hash = { id: message.id, token: message.token }
expansions = api_params["_expansions"]
@@ -111,12 +108,9 @@ module LegacyAPI
# OR an error if the message does not exist.
#
def deliveries
if api_params["id"].blank?
render_parameter_error "`id` parameter is required but is missing"
return
end
message = find_message
return if performed?
message = @current_credential.server.message(api_params["id"])
deliveries = message.deliveries.map do |d|
{
id: d.id,
@@ -136,5 +130,37 @@ module LegacyAPI
id: api_params["id"]
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

عرض الملف

@@ -1,5 +1,7 @@
# frozen_string_literal: true
require "uri"
# == Schema Information
#
# Table name: http_endpoints
@@ -38,6 +40,7 @@ class HTTPEndpoint < ApplicationRecord
validates :name, presence: true
validates :url, presence: true
validate :url_must_be_http_or_https
validates :encoding, inclusion: { in: ENCODINGS }
validates :format, inclusion: { in: FORMATS }
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") }
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

عرض الملف

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

عرض الملف

@@ -29,6 +29,8 @@ postal:
smtp_relays: []
# An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses)
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.
queued_message_lock_stale_days: 1
# 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) }
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
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

عرض الملف

@@ -1,6 +1,7 @@
# frozen_string_literal: true
require "net/https"
require "resolv"
require "uri"
module Postal
@@ -47,19 +48,24 @@ module Postal
request["User-Agent"] = options[:user_agent] || "Postal/#{Postal.version}"
connection = Net::HTTP.new(uri.host, uri.port)
if uri.scheme == "https"
connection.use_ssl = true
connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
ssl = true
else
ssl = false
end
timeout = options[:timeout] || 60
ssl = uri.scheme == "https"
begin
timeout = options[:timeout] || 60
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)
{
code: result.code.to_i,
@@ -68,6 +74,13 @@ module Postal
secure: ssl
}
end
rescue BlockedDestinationError => e
{
code: -4,
body: e.message,
headers: {},
secure: ssl
}
rescue OpenSSL::SSL::SSLError
{
code: -3,
@@ -75,7 +88,7 @@ module Postal
headers: {},
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,
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
#
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
#
@@ -151,11 +151,11 @@ module Postal
if options[:count]
sql_query << " COUNT(id) AS count"
elsif options[:fields]
sql_query << (" " + options[:fields].map { |f| "`#{f}`" }.join(", "))
sql_query << (" " + options[:fields].map { |f| escape_identifier(f) }.join(", "))
else
sql_query << " *"
end
sql_query << " FROM `#{database_name}`.`#{table}`"
sql_query << " FROM #{escape_identifier(database_name)}.#{escape_identifier(table)}"
if options[:where].present?
sql_query << (" " + build_where_string(options[:where], " AND "))
end
@@ -163,7 +163,7 @@ module Postal
direction = (options[:direction] || "ASC").upcase
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
if options[:limit]
@@ -211,7 +211,7 @@ module Postal
# Will return the total number of affected rows.
#
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)}"
if options[:where]
sql_query << (" " + build_where_string(options[:where]))
@@ -227,8 +227,8 @@ module Postal
# Will return the ID of the new item.
#
def insert(table, attributes)
sql_query = "INSERT INTO `#{database_name}`.`#{table}`"
sql_query << (" (" + attributes.keys.map { |k| "`#{k}`" }.join(", ") + ")")
sql_query = "INSERT INTO #{escape_identifier(database_name)}.#{escape_identifier(table)}"
sql_query << (" (" + attributes.keys.map { |k| escape_identifier(k) }.join(", ") + ")")
sql_query << (" VALUES (" + attributes.values.map { |v| escape(v) }.join(", ") + ")")
with_mysql do |mysql|
query_on_connection(mysql, sql_query)
@@ -243,8 +243,8 @@ module Postal
if values.empty?
nil
else
sql_query = "INSERT INTO `#{database_name}`.`#{table}`"
sql_query << (" (" + keys.map { |k| "`#{k}`" }.join(", ") + ")")
sql_query = "INSERT INTO #{escape_identifier(database_name)}.#{escape_identifier(table)}"
sql_query << (" (" + keys.map { |k| escape_identifier(k) }.join(", ") + ")")
sql_query << " VALUES "
sql_query << values.map { |v| "(" + v.map { |r| escape(r) }.join(", ") + ")" }.join(", ")
query(sql_query)
@@ -260,7 +260,7 @@ module Postal
# Will return the total number of affected rows.
#
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 "))
with_mysql do |mysql|
query_on_connection(mysql, sql_query)
@@ -351,32 +351,41 @@ module Postal
def hash_to_sql(hash, joiner = ", ")
hash.map do |key, value|
column = escape_identifier(key)
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)
escaped_values = value.map { |v| escape(v) }.join(", ")
"`#{key}` IN (#{escaped_values})"
"#{column} IN (#{escaped_values})"
elsif value.is_a?(Hash)
sql = []
value.each do |operator, inner_value|
case operator
when :less_than
sql << "`#{key}` < #{escape(inner_value)}"
sql << "#{column} < #{escape(inner_value)}"
when :greater_than
sql << "`#{key}` > #{escape(inner_value)}"
sql << "#{column} > #{escape(inner_value)}"
when :less_than_or_equal_to
sql << "`#{key}` <= #{escape(inner_value)}"
sql << "#{column} <= #{escape(inner_value)}"
when :greater_than_or_equal_to
sql << "`#{key}` >= #{escape(inner_value)}"
sql << "#{column} >= #{escape(inner_value)}"
end
end
sql.empty? ? "1=1" : sql.join(joiner)
else
"`#{key}` = #{escape(value)}"
"#{column} = #{escape(value)}"
end
end.join(joiner)
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

عرض الملف

@@ -64,6 +64,23 @@ RSpec.describe "Legacy Messages API", type: :request do
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
let(:server) { create(:server) }
let(:credential) { create(:credential, server: server) }

عرض الملف

@@ -63,6 +63,56 @@ RSpec.describe "Legacy Messages API", type: :request do
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
let(:server) { create(:server) }
let(:credential) { create(:credential, server: server) }

عرض الملف

@@ -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
expect(database.schema_version).to be_a Integer
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

عرض الملف

@@ -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" }
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)
end
@@ -116,5 +117,26 @@ RSpec.describe WebhookDeliveryService do
expect { webhook_request.reload }.to raise_error(ActiveRecord::RecordNotFound)
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