mirror of
https://github.com/postalserver/postal.git
synced 2026-10-18 18:26:06 +00:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eeed7c023 | ||
|
|
ced77a3860 | ||
|
|
3be663dc89 | ||
|
|
d038eaa8c7 | ||
|
|
3b3defe271 | ||
|
|
029bfe098d | ||
|
|
0445e5c509 | ||
|
|
11c9814474 | ||
|
|
4314a6ec1e | ||
|
|
8be1e27fec | ||
|
|
8ef89606bc | ||
|
|
84f4e20f05 | ||
|
|
9243524035 | ||
|
|
dca7f90b90 | ||
|
|
cad2aa6808 | ||
|
|
b611d577af | ||
|
|
d532922ff7 | ||
|
|
11419f9914 | ||
|
|
b7e5232e07 | ||
|
|
e00098b800 | ||
|
|
d00d978872 | ||
|
|
c78000ca8f | ||
|
|
c03c44b442 | ||
|
|
86de372382 | ||
|
|
7c47422c86 | ||
|
|
f5325c49ff | ||
|
|
f193b8e77f | ||
|
|
ab6d4430ba | ||
|
|
9c5f96ae90 | ||
|
|
fd3c7ccdf6 | ||
|
|
da90e75036 | ||
|
|
2b0919c145 | ||
|
|
3a33e53d84 | ||
|
|
4fa88acea0 |
@@ -52,10 +52,10 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- run: docker-compose pull
|
- run: docker compose pull
|
||||||
env:
|
env:
|
||||||
POSTAL_IMAGE: ghcr.io/postalserver/postal:ci-${{ github.sha }}
|
POSTAL_IMAGE: ghcr.io/postalserver/postal:ci-${{ github.sha }}
|
||||||
- run: docker-compose run postal sh -c 'bundle exec rspec'
|
- run: docker compose run postal sh -c 'bundle exec rspec'
|
||||||
env:
|
env:
|
||||||
POSTAL_IMAGE: ghcr.io/postalserver/postal:ci-${{ github.sha }}
|
POSTAL_IMAGE: ghcr.io/postalserver/postal:ci-${{ github.sha }}
|
||||||
|
|
||||||
|
|||||||
@@ -34,3 +34,6 @@ BRANCH
|
|||||||
.rubocop-https*
|
.rubocop-https*
|
||||||
.env*
|
.env*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "3.3.3"
|
".": "3.3.7"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
AllCops:
|
AllCops:
|
||||||
TargetRubyVersion: 3.0
|
TargetRubyVersion: 3.4
|
||||||
NewCops: enable
|
NewCops: enable
|
||||||
Exclude:
|
Exclude:
|
||||||
- "bin/*"
|
- "bin/*"
|
||||||
@@ -135,7 +135,7 @@ Style/FrozenStringLiteralComment:
|
|||||||
Enabled: true
|
Enabled: true
|
||||||
SafeAutoCorrect: true
|
SafeAutoCorrect: true
|
||||||
|
|
||||||
Naming/PredicateName:
|
Naming/PredicatePrefix:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Layout/LineLength:
|
Layout/LineLength:
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.2.2
|
3.4.10
|
||||||
|
|||||||
@@ -2,6 +2,76 @@
|
|||||||
|
|
||||||
This file contains all the latest changes and updates to Postal.
|
This file contains all the latest changes and updates to Postal.
|
||||||
|
|
||||||
|
## [3.3.7](https://github.com/postalserver/postal/compare/3.3.6...3.3.7) (2026-06-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **http:** prevent SSRF in outbound webhook and HTTP endpoint requests ([11c9814](https://github.com/postalserver/postal/commit/11c9814474f956619da35e8385ef7fab9f304de0))
|
||||||
|
* **message-db:** prevent SQL injection via condition keys (GHSA-x2hq-rfpg-3xr5) ([4314a6e](https://github.com/postalserver/postal/commit/4314a6ec1e2812daa67dd20effd1db1769c1f8e8))
|
||||||
|
* **specs:** stub IPv6 support in address guard IPv6 literal spec ([029bfe0](https://github.com/postalserver/postal/commit/029bfe098d9b8c0b5cafc49eac33e767f5748cd3))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Chores
|
||||||
|
|
||||||
|
* **deps:** upgrade rack & rails ([0445e5c](https://github.com/postalserver/postal/commit/0445e5c509870dfe9c16366c53dee3fc02ad3904))
|
||||||
|
* update security vulnerability reporting instructions ([8be1e27](https://github.com/postalserver/postal/commit/8be1e27fec489ab659ef5e909f705932028b1694))
|
||||||
|
|
||||||
|
## [3.3.6](https://github.com/postalserver/postal/compare/3.3.5...3.3.6) (2026-04-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **messages:** sandbox rendered email HTML as extra XSS defence ([cad2aa6](https://github.com/postalserver/postal/commit/cad2aa6808519a3ff25215f09f4966d9fa3bb372))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Chores
|
||||||
|
|
||||||
|
* ignore node modules and yarn.lock ([b611d57](https://github.com/postalserver/postal/commit/b611d577af79b8e1e75b6d47fa04d1ba03e34eec))
|
||||||
|
|
||||||
|
|
||||||
|
### Code Refactoring
|
||||||
|
|
||||||
|
* **auth:** tighten return_to validation ([84f4e20](https://github.com/postalserver/postal/commit/84f4e20f05db2d11b0144f95960c956f8221e657))
|
||||||
|
* **helpers:** escape interpolated values in select options ([9243524](https://github.com/postalserver/postal/commit/924352403553dcfcc569876ca76c219493fac9d6))
|
||||||
|
* **tracking:** remove unused src image proxy ([dca7f90](https://github.com/postalserver/postal/commit/dca7f90b9046247c0d953567be35921167e79d87))
|
||||||
|
|
||||||
|
## [3.3.5](https://github.com/postalserver/postal/compare/3.3.4...3.3.5) (2026-02-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **deliveries:** escape delivery details to prevent HTML injection ([11419f9](https://github.com/postalserver/postal/commit/11419f99140e13688a9613cab3ee03f8d3cbae45))
|
||||||
|
* **health_server:** use rackup handler instead of rack handler ([7c47422](https://github.com/postalserver/postal/commit/7c47422c865e738c4d6af0fed1cca4405288341f))
|
||||||
|
* oidc scopes are invalid when concatenated ([#3332](https://github.com/postalserver/postal/issues/3332)) ([9c5f96a](https://github.com/postalserver/postal/commit/9c5f96ae90cf06dcd5db776806865752f667bd95))
|
||||||
|
* typo in process logging ([#3212](https://github.com/postalserver/postal/issues/3212)) ([b7e5232](https://github.com/postalserver/postal/commit/b7e5232e077b3c9b7a999dcb6676fba0ec61458e))
|
||||||
|
* typo in the credentials page ([fd3c7cc](https://github.com/postalserver/postal/commit/fd3c7ccdf6dc4ee0a76c9523cbd735159e4b8000))
|
||||||
|
* update url for v2 config ([#3225](https://github.com/postalserver/postal/issues/3225)) ([e00098b](https://github.com/postalserver/postal/commit/e00098b8003cf37f2708f536871b3ade377aed2d))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* **process.rb:** add help about time unit used by metric ([#3339](https://github.com/postalserver/postal/issues/3339)) ([f5325c4](https://github.com/postalserver/postal/commit/f5325c49ff1152ad53eaaec98717ad3412d379ae))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Chores
|
||||||
|
|
||||||
|
* **deps:** upgrade puma, net-imap and other deps ([c03c44b](https://github.com/postalserver/postal/commit/c03c44b442a29aa9881c1e1aae60bead9776a6b6))
|
||||||
|
* **dockerfile:** reduce container size ([86de372](https://github.com/postalserver/postal/commit/86de372382bd62bdd5d1372254f8817b0360bd56))
|
||||||
|
* remove version from docker-compose.yml ([c78000c](https://github.com/postalserver/postal/commit/c78000ca8f2998aa04648f465060768db6467de6))
|
||||||
|
* upgrade resolv to 0.6.2 ([d00d978](https://github.com/postalserver/postal/commit/d00d978872a96369544303d08f6a9d11cdf56b62))
|
||||||
|
* upgrade to rails 7.1 and ruby 3.4 ([#3457](https://github.com/postalserver/postal/issues/3457)) ([ab6d443](https://github.com/postalserver/postal/commit/ab6d4430baa33a05f1aa66e776cc2a5bcaa0ede8))
|
||||||
|
* upgrade uri gem to 1.0.3 ([f193b8e](https://github.com/postalserver/postal/commit/f193b8e77fc096382ab7aaa6a2c29641b4cb12df))
|
||||||
|
|
||||||
|
## [3.3.4](https://github.com/postalserver/postal/compare/3.3.3...3.3.4) (2024-06-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* fix `postal version` command ([4fa88ac](https://github.com/postalserver/postal/commit/4fa88acea0dececd0eae485506a2ad8268fbea59))
|
||||||
|
* fix issue running message pruning task ([3a33e53](https://github.com/postalserver/postal/commit/3a33e53d843584757bb00898746aa059d7616db4))
|
||||||
|
* raise NotImplementedError when no call method on a scheduled task ([2b0919c](https://github.com/postalserver/postal/commit/2b0919c1454eabea93db96f50ecbd8e36bb89f1f))
|
||||||
|
|
||||||
## [3.3.3](https://github.com/postalserver/postal/compare/3.3.2...3.3.3) (2024-04-18)
|
## [3.3.3](https://github.com/postalserver/postal/compare/3.3.2...3.3.3) (2024-04-18)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ This doc explains how to go about running Postal in development to allow you to
|
|||||||
|
|
||||||
You will need a MySQL database server to get started. Postal needs to be able to make databases within that server whenever new mail servers are created so the permissions that you use should be suitable for that.
|
You will need a MySQL database server to get started. Postal needs to be able to make databases within that server whenever new mail servers are created so the permissions that you use should be suitable for that.
|
||||||
|
|
||||||
You'll also need Ruby. Postal currently uses Ruby 3.2.2. Install that using whichever version manager takes your fancy - rbenv, asdf, rvm etc.
|
You'll also need Ruby. Postal currently uses the Ruby version in `.ruby-version`. Install that using whichever version manager takes your fancy - rbenv, asdf, rvm etc.
|
||||||
|
|
||||||
## Clone
|
## Clone
|
||||||
|
|
||||||
|
|||||||
+11
-8
@@ -1,22 +1,25 @@
|
|||||||
FROM ruby:3.2.2-bullseye AS base
|
FROM ruby:3.4.10-slim-bookworm AS base
|
||||||
|
|
||||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install --no-install-recommends -y curl \
|
||||||
software-properties-common dirmngr apt-transport-https \
|
&& apt-get clean \
|
||||||
&& (curl -sL https://deb.nodesource.com/setup_20.x | bash -) \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN (curl -sL https://deb.nodesource.com/setup_20.x | bash -)
|
||||||
|
|
||||||
# Install main dependencies
|
# Install main dependencies
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
netcat \
|
netcat-openbsd \
|
||||||
curl \
|
|
||||||
libmariadb-dev \
|
libmariadb-dev \
|
||||||
libcap2-bin \
|
libcap2-bin \
|
||||||
nano \
|
nano \
|
||||||
nodejs
|
libyaml-dev \
|
||||||
|
nodejs \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/ruby
|
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/ruby
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@ RUN mkdir -p /opt/postal/app /opt/postal/config
|
|||||||
WORKDIR /opt/postal/app
|
WORKDIR /opt/postal/app
|
||||||
|
|
||||||
# Install bundler
|
# Install bundler
|
||||||
RUN gem install bundler -v 2.5.6 --no-doc
|
RUN gem install bundler -v 2.7.2 --no-doc
|
||||||
|
|
||||||
# Install the latest and active gem dependencies and re-run
|
# Install the latest and active gem dependencies and re-run
|
||||||
# the appropriate commands to handle installs.
|
# the appropriate commands to handle installs.
|
||||||
|
|||||||
@@ -1,30 +1,40 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
gem "abbrev"
|
||||||
gem "authie"
|
gem "authie"
|
||||||
gem "autoprefixer-rails"
|
gem "autoprefixer-rails"
|
||||||
gem "bcrypt"
|
gem "bcrypt"
|
||||||
gem "chronic"
|
gem "chronic"
|
||||||
gem "domain_name"
|
gem "domain_name"
|
||||||
gem "dotenv"
|
gem "dotenv"
|
||||||
gem "dynamic_form"
|
gem "execjs"
|
||||||
gem "execjs", "~> 2.7", "< 2.8"
|
|
||||||
gem "gelf"
|
gem "gelf"
|
||||||
gem "haml"
|
gem "haml"
|
||||||
gem "hashie"
|
gem "hashie"
|
||||||
gem "highline", require: false
|
gem "highline", require: false
|
||||||
|
# json 3.0 removed the `quirks_mode` option, which Active Support 7.2 still
|
||||||
|
# passes in both its JSON encoder and decoder. Nothing else constrains json, so
|
||||||
|
# without this pin Bundler resolves to 3.x and every `to_json` call raises
|
||||||
|
# ArgumentError. Active Support dropped those calls in Rails 8.1, so this pin
|
||||||
|
# can go when we move to 8.1.
|
||||||
|
gem "json", "< 3"
|
||||||
gem "jwt"
|
gem "jwt"
|
||||||
gem "kaminari"
|
gem "kaminari"
|
||||||
gem "klogger-logger"
|
gem "klogger-logger"
|
||||||
gem "konfig-config", "~> 3.0"
|
gem "konfig-config", "~> 3.0"
|
||||||
|
gem "logger"
|
||||||
gem "mail"
|
gem "mail"
|
||||||
|
gem "mutex_m"
|
||||||
gem "mysql2"
|
gem "mysql2"
|
||||||
gem "nifty-utils"
|
gem "nifty-utils"
|
||||||
gem "nilify_blanks"
|
gem "nilify_blanks"
|
||||||
gem "nio4r"
|
gem "nio4r"
|
||||||
|
gem "ostruct"
|
||||||
gem "prometheus-client"
|
gem "prometheus-client"
|
||||||
gem "puma"
|
gem "puma"
|
||||||
gem "rails", "= 7.0.8.1"
|
gem "rackup"
|
||||||
|
gem "rails", "= 7.2.3.2"
|
||||||
gem "resolv"
|
gem "resolv"
|
||||||
gem "secure_headers"
|
gem "secure_headers"
|
||||||
gem "sentry-rails"
|
gem "sentry-rails"
|
||||||
@@ -38,7 +48,7 @@ group :oidc do
|
|||||||
gem "omniauth-rails_csrf_protection"
|
gem "omniauth-rails_csrf_protection"
|
||||||
end
|
end
|
||||||
|
|
||||||
group :development, :assets do
|
group :development, :test, :assets do
|
||||||
gem "coffee-rails", "~> 5.0"
|
gem "coffee-rails", "~> 5.0"
|
||||||
gem "jquery-rails"
|
gem "jquery-rails"
|
||||||
gem "sass-rails"
|
gem "sass-rails"
|
||||||
@@ -47,12 +57,15 @@ end
|
|||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
gem "annotate"
|
gem "annotate"
|
||||||
gem "database_cleaner", require: false
|
|
||||||
gem "factory_bot_rails", require: false
|
|
||||||
gem "rspec", require: false
|
|
||||||
gem "rspec-rails", require: false
|
|
||||||
gem "rubocop"
|
gem "rubocop"
|
||||||
gem "rubocop-rails"
|
gem "rubocop-rails"
|
||||||
|
end
|
||||||
|
|
||||||
|
group :test do
|
||||||
|
gem "database_cleaner-active_record"
|
||||||
|
gem "factory_bot_rails"
|
||||||
|
gem "rspec"
|
||||||
|
gem "rspec-rails"
|
||||||
gem "shoulda-matchers"
|
gem "shoulda-matchers"
|
||||||
gem "timecop"
|
gem "timecop"
|
||||||
gem "webmock"
|
gem "webmock"
|
||||||
|
|||||||
+297
-232
@@ -1,88 +1,99 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (7.0.8.1)
|
abbrev (0.1.2)
|
||||||
actionpack (= 7.0.8.1)
|
actioncable (7.2.3.2)
|
||||||
activesupport (= 7.0.8.1)
|
actionpack (= 7.2.3.2)
|
||||||
|
activesupport (= 7.2.3.2)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
actionmailbox (7.0.8.1)
|
zeitwerk (~> 2.6)
|
||||||
actionpack (= 7.0.8.1)
|
actionmailbox (7.2.3.2)
|
||||||
activejob (= 7.0.8.1)
|
actionpack (= 7.2.3.2)
|
||||||
activerecord (= 7.0.8.1)
|
activejob (= 7.2.3.2)
|
||||||
activestorage (= 7.0.8.1)
|
activerecord (= 7.2.3.2)
|
||||||
activesupport (= 7.0.8.1)
|
activestorage (= 7.2.3.2)
|
||||||
mail (>= 2.7.1)
|
activesupport (= 7.2.3.2)
|
||||||
net-imap
|
mail (>= 2.8.0)
|
||||||
net-pop
|
actionmailer (7.2.3.2)
|
||||||
net-smtp
|
actionpack (= 7.2.3.2)
|
||||||
actionmailer (7.0.8.1)
|
actionview (= 7.2.3.2)
|
||||||
actionpack (= 7.0.8.1)
|
activejob (= 7.2.3.2)
|
||||||
actionview (= 7.0.8.1)
|
activesupport (= 7.2.3.2)
|
||||||
activejob (= 7.0.8.1)
|
mail (>= 2.8.0)
|
||||||
activesupport (= 7.0.8.1)
|
rails-dom-testing (~> 2.2)
|
||||||
mail (~> 2.5, >= 2.5.4)
|
actionpack (7.2.3.2)
|
||||||
net-imap
|
actionview (= 7.2.3.2)
|
||||||
net-pop
|
activesupport (= 7.2.3.2)
|
||||||
net-smtp
|
cgi
|
||||||
rails-dom-testing (~> 2.0)
|
nokogiri (>= 1.8.5)
|
||||||
actionpack (7.0.8.1)
|
racc
|
||||||
actionview (= 7.0.8.1)
|
rack (>= 2.2.4, < 3.3)
|
||||||
activesupport (= 7.0.8.1)
|
rack-session (>= 1.0.1)
|
||||||
rack (~> 2.0, >= 2.2.4)
|
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
rails-html-sanitizer (~> 1.6)
|
||||||
actiontext (7.0.8.1)
|
useragent (~> 0.16)
|
||||||
actionpack (= 7.0.8.1)
|
actiontext (7.2.3.2)
|
||||||
activerecord (= 7.0.8.1)
|
actionpack (= 7.2.3.2)
|
||||||
activestorage (= 7.0.8.1)
|
activerecord (= 7.2.3.2)
|
||||||
activesupport (= 7.0.8.1)
|
activestorage (= 7.2.3.2)
|
||||||
|
activesupport (= 7.2.3.2)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (7.0.8.1)
|
actionview (7.2.3.2)
|
||||||
activesupport (= 7.0.8.1)
|
activesupport (= 7.2.3.2)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.4)
|
cgi
|
||||||
rails-dom-testing (~> 2.0)
|
erubi (~> 1.11)
|
||||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
rails-dom-testing (~> 2.2)
|
||||||
activejob (7.0.8.1)
|
rails-html-sanitizer (~> 1.6)
|
||||||
activesupport (= 7.0.8.1)
|
activejob (7.2.3.2)
|
||||||
|
activesupport (= 7.2.3.2)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (7.0.8.1)
|
activemodel (7.2.3.2)
|
||||||
activesupport (= 7.0.8.1)
|
activesupport (= 7.2.3.2)
|
||||||
activerecord (7.0.8.1)
|
activerecord (7.2.3.2)
|
||||||
activemodel (= 7.0.8.1)
|
activemodel (= 7.2.3.2)
|
||||||
activesupport (= 7.0.8.1)
|
activesupport (= 7.2.3.2)
|
||||||
activestorage (7.0.8.1)
|
timeout (>= 0.4.0)
|
||||||
actionpack (= 7.0.8.1)
|
activestorage (7.2.3.2)
|
||||||
activejob (= 7.0.8.1)
|
actionpack (= 7.2.3.2)
|
||||||
activerecord (= 7.0.8.1)
|
activejob (= 7.2.3.2)
|
||||||
activesupport (= 7.0.8.1)
|
activerecord (= 7.2.3.2)
|
||||||
|
activesupport (= 7.2.3.2)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
mini_mime (>= 1.1.0)
|
activesupport (7.2.3.2)
|
||||||
activesupport (7.0.8.1)
|
base64
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
benchmark (>= 0.3)
|
||||||
|
bigdecimal
|
||||||
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||||
|
connection_pool (>= 2.2.5)
|
||||||
|
drb
|
||||||
i18n (>= 1.6, < 2)
|
i18n (>= 1.6, < 2)
|
||||||
minitest (>= 5.1)
|
logger (>= 1.4.2)
|
||||||
tzinfo (~> 2.0)
|
minitest (>= 5.1, < 6)
|
||||||
addressable (2.8.6)
|
securerandom (>= 0.3)
|
||||||
public_suffix (>= 2.0.2, < 6.0)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
|
addressable (2.9.0)
|
||||||
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
aes_key_wrap (1.1.0)
|
aes_key_wrap (1.1.0)
|
||||||
annotate (3.2.0)
|
annotate (3.2.0)
|
||||||
activerecord (>= 3.2, < 8.0)
|
activerecord (>= 3.2, < 8.0)
|
||||||
rake (>= 10.4, < 14.0)
|
rake (>= 10.4, < 14.0)
|
||||||
ast (2.4.2)
|
ast (2.4.3)
|
||||||
attr_required (1.0.2)
|
attr_required (1.0.2)
|
||||||
authie (4.1.3)
|
authie (5.0.0)
|
||||||
activerecord (>= 6.1, < 8.0)
|
activerecord (>= 6.1, < 9.0)
|
||||||
autoprefixer-rails (10.4.13.0)
|
autoprefixer-rails (10.4.21.0)
|
||||||
execjs (~> 2)
|
execjs (~> 2)
|
||||||
base64 (0.2.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.22)
|
||||||
bigdecimal (3.1.6)
|
benchmark (0.5.0)
|
||||||
bindata (2.5.0)
|
bigdecimal (4.1.3)
|
||||||
builder (3.2.4)
|
bindata (3.0.0)
|
||||||
|
builder (3.3.0)
|
||||||
|
cgi (0.5.2)
|
||||||
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)
|
||||||
@@ -91,66 +102,74 @@ GEM
|
|||||||
coffee-script-source
|
coffee-script-source
|
||||||
execjs
|
execjs
|
||||||
coffee-script-source (1.12.2)
|
coffee-script-source (1.12.2)
|
||||||
concurrent-ruby (1.2.3)
|
concurrent-ruby (1.3.8)
|
||||||
crack (1.0.0)
|
connection_pool (3.0.2)
|
||||||
|
crack (1.0.1)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.7)
|
||||||
database_cleaner (2.0.2)
|
database_cleaner-active_record (2.2.2)
|
||||||
database_cleaner-active_record (>= 2, < 3)
|
|
||||||
database_cleaner-active_record (2.1.0)
|
|
||||||
activerecord (>= 5.a)
|
activerecord (>= 5.a)
|
||||||
database_cleaner-core (~> 2.0.0)
|
database_cleaner-core (~> 2.0)
|
||||||
database_cleaner-core (2.0.1)
|
database_cleaner-core (2.1.0)
|
||||||
date (3.3.4)
|
date (3.5.1)
|
||||||
diff-lcs (1.5.0)
|
diff-lcs (1.6.2)
|
||||||
domain_name (0.6.20240107)
|
domain_name (0.6.20260907)
|
||||||
dotenv (3.0.2)
|
dotenv (3.2.0)
|
||||||
dynamic_form (1.3.1)
|
drb (2.2.3)
|
||||||
actionview (> 5.2.0)
|
erb (6.0.7)
|
||||||
activemodel (> 5.2.0)
|
erubi (1.13.1)
|
||||||
email_validator (2.2.4)
|
execjs (2.10.2)
|
||||||
activemodel
|
json (>= 2)
|
||||||
erubi (1.12.0)
|
factory_bot (6.6.0)
|
||||||
execjs (2.7.0)
|
activesupport (>= 6.1.0)
|
||||||
factory_bot (6.4.6)
|
factory_bot_rails (6.5.1)
|
||||||
activesupport (>= 5.0.0)
|
factory_bot (~> 6.5)
|
||||||
factory_bot_rails (6.4.3)
|
railties (>= 6.1.0)
|
||||||
factory_bot (~> 6.4)
|
faraday (2.14.3)
|
||||||
railties (>= 5.0.0)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
faraday (2.9.0)
|
json
|
||||||
faraday-net_http (>= 2.0, < 3.2)
|
logger
|
||||||
faraday-follow_redirects (0.3.0)
|
faraday-follow_redirects (0.5.0)
|
||||||
faraday (>= 1, < 3)
|
faraday (>= 1, < 3)
|
||||||
faraday-net_http (3.1.0)
|
faraday-net_http (3.4.4)
|
||||||
net-http
|
net-http (~> 0.5)
|
||||||
ffi (1.15.5)
|
ffi (1.15.5)
|
||||||
gelf (3.1.0)
|
gelf (3.1.0)
|
||||||
json
|
json
|
||||||
globalid (1.2.1)
|
globalid (1.4.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
haml (6.3.0)
|
haml (7.5.1)
|
||||||
|
prism (>= 1.1.0)
|
||||||
temple (>= 0.8.2)
|
temple (>= 0.8.2)
|
||||||
thor
|
thor
|
||||||
tilt
|
tilt
|
||||||
hashdiff (1.1.0)
|
hashdiff (1.2.1)
|
||||||
hashie (5.0.0)
|
hashie (5.1.0)
|
||||||
highline (2.1.0)
|
logger
|
||||||
i18n (1.14.1)
|
highline (3.1.2)
|
||||||
|
reline
|
||||||
|
i18n (1.15.2)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
jquery-rails (4.5.1)
|
io-console (0.9.2)
|
||||||
|
irb (1.18.0)
|
||||||
|
pp (>= 0.6.0)
|
||||||
|
prism (>= 1.3.0)
|
||||||
|
rdoc (>= 4.0.0)
|
||||||
|
reline (>= 0.4.2)
|
||||||
|
jquery-rails (4.6.1)
|
||||||
rails-dom-testing (>= 1, < 3)
|
rails-dom-testing (>= 1, < 3)
|
||||||
railties (>= 4.2.0)
|
railties (>= 4.2.0)
|
||||||
thor (>= 0.14, < 2.0)
|
thor (>= 0.14, < 2.0)
|
||||||
json (2.7.1)
|
json (2.21.2)
|
||||||
json-jwt (1.16.6)
|
json-jwt (1.17.2)
|
||||||
activesupport (>= 4.2)
|
activesupport (>= 4.2)
|
||||||
aes_key_wrap
|
aes_key_wrap
|
||||||
base64
|
base64
|
||||||
bindata
|
bindata
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday-follow_redirects
|
faraday-follow_redirects
|
||||||
jwt (2.8.1)
|
jwt (3.3.0)
|
||||||
base64
|
base64
|
||||||
kaminari (1.2.2)
|
kaminari (1.2.2)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
@@ -164,67 +183,68 @@ GEM
|
|||||||
activerecord
|
activerecord
|
||||||
kaminari-core (= 1.2.2)
|
kaminari-core (= 1.2.2)
|
||||||
kaminari-core (1.2.2)
|
kaminari-core (1.2.2)
|
||||||
klogger-logger (1.4.0)
|
klogger-logger (1.5.0)
|
||||||
concurrent-ruby (>= 1.0, < 2.0)
|
concurrent-ruby (>= 1.0, < 2.0)
|
||||||
json
|
json
|
||||||
rouge (>= 3.30, < 5.0)
|
rouge (>= 3.30, < 5.0)
|
||||||
konfig-config (3.0.0)
|
konfig-config (3.0.0)
|
||||||
hashie
|
hashie
|
||||||
loofah (2.22.0)
|
language_server-protocol (3.17.0.6)
|
||||||
|
lint_roller (1.1.0)
|
||||||
|
logger (1.7.0)
|
||||||
|
loofah (2.25.2)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.8.1)
|
mail (2.9.1)
|
||||||
|
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.0.2)
|
marcel (1.2.1)
|
||||||
method_source (1.0.0)
|
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.5)
|
minitest (5.27.0)
|
||||||
minitest (5.22.2)
|
mutex_m (0.3.0)
|
||||||
mysql2 (0.5.6)
|
mysql2 (0.5.7)
|
||||||
net-http (0.4.1)
|
bigdecimal
|
||||||
uri
|
net-http (0.9.1)
|
||||||
net-imap (0.4.10)
|
uri (>= 0.11.1)
|
||||||
|
net-imap (0.6.7)
|
||||||
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.3.0)
|
||||||
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.0)
|
nio4r (2.7.5)
|
||||||
nokogiri (1.16.2)
|
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||||
mini_portile2 (~> 2.8.2)
|
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.16.2-aarch64-linux)
|
nokogiri (1.19.4-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.16.2-arm64-darwin)
|
nokogiri (1.19.4-x86_64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.16.2-x86_64-darwin)
|
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.16.2-x86_64-linux)
|
omniauth (2.1.4)
|
||||||
racc (~> 1.4)
|
|
||||||
omniauth (2.1.2)
|
|
||||||
hashie (>= 3.4.6)
|
hashie (>= 3.4.6)
|
||||||
|
logger
|
||||||
rack (>= 2.2.3)
|
rack (>= 2.2.3)
|
||||||
rack-protection
|
rack-protection
|
||||||
omniauth-rails_csrf_protection (1.0.1)
|
omniauth-rails_csrf_protection (2.0.1)
|
||||||
actionpack (>= 4.2)
|
actionpack (>= 4.2)
|
||||||
omniauth (~> 2.0)
|
omniauth (~> 2.0)
|
||||||
omniauth_openid_connect (0.7.1)
|
omniauth_openid_connect (0.8.0)
|
||||||
omniauth (>= 1.9, < 3)
|
omniauth (>= 1.9, < 3)
|
||||||
openid_connect (~> 2.2)
|
openid_connect (~> 2.2)
|
||||||
openid_connect (2.3.0)
|
openid_connect (2.5.0)
|
||||||
activemodel
|
activemodel
|
||||||
attr_required (>= 1.0.0)
|
attr_required (>= 1.0.0)
|
||||||
email_validator
|
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday-follow_redirects
|
faraday-follow_redirects
|
||||||
json-jwt (>= 1.16)
|
json-jwt (>= 1.16)
|
||||||
@@ -234,98 +254,129 @@ GEM
|
|||||||
tzinfo
|
tzinfo
|
||||||
validate_url
|
validate_url
|
||||||
webfinger (~> 2.0)
|
webfinger (~> 2.0)
|
||||||
parallel (1.22.1)
|
ostruct (0.6.3)
|
||||||
parser (3.2.1.1)
|
parallel (2.2.0)
|
||||||
|
parser (3.3.12.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
prometheus-client (4.2.2)
|
racc
|
||||||
public_suffix (5.0.4)
|
pp (0.6.4)
|
||||||
puma (6.4.2)
|
prettyprint
|
||||||
|
prettyprint (0.2.0)
|
||||||
|
prism (1.9.0)
|
||||||
|
prometheus-client (5.0.0)
|
||||||
|
base64
|
||||||
|
public_suffix (7.0.5)
|
||||||
|
puma (8.0.2)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
racc (1.7.3)
|
racc (1.8.1)
|
||||||
rack (2.2.8.1)
|
rack (3.2.7)
|
||||||
rack-oauth2 (2.2.1)
|
rack-oauth2 (2.3.0)
|
||||||
activesupport
|
activesupport
|
||||||
attr_required
|
attr_required
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday-follow_redirects
|
faraday-follow_redirects
|
||||||
json-jwt (>= 1.11.0)
|
json-jwt (>= 1.11.0)
|
||||||
rack (>= 2.1.0)
|
rack (>= 2.1.0)
|
||||||
rack-protection (3.2.0)
|
rack-protection (4.2.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (~> 2.2, >= 2.2.4)
|
logger (>= 1.6.0)
|
||||||
rack-test (2.1.0)
|
rack (>= 3.0.0, < 4)
|
||||||
|
rack-session (2.1.2)
|
||||||
|
base64 (>= 0.1.0)
|
||||||
|
rack (>= 3.0.0)
|
||||||
|
rack-test (2.2.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rails (7.0.8.1)
|
rackup (2.3.1)
|
||||||
actioncable (= 7.0.8.1)
|
rack (>= 3)
|
||||||
actionmailbox (= 7.0.8.1)
|
rails (7.2.3.2)
|
||||||
actionmailer (= 7.0.8.1)
|
actioncable (= 7.2.3.2)
|
||||||
actionpack (= 7.0.8.1)
|
actionmailbox (= 7.2.3.2)
|
||||||
actiontext (= 7.0.8.1)
|
actionmailer (= 7.2.3.2)
|
||||||
actionview (= 7.0.8.1)
|
actionpack (= 7.2.3.2)
|
||||||
activejob (= 7.0.8.1)
|
actiontext (= 7.2.3.2)
|
||||||
activemodel (= 7.0.8.1)
|
actionview (= 7.2.3.2)
|
||||||
activerecord (= 7.0.8.1)
|
activejob (= 7.2.3.2)
|
||||||
activestorage (= 7.0.8.1)
|
activemodel (= 7.2.3.2)
|
||||||
activesupport (= 7.0.8.1)
|
activerecord (= 7.2.3.2)
|
||||||
|
activestorage (= 7.2.3.2)
|
||||||
|
activesupport (= 7.2.3.2)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 7.0.8.1)
|
railties (= 7.2.3.2)
|
||||||
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.0)
|
rails-html-sanitizer (1.7.1)
|
||||||
loofah (~> 2.21)
|
loofah (~> 2.25, >= 2.25.2)
|
||||||
nokogiri (~> 1.14)
|
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.0.8.1)
|
railties (7.2.3.2)
|
||||||
actionpack (= 7.0.8.1)
|
actionpack (= 7.2.3.2)
|
||||||
activesupport (= 7.0.8.1)
|
activesupport (= 7.2.3.2)
|
||||||
method_source
|
cgi
|
||||||
|
irb (~> 1.13)
|
||||||
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0, >= 1.2.2)
|
||||||
zeitwerk (~> 2.5)
|
tsort (>= 0.2)
|
||||||
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.1.0)
|
rake (13.4.2)
|
||||||
regexp_parser (2.7.0)
|
rbs (4.2.0)
|
||||||
resolv (0.3.0)
|
logger
|
||||||
rexml (3.2.5)
|
prism (>= 1.6.0)
|
||||||
rouge (4.2.0)
|
tsort
|
||||||
rspec (3.12.0)
|
rdoc (8.0.0)
|
||||||
rspec-core (~> 3.12.0)
|
erb
|
||||||
rspec-expectations (~> 3.12.0)
|
prism (>= 1.6.0)
|
||||||
rspec-mocks (~> 3.12.0)
|
rbs (>= 4.0.0)
|
||||||
rspec-core (3.12.1)
|
tsort
|
||||||
rspec-support (~> 3.12.0)
|
regexp_parser (2.12.0)
|
||||||
rspec-expectations (3.12.2)
|
reline (0.7.0)
|
||||||
|
io-console (~> 0.5)
|
||||||
|
resolv (0.7.2)
|
||||||
|
rexml (3.4.4)
|
||||||
|
rouge (4.7.0)
|
||||||
|
rspec (3.13.2)
|
||||||
|
rspec-core (~> 3.13.0)
|
||||||
|
rspec-expectations (~> 3.13.0)
|
||||||
|
rspec-mocks (~> 3.13.0)
|
||||||
|
rspec-core (3.13.6)
|
||||||
|
rspec-support (~> 3.13.0)
|
||||||
|
rspec-expectations (3.13.5)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.12.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-mocks (3.12.4)
|
rspec-mocks (3.13.8)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.12.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-rails (6.1.1)
|
rspec-rails (8.0.4)
|
||||||
actionpack (>= 6.1)
|
actionpack (>= 7.2)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 7.2)
|
||||||
railties (>= 6.1)
|
railties (>= 7.2)
|
||||||
rspec-core (~> 3.12)
|
rspec-core (>= 3.13.0, < 5.0.0)
|
||||||
rspec-expectations (~> 3.12)
|
rspec-expectations (>= 3.13.0, < 5.0.0)
|
||||||
rspec-mocks (~> 3.12)
|
rspec-mocks (>= 3.13.0, < 5.0.0)
|
||||||
rspec-support (~> 3.12)
|
rspec-support (>= 3.13.0, < 5.0.0)
|
||||||
rspec-support (3.12.0)
|
rspec-support (3.13.7)
|
||||||
rubocop (1.48.1)
|
rubocop (1.91.0)
|
||||||
json (~> 2.3)
|
json (>= 2.3)
|
||||||
parallel (~> 1.10)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
parser (>= 3.2.0.0)
|
lint_roller (~> 1.1.0)
|
||||||
|
parallel (>= 1.10)
|
||||||
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 1.8, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rexml (>= 3.2.5, < 4.0)
|
rubocop-ast (>= 1.49.0, < 2.0)
|
||||||
rubocop-ast (>= 1.26.0, < 2.0)
|
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 3.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.27.0)
|
rubocop-ast (1.50.0)
|
||||||
parser (>= 3.2.1.0)
|
parser (>= 3.3.7.2)
|
||||||
rubocop-rails (2.18.0)
|
prism (~> 1.7)
|
||||||
|
rubocop-rails (2.37.0)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
|
lint_roller (~> 1.1)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.33.0, < 2.0)
|
rubocop (>= 1.89.0, < 2.0)
|
||||||
|
rubocop-ast (>= 1.44.0, < 2.0)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
sass-rails (6.0.0)
|
sass-rails (6.0.0)
|
||||||
sassc-rails (~> 2.1, >= 2.1.1)
|
sassc-rails (~> 2.1, >= 2.1.1)
|
||||||
@@ -337,40 +388,49 @@ GEM
|
|||||||
sprockets (> 3.0)
|
sprockets (> 3.0)
|
||||||
sprockets-rails
|
sprockets-rails
|
||||||
tilt
|
tilt
|
||||||
secure_headers (6.5.0)
|
secure_headers (7.3.0)
|
||||||
sentry-rails (5.16.1)
|
cgi (>= 0.1)
|
||||||
railties (>= 5.0)
|
securerandom (0.4.1)
|
||||||
sentry-ruby (~> 5.16.1)
|
sentry-rails (7.0.0)
|
||||||
sentry-ruby (5.16.1)
|
railties (>= 5.2.0)
|
||||||
|
sentry-ruby (~> 7.0.0)
|
||||||
|
sentry-ruby (7.0.0)
|
||||||
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
shoulda-matchers (6.1.0)
|
logger
|
||||||
activesupport (>= 5.2.0)
|
shoulda-matchers (8.0.1)
|
||||||
sprockets (4.2.0)
|
activesupport (>= 7.2)
|
||||||
concurrent-ruby (~> 1.0)
|
sprockets (4.4.1)
|
||||||
|
concurrent-ruby (~> 1.1)
|
||||||
|
logger
|
||||||
rack (>= 2.2.4, < 4)
|
rack (>= 2.2.4, < 4)
|
||||||
sprockets-rails (3.4.2)
|
sprockets-rails (3.5.2)
|
||||||
actionpack (>= 5.2)
|
actionpack (>= 6.1)
|
||||||
activesupport (>= 5.2)
|
activesupport (>= 6.1)
|
||||||
sprockets (>= 3.0.0)
|
sprockets (>= 3.0.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.7)
|
||||||
thor (1.3.0)
|
thor (1.5.0)
|
||||||
tilt (2.3.0)
|
tilt (2.9.0)
|
||||||
timecop (0.9.8)
|
timecop (0.9.11)
|
||||||
timeout (0.4.1)
|
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)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
uglifier (4.2.0)
|
uglifier (4.2.1)
|
||||||
execjs (>= 0.3.0, < 3)
|
execjs (>= 0.3.0, < 3)
|
||||||
unicode-display_width (2.4.2)
|
unicode-display_width (3.2.0)
|
||||||
uri (0.13.0)
|
unicode-emoji (~> 4.1)
|
||||||
|
unicode-emoji (4.2.0)
|
||||||
|
uri (1.1.1)
|
||||||
|
useragent (0.16.11)
|
||||||
validate_url (1.0.15)
|
validate_url (1.0.15)
|
||||||
activemodel (>= 3.0.0)
|
activemodel (>= 3.0.0)
|
||||||
public_suffix
|
public_suffix
|
||||||
@@ -378,55 +438,60 @@ GEM
|
|||||||
activesupport
|
activesupport
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday-follow_redirects
|
faraday-follow_redirects
|
||||||
webmock (3.20.0)
|
webmock (3.26.4)
|
||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
webrick (1.8.1)
|
webrick (1.9.2)
|
||||||
websocket-driver (0.7.6)
|
websocket-driver (0.8.2)
|
||||||
|
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.3)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux
|
aarch64-linux
|
||||||
arm64-darwin
|
arm64-darwin
|
||||||
ruby
|
|
||||||
x86_64-darwin
|
x86_64-darwin
|
||||||
x86_64-linux
|
x86_64-linux
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
|
abbrev
|
||||||
annotate
|
annotate
|
||||||
authie
|
authie
|
||||||
autoprefixer-rails
|
autoprefixer-rails
|
||||||
bcrypt
|
bcrypt
|
||||||
chronic
|
chronic
|
||||||
coffee-rails (~> 5.0)
|
coffee-rails (~> 5.0)
|
||||||
database_cleaner
|
database_cleaner-active_record
|
||||||
domain_name
|
domain_name
|
||||||
dotenv
|
dotenv
|
||||||
dynamic_form
|
execjs
|
||||||
execjs (~> 2.7, < 2.8)
|
|
||||||
factory_bot_rails
|
factory_bot_rails
|
||||||
gelf
|
gelf
|
||||||
haml
|
haml
|
||||||
hashie
|
hashie
|
||||||
highline
|
highline
|
||||||
jquery-rails
|
jquery-rails
|
||||||
|
json (< 3)
|
||||||
jwt
|
jwt
|
||||||
kaminari
|
kaminari
|
||||||
klogger-logger
|
klogger-logger
|
||||||
konfig-config (~> 3.0)
|
konfig-config (~> 3.0)
|
||||||
|
logger
|
||||||
mail
|
mail
|
||||||
|
mutex_m
|
||||||
mysql2
|
mysql2
|
||||||
nifty-utils
|
nifty-utils
|
||||||
nilify_blanks
|
nilify_blanks
|
||||||
nio4r
|
nio4r
|
||||||
omniauth-rails_csrf_protection
|
omniauth-rails_csrf_protection
|
||||||
omniauth_openid_connect
|
omniauth_openid_connect
|
||||||
|
ostruct
|
||||||
prometheus-client
|
prometheus-client
|
||||||
puma
|
puma
|
||||||
rails (= 7.0.8.1)
|
rackup
|
||||||
|
rails (= 7.2.3.2)
|
||||||
resolv
|
resolv
|
||||||
rspec
|
rspec
|
||||||
rspec-rails
|
rspec-rails
|
||||||
@@ -443,4 +508,4 @@ DEPENDENCIES
|
|||||||
webrick
|
webrick
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.5.6
|
2.7.2
|
||||||
|
|||||||
+2
-2
@@ -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).
|
||||||
|
|||||||
@@ -9,14 +9,6 @@
|
|||||||
line-height:1.5;
|
line-height:1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorExplanation h2 {
|
|
||||||
display:none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorExplanation p {
|
|
||||||
display:none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorExplanation ul li {
|
.errorExplanation ul li {
|
||||||
list-style:disc;
|
list-style:disc;
|
||||||
margin-left:20px;
|
margin-left:20px;
|
||||||
|
|||||||
@@ -62,10 +62,13 @@ class ApplicationController < ActionController::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def url_with_return_to(url)
|
def url_with_return_to(url)
|
||||||
if params[:return_to].blank? || !params[:return_to].starts_with?("/")
|
return_to = params[:return_to]
|
||||||
|
if return_to.blank? ||
|
||||||
|
!return_to.start_with?("/") ||
|
||||||
|
return_to.start_with?("//", "/\\")
|
||||||
url_for(url)
|
url_for(url)
|
||||||
else
|
else
|
||||||
params[:return_to]
|
return_to
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,9 @@ module LegacyAPI
|
|||||||
# OR an error if the message does not exist.
|
# OR an error if the message does not exist.
|
||||||
#
|
#
|
||||||
def message
|
def message
|
||||||
if api_params["id"].blank?
|
message = find_message
|
||||||
render_parameter_error "`id` parameter is required but is missing"
|
return if performed?
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
message = @current_credential.server.message(api_params["id"])
|
|
||||||
message_hash = { id: message.id, token: message.token }
|
message_hash = { id: message.id, token: message.token }
|
||||||
expansions = api_params["_expansions"]
|
expansions = api_params["_expansions"]
|
||||||
|
|
||||||
@@ -111,12 +108,9 @@ module LegacyAPI
|
|||||||
# OR an error if the message does not exist.
|
# OR an error if the message does not exist.
|
||||||
#
|
#
|
||||||
def deliveries
|
def deliveries
|
||||||
if api_params["id"].blank?
|
message = find_message
|
||||||
render_parameter_error "`id` parameter is required but is missing"
|
return if performed?
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
message = @current_credential.server.message(api_params["id"])
|
|
||||||
deliveries = message.deliveries.map do |d|
|
deliveries = message.deliveries.map do |d|
|
||||||
{
|
{
|
||||||
id: d.id,
|
id: d.id,
|
||||||
@@ -136,5 +130,37 @@ module LegacyAPI
|
|||||||
id: api_params["id"]
|
id: api_params["id"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Look up the message referenced by the request's `id` parameter.
|
||||||
|
#
|
||||||
|
# The legacy API only ever identifies a message by its integer ID. The
|
||||||
|
# request body is parsed as JSON, so without validation a JSON object or
|
||||||
|
# array supplied for `id` would arrive as a Ruby Hash/Array and be passed
|
||||||
|
# straight through to the message database as a raw set of SQL conditions.
|
||||||
|
# We therefore reject anything that is not a simple scalar before it can
|
||||||
|
# reach the database and coerce the value to an integer ID.
|
||||||
|
#
|
||||||
|
# Renders an error and returns nil when the parameter is missing or is not
|
||||||
|
# a scalar; otherwise returns the matched message (raising NotFound when no
|
||||||
|
# message matches, which the actions rescue).
|
||||||
|
#
|
||||||
|
# @return [Postal::MessageDB::Message, nil]
|
||||||
|
def find_message
|
||||||
|
id = api_params["id"]
|
||||||
|
|
||||||
|
if id.blank?
|
||||||
|
render_parameter_error "`id` parameter is required but is missing"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
unless id.is_a?(String) || id.is_a?(Integer)
|
||||||
|
render_parameter_error "`id` parameter must be a string or integer"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@current_credential.server.message(id.to_i)
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -89,6 +89,18 @@ class MessagesController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def html_raw
|
def html_raw
|
||||||
|
override_content_security_policy_directives(
|
||||||
|
default_src: %w('none'),
|
||||||
|
script_src: %w('none'),
|
||||||
|
style_src: %w('unsafe-inline'),
|
||||||
|
img_src: %w(* data:),
|
||||||
|
font_src: %w(*),
|
||||||
|
frame_ancestors: %w('self'),
|
||||||
|
form_action: %w('none'),
|
||||||
|
base_uri: %w('none')
|
||||||
|
)
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["Referrer-Policy"] = "no-referrer"
|
||||||
render html: @message.html_body_without_tracking_image.html_safe
|
render html: @message.html_body_without_tracking_image.html_safe
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
module ApplicationHelper
|
module ApplicationHelper
|
||||||
|
|
||||||
def format_delivery_details(server, text)
|
def format_delivery_details(server, text)
|
||||||
|
text = h(text)
|
||||||
text.gsub!(/<msg:(\d+)>/) do
|
text.gsub!(/<msg:(\d+)>/) do
|
||||||
id = ::Regexp.last_match(1).to_i
|
id = ::Regexp.last_match(1).to_i
|
||||||
link_to("message ##{id}", organization_server_message_path(server.organization, server, id), class: "u-link")
|
link_to("message ##{id}", organization_server_message_path(server.organization, server, id), class: "u-link")
|
||||||
@@ -32,7 +33,7 @@ module ApplicationHelper
|
|||||||
s << "<optgroup label='Server Domains'>"
|
s << "<optgroup label='Server Domains'>"
|
||||||
server_domains.each do |domain|
|
server_domains.each do |domain|
|
||||||
selected = domain == selected_domain ? "selected='selected'" : ""
|
selected = domain == selected_domain ? "selected='selected'" : ""
|
||||||
s << "<option value='#{domain.id}' #{selected}>#{domain.name}</option>"
|
s << "<option value='#{h(domain.id)}' #{selected}>#{h(domain.name)}</option>"
|
||||||
end
|
end
|
||||||
s << "</optgroup>"
|
s << "</optgroup>"
|
||||||
end
|
end
|
||||||
@@ -42,7 +43,7 @@ module ApplicationHelper
|
|||||||
s << "<optgroup label='Organization Domains'>"
|
s << "<optgroup label='Organization Domains'>"
|
||||||
organization_domains.each do |domain|
|
organization_domains.each do |domain|
|
||||||
selected = domain == selected_domain ? "selected='selected'" : ""
|
selected = domain == selected_domain ? "selected='selected'" : ""
|
||||||
s << "<option value='#{domain.id}' #{selected}>#{domain.name}</option>"
|
s << "<option value='#{h(domain.id)}' #{selected}>#{h(domain.name)}</option>"
|
||||||
end
|
end
|
||||||
s << "</optgroup>"
|
s << "</optgroup>"
|
||||||
end
|
end
|
||||||
@@ -59,7 +60,7 @@ module ApplicationHelper
|
|||||||
http_endpoints.each do |endpoint|
|
http_endpoints.each do |endpoint|
|
||||||
value = "#{endpoint.class}##{endpoint.uuid}"
|
value = "#{endpoint.class}##{endpoint.uuid}"
|
||||||
selected = value == selected_value ? "selected='selected'" : ""
|
selected = value == selected_value ? "selected='selected'" : ""
|
||||||
s << "<option value='#{value}' #{selected}>#{endpoint.description}</option>"
|
s << "<option value='#{h(value)}' #{selected}>#{h(endpoint.description)}</option>"
|
||||||
end
|
end
|
||||||
s << "</optgroup>"
|
s << "</optgroup>"
|
||||||
end
|
end
|
||||||
@@ -70,7 +71,7 @@ module ApplicationHelper
|
|||||||
smtp_endpoints.each do |endpoint|
|
smtp_endpoints.each do |endpoint|
|
||||||
value = "#{endpoint.class}##{endpoint.uuid}"
|
value = "#{endpoint.class}##{endpoint.uuid}"
|
||||||
selected = value == selected_value ? "selected='selected'" : ""
|
selected = value == selected_value ? "selected='selected'" : ""
|
||||||
s << "<option value='#{value}' #{selected}>#{endpoint.description}</option>"
|
s << "<option value='#{h(value)}' #{selected}>#{h(endpoint.description)}</option>"
|
||||||
end
|
end
|
||||||
s << "</optgroup>"
|
s << "</optgroup>"
|
||||||
end
|
end
|
||||||
@@ -81,7 +82,7 @@ module ApplicationHelper
|
|||||||
address_endpoints.each do |endpoint|
|
address_endpoints.each do |endpoint|
|
||||||
value = "#{endpoint.class}##{endpoint.uuid}"
|
value = "#{endpoint.class}##{endpoint.uuid}"
|
||||||
selected = value == selected_value ? "selected='selected'" : ""
|
selected = value == selected_value ? "selected='selected'" : ""
|
||||||
s << "<option value='#{value}' #{selected}>#{endpoint.address}</option>"
|
s << "<option value='#{h(value)}' #{selected}>#{h(endpoint.address)}</option>"
|
||||||
end
|
end
|
||||||
s << "</optgroup>"
|
s << "</optgroup>"
|
||||||
end
|
end
|
||||||
@@ -93,7 +94,7 @@ module ApplicationHelper
|
|||||||
|
|
||||||
selected = (selected_value == mode ? "selected='selected'" : "")
|
selected = (selected_value == mode ? "selected='selected'" : "")
|
||||||
text = t("route_modes.#{mode.underscore}")
|
text = t("route_modes.#{mode.underscore}")
|
||||||
s << "<option value='#{mode}' #{selected}>#{text}</option>"
|
s << "<option value='#{h(mode)}' #{selected}>#{h(text)}</option>"
|
||||||
end
|
end
|
||||||
s << "</optgroup>"
|
s << "</optgroup>"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module ErrorMessagesHelper
|
||||||
|
|
||||||
|
# Renders a record's validation errors.
|
||||||
|
#
|
||||||
|
# This replaces the helper of the same name from the dynamic_form gem, which
|
||||||
|
# is unmaintained and cannot be installed on Ruby 3.4. The markup matches what
|
||||||
|
# that gem produced, minus the heading and description which the
|
||||||
|
# errorExplanation styles have always hidden, and matches the structure
|
||||||
|
# ajax.coffee builds when it renders errors from a remote form.
|
||||||
|
def error_messages_for(record)
|
||||||
|
record = record.to_model if record.respond_to?(:to_model)
|
||||||
|
return "".html_safe if record.nil? || record.errors.empty?
|
||||||
|
|
||||||
|
items = safe_join(record.errors.full_messages.map { |message| tag.li(message) })
|
||||||
|
tag.div(tag.ul(items), id: "errorExplanation", class: "errorExplanation")
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
@@ -202,7 +202,7 @@ module Worker
|
|||||||
logger.info "stopping tasks thread"
|
logger.info "stopping tasks thread"
|
||||||
ActiveRecord::Base.connection_pool.with_connection do
|
ActiveRecord::Base.connection_pool.with_connection do
|
||||||
if WorkerRole.release(:tasks)
|
if WorkerRole.release(:tasks)
|
||||||
logger.info "releasesd tasks role"
|
logger.info "released tasks role"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -298,7 +298,7 @@ module Worker
|
|||||||
labels: [:thread, :job]
|
labels: [:thread, :job]
|
||||||
|
|
||||||
register_prometheus_histogram :postal_worker_job_runtime,
|
register_prometheus_histogram :postal_worker_job_runtime,
|
||||||
docstring: "The time taken to process jobs",
|
docstring: "The time taken to process jobs (in seconds)",
|
||||||
labels: [:thread, :job]
|
labels: [:thread, :job]
|
||||||
|
|
||||||
register_prometheus_counter :postal_worker_errors,
|
register_prometheus_counter :postal_worker_errors,
|
||||||
@@ -306,11 +306,11 @@ module Worker
|
|||||||
labels: [:error]
|
labels: [:error]
|
||||||
|
|
||||||
register_prometheus_histogram :postal_worker_task_runtime,
|
register_prometheus_histogram :postal_worker_task_runtime,
|
||||||
docstring: "The time taken to process tasks",
|
docstring: "The time taken to process tasks (in seconds)",
|
||||||
labels: [:task]
|
labels: [:task]
|
||||||
|
|
||||||
register_prometheus_histogram :postal_message_queue_latency,
|
register_prometheus_histogram :postal_message_queue_latency,
|
||||||
docstring: "The length of time between a message being queued and being dequeued"
|
docstring: "The length of time between a message being queued and being dequeued (in seconds)"
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class Credential < ApplicationRecord
|
|||||||
validate :validate_key_cannot_be_changed
|
validate :validate_key_cannot_be_changed
|
||||||
validate :validate_key_for_smtp_ip
|
validate :validate_key_for_smtp_ip
|
||||||
|
|
||||||
serialize :options, Hash
|
serialize :options, type: Hash, coder: YAML
|
||||||
|
|
||||||
before_validation :generate_key
|
before_validation :generate_key
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class WebhookRequest < ApplicationRecord
|
|||||||
validates :url, presence: true
|
validates :url, presence: true
|
||||||
validates :event, presence: true
|
validates :event, presence: true
|
||||||
|
|
||||||
serialize :payload, Hash
|
serialize :payload, type: Hash, coder: YAML
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ class ApplicationScheduledTask
|
|||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
# override me
|
raise NotImplementedError
|
||||||
end
|
end
|
||||||
|
|
||||||
attr_reader :logger
|
attr_reader :logger
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
class ProcessMessageRetentionScheduledTask < ApplicationScheduledTask
|
class ProcessMessageRetentionScheduledTask < ApplicationScheduledTask
|
||||||
|
|
||||||
def perform
|
def call
|
||||||
Server.all.each do |server|
|
Server.all.each do |server|
|
||||||
if server.raw_message_retention_days
|
if server.raw_message_retention_days
|
||||||
# If the server has a maximum number of retained raw messages, remove any that are older than this
|
# If the server has a maximum number of retained raw messages, remove any that are older than this
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require "socket"
|
require "socket"
|
||||||
require "rack/handler/webrick"
|
require "rackup/handler/webrick"
|
||||||
require "prometheus/client/formats/text"
|
require "prometheus/client/formats/text"
|
||||||
|
|
||||||
class HealthServer
|
class HealthServer
|
||||||
@@ -55,7 +55,7 @@ class HealthServer
|
|||||||
port = ENV.fetch("HEALTH_SERVER_PORT", default_port)
|
port = ENV.fetch("HEALTH_SERVER_PORT", default_port)
|
||||||
bind_address = ENV.fetch("HEALTH_SERVER_BIND_ADDRESS", default_bind_address)
|
bind_address = ENV.fetch("HEALTH_SERVER_BIND_ADDRESS", default_bind_address)
|
||||||
|
|
||||||
Rack::Handler::WEBrick.run(new(**options),
|
Rackup::Handler::WEBrick.run(new(**options),
|
||||||
Port: port,
|
Port: port,
|
||||||
BindAddress: bind_address,
|
BindAddress: bind_address,
|
||||||
AccessLog: [],
|
AccessLog: [],
|
||||||
@@ -95,7 +95,7 @@ class HealthServer
|
|||||||
Postal.logger.info "stopped health server", component: "health-server"
|
Postal.logger.info "stopped health server", component: "health-server"
|
||||||
when /\AWEBrick [\d.]+/,
|
when /\AWEBrick [\d.]+/,
|
||||||
/\Aruby ([\d.]+)/,
|
/\Aruby ([\d.]+)/,
|
||||||
/\ARack::Handler::WEBrick is mounted/,
|
/\ARackup::Handler::WEBrick is mounted/,
|
||||||
/\Aclose TCPSocket/,
|
/\Aclose TCPSocket/,
|
||||||
/\Agoing to shutdown/
|
/\Agoing to shutdown/
|
||||||
# Don't actually print routine messages to avoid too much
|
# Don't actually print routine messages to avoid too much
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
= f.select :hold, [["Process all messages", false], ["Hold messages from this credential", true]], {}, :class => 'input input--select'
|
= f.select :hold, [["Process all messages", false], ["Hold messages from this credential", true]], {}, :class => 'input input--select'
|
||||||
%p.fieldSet__text
|
%p.fieldSet__text
|
||||||
You may wish to automatically hold all messages that are sent by this credential. This allows you to preview them
|
You may wish to automatically hold all messages that are sent by this credential. This allows you to preview them
|
||||||
for they are delivered to their recipients. This is useful for credentials for development environments.
|
before they are delivered to their recipients. This is useful for credentials for development environments.
|
||||||
|
|
||||||
.fieldSetSubmit.buttonSet
|
.fieldSetSubmit.buttonSet
|
||||||
= f.submit @credential.new_record? ? "Create credential" : "Save credential", :class => 'button button--positive js-form-submit'
|
= f.submit @credential.new_record? ? "Create credential" : "Save credential", :class => 'button button--positive js-form-submit'
|
||||||
|
|||||||
@@ -14,4 +14,4 @@
|
|||||||
This means that we no longer store the raw data for this e-mail
|
This means that we no longer store the raw data for this e-mail
|
||||||
or the e-mail didn't include a HTML part.
|
or the e-mail didn't include a HTML part.
|
||||||
- else
|
- else
|
||||||
%iframe{:width => "100%", :height => "100%", :src => html_raw_organization_server_message_path(organization, @server, @message.id)}
|
%iframe{:width => "100%", :height => "100%", :sandbox => "allow-popups allow-popups-to-escape-sandbox", :referrerpolicy => "no-referrer", :src => html_raw_organization_server_message_path(organization, @server, @message.id)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Rails.application.configure do
|
|||||||
# In the development environment your application's code is reloaded on
|
# In the development environment your application's code is reloaded on
|
||||||
# every request. This slows down response time but is perfect for development
|
# every request. This slows down response time but is perfect for development
|
||||||
# since you don't have to restart the web server when you make code changes.
|
# since you don't have to restart the web server when you make code changes.
|
||||||
config.cache_classes = false
|
config.enable_reloading = true
|
||||||
|
|
||||||
# Do not eager load code on boot.
|
# Do not eager load code on boot.
|
||||||
config.eager_load = false
|
config.eager_load = false
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Rails.application.configure do
|
|||||||
# Settings specified here will take precedence over those in config/application.rb.
|
# Settings specified here will take precedence over those in config/application.rb.
|
||||||
|
|
||||||
# Code is not reloaded between requests.
|
# Code is not reloaded between requests.
|
||||||
config.cache_classes = true
|
config.enable_reloading = false
|
||||||
|
|
||||||
# Eager load code on boot. This eager loads most of Rails and
|
# Eager load code on boot. This eager loads most of Rails and
|
||||||
# your application in memory, allowing both threaded web servers
|
# your application in memory, allowing both threaded web servers
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Rails.application.configure do
|
|||||||
# test suite. You never need to work with it otherwise. Remember that
|
# test suite. You never need to work with it otherwise. Remember that
|
||||||
# your test database is "scratch space" for the test suite and is wiped
|
# your test database is "scratch space" for the test suite and is wiped
|
||||||
# and recreated between test runs. Don't rely on the data there!
|
# and recreated between test runs. Don't rely on the data there!
|
||||||
config.cache_classes = true
|
config.enable_reloading = false
|
||||||
|
|
||||||
# Do not eager load code on boot. This avoids loading your whole application
|
# Do not eager load code on boot. This avoids loading your whole application
|
||||||
# just for the purpose of running a single test. If you are using a tool that
|
# just for the purpose of running a single test. If you are using a tool that
|
||||||
@@ -25,7 +25,7 @@ Rails.application.configure do
|
|||||||
config.action_controller.perform_caching = false
|
config.action_controller.perform_caching = false
|
||||||
|
|
||||||
# Raise exceptions instead of rendering exception templates.
|
# Raise exceptions instead of rendering exception templates.
|
||||||
config.action_dispatch.show_exceptions = false
|
config.action_dispatch.show_exceptions = :none
|
||||||
|
|
||||||
# Disable request forgery protection in test environment.
|
# Disable request forgery protection in test environment.
|
||||||
config.action_controller.allow_forgery_protection = false
|
config.action_controller.allow_forgery_protection = false
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Provides `f.error_messages` on form builders. This used to come from the
|
||||||
|
# dynamic_form gem - see ErrorMessagesHelper for why that was removed.
|
||||||
|
ActiveSupport.on_load(:action_view) do
|
||||||
|
ActionView::Helpers::FormBuilder.class_eval do
|
||||||
|
def error_messages
|
||||||
|
@template.error_messages_for(@object)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
# Be sure to restart your server when you modify this file.
|
|
||||||
#
|
|
||||||
# This file eases your Rails 7.0 framework defaults upgrade.
|
|
||||||
#
|
|
||||||
# Uncomment each configuration one by one to switch to the new default.
|
|
||||||
# Once your application is ready to run with all new defaults, you can remove
|
|
||||||
# this file and set the `config.load_defaults` to `7.0`.
|
|
||||||
#
|
|
||||||
# Read the Guide for Upgrading Ruby on Rails for more info on each option.
|
|
||||||
# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
|
|
||||||
|
|
||||||
# `button_to` view helper will render `<button>` element, regardless of whether
|
|
||||||
# or not the content is passed as the first argument or as a block.
|
|
||||||
# Rails.application.config.action_view.button_to_generates_button_tag = true
|
|
||||||
|
|
||||||
# `stylesheet_link_tag` view helper will not render the media attribute by default.
|
|
||||||
# Rails.application.config.action_view.apply_stylesheet_media_default = false
|
|
||||||
|
|
||||||
# Change the digest class for the key generators to `OpenSSL::Digest::SHA256`.
|
|
||||||
# Changing this default means invalidate all encrypted messages generated by
|
|
||||||
# your application and, all the encrypted cookies. Only change this after you
|
|
||||||
# rotated all the messages using the key rotator.
|
|
||||||
#
|
|
||||||
# See upgrading guide for more information on how to build a rotator.
|
|
||||||
# https://guides.rubyonrails.org/v7.0/upgrading_ruby_on_rails.html
|
|
||||||
# Rails.application.config.active_support.key_generator_hash_digest_class = OpenSSL::Digest::SHA256
|
|
||||||
|
|
||||||
# Change the digest class for ActiveSupport::Digest.
|
|
||||||
# Changing this default means that for example Etags change and
|
|
||||||
# various cache keys leading to cache invalidation.
|
|
||||||
# Rails.application.config.active_support.hash_digest_class = OpenSSL::Digest::SHA256
|
|
||||||
|
|
||||||
# Don't override ActiveSupport::TimeWithZone.name and use the default Ruby
|
|
||||||
# implementation.
|
|
||||||
# Rails.application.config.active_support.remove_deprecated_time_with_zone_name = true
|
|
||||||
|
|
||||||
# Calls `Rails.application.executor.wrap` around test cases.
|
|
||||||
# This makes test cases behave closer to an actual request or job.
|
|
||||||
# Several features that are normally disabled in test, such as Active Record query cache
|
|
||||||
# and asynchronous queries will then be enabled.
|
|
||||||
# Rails.application.config.active_support.executor_around_test_case = true
|
|
||||||
|
|
||||||
# Set both the `:open_timeout` and `:read_timeout` values for `:smtp` delivery method.
|
|
||||||
# Rails.application.config.action_mailer.smtp_timeout = 5
|
|
||||||
|
|
||||||
# The ActiveStorage video previewer will now use scene change detection to generate
|
|
||||||
# better preview images (rather than the previous default of using the first frame
|
|
||||||
# of the video).
|
|
||||||
# Rails.application.config.active_storage.video_preview_arguments =
|
|
||||||
# "-vf 'select=eq(n\\,0)+eq(key\\,1)+gt(scene\\,0.015),loop=loop=-1:size=2,trim=start_frame=1' -frames:v 1 -f image2"
|
|
||||||
|
|
||||||
# Automatically infer `inverse_of` for associations with a scope.
|
|
||||||
# Rails.application.config.active_record.automatic_scope_inversing = true
|
|
||||||
|
|
||||||
# Raise when running tests if fixtures contained foreign key violations
|
|
||||||
# Rails.application.config.active_record.verify_foreign_keys_for_fixtures = true
|
|
||||||
|
|
||||||
# Disable partial inserts.
|
|
||||||
# This default means that all columns will be referenced in INSERT queries
|
|
||||||
# regardless of whether they have a default or not.
|
|
||||||
# Rails.application.config.active_record.partial_inserts = false
|
|
||||||
|
|
||||||
# Protect from open redirect attacks in `redirect_back_or_to` and `redirect_to`.
|
|
||||||
# Rails.application.config.action_controller.raise_on_open_redirects = true
|
|
||||||
|
|
||||||
# Change the variant processor for Active Storage.
|
|
||||||
# Changing this default means updating all places in your code that
|
|
||||||
# generate variants to use image processing macros and ruby-vips
|
|
||||||
# operations. See the upgrading guide for detail on the changes required.
|
|
||||||
# The `:mini_magick` option is not deprecated; it's fine to keep using it.
|
|
||||||
# Rails.application.config.active_storage.variant_processor = :vips
|
|
||||||
|
|
||||||
# Enable parameter wrapping for JSON.
|
|
||||||
# Previously this was set in an initializer. It's fine to keep using that initializer if you've customized it.
|
|
||||||
# To disable parameter wrapping entirely, set this config to `false`.
|
|
||||||
# Rails.application.config.action_controller.wrap_parameters_by_default = true
|
|
||||||
|
|
||||||
# Specifies whether generated namespaced UUIDs follow the RFC 4122 standard for namespace IDs provided as a
|
|
||||||
# `String` to `Digest::UUID.uuid_v3` or `Digest::UUID.uuid_v5` method calls.
|
|
||||||
#
|
|
||||||
# See https://guides.rubyonrails.org/configuring.html#config-active-support-use-rfc4122-namespaced-uuids for
|
|
||||||
# more information.
|
|
||||||
# Rails.application.config.active_support.use_rfc4122_namespaced_uuids = true
|
|
||||||
|
|
||||||
# Change the default headers to disable browsers' flawed legacy XSS protection.
|
|
||||||
# Rails.application.config.action_dispatch.default_headers = {
|
|
||||||
# "X-Frame-Options" => "SAMEORIGIN",
|
|
||||||
# "X-XSS-Protection" => "0",
|
|
||||||
# "X-Content-Type-Options" => "nosniff",
|
|
||||||
# "X-Download-Options" => "noopen",
|
|
||||||
# "X-Permitted-Cross-Domain-Policies" => "none",
|
|
||||||
# "Referrer-Policy" => "strict-origin-when-cross-origin"
|
|
||||||
# }
|
|
||||||
|
|
||||||
# ** Please read carefully, this must be configured in config/application.rb **
|
|
||||||
# Change the format of the cache entry.
|
|
||||||
# Changing this default means that all new cache entries added to the cache
|
|
||||||
# will have a different format that is not supported by Rails 6.1 applications.
|
|
||||||
# Only change this value after your application is fully deployed to Rails 7.0
|
|
||||||
# and you have no plans to rollback.
|
|
||||||
# When you're ready to change format, add this to `config/application.rb` (NOT this file):
|
|
||||||
# config.active_support.cache_format_version = 7.0
|
|
||||||
|
|
||||||
# Cookie serializer: 2 options
|
|
||||||
#
|
|
||||||
# If you're upgrading and haven't set `cookies_serializer` previously, your cookie serializer
|
|
||||||
# is `:marshal`. The default for new apps is `:json`.
|
|
||||||
#
|
|
||||||
# Rails.application.config.action_dispatch.cookies_serializer = :json
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# To migrate an existing application to the `:json` serializer, use the `:hybrid` option.
|
|
||||||
#
|
|
||||||
# Rails transparently deserializes existing (Marshal-serialized) cookies on read and
|
|
||||||
# re-writes them in the JSON format.
|
|
||||||
#
|
|
||||||
# It is fine to use `:hybrid` long term; you should do that until you're confident *all* your cookies
|
|
||||||
# have been converted to JSON. To keep using `:hybrid` long term, move this config to its own
|
|
||||||
# initializer or to `config/application.rb`.
|
|
||||||
#
|
|
||||||
# Rails.application.config.action_dispatch.cookies_serializer = :hybrid
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# If your cookies can't yet be serialized to JSON, keep using `:marshal` for backward-compatibility.
|
|
||||||
#
|
|
||||||
# If you have configured the serializer elsewhere, you can remove this section of the file.
|
|
||||||
#
|
|
||||||
# See https://guides.rubyonrails.org/action_controller_overview.html#cookies for more information.
|
|
||||||
|
|
||||||
# Change the return value of `ActionDispatch::Request#content_type` to the Content-Type header without modification.
|
|
||||||
# Rails.application.config.action_dispatch.return_only_request_media_type_on_content_type = false
|
|
||||||
|
|
||||||
# Active Storage `has_many_attached` relationships will default to replacing the current collection instead of appending to it.
|
|
||||||
# Thus, to support submitting an empty collection, the `file_field` helper will render an hidden field `include_hidden` by default when `multiple_file_field_include_hidden` is set to `true`.
|
|
||||||
# See https://guides.rubyonrails.org/configuring.html#config-active-storage-multiple-file-field-include-hidden for more information.
|
|
||||||
# Rails.application.config.active_storage.multiple_file_field_include_hidden = true
|
|
||||||
|
|
||||||
# ** Please read carefully, this must be configured in config/application.rb (NOT this file) **
|
|
||||||
# Disables the deprecated #to_s override in some Ruby core classes
|
|
||||||
# See https://guides.rubyonrails.org/configuring.html#config-active-support-disable-to-s-conversion for more information.
|
|
||||||
# config.active_support.disable_to_s_conversion = true
|
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Be sure to restart your server when you modify this file.
|
||||||
|
#
|
||||||
|
# This file eases your Rails 7.1 framework defaults upgrade.
|
||||||
|
#
|
||||||
|
# Uncomment each configuration one by one to switch to the new default.
|
||||||
|
# Once your application is ready to run with all new defaults, you can remove
|
||||||
|
# this file and set the `config.load_defaults` to `7.1`.
|
||||||
|
#
|
||||||
|
# Read the Guide for Upgrading Ruby on Rails for more info on each option.
|
||||||
|
# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
|
||||||
|
|
||||||
|
###
|
||||||
|
# No longer add autoloaded paths into `$LOAD_PATH`. This means that you won't be able
|
||||||
|
# to manually require files that are managed by the autoloader, which you shouldn't do anyway.
|
||||||
|
#
|
||||||
|
# This will reduce the size of the load path, making `require` faster if you don't use bootsnap, or reduce the size
|
||||||
|
# of the bootsnap cache if you use it.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.add_autoload_paths_to_load_path = false
|
||||||
|
|
||||||
|
###
|
||||||
|
# Remove the default X-Download-Options header since it is used only by Internet Explorer.
|
||||||
|
# If you need to support Internet Explorer, add back `"X-Download-Options" => "noopen"`.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.action_dispatch.default_headers = {
|
||||||
|
# "X-Frame-Options" => "SAMEORIGIN",
|
||||||
|
# "X-XSS-Protection" => "0",
|
||||||
|
# "X-Content-Type-Options" => "nosniff",
|
||||||
|
# "X-Permitted-Cross-Domain-Policies" => "none",
|
||||||
|
# "Referrer-Policy" => "strict-origin-when-cross-origin"
|
||||||
|
# }
|
||||||
|
|
||||||
|
###
|
||||||
|
# Do not treat an `ActiveSupport::Duration` as a number of seconds when it is
|
||||||
|
# used as a cache expiry. Raise instead, because the intent is ambiguous.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_support.raise_on_invalid_cache_expiration_time = true
|
||||||
|
|
||||||
|
###
|
||||||
|
# Specify the default serializer used by `MessageEncryptor` and `MessageVerifier`
|
||||||
|
# instances.
|
||||||
|
#
|
||||||
|
# The legacy default is `:marshal`, which is a potential vector for
|
||||||
|
# deserialization attacks in cases where a message signing secret has been
|
||||||
|
# leaked.
|
||||||
|
#
|
||||||
|
# In Rails 7.1, the new default is `:json_allow_marshal`, which serializes and
|
||||||
|
# deserializes with `ActiveSupport::JSON`, but can fall back to deserializing
|
||||||
|
# with `Marshal` so that legacy messages can still be read.
|
||||||
|
#
|
||||||
|
# In Rails 7.2, the default will become `:json`, which serializes and
|
||||||
|
# deserializes with `ActiveSupport::JSON` only.
|
||||||
|
#
|
||||||
|
# Alternatively, you can choose `:message_pack` or `:message_pack_allow_marshal`,
|
||||||
|
# which serialize with `ActiveSupport::MessagePack`. `ActiveSupport::MessagePack`
|
||||||
|
# can roundtrip some Ruby types that are not supported by JSON, and may provide
|
||||||
|
# improved performance, but it requires the `msgpack` gem.
|
||||||
|
#
|
||||||
|
# For more information, see
|
||||||
|
# https://guides.rubyonrails.org/v7.1/configuring.html#config-active-support-message-serializer
|
||||||
|
#
|
||||||
|
# NOTE: Changing this affects the session cookie and any other signed or
|
||||||
|
# encrypted message. Deploy `:json_allow_marshal` everywhere before considering
|
||||||
|
# `:json`, and be aware that rolling back to a version which cannot read the new
|
||||||
|
# format will invalidate existing sessions.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_support.message_serializer = :json_allow_marshal
|
||||||
|
|
||||||
|
###
|
||||||
|
# Enable a performance optimization that serializes message data and metadata
|
||||||
|
# together. This changes the message format, so messages serialized this way
|
||||||
|
# cannot be read by older versions of Rails. However, messages that use the old
|
||||||
|
# format can still be read, regardless of whether this optimization is enabled.
|
||||||
|
#
|
||||||
|
# To perform a rolling deploy of a Rails 7.1 upgrade, wherein servers that have
|
||||||
|
# not yet been upgraded must be able to read messages from upgraded servers, leave
|
||||||
|
# this optimization off on the first deploy, then enable it on a subsequent deploy.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_support.use_message_serializer_for_metadata = true
|
||||||
|
|
||||||
|
###
|
||||||
|
# Set the maximum size for Rails log files.
|
||||||
|
#
|
||||||
|
# `config.load_defaults "7.1"` does not set this value for environments other than
|
||||||
|
# development and test.
|
||||||
|
#++
|
||||||
|
# if Rails.env.local?
|
||||||
|
# Rails.application.config.log_file_size = 100 * 1024 * 1024
|
||||||
|
# end
|
||||||
|
|
||||||
|
###
|
||||||
|
# Enable raising on assignment to attr_readonly attributes. The previous behavior
|
||||||
|
# would allow assignment but silently not persist changes to the database.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_record.raise_on_assign_to_attr_readonly = true
|
||||||
|
|
||||||
|
###
|
||||||
|
# Enable validating only parent-related columns for presence when the parent is mandatory.
|
||||||
|
# The previous behavior was to validate the presence of the parent record, which performed an extra query
|
||||||
|
# to get the parent every time the child record was updated, even when parent has not changed.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_record.belongs_to_required_validates_foreign_key = false
|
||||||
|
|
||||||
|
###
|
||||||
|
# Enable preloading of the `before_committed!` callbacks on all records in a transaction,
|
||||||
|
# instead of only the first copy of a record.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_record.before_committed_on_all_records = true
|
||||||
|
|
||||||
|
###
|
||||||
|
# Disable deprecated singular associations names.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_record.allow_deprecated_singular_associations_name = false
|
||||||
|
|
||||||
|
###
|
||||||
|
# Enable running `after_commit` callbacks in the order they are defined,
|
||||||
|
# rather than in reverse order.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_record.run_after_transaction_callbacks_in_order_defined = true
|
||||||
|
|
||||||
|
###
|
||||||
|
# Run `after_commit` and `after_rollback` callbacks on the first saved instance
|
||||||
|
# of a record in a transaction, rather than the instance which was saved last.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_record.run_commit_callbacks_on_first_saved_instances_in_transaction = false
|
||||||
|
|
||||||
|
###
|
||||||
|
# Configure the query log tags to use the SQLCommenter format.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_record.query_log_tags_format = :sqlcommenter
|
||||||
|
|
||||||
|
###
|
||||||
|
# Specify the default serializer used by `ActiveRecord::Base#serialize`.
|
||||||
|
#
|
||||||
|
# `nil` means there is no default, so every `serialize` call must declare its
|
||||||
|
# `coder:` explicitly. This app already does that, so this option is safe to
|
||||||
|
# enable. Do not remove those explicit coders.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_record.default_column_serializer = nil
|
||||||
|
|
||||||
|
###
|
||||||
|
# Use a 7.1-compatible marshalling format when caching Active Record objects.
|
||||||
|
#
|
||||||
|
# This format is more efficient, but cannot be read by Rails 7.0 and earlier, so
|
||||||
|
# do not enable this until every process is running 7.1 or later.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_record.marshalling_format_version = 7.1
|
||||||
|
|
||||||
|
###
|
||||||
|
# Generate a `has_secure_token` value when the record is initialized rather than
|
||||||
|
# when it is created.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_record.generate_secure_token_on = :initialize
|
||||||
|
|
||||||
|
###
|
||||||
|
# Use a modern HTML5 sanitizer (via Nokogiri's HTML5 parser) rather than the
|
||||||
|
# legacy HTML4 one, where it is supported.
|
||||||
|
#
|
||||||
|
# This changes how malformed markup is parsed and sanitized. Review anywhere the
|
||||||
|
# application sanitizes user-supplied HTML before enabling it.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.action_view.sanitizer_vendor = Rails::HTML::Sanitizer.best_supported_vendor
|
||||||
|
|
||||||
|
###
|
||||||
|
# Parse HTML in tests with an HTML5 parser rather than an HTML4 one.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.dom_testing_default_html_version = :html5
|
||||||
|
|
||||||
|
###
|
||||||
|
# Precompile the filter parameters (`config.filter_parameters`) so that filtering
|
||||||
|
# is faster at request time.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.precompile_filter_parameters = true
|
||||||
|
|
||||||
|
###
|
||||||
|
# Log exceptions that are handled by `rescue_from` (or otherwise rendered as an
|
||||||
|
# error page) at the `error` level rather than `fatal`.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.action_dispatch.debug_exception_log_level = :error
|
||||||
|
|
||||||
|
###
|
||||||
|
# Change the cache entry format, so that new entries are written in a format
|
||||||
|
# which Rails 7.0 and earlier cannot read.
|
||||||
|
#
|
||||||
|
# Only enable this once every process is running 7.1 or later, and you have no
|
||||||
|
# plans to roll back.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_support.cache_format_version = 7.1
|
||||||
|
|
||||||
|
###
|
||||||
|
# Use SHA-256 to derive Active Record Encryption keys and stop supporting SHA-1
|
||||||
|
# for non-deterministic encryption.
|
||||||
|
#
|
||||||
|
# This app does not currently use Active Record Encryption. If that changes,
|
||||||
|
# read the upgrade guide before enabling these.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_record.encryption.hash_digest_class = OpenSSL::Digest::SHA256
|
||||||
|
# Rails.application.config.active_record.encryption.support_sha1_for_non_deterministic_encryption = false
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Be sure to restart your server when you modify this file.
|
||||||
|
#
|
||||||
|
# This file eases your Rails 7.2 framework defaults upgrade.
|
||||||
|
#
|
||||||
|
# Uncomment each configuration one by one to switch to the new default.
|
||||||
|
# Once your application is ready to run with all new defaults, you can remove
|
||||||
|
# this file and set the `config.load_defaults` to `7.2`.
|
||||||
|
#
|
||||||
|
# Read the Guide for Upgrading Ruby on Rails for more info on each option.
|
||||||
|
# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
|
||||||
|
|
||||||
|
###
|
||||||
|
# Controls whether Active Job's `#perform_later` and similar methods automatically defer
|
||||||
|
# the job queuing to after the current Active Record transaction is committed.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# Topic.transaction do
|
||||||
|
# topic = Topic.create(...)
|
||||||
|
# NewTopicNotificationJob.perform_later(topic)
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# In this example, if the configuration is set to `:never`, the job will
|
||||||
|
# be enqueued immediately, even though the `Topic` hasn't been committed yet.
|
||||||
|
# Because of this, if the job is picked up almost immediately, or if the
|
||||||
|
# transaction doesn't succeed for some reason, the job will fail to find this
|
||||||
|
# topic in the database.
|
||||||
|
#
|
||||||
|
# If `enqueue_after_transaction_commit` is set to `:default`, the queue adapter
|
||||||
|
# will define the behaviour.
|
||||||
|
#
|
||||||
|
# Note: Active Job backends can disable this feature. This is generally done by
|
||||||
|
# backends that use the same database as Active Record as a queue, hence they
|
||||||
|
# don't need this feature.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_job.enqueue_after_transaction_commit = :default
|
||||||
|
|
||||||
|
###
|
||||||
|
# Adds image/webp to the list of content types Active Storage considers as an image
|
||||||
|
# Prevents automatic conversion to a fallback PNG, and assumes clients support WebP, as they support gif, jpeg, and png.
|
||||||
|
# This is possible due to broad browser support for WebP, but older browsers and email clients may still not support
|
||||||
|
# WebP. Requires imagemagick/libvips built with WebP support.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_storage.web_image_content_types = %w[image/png image/jpeg image/gif image/webp]
|
||||||
|
|
||||||
|
###
|
||||||
|
# Enable validation of migration timestamps. When set, an ActiveRecord::InvalidMigrationTimestampError
|
||||||
|
# will be raised if the timestamp prefix for a migration is more than a day ahead of the timestamp
|
||||||
|
# associated with the current time. This is done to prevent forward-dating of migration files, which can
|
||||||
|
# impact migration generation and other migration commands.
|
||||||
|
#
|
||||||
|
# Applications with existing timestamped migrations that do not adhere to the
|
||||||
|
# expected format can disable validation by setting this config to `false`.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_record.validate_migration_timestamps = true
|
||||||
|
|
||||||
|
###
|
||||||
|
# Controls whether the PostgresqlAdapter should decode dates automatically with manual queries.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.select_value("select '2024-01-01'::date") #=> Date
|
||||||
|
#
|
||||||
|
# This query used to return a `String`.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.active_record.postgresql_adapter_decode_dates = true
|
||||||
|
|
||||||
|
###
|
||||||
|
# Enables YJIT as of Ruby 3.3, to bring sizeable performance improvements. If you are
|
||||||
|
# deploying to a memory constrained environment you may want to set this to `false`.
|
||||||
|
#++
|
||||||
|
# Rails.application.config.yjit = true
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
if Postal::Config.rails.secret_key
|
if Postal::Config.rails.secret_key
|
||||||
Rails.application.secrets.secret_key_base = Postal::Config.rails.secret_key
|
Rails.application.credentials.secret_key_base = Postal::Config.rails.secret_key
|
||||||
else
|
else
|
||||||
warn "No secret key was specified in the Postal config file. Using one for just this session"
|
warn "No secret key was specified in the Postal config file. Using one for just this session"
|
||||||
Rails.application.secrets.secret_key_base = SecureRandom.hex(128)
|
Rails.application.credentials.secret_key_base = SecureRandom.hex(128)
|
||||||
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 |
|
||||||
@@ -42,6 +43,7 @@ This document contains all the environment variables which are available for thi
|
|||||||
| `LOGGING_RAILS_LOG_ENABLED` | Boolean | Enable the default Rails logger | false |
|
| `LOGGING_RAILS_LOG_ENABLED` | Boolean | Enable the default Rails logger | false |
|
||||||
| `LOGGING_SENTRY_DSN` | String | A DSN which should be used to report exceptions to Sentry | |
|
| `LOGGING_SENTRY_DSN` | String | A DSN which should be used to report exceptions to Sentry | |
|
||||||
| `LOGGING_ENABLED` | Boolean | Enable the Postal logger to log to STDOUT | true |
|
| `LOGGING_ENABLED` | Boolean | Enable the Postal logger to log to STDOUT | true |
|
||||||
|
| `LOGGING_LEVEL` | String | The minimum log level for the Postal logger (debug, info, warn, error, fatal) | INFO |
|
||||||
| `LOGGING_HIGHLIGHTING_ENABLED` | Boolean | Enable highlighting of log lines | false |
|
| `LOGGING_HIGHLIGHTING_ENABLED` | Boolean | Enable highlighting of log lines | false |
|
||||||
| `GELF_HOST` | String | GELF-capable host to send logs to | |
|
| `GELF_HOST` | String | GELF-capable host to send logs to | |
|
||||||
| `GELF_PORT` | Integer | GELF port to send logs to | 12201 |
|
| `GELF_PORT` | Integer | GELF port to send logs to | 12201 |
|
||||||
@@ -105,7 +107,7 @@ This document contains all the environment variables which are available for thi
|
|||||||
| `OIDC_ISSUER` | String | The OIDC issuer URL | |
|
| `OIDC_ISSUER` | String | The OIDC issuer URL | |
|
||||||
| `OIDC_IDENTIFIER` | String | The client ID for OIDC | |
|
| `OIDC_IDENTIFIER` | String | The client ID for OIDC | |
|
||||||
| `OIDC_SECRET` | String | The client secret for OIDC | |
|
| `OIDC_SECRET` | String | The client secret for OIDC | |
|
||||||
| `OIDC_SCOPES` | Array of strings | Scopes to request from the OIDC server. | openid,email |
|
| `OIDC_SCOPES` | Array of strings | Scopes to request from the OIDC server. | ["openid", "email"] |
|
||||||
| `OIDC_UID_FIELD` | String | The field to use to determine the user's UID | sub |
|
| `OIDC_UID_FIELD` | String | The field to use to determine the user's UID | sub |
|
||||||
| `OIDC_EMAIL_ADDRESS_FIELD` | String | The field to use to determine the user's email address | email |
|
| `OIDC_EMAIL_ADDRESS_FIELD` | String | The field to use to determine the user's email address | email |
|
||||||
| `OIDC_NAME_FIELD` | String | The field to use to determine the user's name | name |
|
| `OIDC_NAME_FIELD` | String | The field to use to determine the user's name | name |
|
||||||
|
|||||||
+6
-1
@@ -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
|
||||||
@@ -87,6 +89,8 @@ logging:
|
|||||||
sentry_dsn:
|
sentry_dsn:
|
||||||
# Enable the Postal logger to log to STDOUT
|
# Enable the Postal logger to log to STDOUT
|
||||||
enabled: true
|
enabled: true
|
||||||
|
# The minimum log level for the Postal logger (debug, info, warn, error, fatal)
|
||||||
|
level: INFO
|
||||||
# Enable highlighting of log lines
|
# Enable highlighting of log lines
|
||||||
highlighting_enabled: false
|
highlighting_enabled: false
|
||||||
|
|
||||||
@@ -239,7 +243,8 @@ oidc:
|
|||||||
secret:
|
secret:
|
||||||
# Scopes to request from the OIDC server.
|
# Scopes to request from the OIDC server.
|
||||||
scopes:
|
scopes:
|
||||||
- openid,email
|
- openid
|
||||||
|
- email
|
||||||
# The field to use to determine the user's UID
|
# The field to use to determine the user's UID
|
||||||
uid_field: sub
|
uid_field: sub
|
||||||
# The field to use to determine the user's email address
|
# The field to use to determine the user's email address
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
version: "3"
|
|
||||||
services:
|
services:
|
||||||
postal:
|
postal:
|
||||||
image: ${POSTAL_IMAGE}
|
image: ${POSTAL_IMAGE}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class MigrationWaiter
|
|||||||
def wait
|
def wait
|
||||||
attempts_remaining = ATTEMPTS
|
attempts_remaining = ATTEMPTS
|
||||||
loop do
|
loop do
|
||||||
pending_migrations = ActiveRecord::Base.connection.migration_context.open.pending_migrations.size
|
pending_migrations = pending_migration_count
|
||||||
if pending_migrations.zero?
|
if pending_migrations.zero?
|
||||||
Postal.logger.info "no pending migrations, continuing"
|
Postal.logger.info "no pending migrations, continuing"
|
||||||
return
|
return
|
||||||
@@ -33,6 +33,12 @@ class MigrationWaiter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns the number of migrations which exist in the application but have
|
||||||
|
# not yet been applied to the database.
|
||||||
|
def pending_migration_count
|
||||||
|
ActiveRecord::Base.connection_pool.migration_context.open.pending_migrations.size
|
||||||
|
end
|
||||||
|
|
||||||
def wait_if_appropriate
|
def wait_if_appropriate
|
||||||
# Don't wait if not configured
|
# Don't wait if not configured
|
||||||
return unless Postal::Config.migration_waiter.enabled?
|
return unless Postal::Config.migration_waiter.enabled?
|
||||||
|
|||||||
+11
-5
@@ -11,6 +11,7 @@ require "konfig/sources/environment"
|
|||||||
require "konfig/sources/yaml"
|
require "konfig/sources/yaml"
|
||||||
require "dotenv"
|
require "dotenv"
|
||||||
require "klogger"
|
require "klogger"
|
||||||
|
require "securerandom"
|
||||||
|
|
||||||
require_relative "error"
|
require_relative "error"
|
||||||
require_relative "config_schema"
|
require_relative "config_schema"
|
||||||
@@ -58,7 +59,7 @@ module Postal
|
|||||||
unless silence_config_messages
|
unless silence_config_messages
|
||||||
warn "WARNING: Using legacy config file format. Upgrade your postal.yml to use"
|
warn "WARNING: Using legacy config file format. Upgrade your postal.yml to use"
|
||||||
warn "version 2 of the Postal configuration or configure using environment"
|
warn "version 2 of the Postal configuration or configure using environment"
|
||||||
warn "variables. See https://postalserver.io/config-v2 for details."
|
warn "variables. See https://docs.postalserver.io/config-v2 for details."
|
||||||
end
|
end
|
||||||
sources << LegacyConfigSource.new(yaml)
|
sources << LegacyConfigSource.new(yaml)
|
||||||
when 2
|
when 2
|
||||||
@@ -82,16 +83,21 @@ module Postal
|
|||||||
def logger
|
def logger
|
||||||
@logger ||= begin
|
@logger ||= begin
|
||||||
k = Klogger.new(nil, destination: Config.logging.enabled? ? $stdout : "/dev/null", highlight: Config.logging.highlighting_enabled?)
|
k = Klogger.new(nil, destination: Config.logging.enabled? ? $stdout : "/dev/null", highlight: Config.logging.highlighting_enabled?)
|
||||||
|
k.level = Config.logging.level
|
||||||
k.add_destination(graylog_logging_destination) if Config.gelf.host.present?
|
k.add_destination(graylog_logging_destination) if Config.gelf.host.present?
|
||||||
k
|
k
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def process_identity
|
||||||
|
@process_identity ||= SecureRandom.hex(8)
|
||||||
|
end
|
||||||
|
|
||||||
def process_name
|
def process_name
|
||||||
@process_name ||= begin
|
@process_name ||= begin
|
||||||
"host:#{Socket.gethostname} pid:#{Process.pid}"
|
"host:#{Socket.gethostname} pid:#{Process.pid} id:#{process_identity}"
|
||||||
rescue StandardError
|
rescue StandardError
|
||||||
"pid:#{Process.pid}"
|
"pid:#{Process.pid} id:#{process_identity}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -145,7 +151,7 @@ module Postal
|
|||||||
ActiveRecord::Base.connection_pool.disconnect!
|
ActiveRecord::Base.connection_pool.disconnect!
|
||||||
|
|
||||||
config = ActiveRecord::Base.configurations
|
config = ActiveRecord::Base.configurations
|
||||||
.configs_for(env_name: Rails.env)
|
.configs_for(env_name: Config.rails.environment)
|
||||||
.first
|
.first
|
||||||
.configuration_hash
|
.configuration_hash
|
||||||
|
|
||||||
@@ -173,7 +179,7 @@ module Postal
|
|||||||
private
|
private
|
||||||
|
|
||||||
def read_version_file(file)
|
def read_version_file(file)
|
||||||
path = Rails.root.join(file)
|
path = File.expand_path("../../../" + file, __FILE__)
|
||||||
return unless File.exist?(path)
|
return unless File.exist?(path)
|
||||||
|
|
||||||
value = File.read(path).strip
|
value = File.read(path).strip
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -219,6 +227,19 @@ module Postal
|
|||||||
default true
|
default true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
string :level do
|
||||||
|
description "The minimum log level for the Postal logger (debug, info, warn, error, fatal)"
|
||||||
|
default "INFO"
|
||||||
|
transform do |value|
|
||||||
|
normalized = value.to_s.strip.downcase
|
||||||
|
normalized = "info" if normalized.empty?
|
||||||
|
unless %w[debug info warn error fatal].include?(normalized)
|
||||||
|
raise ArgumentError, "logging.level must be one of: debug, info, warn, error, fatal (got #{value.inspect})"
|
||||||
|
end
|
||||||
|
normalized
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
boolean :highlighting_enabled do
|
boolean :highlighting_enabled do
|
||||||
description "Enable highlighting of log lines"
|
description "Enable highlighting of log lines"
|
||||||
default false
|
default false
|
||||||
@@ -550,7 +571,7 @@ module Postal
|
|||||||
string :scopes do
|
string :scopes do
|
||||||
description "Scopes to request from the OIDC server."
|
description "Scopes to request from the OIDC server."
|
||||||
array
|
array
|
||||||
default "openid,email"
|
default ["openid", "email"]
|
||||||
end
|
end
|
||||||
|
|
||||||
string :uid_field do
|
string :uid_field do
|
||||||
|
|||||||
+20
-7
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "ipaddr"
|
||||||
|
require "resolv"
|
||||||
|
require "socket"
|
||||||
|
|
||||||
|
module Postal
|
||||||
|
module HTTP
|
||||||
|
# Guards outbound HTTP requests against SSRF by resolving the destination
|
||||||
|
# host and refusing to connect to private, loopback, link-local, multicast
|
||||||
|
# or otherwise reserved addresses (for example cloud metadata endpoints).
|
||||||
|
#
|
||||||
|
# Administrators can permit specific destinations by adding hostnames or
|
||||||
|
# IP/CIDR ranges to the `postal.allowed_request_destinations` config option.
|
||||||
|
class AddressGuard
|
||||||
|
|
||||||
|
# IP ranges that outbound requests are never allowed to reach unless the
|
||||||
|
# destination has been explicitly allowlisted.
|
||||||
|
BLOCKED_RANGES = [
|
||||||
|
# IPv4
|
||||||
|
"0.0.0.0/8", # "this host on this network"
|
||||||
|
"10.0.0.0/8", # RFC1918 private
|
||||||
|
"100.64.0.0/10", # RFC6598 carrier-grade NAT
|
||||||
|
"127.0.0.0/8", # loopback
|
||||||
|
"169.254.0.0/16", # link-local (incl. 169.254.169.254 metadata)
|
||||||
|
"172.16.0.0/12", # RFC1918 private
|
||||||
|
"192.0.0.0/24", # IETF protocol assignments
|
||||||
|
"192.168.0.0/16", # RFC1918 private
|
||||||
|
"198.18.0.0/15", # benchmarking
|
||||||
|
"224.0.0.0/4", # multicast
|
||||||
|
"240.0.0.0/4", # reserved
|
||||||
|
# IPv6
|
||||||
|
"::/128", # unspecified
|
||||||
|
"::1/128", # loopback
|
||||||
|
"::ffff:0:0/96", # IPv4-mapped (also re-checked against the v4 list)
|
||||||
|
"fc00::/7", # unique-local
|
||||||
|
"fe80::/10", # link-local
|
||||||
|
"ff00::/8", # multicast
|
||||||
|
].map { |range| IPAddr.new(range) }.freeze
|
||||||
|
|
||||||
|
class << self
|
||||||
|
|
||||||
|
# Resolve and validate the given host, returning the IP address the
|
||||||
|
# connection should be pinned to (as a string). Pinning the connection
|
||||||
|
# to the validated address prevents a DNS-rebinding race between the
|
||||||
|
# check here and the actual connection.
|
||||||
|
#
|
||||||
|
# @param [String] host the hostname or IP literal from the request URL
|
||||||
|
# @raise [Postal::HTTP::BlockedDestinationError] if the host cannot be
|
||||||
|
# resolved or any resolved address is not permitted
|
||||||
|
# @raise [SocketError] if the host only resolves to addresses whose
|
||||||
|
# family this server cannot reach (e.g. IPv6 with no IPv6 support)
|
||||||
|
# @return [String] the validated IP address to connect to
|
||||||
|
def safe_connect_address(host)
|
||||||
|
new(host).safe_connect_address
|
||||||
|
end
|
||||||
|
|
||||||
|
# Whether this server has IPv6 connectivity (a global IPv6 address on
|
||||||
|
# one of its interfaces). Memoized as it does not change at runtime.
|
||||||
|
def ipv6_supported?
|
||||||
|
return @ipv6_supported unless @ipv6_supported.nil?
|
||||||
|
|
||||||
|
@ipv6_supported = local_families.include?(:ipv6)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Whether this server has IPv4 connectivity. Defaults to true unless the
|
||||||
|
# host clearly only has IPv6, so that a host reporting no global
|
||||||
|
# addresses at all (e.g. inside a minimal container) still attempts IPv4
|
||||||
|
# as it did before this guard existed.
|
||||||
|
def ipv4_supported?
|
||||||
|
return @ipv4_supported unless @ipv4_supported.nil?
|
||||||
|
|
||||||
|
families = local_families
|
||||||
|
@ipv4_supported = families.include?(:ipv4) || !families.include?(:ipv6)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def local_families
|
||||||
|
families = []
|
||||||
|
Socket.ip_address_list.each do |address|
|
||||||
|
families << :ipv4 if address.ipv4? && !address.ipv4_loopback?
|
||||||
|
families << :ipv6 if address.ipv6? && !address.ipv6_loopback? && !address.ipv6_linklocal?
|
||||||
|
end
|
||||||
|
families.uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param [String] host
|
||||||
|
def initialize(host)
|
||||||
|
@host = host.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def safe_connect_address
|
||||||
|
if @host.empty?
|
||||||
|
raise BlockedDestinationError, "No host was given for the request"
|
||||||
|
end
|
||||||
|
|
||||||
|
addresses = resolve
|
||||||
|
if addresses.empty?
|
||||||
|
raise BlockedDestinationError, "Could not resolve '#{@host}' to any IP address"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reject the whole request if *any* resolved address is blocked. This is
|
||||||
|
# checked before the reachability filtering below so that a blocked
|
||||||
|
# destination is always reported as such, regardless of which address
|
||||||
|
# families this particular server can reach. It also defeats DNS
|
||||||
|
# responses that mix a public and a private address to slip past.
|
||||||
|
addresses.each do |address|
|
||||||
|
next unless blocked?(address)
|
||||||
|
|
||||||
|
raise BlockedDestinationError,
|
||||||
|
"Destination '#{@host}' (#{address}) is not permitted"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only connect to an address whose family this server can actually
|
||||||
|
# reach. Otherwise we might pin the connection to an IPv6 address on a
|
||||||
|
# host without IPv6 connectivity and fail to connect even when a usable
|
||||||
|
# IPv4 address was available.
|
||||||
|
usable = addresses.select { |address| family_reachable?(address) }
|
||||||
|
if usable.empty?
|
||||||
|
raise SocketError,
|
||||||
|
"'#{@host}' only resolves to addresses this server cannot reach " \
|
||||||
|
"(#{addresses.join(', ')})"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Prefer IPv4 for predictability; only use IPv6 when it is the only
|
||||||
|
# reachable option.
|
||||||
|
(usable.find(&:ipv4?) || usable.first).to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# @return [Array<IPAddr>]
|
||||||
|
def resolve
|
||||||
|
return [IPAddr.new(@host)] if ip_literal?
|
||||||
|
|
||||||
|
Resolv.getaddresses(@host).filter_map do |address|
|
||||||
|
IPAddr.new(address)
|
||||||
|
rescue IPAddr::InvalidAddressError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def ip_literal?
|
||||||
|
IPAddr.new(@host)
|
||||||
|
true
|
||||||
|
rescue IPAddr::InvalidAddressError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param [IPAddr] address
|
||||||
|
def family_reachable?(address)
|
||||||
|
if address.ipv6? && !address.ipv4_mapped?
|
||||||
|
self.class.ipv6_supported?
|
||||||
|
else
|
||||||
|
self.class.ipv4_supported?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param [IPAddr] address
|
||||||
|
def blocked?(address)
|
||||||
|
return false if allowlisted?(address)
|
||||||
|
|
||||||
|
# IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) must be checked against the
|
||||||
|
# IPv4 rules using the embedded address, otherwise they bypass the list.
|
||||||
|
if address.ipv6? && address.ipv4_mapped?
|
||||||
|
mapped = address.native
|
||||||
|
return true if mapped.ipv4? && BLOCKED_RANGES.any? { |range| range.include?(mapped) }
|
||||||
|
end
|
||||||
|
|
||||||
|
BLOCKED_RANGES.any? { |range| range.include?(address) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param [IPAddr] address
|
||||||
|
def allowlisted?(address)
|
||||||
|
allowlist.any? do |entry|
|
||||||
|
if entry.is_a?(IPAddr)
|
||||||
|
entry.include?(address)
|
||||||
|
else
|
||||||
|
entry.casecmp?(@host)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Allowlist entries are kept as strings in config. An entry that parses as
|
||||||
|
# an IP/CIDR is matched against the resolved address; anything else is
|
||||||
|
# matched against the request hostname (case-insensitively).
|
||||||
|
#
|
||||||
|
# @return [Array<IPAddr, String>]
|
||||||
|
def allowlist
|
||||||
|
@allowlist ||= Array(Postal::Config.postal.allowed_request_destinations).map do |entry|
|
||||||
|
IPAddr.new(entry.to_s)
|
||||||
|
rescue IPAddr::InvalidAddressError
|
||||||
|
entry.to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Postal
|
||||||
|
module HTTP
|
||||||
|
# Raised when an outbound request would be sent to an address that is not
|
||||||
|
# permitted (a private, loopback, link-local or otherwise reserved address
|
||||||
|
# that has not been explicitly allowlisted). Used as an SSRF guard.
|
||||||
|
class BlockedDestinationError < StandardError
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -70,7 +70,7 @@ module Postal
|
|||||||
# Return the total size of all stored messages
|
# Return the total size of all stored messages
|
||||||
#
|
#
|
||||||
def total_size
|
def total_size
|
||||||
query("SELECT SUM(size) AS size FROM `#{database_name}`.`raw_message_sizes`").first["size"] || 0
|
query("SELECT SUM(size) AS size FROM #{escape_identifier(database_name)}.`raw_message_sizes`").first["size"] || 0
|
||||||
end
|
end
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -151,11 +151,11 @@ module Postal
|
|||||||
if options[:count]
|
if options[:count]
|
||||||
sql_query << " COUNT(id) AS count"
|
sql_query << " COUNT(id) AS count"
|
||||||
elsif options[:fields]
|
elsif options[:fields]
|
||||||
sql_query << (" " + options[:fields].map { |f| "`#{f}`" }.join(", "))
|
sql_query << (" " + options[:fields].map { |f| escape_identifier(f) }.join(", "))
|
||||||
else
|
else
|
||||||
sql_query << " *"
|
sql_query << " *"
|
||||||
end
|
end
|
||||||
sql_query << " FROM `#{database_name}`.`#{table}`"
|
sql_query << " FROM #{escape_identifier(database_name)}.#{escape_identifier(table)}"
|
||||||
if options[:where].present?
|
if options[:where].present?
|
||||||
sql_query << (" " + build_where_string(options[:where], " AND "))
|
sql_query << (" " + build_where_string(options[:where], " AND "))
|
||||||
end
|
end
|
||||||
@@ -163,7 +163,7 @@ module Postal
|
|||||||
direction = (options[:direction] || "ASC").upcase
|
direction = (options[:direction] || "ASC").upcase
|
||||||
raise Postal::Error, "Invalid direction #{options[:direction]}" unless %w[ASC DESC].include?(direction)
|
raise Postal::Error, "Invalid direction #{options[:direction]}" unless %w[ASC DESC].include?(direction)
|
||||||
|
|
||||||
sql_query << " ORDER BY `#{options[:order]}` #{direction}"
|
sql_query << " ORDER BY #{escape_identifier(options[:order])} #{direction}"
|
||||||
end
|
end
|
||||||
|
|
||||||
if options[:limit]
|
if options[:limit]
|
||||||
@@ -211,7 +211,7 @@ module Postal
|
|||||||
# Will return the total number of affected rows.
|
# Will return the total number of affected rows.
|
||||||
#
|
#
|
||||||
def update(table, attributes, options = {})
|
def update(table, attributes, options = {})
|
||||||
sql_query = "UPDATE `#{database_name}`.`#{table}` SET"
|
sql_query = "UPDATE #{escape_identifier(database_name)}.#{escape_identifier(table)} SET"
|
||||||
sql_query << " #{hash_to_sql(attributes)}"
|
sql_query << " #{hash_to_sql(attributes)}"
|
||||||
if options[:where]
|
if options[:where]
|
||||||
sql_query << (" " + build_where_string(options[:where]))
|
sql_query << (" " + build_where_string(options[:where]))
|
||||||
@@ -227,8 +227,8 @@ module Postal
|
|||||||
# Will return the ID of the new item.
|
# Will return the ID of the new item.
|
||||||
#
|
#
|
||||||
def insert(table, attributes)
|
def insert(table, attributes)
|
||||||
sql_query = "INSERT INTO `#{database_name}`.`#{table}`"
|
sql_query = "INSERT INTO #{escape_identifier(database_name)}.#{escape_identifier(table)}"
|
||||||
sql_query << (" (" + attributes.keys.map { |k| "`#{k}`" }.join(", ") + ")")
|
sql_query << (" (" + attributes.keys.map { |k| escape_identifier(k) }.join(", ") + ")")
|
||||||
sql_query << (" VALUES (" + attributes.values.map { |v| escape(v) }.join(", ") + ")")
|
sql_query << (" VALUES (" + attributes.values.map { |v| escape(v) }.join(", ") + ")")
|
||||||
with_mysql do |mysql|
|
with_mysql do |mysql|
|
||||||
query_on_connection(mysql, sql_query)
|
query_on_connection(mysql, sql_query)
|
||||||
@@ -243,8 +243,8 @@ module Postal
|
|||||||
if values.empty?
|
if values.empty?
|
||||||
nil
|
nil
|
||||||
else
|
else
|
||||||
sql_query = "INSERT INTO `#{database_name}`.`#{table}`"
|
sql_query = "INSERT INTO #{escape_identifier(database_name)}.#{escape_identifier(table)}"
|
||||||
sql_query << (" (" + keys.map { |k| "`#{k}`" }.join(", ") + ")")
|
sql_query << (" (" + keys.map { |k| escape_identifier(k) }.join(", ") + ")")
|
||||||
sql_query << " VALUES "
|
sql_query << " VALUES "
|
||||||
sql_query << values.map { |v| "(" + v.map { |r| escape(r) }.join(", ") + ")" }.join(", ")
|
sql_query << values.map { |v| "(" + v.map { |r| escape(r) }.join(", ") + ")" }.join(", ")
|
||||||
query(sql_query)
|
query(sql_query)
|
||||||
@@ -260,7 +260,7 @@ module Postal
|
|||||||
# Will return the total number of affected rows.
|
# Will return the total number of affected rows.
|
||||||
#
|
#
|
||||||
def delete(table, options = {})
|
def delete(table, options = {})
|
||||||
sql_query = "DELETE FROM `#{database_name}`.`#{table}`"
|
sql_query = "DELETE FROM #{escape_identifier(database_name)}.#{escape_identifier(table)}"
|
||||||
sql_query << (" " + build_where_string(options[:where], " AND "))
|
sql_query << (" " + build_where_string(options[:where], " AND "))
|
||||||
with_mysql do |mysql|
|
with_mysql do |mysql|
|
||||||
query_on_connection(mysql, sql_query)
|
query_on_connection(mysql, sql_query)
|
||||||
@@ -351,32 +351,41 @@ module Postal
|
|||||||
|
|
||||||
def hash_to_sql(hash, joiner = ", ")
|
def hash_to_sql(hash, joiner = ", ")
|
||||||
hash.map do |key, value|
|
hash.map do |key, value|
|
||||||
|
column = escape_identifier(key)
|
||||||
if value.is_a?(Array) && value.all? { |v| v.is_a?(Integer) }
|
if value.is_a?(Array) && value.all? { |v| v.is_a?(Integer) }
|
||||||
"`#{key}` IN (#{value.join(', ')})"
|
"#{column} IN (#{value.join(', ')})"
|
||||||
elsif value.is_a?(Array)
|
elsif value.is_a?(Array)
|
||||||
escaped_values = value.map { |v| escape(v) }.join(", ")
|
escaped_values = value.map { |v| escape(v) }.join(", ")
|
||||||
"`#{key}` IN (#{escaped_values})"
|
"#{column} IN (#{escaped_values})"
|
||||||
elsif value.is_a?(Hash)
|
elsif value.is_a?(Hash)
|
||||||
sql = []
|
sql = []
|
||||||
value.each do |operator, inner_value|
|
value.each do |operator, inner_value|
|
||||||
case operator
|
case operator
|
||||||
when :less_than
|
when :less_than
|
||||||
sql << "`#{key}` < #{escape(inner_value)}"
|
sql << "#{column} < #{escape(inner_value)}"
|
||||||
when :greater_than
|
when :greater_than
|
||||||
sql << "`#{key}` > #{escape(inner_value)}"
|
sql << "#{column} > #{escape(inner_value)}"
|
||||||
when :less_than_or_equal_to
|
when :less_than_or_equal_to
|
||||||
sql << "`#{key}` <= #{escape(inner_value)}"
|
sql << "#{column} <= #{escape(inner_value)}"
|
||||||
when :greater_than_or_equal_to
|
when :greater_than_or_equal_to
|
||||||
sql << "`#{key}` >= #{escape(inner_value)}"
|
sql << "#{column} >= #{escape(inner_value)}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
sql.empty? ? "1=1" : sql.join(joiner)
|
sql.empty? ? "1=1" : sql.join(joiner)
|
||||||
else
|
else
|
||||||
"`#{key}` = #{escape(value)}"
|
"#{column} = #{escape(value)}"
|
||||||
end
|
end
|
||||||
end.join(joiner)
|
end.join(joiner)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Escape a value for safe use as a MySQL identifier (e.g. a column or
|
||||||
|
# table name). Identifiers are wrapped in backticks and any backtick
|
||||||
|
# within the identifier is doubled so it cannot break out of the quoting
|
||||||
|
# and inject arbitrary SQL.
|
||||||
|
def escape_identifier(identifier)
|
||||||
|
"`" + identifier.to_s.gsub("`", "``") + "`"
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -48,25 +48,11 @@ class TrackingMiddleware
|
|||||||
Sentry.capture_exception(e) if defined?(Sentry)
|
Sentry.capture_exception(e) if defined?(Sentry)
|
||||||
end
|
end
|
||||||
|
|
||||||
source_image = request.params["src"]
|
if request.params["src"].nil?
|
||||||
case source_image
|
|
||||||
when nil
|
|
||||||
headers = {}
|
headers = {}
|
||||||
headers["Content-Type"] = "image/png"
|
headers["Content-Type"] = "image/png"
|
||||||
headers["Content-Length"] = TRACKING_PIXEL.bytesize.to_s
|
headers["Content-Length"] = TRACKING_PIXEL.bytesize.to_s
|
||||||
[200, headers, [TRACKING_PIXEL]]
|
[200, headers, [TRACKING_PIXEL]]
|
||||||
when /\Ahttps?:\/\//
|
|
||||||
response = Postal::HTTP.get(source_image, timeout: 3)
|
|
||||||
return [404, {}, ["Not found"]] unless response[:code] == 200
|
|
||||||
|
|
||||||
headers = {}
|
|
||||||
headers["Content-Type"] = response[:headers]["content-type"]&.first
|
|
||||||
headers["Last-Modified"] = response[:headers]["last-modified"]&.first
|
|
||||||
headers["Cache-Control"] = response[:headers]["cache-control"]&.first
|
|
||||||
headers["Etag"] = response[:headers]["etag"]&.first
|
|
||||||
headers["Content-Length"] = response[:body].bytesize.to_s
|
|
||||||
[200, headers, [response[:body]]]
|
|
||||||
|
|
||||||
else
|
else
|
||||||
[400, {}, ["Invalid/missing source image"]]
|
[400, {}, ["Invalid/missing source image"]]
|
||||||
end
|
end
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env ruby
|
#!/usr/bin/env ruby
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require File.expand_path("../lib/postal/version", __dir__)
|
require File.expand_path("../lib/postal/config", __dir__)
|
||||||
puts Postal.version
|
puts Postal.version
|
||||||
|
|||||||
@@ -64,6 +64,23 @@ RSpec.describe "Legacy Messages API", type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Regression test for GHSA-x2hq-rfpg-3xr5 (see message_spec.rb). A JSON
|
||||||
|
# object supplied for `id` must be rejected before reaching the database
|
||||||
|
# rather than being interpreted as a raw set of SQL conditions.
|
||||||
|
context "when the message ID is a JSON object (SQL injection attempt)" do
|
||||||
|
it "rejects it with a parameter error and never reaches the database" do
|
||||||
|
expect_any_instance_of(Server).not_to receive(:message)
|
||||||
|
post "/api/v1/messages/deliveries",
|
||||||
|
headers: { "x-server-api-key" => credential.key,
|
||||||
|
"content-type" => "application/json" },
|
||||||
|
params: { id: { "id`=0 OR SLEEP(5)#" => "x" } }.to_json
|
||||||
|
expect(response.status).to eq 200
|
||||||
|
parsed_body = JSON.parse(response.body)
|
||||||
|
expect(parsed_body["status"]).to eq "parameter-error"
|
||||||
|
expect(parsed_body["data"]["message"]).to match(/must be a string or integer/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context "when the message ID exists" do
|
context "when the message ID exists" do
|
||||||
let(:server) { create(:server) }
|
let(:server) { create(:server) }
|
||||||
let(:credential) { create(:credential, server: server) }
|
let(:credential) { create(:credential, server: server) }
|
||||||
|
|||||||
@@ -63,6 +63,56 @@ RSpec.describe "Legacy Messages API", type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Regression tests for GHSA-x2hq-rfpg-3xr5. The request body is parsed as
|
||||||
|
# JSON, so a JSON object/array supplied for `id` would otherwise arrive as
|
||||||
|
# a Ruby Hash/Array and be passed straight through to the message database
|
||||||
|
# as a raw set of SQL conditions (blind SQL injection). These must be
|
||||||
|
# rejected before reaching the database.
|
||||||
|
context "when the message ID is a JSON object (SQL injection attempt)" do
|
||||||
|
it "rejects it with a parameter error and never reaches the database" do
|
||||||
|
expect_any_instance_of(Server).not_to receive(:message)
|
||||||
|
post "/api/v1/messages/message",
|
||||||
|
headers: { "x-server-api-key" => credential.key,
|
||||||
|
"content-type" => "application/json" },
|
||||||
|
params: { id: { "id`=0 OR SLEEP(5)#" => "x" } }.to_json
|
||||||
|
expect(response.status).to eq 200
|
||||||
|
parsed_body = JSON.parse(response.body)
|
||||||
|
expect(parsed_body["status"]).to eq "parameter-error"
|
||||||
|
expect(parsed_body["data"]["message"]).to match(/must be a string or integer/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the message ID is a JSON array" do
|
||||||
|
it "rejects it with a parameter error" do
|
||||||
|
post "/api/v1/messages/message",
|
||||||
|
headers: { "x-server-api-key" => credential.key,
|
||||||
|
"content-type" => "application/json" },
|
||||||
|
params: { id: [1, 2, 3] }.to_json
|
||||||
|
expect(response.status).to eq 200
|
||||||
|
parsed_body = JSON.parse(response.body)
|
||||||
|
expect(parsed_body["status"]).to eq "parameter-error"
|
||||||
|
expect(parsed_body["data"]["message"]).to match(/must be a string or integer/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the message ID is provided as a numeric string" do
|
||||||
|
let(:message) { MessageFactory.outgoing(server) }
|
||||||
|
|
||||||
|
it "is coerced to an integer and looks the message up" do
|
||||||
|
post "/api/v1/messages/message",
|
||||||
|
headers: { "x-server-api-key" => credential.key,
|
||||||
|
"content-type" => "application/json" },
|
||||||
|
params: { id: message.id.to_s }.to_json
|
||||||
|
expect(response.status).to eq 200
|
||||||
|
parsed_body = JSON.parse(response.body)
|
||||||
|
expect(parsed_body["status"]).to eq "success"
|
||||||
|
expect(parsed_body["data"]).to match({
|
||||||
|
"id" => message.id,
|
||||||
|
"token" => message.token
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context "when the message ID exists" do
|
context "when the message ID exists" do
|
||||||
let(:server) { create(:server) }
|
let(:server) { create(:server) }
|
||||||
let(:credential) { create(:credential, server: server) }
|
let(:credential) { create(:credential, server: server) }
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe ApplicationHelper, type: :helper do
|
||||||
|
describe "#endpoint_options_for_select" do
|
||||||
|
let(:server) { create(:server) }
|
||||||
|
|
||||||
|
context "when an endpoint has HTML characters in its description" do
|
||||||
|
let(:payload) { %q(x'"><script>alert(1)</script>) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:http_endpoint, server: server, name: payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "HTML-escapes the endpoint description in the option text" do
|
||||||
|
html = helper.endpoint_options_for_select(server)
|
||||||
|
|
||||||
|
# The raw payload must not appear verbatim — if it does, the browser
|
||||||
|
# will execute the <script> tag.
|
||||||
|
expect(html).not_to include("<script>alert(1)</script>")
|
||||||
|
|
||||||
|
# Escaped form should appear instead.
|
||||||
|
expect(html).to include("<script>alert(1)</script>")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not allow the payload to break out of the option tag" do
|
||||||
|
html = helper.endpoint_options_for_select(server)
|
||||||
|
|
||||||
|
# The ' and > characters in the payload must be escaped so they
|
||||||
|
# cannot close the opening <option value='...'> or terminate the
|
||||||
|
# element early.
|
||||||
|
expect(html).not_to match(/<option[^>]*>[^<]*<script/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe ErrorMessagesHelper, type: :helper do
|
||||||
|
describe "#error_messages_for" do
|
||||||
|
let(:user) { User.new }
|
||||||
|
|
||||||
|
context "when the record has no errors" do
|
||||||
|
it "renders nothing" do
|
||||||
|
expect(helper.error_messages_for(user)).to eq ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the record is nil" do
|
||||||
|
it "renders nothing" do
|
||||||
|
expect(helper.error_messages_for(nil)).to eq ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the record has errors" do
|
||||||
|
before do
|
||||||
|
user.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renders the errorExplanation container the stylesheet targets" do
|
||||||
|
html = helper.error_messages_for(user)
|
||||||
|
expect(html).to include %(<div id="errorExplanation" class="errorExplanation">)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renders each full message as a list item" do
|
||||||
|
html = helper.error_messages_for(user)
|
||||||
|
user.errors.full_messages.each do |message|
|
||||||
|
expect(html).to include "<li>#{ERB::Util.html_escape(message)}</li>"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renders one list item per error" do
|
||||||
|
html = helper.error_messages_for(user)
|
||||||
|
expect(html.scan("<li>").size).to eq user.errors.count
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns an html_safe string" do
|
||||||
|
expect(helper.error_messages_for(user)).to be_html_safe
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when an error message contains HTML" do
|
||||||
|
before do
|
||||||
|
user.errors.add(:base, %q(x'"><script>alert(1)</script>))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "escapes it rather than emitting it verbatim" do
|
||||||
|
html = helper.error_messages_for(user)
|
||||||
|
expect(html).to_not include "<script>alert(1)</script>"
|
||||||
|
expect(html).to include "<script>alert(1)</script>"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
# Active Support 7.2 calls `JSON.generate(..., quirks_mode: true)` and
|
||||||
|
# `JSON.parse(json, quirks_mode: true)`. The json gem removed `quirks_mode` in
|
||||||
|
# 3.0, so an unpinned json resolves to 3.x and every `to_json` in the app raises
|
||||||
|
# ArgumentError - webhook payloads and JSON API responses included. The Gemfile
|
||||||
|
# pins json to < 3 because of this; these examples fail loudly if that pin is
|
||||||
|
# removed before we are on a Rails version that no longer passes the option.
|
||||||
|
RSpec.describe "JSON encoding" do
|
||||||
|
it "encodes a hash" do
|
||||||
|
expect({ "a" => 1 }.to_json).to eq '{"a":1}'
|
||||||
|
end
|
||||||
|
|
||||||
|
it "encodes nested structures with mixed types" do
|
||||||
|
payload = { "event" => "MessageSent", "timestamp" => 1.5, "payload" => { "list" => [1, "two", nil, true] } }
|
||||||
|
expect(JSON.parse(payload.to_json)).to eq payload
|
||||||
|
end
|
||||||
|
|
||||||
|
it "round trips through Active Support's encoder" do
|
||||||
|
payload = { "uuid" => "abc-123", "nested" => { "x" => [1, 2] } }
|
||||||
|
expect(ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(payload))).to eq payload
|
||||||
|
end
|
||||||
|
|
||||||
|
it "encodes a bare value, which is what quirks_mode allowed" do
|
||||||
|
expect(ActiveSupport::JSON.encode("hello")).to eq '"hello"'
|
||||||
|
expect(ActiveSupport::JSON.encode(42)).to eq "42"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "encodes times the way the webhook payload relies on" do
|
||||||
|
expect(Time.at(1_700_000_000).utc.to_f.to_json).to eq "1700000000.0"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
require "migration_waiter"
|
||||||
|
|
||||||
|
RSpec.describe MigrationWaiter do
|
||||||
|
describe ".pending_migration_count" do
|
||||||
|
# This talks to Active Record's migration APIs directly and is the part of
|
||||||
|
# this class most likely to break on a Rails upgrade (the call moved from
|
||||||
|
# the connection to the connection pool in Rails 7.2). The rest of this
|
||||||
|
# class is stubbed, so this example is what actually exercises it.
|
||||||
|
it "returns the number of migrations which have not been applied" do
|
||||||
|
expect(described_class.pending_migration_count).to be_a Integer
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns zero when the test database is up to date" do
|
||||||
|
expect(described_class.pending_migration_count).to eq 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".wait" do
|
||||||
|
before do
|
||||||
|
allow(Postal.logger).to receive(:info)
|
||||||
|
allow(described_class).to receive(:sleep)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns immediately when there are no pending migrations" do
|
||||||
|
allow(described_class).to receive(:pending_migration_count).and_return(0)
|
||||||
|
described_class.wait
|
||||||
|
expect(described_class).to have_received(:pending_migration_count).once
|
||||||
|
expect(described_class).to_not have_received(:sleep)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "waits until there are no pending migrations remaining" do
|
||||||
|
allow(described_class).to receive(:pending_migration_count).and_return(2, 1, 0)
|
||||||
|
described_class.wait
|
||||||
|
expect(described_class).to have_received(:sleep).with(described_class::SLEEP_TIME).twice
|
||||||
|
end
|
||||||
|
|
||||||
|
it "exits when migrations are still pending after the maximum number of attempts" do
|
||||||
|
stub_const("#{described_class}::ATTEMPTS", 3)
|
||||||
|
allow(described_class).to receive(:pending_migration_count).and_return(1)
|
||||||
|
# Process.exit raises SystemExit for real. It has to do so here too,
|
||||||
|
# otherwise the wait loop never terminates.
|
||||||
|
allow(Process).to receive(:exit).and_raise(SystemExit)
|
||||||
|
|
||||||
|
expect { described_class.wait }.to raise_error(SystemExit)
|
||||||
|
|
||||||
|
expect(Process).to have_received(:exit).with(1)
|
||||||
|
expect(described_class).to have_received(:sleep).twice
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".wait_if_appropriate" do
|
||||||
|
before do
|
||||||
|
allow(described_class).to receive(:wait)
|
||||||
|
allow(described_class).to receive(:console?).and_return(false)
|
||||||
|
allow(described_class).to receive(:rake_task?).and_return(false)
|
||||||
|
allow(described_class).to receive(:rails_command?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the migration waiter is not enabled" do
|
||||||
|
it "does not wait" do
|
||||||
|
allow(Postal::Config.migration_waiter).to receive(:enabled?).and_return(false)
|
||||||
|
described_class.wait_if_appropriate
|
||||||
|
expect(described_class).to_not have_received(:wait)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the migration waiter is enabled" do
|
||||||
|
before do
|
||||||
|
allow(Postal::Config.migration_waiter).to receive(:enabled?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "waits" do
|
||||||
|
described_class.wait_if_appropriate
|
||||||
|
expect(described_class).to have_received(:wait)
|
||||||
|
end
|
||||||
|
|
||||||
|
%w[console? rake_task? rails_command?].each do |predicate|
|
||||||
|
it "does not wait when #{predicate.delete_suffix('?')} is true" do
|
||||||
|
allow(described_class).to receive(predicate).and_return(true)
|
||||||
|
described_class.wait_if_appropriate
|
||||||
|
expect(described_class).to_not have_received(:wait)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Postal::HTTP::AddressGuard do
|
||||||
|
describe ".safe_connect_address" do
|
||||||
|
subject(:call) { described_class.safe_connect_address(host) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Postal::Config.postal).to receive(:allowed_request_destinations).and_return(allowlist)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:allowlist) { [] }
|
||||||
|
|
||||||
|
context "when given a public IP literal" do
|
||||||
|
let(:host) { "93.184.216.34" }
|
||||||
|
|
||||||
|
it "returns the address to connect to" do
|
||||||
|
expect(call).to eq "93.184.216.34"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when given a public IPv6 literal" do
|
||||||
|
let(:host) { "2606:2800:220:1:248:1893:25c8:1946" }
|
||||||
|
|
||||||
|
before { allow(described_class).to receive(:ipv6_supported?).and_return(true) }
|
||||||
|
|
||||||
|
it "returns the address to connect to" do
|
||||||
|
expect(call).to eq "2606:2800:220:1:248:1893:25c8:1946"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
[
|
||||||
|
"127.0.0.1",
|
||||||
|
"10.0.0.1",
|
||||||
|
"172.16.5.4",
|
||||||
|
"192.168.1.1",
|
||||||
|
"169.254.169.254", # cloud metadata
|
||||||
|
"100.64.0.1", # carrier-grade NAT
|
||||||
|
"0.0.0.0",
|
||||||
|
"::1",
|
||||||
|
"fd00::1", # unique-local IPv6
|
||||||
|
"fe80::1", # link-local IPv6
|
||||||
|
"::ffff:127.0.0.1", # IPv4-mapped loopback
|
||||||
|
].each do |blocked|
|
||||||
|
context "when given the blocked address #{blocked}" do
|
||||||
|
let(:host) { blocked }
|
||||||
|
|
||||||
|
it "raises BlockedDestinationError" do
|
||||||
|
expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when given a hostname that resolves to a public address" do
|
||||||
|
let(:host) { "example.com" }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).with(host).and_return(["93.184.216.34"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns the resolved address" do
|
||||||
|
expect(call).to eq "93.184.216.34"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when given a hostname that resolves to a private address" do
|
||||||
|
let(:host) { "internal.example.com" }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).with(host).and_return(["10.1.2.3"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "raises BlockedDestinationError" do
|
||||||
|
expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when a hostname resolves to both a public and a private address" do
|
||||||
|
let(:host) { "rebind.example.com" }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).with(host).and_return(["93.184.216.34", "127.0.0.1"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "raises BlockedDestinationError because one address is blocked" do
|
||||||
|
expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when a hostname resolves to both IPv4 and IPv6 addresses" do
|
||||||
|
let(:host) { "dualstack.example.com" }
|
||||||
|
let(:ipv6) { "2606:2800:220:1:248:1893:25c8:1946" }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).with(host).and_return([ipv6, "93.184.216.34"])
|
||||||
|
end
|
||||||
|
|
||||||
|
context "and the server does not support IPv6" do
|
||||||
|
before { allow(described_class).to receive(:ipv6_supported?).and_return(false) }
|
||||||
|
|
||||||
|
it "connects over IPv4" do
|
||||||
|
expect(call).to eq "93.184.216.34"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "and the server supports IPv6" do
|
||||||
|
before { allow(described_class).to receive(:ipv6_supported?).and_return(true) }
|
||||||
|
|
||||||
|
it "still prefers IPv4 for predictability" do
|
||||||
|
expect(call).to eq "93.184.216.34"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when a hostname resolves only to an IPv6 address" do
|
||||||
|
let(:host) { "v6only.example.com" }
|
||||||
|
let(:ipv6) { "2606:2800:220:1:248:1893:25c8:1946" }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).with(host).and_return([ipv6])
|
||||||
|
end
|
||||||
|
|
||||||
|
context "and the server does not support IPv6" do
|
||||||
|
before { allow(described_class).to receive(:ipv6_supported?).and_return(false) }
|
||||||
|
|
||||||
|
it "raises a SocketError because the address is unreachable" do
|
||||||
|
expect { call }.to raise_error(SocketError)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "and the server supports IPv6" do
|
||||||
|
before { allow(described_class).to receive(:ipv6_supported?).and_return(true) }
|
||||||
|
|
||||||
|
it "connects over IPv6" do
|
||||||
|
expect(call).to eq ipv6
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when a hostname cannot be resolved" do
|
||||||
|
let(:host) { "nope.example.com" }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).with(host).and_return([])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "raises BlockedDestinationError" do
|
||||||
|
expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError, /resolve/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the host is blank" do
|
||||||
|
let(:host) { "" }
|
||||||
|
|
||||||
|
it "raises BlockedDestinationError" do
|
||||||
|
expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when a blocked address is allowlisted by CIDR" do
|
||||||
|
let(:host) { "10.0.0.5" }
|
||||||
|
let(:allowlist) { ["10.0.0.0/8"] }
|
||||||
|
|
||||||
|
it "returns the address" do
|
||||||
|
expect(call).to eq "10.0.0.5"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when a blocked address is allowlisted by exact IP" do
|
||||||
|
let(:host) { "127.0.0.1" }
|
||||||
|
let(:allowlist) { ["127.0.0.1"] }
|
||||||
|
|
||||||
|
it "returns the address" do
|
||||||
|
expect(call).to eq "127.0.0.1"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when a hostname resolving to a private address is allowlisted by name" do
|
||||||
|
let(:host) { "internal.example.com" }
|
||||||
|
let(:allowlist) { ["internal.example.com"] }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).with(host).and_return(["10.1.2.3"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns the resolved address" do
|
||||||
|
expect(call).to eq "10.1.2.3"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Postal::HTTP do
|
||||||
|
before do
|
||||||
|
allow(Postal::Config.postal).to receive(:allowed_request_destinations).and_return([])
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".post" do
|
||||||
|
context "when the host resolves to a blocked address" do
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).with("internal.example.com").and_return(["127.0.0.1"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not make a request and returns a blocked-destination result" do
|
||||||
|
result = described_class.post("http://internal.example.com/hook", json: "{}")
|
||||||
|
expect(result[:code]).to eq(-4)
|
||||||
|
expect(result[:body]).to match(/not permitted/)
|
||||||
|
expect(WebMock).not_to have_requested(:post, "http://internal.example.com/hook")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when resolving the host raises an error" do
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).with("example.com").and_raise(Resolv::ResolvError, "resolver failed")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns a connection error result" do
|
||||||
|
result = described_class.post("http://example.com/hook", json: "{}")
|
||||||
|
expect(result[:code]).to eq(-2)
|
||||||
|
expect(result[:body]).to match(/resolver failed/)
|
||||||
|
expect(WebMock).not_to have_requested(:post, "http://example.com/hook")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when resolving the host exceeds the request timeout" do
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).with("example.com") do
|
||||||
|
sleep 0.2
|
||||||
|
["93.184.216.34"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns a timeout result before making a request" do
|
||||||
|
result = described_class.post("http://example.com/hook", json: "{}", timeout: 0.05)
|
||||||
|
expect(result[:code]).to eq(-1)
|
||||||
|
expect(WebMock).not_to have_requested(:post, "http://example.com/hook")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the host resolves to a public address" do
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).with("example.com").and_return(["93.184.216.34"])
|
||||||
|
stub_request(:post, "http://example.com/hook").to_return(status: 200, body: "OK")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "pins the connection to the validated address and performs the request" do
|
||||||
|
expect_any_instance_of(Net::HTTP).to receive(:ipaddr=).with("93.184.216.34").and_call_original
|
||||||
|
result = described_class.post("http://example.com/hook", json: "{}")
|
||||||
|
expect(result[:code]).to eq(200)
|
||||||
|
expect(WebMock).to have_requested(:post, "http://example.com/hook")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the blocked host is allowlisted" do
|
||||||
|
before do
|
||||||
|
allow(Postal::Config.postal).to receive(:allowed_request_destinations).and_return(["internal.example.com"])
|
||||||
|
allow(Resolv).to receive(:getaddresses).with("internal.example.com").and_return(["10.0.0.5"])
|
||||||
|
stub_request(:post, "http://internal.example.com/hook").to_return(status: 200, body: "OK")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "performs the request" do
|
||||||
|
result = described_class.post("http://internal.example.com/hook", json: "{}")
|
||||||
|
expect(result[:code]).to eq(200)
|
||||||
|
expect(WebMock).to have_requested(:post, "http://internal.example.com/hook")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -14,5 +14,64 @@ describe Postal::MessageDB::Database do
|
|||||||
it "should return the current schema version" do
|
it "should return the current schema version" do
|
||||||
expect(database.schema_version).to be_a Integer
|
expect(database.schema_version).to be_a Integer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#escape_identifier" do
|
||||||
|
it "wraps a plain identifier in backticks" do
|
||||||
|
expect(database.send(:escape_identifier, "id")).to eq "`id`"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doubles embedded backticks so the value cannot break out of the quoting" do
|
||||||
|
expect(database.send(:escape_identifier, "id`=0 OR SLEEP(5)#"))
|
||||||
|
.to eq "`id``=0 OR SLEEP(5)#`"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "coerces non-string identifiers to a string" do
|
||||||
|
expect(database.send(:escape_identifier, :token)).to eq "`token`"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#hash_to_sql" do
|
||||||
|
it "builds a simple equality condition" do
|
||||||
|
expect(database.send(:hash_to_sql, { "id" => 5 })).to eq "`id` = '5'"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "builds an IN condition for an array of integers" do
|
||||||
|
expect(database.send(:hash_to_sql, { "id" => [1, 2] })).to eq "`id` IN (1, 2)"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "builds operator conditions for a hash value" do
|
||||||
|
expect(database.send(:hash_to_sql, { "id" => { greater_than: 1 } }))
|
||||||
|
.to eq "`id` > '1'"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Regression tests for GHSA-x2hq-rfpg-3xr5: a backtick in the condition
|
||||||
|
# key must be neutralised so it cannot close the identifier quoting and
|
||||||
|
# inject arbitrary SQL.
|
||||||
|
it "neutralises a backtick injection in an equality key" do
|
||||||
|
sql = database.send(:hash_to_sql, { "id`=0 OR SLEEP(5)#" => "x" })
|
||||||
|
expect(sql).to eq "`id``=0 OR SLEEP(5)#` = 'x'"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "neutralises a backtick injection in an IN key" do
|
||||||
|
sql = database.send(:hash_to_sql, { "id`)#" => %w[a b] })
|
||||||
|
expect(sql).to eq "`id``)#` IN ('a', 'b')"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "neutralises a backtick injection in an operator key" do
|
||||||
|
sql = database.send(:hash_to_sql, { "id`#" => { greater_than: 1 } })
|
||||||
|
expect(sql).to eq "`id``#` > '1'"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#select with a hostile condition key" do
|
||||||
|
# End-to-end proof against the live test database: the injected key is
|
||||||
|
# treated as a single (non-existent) column identifier, so MySQL rejects
|
||||||
|
# the query instead of executing the injected SQL.
|
||||||
|
it "does not allow SQL injection via the condition key" do
|
||||||
|
expect do
|
||||||
|
database.select("messages", where: { "id`=0 OR 1=1#" => "x" }, limit: 1)
|
||||||
|
end.to raise_error(Mysql2::Error)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
require "rack/test"
|
||||||
|
|
||||||
|
RSpec.describe TrackingMiddleware do
|
||||||
|
include Rack::Test::Methods
|
||||||
|
|
||||||
|
let(:inner_app) { ->(_env) { [200, {}, ["inner"]] } }
|
||||||
|
let(:app) { described_class.new(inner_app) }
|
||||||
|
|
||||||
|
let(:server) { create(:server) }
|
||||||
|
let(:message) do
|
||||||
|
MessageFactory.incoming(server) do |_msg, mail|
|
||||||
|
mail.html_part = Mail::Part.new do
|
||||||
|
content_type "text/html; charset=UTF-8"
|
||||||
|
body "<html><body>hi</body></html>"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def track_headers
|
||||||
|
{ "HTTP_X_POSTAL_TRACK_HOST" => "1" }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /img/:server_token/:message_token (open tracking pixel)" do
|
||||||
|
before do
|
||||||
|
get "/img/#{server.token}/#{message.token}", {}, track_headers
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns the tracking pixel PNG" do
|
||||||
|
expect(last_response.status).to eq 200
|
||||||
|
expect(last_response.headers["Content-Type"]).to eq "image/png"
|
||||||
|
expect(last_response.body.bytesize).to be > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
it "records a load for the message" do
|
||||||
|
# Re-fetch the message so loads are read fresh from the DB.
|
||||||
|
reloaded = server.message_db.message(message.id)
|
||||||
|
expect(reloaded.loads.size).to eq 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /img/:server_token/:message_token?src=<url> (image proxy)" do
|
||||||
|
let(:attacker_url) { "http://internal.example.com/secret" }
|
||||||
|
|
||||||
|
before do
|
||||||
|
stub_request(:get, attacker_url).to_return(status: 200, body: "internal-secret")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not fetch the URL and returns 400" do
|
||||||
|
get "/img/#{server.token}/#{message.token}", { src: attacker_url }, track_headers
|
||||||
|
|
||||||
|
expect(last_response.status).to eq 400
|
||||||
|
expect(WebMock).not_to have_requested(:get, attacker_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not fetch the URL even when the message token is invalid" do
|
||||||
|
get "/img/#{server.token}/nonexistent", { src: attacker_url }, track_headers
|
||||||
|
|
||||||
|
expect(WebMock).not_to have_requested(:get, attacker_url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "when the track-host header is missing" do
|
||||||
|
it "passes the request through to the inner app untouched" do
|
||||||
|
get "/img/#{server.token}/#{message.token}"
|
||||||
|
expect(last_response.body).to eq "inner"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe HTTPEndpoint do
|
||||||
|
describe "validations" do
|
||||||
|
subject(:endpoint) { build(:http_endpoint, url: url) }
|
||||||
|
|
||||||
|
[
|
||||||
|
"https://example.com/messages/~user;v=1?token=a+b#section",
|
||||||
|
"http://example.com:8080/path?x=1&y=2",
|
||||||
|
"https://[2606:2800:220:1:248:1893:25c8:1946]/hook",
|
||||||
|
].each do |valid_url|
|
||||||
|
context "with #{valid_url}" do
|
||||||
|
let(:url) { valid_url }
|
||||||
|
|
||||||
|
it "is valid" do
|
||||||
|
expect(endpoint).to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
[
|
||||||
|
"ftp://example.com/hook",
|
||||||
|
"https:///missing-host",
|
||||||
|
"not a url",
|
||||||
|
].each do |invalid_url|
|
||||||
|
context "with #{invalid_url}" do
|
||||||
|
let(:url) { invalid_url }
|
||||||
|
|
||||||
|
it "is invalid" do
|
||||||
|
expect(endpoint).not_to be_valid
|
||||||
|
expect(endpoint.errors[:url]).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
# Active Record's serialization defaults have changed across recent Rails
|
||||||
|
# releases (`config.active_record.default_column_serializer` becomes `nil` in
|
||||||
|
# the 7.1 defaults, which makes an implicit coder an error rather than YAML).
|
||||||
|
# These columns hold data written by older versions of Postal, so the on-disk
|
||||||
|
# format has to stay YAML. These examples pin both the format and the round trip
|
||||||
|
# so a future framework defaults bump cannot change them silently.
|
||||||
|
RSpec.describe "serialized columns" do
|
||||||
|
shared_examples "a YAML serialized hash column" do |attribute|
|
||||||
|
let(:value) { { "string" => "value", "number" => 123, "nested" => { "a" => [1, 2] } } }
|
||||||
|
|
||||||
|
def write_raw(record, attribute, raw)
|
||||||
|
sql = described_class.sanitize_sql_array(
|
||||||
|
["UPDATE #{described_class.table_name} SET #{attribute} = ? WHERE id = ?", raw, record.id]
|
||||||
|
)
|
||||||
|
described_class.connection.update(sql)
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_raw(record, attribute)
|
||||||
|
described_class.connection.select_value(
|
||||||
|
described_class.sanitize_sql_array(
|
||||||
|
["SELECT #{attribute} FROM #{described_class.table_name} WHERE id = ?", record.id]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "defaults to an empty hash" do
|
||||||
|
expect(described_class.new.public_send(attribute)).to eq({})
|
||||||
|
end
|
||||||
|
|
||||||
|
it "round trips a hash through the database" do
|
||||||
|
record.update!(attribute => value)
|
||||||
|
expect(record.reload.public_send(attribute)).to eq value
|
||||||
|
end
|
||||||
|
|
||||||
|
it "stores the value as YAML in the database" do
|
||||||
|
record.update!(attribute => value)
|
||||||
|
raw = read_raw(record, attribute)
|
||||||
|
|
||||||
|
expect(raw).to be_a String
|
||||||
|
expect(YAML.safe_load(raw)).to eq value
|
||||||
|
end
|
||||||
|
|
||||||
|
it "reads YAML which was written by a previous version of Postal" do
|
||||||
|
write_raw(record, attribute, value.to_yaml)
|
||||||
|
expect(record.reload.public_send(attribute)).to eq value
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects a value which is not a hash" do
|
||||||
|
expect { record.update!(attribute => "not a hash") }.to raise_error ActiveRecord::SerializationTypeMismatch
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe Credential do
|
||||||
|
let(:record) { create(:credential) }
|
||||||
|
|
||||||
|
include_examples "a YAML serialized hash column", :options
|
||||||
|
end
|
||||||
|
|
||||||
|
describe WebhookRequest do
|
||||||
|
let(:record) { create(:webhook_request) }
|
||||||
|
|
||||||
|
include_examples "a YAML serialized hash column", :payload
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -120,6 +120,8 @@ describe Server do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "deletion" do
|
describe "deletion" do
|
||||||
|
let(:server) { create(:server) }
|
||||||
|
|
||||||
it "removes the database" do
|
it "removes the database" do
|
||||||
expect(server.message_db.provisioner).to receive(:drop).once
|
expect(server.message_db.provisioner).to receive(:drop).once
|
||||||
server.provision_database = true
|
server.provision_database = true
|
||||||
|
|||||||
@@ -10,15 +10,13 @@ require "rspec/rails"
|
|||||||
require "spec_helper"
|
require "spec_helper"
|
||||||
require "factory_bot"
|
require "factory_bot"
|
||||||
require "timecop"
|
require "timecop"
|
||||||
require "database_cleaner"
|
|
||||||
require "webmock/rspec"
|
require "webmock/rspec"
|
||||||
require "shoulda-matchers"
|
require "shoulda-matchers"
|
||||||
|
|
||||||
DatabaseCleaner.allow_remote_database_url = true
|
DatabaseCleaner.allow_remote_database_url = true
|
||||||
ActiveRecord::Base.logger = Logger.new("/dev/null")
|
ActiveRecord::Base.logger = Logger.new("/dev/null")
|
||||||
|
|
||||||
Dir[File.expand_path("factories/*.rb", __dir__)].each { |f| require f }
|
Dir[File.expand_path("helpers/**/*.rb", __dir__)].reject { |f| f.end_with?("_spec.rb") }.each { |f| require f }
|
||||||
Dir[File.expand_path("helpers/**/*.rb", __dir__)].each { |f| require f }
|
|
||||||
|
|
||||||
ActionMailer::Base.delivery_method = :test
|
ActionMailer::Base.delivery_method = :test
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "MessagesController", type: :request do
|
||||||
|
let(:user) { create(:user, admin: true) }
|
||||||
|
let(:organization) { create(:organization, owner: user) }
|
||||||
|
let(:server) { create(:server, organization: organization) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
post "/login", params: { email_address: user.email_address, password: "passw0rd" }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /org/:org/servers/:server/messages/:id/html_raw" do
|
||||||
|
let(:xss_payload) { %(<script>alert("XSS")</script>) }
|
||||||
|
let(:message) do
|
||||||
|
payload = xss_payload
|
||||||
|
MessageFactory.incoming(server) do |_msg, mail|
|
||||||
|
mail.html_part = Mail::Part.new do
|
||||||
|
content_type "text/html; charset=UTF-8"
|
||||||
|
body %(<html><body><p>hello</p>#{payload}</body></html>)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
get "/org/#{organization.permalink}/servers/#{server.permalink}/messages/#{message.id}/html_raw"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns the stored email HTML" do
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("hello")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "serves a restrictive Content-Security-Policy that blocks scripts" do
|
||||||
|
csp = response.headers["Content-Security-Policy"]
|
||||||
|
expect(csp).to include("script-src 'none'")
|
||||||
|
expect(csp).to include("default-src 'none'")
|
||||||
|
expect(csp).to include("form-action 'none'")
|
||||||
|
expect(csp).to include("base-uri 'none'")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets X-Content-Type-Options and Referrer-Policy on the response" do
|
||||||
|
expect(response.headers["X-Content-Type-Options"]).to eq "nosniff"
|
||||||
|
expect(response.headers["Referrer-Policy"]).to eq "no-referrer"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "messages/html view template" do
|
||||||
|
# We assert against the template source rather than rendering it in a
|
||||||
|
# request spec because the full application layout depends on the asset
|
||||||
|
# pipeline which is not configured in this test environment.
|
||||||
|
it "embeds the html_raw view inside a sandboxed iframe" do
|
||||||
|
template = Rails.root.join("app/views/messages/html.html.haml").read
|
||||||
|
expect(template).to match(/%iframe\{[^}]*:sandbox\s*=>/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
# These examples exist to exercise the view layer end to end - HAML, the asset
|
||||||
|
# pipeline, the layout, form helpers and the session middleware. The rest of the
|
||||||
|
# request specs only assert on redirects, so without these nothing in the suite
|
||||||
|
# would notice if a Rails upgrade broke rendering.
|
||||||
|
RSpec.describe "rendering", type: :request do
|
||||||
|
before do
|
||||||
|
host! Postal::Config.postal.web_hostname
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "the login page" do
|
||||||
|
it "renders" do
|
||||||
|
get "/login"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.media_type).to eq "text/html"
|
||||||
|
expect(response.body).to include "<form"
|
||||||
|
expect(response.body).to include "password"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renders the layout including compiled assets" do
|
||||||
|
get "/login"
|
||||||
|
|
||||||
|
expect(response.body).to include "<html"
|
||||||
|
expect(response.body).to match(/<link[^>]+stylesheets?/i).or match(/<link[^>]+\.css/i)
|
||||||
|
expect(response.body).to match(/<script[^>]+\.js/i)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "when not authenticated" do
|
||||||
|
it "redirects to the login page" do
|
||||||
|
get "/"
|
||||||
|
|
||||||
|
expect(response).to redirect_to("/login?return_to=%2F")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "when authenticated" do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
post "/login", params: { email_address: user.email_address, password: "passw0rd" }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renders the organizations index" do
|
||||||
|
get "/"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include "<html"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renders the user settings page" do
|
||||||
|
get "/settings"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
# `f.error_messages` and `error_messages_for` used to come from the
|
||||||
|
# dynamic_form gem. The app provides them itself now (see
|
||||||
|
# ErrorMessagesHelper and config/initializers/form_builder_extensions.rb),
|
||||||
|
# so the rendered output is our responsibility to keep working.
|
||||||
|
describe "a form which fails validation" do
|
||||||
|
before do
|
||||||
|
patch "/settings", params: {
|
||||||
|
password: "passw0rd",
|
||||||
|
user: { first_name: "", last_name: "Smith", email_address: user.email_address }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it "re-renders the form" do
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renders the validation errors in the errorExplanation container" do
|
||||||
|
expect(response.body).to include %(<div id="errorExplanation" class="errorExplanation">)
|
||||||
|
expect(response.body).to include "<li>First name can't be blank</li>"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not render an error container on a form which has no errors" do
|
||||||
|
get "/settings"
|
||||||
|
|
||||||
|
expect(response.body).to_not include "errorExplanation"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
# The security headers come from the secure_headers gem plus Rails' own
|
||||||
|
# defaults, and both have changed them across major versions. Nothing else in
|
||||||
|
# the suite looks at response headers, so pin them here.
|
||||||
|
RSpec.describe "security headers", type: :request do
|
||||||
|
before do
|
||||||
|
host! Postal::Config.postal.web_hostname
|
||||||
|
get "/login"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "responds successfully" do
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets X-Frame-Options to sameorigin" do
|
||||||
|
expect(response.headers["x-frame-options"]).to eq "sameorigin"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets X-Content-Type-Options to nosniff" do
|
||||||
|
expect(response.headers["x-content-type-options"]).to eq "nosniff"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "disables the legacy XSS auditor rather than enabling it" do
|
||||||
|
# secure_headers 7 changed this from "1; mode=block". `0` is correct: the
|
||||||
|
# legacy auditor is deprecated and blocking mode was itself exploitable.
|
||||||
|
expect(response.headers["x-xss-protection"]).to eq "0"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets X-Permitted-Cross-Domain-Policies to none" do
|
||||||
|
expect(response.headers["x-permitted-cross-domain-policies"]).to eq "none"
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "the content security policy" do
|
||||||
|
let(:csp) { response.headers["content-security-policy"] }
|
||||||
|
|
||||||
|
it "is present" do
|
||||||
|
expect(csp).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "restricts script-src to self" do
|
||||||
|
expect(csp).to include "script-src 'self'"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "restricts connect-src to self" do
|
||||||
|
expect(csp).to include "connect-src 'self'"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "forbids object-src" do
|
||||||
|
expect(csp).to include "object-src 'none'"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "cookies" do
|
||||||
|
# Rack 3 returns set-cookie as a String for one cookie and an Array for
|
||||||
|
# several, so normalise before looking for a particular cookie.
|
||||||
|
def cookie_named(name)
|
||||||
|
Array(response.headers["set-cookie"]).flat_map { |v| v.split("\n") }.find { |c| c.start_with?("#{name}=") }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "marks the browser id cookie httponly and samesite lax" do
|
||||||
|
expect(cookie_named("browser_id")).to include "httponly"
|
||||||
|
expect(cookie_named("browser_id")).to include "samesite=lax"
|
||||||
|
end
|
||||||
|
|
||||||
|
context "once logged in" do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
post "/login", params: { email_address: user.email_address, password: "passw0rd" }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "marks the authie session cookie httponly and samesite lax" do
|
||||||
|
expect(cookie_named("user_session")).to include "httponly"
|
||||||
|
expect(cookie_named("user_session")).to include "samesite=lax"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "marks the rails session cookie httponly and samesite lax" do
|
||||||
|
expect(cookie_named("_postal_session")).to include "httponly"
|
||||||
|
expect(cookie_named("_postal_session")).to include "samesite=lax"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "SessionsController", type: :request do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
|
||||||
|
describe "POST /login with return_to" do
|
||||||
|
def login_with(return_to:)
|
||||||
|
post "/login", params: {
|
||||||
|
email_address: user.email_address,
|
||||||
|
password: "passw0rd",
|
||||||
|
return_to: return_to
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
shared_examples "rejects unsafe return_to" do
|
||||||
|
it "does not redirect to the attacker-controlled location" do
|
||||||
|
login_with(return_to: unsafe_path)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:found)
|
||||||
|
# Whatever the fallback is, it must be same-origin: a Location that
|
||||||
|
# either omits a host or points at our own host. A browser must not
|
||||||
|
# end up at attacker.example.
|
||||||
|
location = response.location
|
||||||
|
expect(location).not_to include("attacker.example")
|
||||||
|
# Reject protocol-relative and absolute redirects entirely.
|
||||||
|
expect(location).not_to match(%r{\A//})
|
||||||
|
expect(location).not_to match(%r{\Ahttps?://attacker})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with a protocol-relative URL (//host)" do
|
||||||
|
let(:unsafe_path) { "//attacker.example/phish" }
|
||||||
|
include_examples "rejects unsafe return_to"
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with a backslash-prefixed URL (/\\host)" do
|
||||||
|
let(:unsafe_path) { "/\\attacker.example/phish" }
|
||||||
|
include_examples "rejects unsafe return_to"
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with an absolute http(s) URL" do
|
||||||
|
let(:unsafe_path) { "https://attacker.example/phish" }
|
||||||
|
include_examples "rejects unsafe return_to"
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with a javascript: URL" do
|
||||||
|
let(:unsafe_path) { "javascript:alert(1)" }
|
||||||
|
include_examples "rejects unsafe return_to"
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with a safe relative path" do
|
||||||
|
it "honours the return_to" do
|
||||||
|
login_with(return_to: "/org/acme/settings")
|
||||||
|
expect(response).to redirect_to("/org/acme/settings")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with no return_to" do
|
||||||
|
it "redirects to the default root" do
|
||||||
|
post "/login", params: {
|
||||||
|
email_address: user.email_address,
|
||||||
|
password: "passw0rd"
|
||||||
|
}
|
||||||
|
expect(response).to have_http_status(:found)
|
||||||
|
expect(response.location).not_to match(%r{\A//})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -13,6 +13,7 @@ RSpec.describe WebhookDeliveryService do
|
|||||||
let(:response_body) { "OK" }
|
let(:response_body) { "OK" }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).with("example.com").and_return(["93.184.216.34"])
|
||||||
stub_request(:post, webhook.url).to_return(status: response_status, body: response_body)
|
stub_request(:post, webhook.url).to_return(status: response_status, body: response_body)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -59,8 +60,11 @@ RSpec.describe WebhookDeliveryService do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it "updates the last used at time on the webhook" do
|
it "updates the last used at time on the webhook" do
|
||||||
|
frozen_time = Time.current.change(usec: 0)
|
||||||
|
Timecop.freeze(frozen_time) do
|
||||||
service.call
|
service.call
|
||||||
expect(webhook.reload.last_used_at).to be_within(1.second).of(Time.current)
|
expect(webhook.reload.last_used_at).to eq(frozen_time)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -113,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
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
require "haml"
|
||||||
|
|
||||||
|
# Compiles every HAML template and checks the Ruby it produces actually parses.
|
||||||
|
# A HAML major version bump changes the compiler, and the request specs only
|
||||||
|
# render a handful of pages, so without this most of app/views is unverified
|
||||||
|
# until someone visits the page in production.
|
||||||
|
RSpec.describe "HAML templates" do
|
||||||
|
def compile(source)
|
||||||
|
# Wrapped in a method body because layouts use `yield`, which is not legal
|
||||||
|
# at the top level.
|
||||||
|
RubyVM::AbstractSyntaxTree.parse("def _template\n#{Haml::Engine.new.call(source)}\nend")
|
||||||
|
end
|
||||||
|
|
||||||
|
templates = Dir[Rails.root.join("app/views/**/*.haml")]
|
||||||
|
|
||||||
|
it "finds the templates to check" do
|
||||||
|
expect(templates.size).to be > 50
|
||||||
|
end
|
||||||
|
|
||||||
|
# Without this, a change in how HAML reports errors could quietly turn every
|
||||||
|
# example below into a no-op that passes on anything.
|
||||||
|
describe "the check itself" do
|
||||||
|
it "rejects a template whose Ruby does not parse" do
|
||||||
|
expect { compile("= foo(\n") }.to raise_error(SyntaxError)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects a template with a malformed attribute hash" do
|
||||||
|
expect { compile("%div{:a =>}\n") }.to raise_error(SyntaxError)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "accepts a valid template" do
|
||||||
|
expect { compile("%div\n %p hello\n") }.to_not raise_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
templates.each do |path|
|
||||||
|
relative = path.sub("#{Rails.root}/", "")
|
||||||
|
|
||||||
|
it "compiles #{relative}" do
|
||||||
|
expect { compile(File.read(path)) }.to_not raise_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user