diff --git a/.gitignore b/.gitignore index b89b11b4f..96a88d934 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ BRANCH .rubocop-https* .env* +node_modules +yarn.lock + diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 931d27504..8e34c5c9b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.3.4" + ".": "3.3.7" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b78a9257..da81f33bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,67 @@ 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) diff --git a/Gemfile b/Gemfile index 48d8bf75c..4d3c0deac 100644 --- a/Gemfile +++ b/Gemfile @@ -29,7 +29,7 @@ gem "ostruct" gem "prometheus-client" gem "puma" gem "rackup" -gem "rails", "= 7.1.5.2" +gem "rails", "= 7.1.6" gem "resolv" gem "secure_headers" gem "sentry-rails" diff --git a/Gemfile.lock b/Gemfile.lock index a4ad73ca5..16adf02bf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,35 +2,36 @@ GEM remote: https://rubygems.org/ specs: abbrev (0.1.2) - actioncable (7.1.5.2) - actionpack (= 7.1.5.2) - activesupport (= 7.1.5.2) + actioncable (7.1.6) + actionpack (= 7.1.6) + activesupport (= 7.1.6) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.5.2) - actionpack (= 7.1.5.2) - activejob (= 7.1.5.2) - activerecord (= 7.1.5.2) - activestorage (= 7.1.5.2) - activesupport (= 7.1.5.2) + actionmailbox (7.1.6) + actionpack (= 7.1.6) + activejob (= 7.1.6) + activerecord (= 7.1.6) + activestorage (= 7.1.6) + activesupport (= 7.1.6) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.1.5.2) - actionpack (= 7.1.5.2) - actionview (= 7.1.5.2) - activejob (= 7.1.5.2) - activesupport (= 7.1.5.2) + actionmailer (7.1.6) + actionpack (= 7.1.6) + actionview (= 7.1.6) + activejob (= 7.1.6) + activesupport (= 7.1.6) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.2) - actionpack (7.1.5.2) - actionview (= 7.1.5.2) - activesupport (= 7.1.5.2) + actionpack (7.1.6) + actionview (= 7.1.6) + activesupport (= 7.1.6) + cgi nokogiri (>= 1.8.5) racc rack (>= 2.2.4) @@ -38,35 +39,36 @@ GEM rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.5.2) - actionpack (= 7.1.5.2) - activerecord (= 7.1.5.2) - activestorage (= 7.1.5.2) - activesupport (= 7.1.5.2) + actiontext (7.1.6) + actionpack (= 7.1.6) + activerecord (= 7.1.6) + activestorage (= 7.1.6) + activesupport (= 7.1.6) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.5.2) - activesupport (= 7.1.5.2) + actionview (7.1.6) + activesupport (= 7.1.6) builder (~> 3.1) + cgi erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.1.5.2) - activesupport (= 7.1.5.2) + activejob (7.1.6) + activesupport (= 7.1.6) globalid (>= 0.3.6) - activemodel (7.1.5.2) - activesupport (= 7.1.5.2) - activerecord (7.1.5.2) - activemodel (= 7.1.5.2) - activesupport (= 7.1.5.2) + activemodel (7.1.6) + activesupport (= 7.1.6) + activerecord (7.1.6) + activemodel (= 7.1.6) + activesupport (= 7.1.6) timeout (>= 0.4.0) - activestorage (7.1.5.2) - actionpack (= 7.1.5.2) - activejob (= 7.1.5.2) - activerecord (= 7.1.5.2) - activesupport (= 7.1.5.2) + activestorage (7.1.6) + actionpack (= 7.1.6) + activejob (= 7.1.6) + activerecord (= 7.1.6) + activesupport (= 7.1.6) marcel (~> 1.0) - activesupport (7.1.5.2) + activesupport (7.1.6) base64 benchmark (>= 0.3) bigdecimal @@ -93,10 +95,11 @@ GEM execjs (~> 2) base64 (0.3.0) bcrypt (3.1.20) - benchmark (0.4.1) - bigdecimal (3.2.3) + benchmark (0.5.0) + bigdecimal (4.1.2) bindata (2.5.0) - builder (3.2.4) + builder (3.3.0) + cgi (0.5.1) chronic (0.10.2) coffee-rails (5.0.0) coffee-script (>= 2.2.0) @@ -105,8 +108,8 @@ GEM coffee-script-source execjs coffee-script-source (1.12.2) - concurrent-ruby (1.3.5) - connection_pool (2.5.4) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) crack (1.0.0) bigdecimal rexml @@ -115,7 +118,7 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0) database_cleaner-core (2.0.1) - date (3.4.1) + date (3.5.1) diff-lcs (1.6.2) domain_name (0.6.20240107) dotenv (3.0.2) @@ -123,8 +126,8 @@ GEM dynamic_form (1.2.0) email_validator (2.2.4) activemodel - erb (5.0.2) - erubi (1.12.0) + erb (6.0.4) + erubi (1.13.1) execjs (2.7.0) factory_bot (6.4.6) activesupport (>= 5.0.0) @@ -149,11 +152,12 @@ GEM hashdiff (1.1.0) hashie (5.0.0) highline (2.1.0) - i18n (1.14.7) + i18n (1.14.8) concurrent-ruby (~> 1.0) - io-console (0.8.1) - irb (1.15.2) + io-console (0.8.2) + irb (1.18.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jquery-rails (4.5.1) @@ -189,42 +193,45 @@ GEM konfig-config (3.0.0) hashie logger (1.7.0) - loofah (2.24.1) + loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.1.0) + marcel (1.2.1) mini_mime (1.1.5) - minitest (5.25.5) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) mutex_m (0.3.0) mysql2 (0.5.6) net-http (0.4.1) uri - net-imap (0.5.11) + net-imap (0.6.4) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-smtp (0.4.0.1) + net-smtp (0.5.1) net-protocol nifty-utils (1.1.7) nilify_blanks (1.4.0) activerecord (>= 4.0.0) activesupport (>= 4.0.0) - nio4r (2.7.4) - nokogiri (1.18.10-aarch64-linux-gnu) + nio4r (2.7.5) + nokogiri (1.19.3-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.10-arm64-darwin) + nokogiri (1.19.3-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.10-x86_64-darwin) + nokogiri (1.19.3-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.10-x86_64-linux-gnu) + nokogiri (1.19.3-x86_64-linux-gnu) racc (~> 1.4) omniauth (2.1.2) hashie (>= 3.4.6) @@ -253,18 +260,19 @@ GEM parallel (1.22.1) parser (3.2.1.1) ast (~> 2.4.1) - pp (0.6.2) + pp (0.6.3) prettyprint prettyprint (0.2.0) + prism (1.9.0) prometheus-client (4.2.2) - psych (5.2.6) + psych (5.4.0) date stringio public_suffix (5.0.4) puma (7.0.4) nio4r (~> 2.0) racc (1.8.1) - rack (3.2.1) + rack (3.2.6) rack-oauth2 (2.2.1) activesupport attr_required @@ -276,49 +284,52 @@ GEM base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) - rack-session (2.1.1) + rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) - rack-test (2.1.0) + rack-test (2.2.0) rack (>= 1.3) - rackup (2.2.1) + rackup (2.3.1) rack (>= 3) - rails (7.1.5.2) - actioncable (= 7.1.5.2) - actionmailbox (= 7.1.5.2) - actionmailer (= 7.1.5.2) - actionpack (= 7.1.5.2) - actiontext (= 7.1.5.2) - actionview (= 7.1.5.2) - activejob (= 7.1.5.2) - activemodel (= 7.1.5.2) - activerecord (= 7.1.5.2) - activestorage (= 7.1.5.2) - activesupport (= 7.1.5.2) + rails (7.1.6) + actioncable (= 7.1.6) + actionmailbox (= 7.1.6) + actionmailer (= 7.1.6) + actionpack (= 7.1.6) + actiontext (= 7.1.6) + actionview (= 7.1.6) + activejob (= 7.1.6) + activemodel (= 7.1.6) + activerecord (= 7.1.6) + activestorage (= 7.1.6) + activesupport (= 7.1.6) bundler (>= 1.15.0) - railties (= 7.1.5.2) - rails-dom-testing (2.2.0) + railties (= 7.1.6) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (7.1.5.2) - actionpack (= 7.1.5.2) - activesupport (= 7.1.5.2) + railties (7.1.6) + actionpack (= 7.1.6) + activesupport (= 7.1.6) + cgi irb rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.1.0) - rdoc (6.14.2) + rake (13.4.2) + rdoc (7.2.0) erb psych (>= 4.0.0) + tsort regexp_parser (2.7.0) - reline (0.6.2) + reline (0.6.3) io-console (~> 0.5) resolv (0.6.2) rexml (3.4.4) @@ -387,17 +398,18 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) - stringio (3.1.7) + stringio (3.2.0) swd (2.0.3) activesupport (>= 3) attr_required (>= 0.0.5) faraday (~> 2.0) faraday-follow_redirects temple (0.10.3) - thor (1.3.0) + thor (1.5.0) tilt (2.3.0) timecop (0.9.8) - timeout (0.4.3) + timeout (0.6.1) + tsort (0.2.0) turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) @@ -423,7 +435,7 @@ GEM base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.6.13) + zeitwerk (2.8.2) PLATFORMS aarch64-linux @@ -467,7 +479,7 @@ DEPENDENCIES prometheus-client puma rackup - rails (= 7.1.5.2) + rails (= 7.1.6) resolv rspec rspec-rails diff --git a/SECURITY.md b/SECURITY.md index 7936c0808..23e945be0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,5 +11,5 @@ We only support updates to the 3.x versions of Postal. ## Reporting a Vulnerability -If you discover a vulnerability in Postal, please do not post an issue on GitHub. Instead you should send an -e-mail to security@postalserver.io with details. We will get back to you directly. +If you discover a vulnerability in Postal, please do not post an issue on GitHub. Please, +instead, [create a new security advisory through GitHub](https://github.com/postalserver/postal/security/advisories/new). diff --git a/app/controllers/api/v2/base_controller.rb b/app/controllers/api/v2/base_controller.rb new file mode 100644 index 000000000..d1b5bdc5b --- /dev/null +++ b/app/controllers/api/v2/base_controller.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module API + module V2 + class BaseController < ActionController::API + + include Idempotency + + # Authie extends ActionController::API globally. Bearer-authenticated + # control requests deliberately do not create browser sessions/cookies. + skip_before_action :set_browser_id, raise: false + skip_before_action :validate_auth_session, raise: false + prepend_before_action :skip_touch_auth_session! + + MAX_PAGE_SIZE = 100 + DEFAULT_PAGE_SIZE = 25 + RATE_LIMIT = 300 + RATE_LIMIT_WINDOW = 5.minutes + + before_action :set_request_id + before_action :authenticate_control_api_key! + before_action :enforce_rate_limit! + + rescue_from ActiveRecord::RecordNotFound, with: :record_not_found + rescue_from ActionController::ParameterMissing, with: :parameter_missing + rescue_from ActionController::UnpermittedParameters, with: :unpermitted_parameters + rescue_from ActiveRecord::RecordInvalid, with: :record_invalid + + private + + attr_reader :current_control_api_key + + def authenticate_control_api_key! + header = request.authorization.to_s + token = header[/\ABearer\s+([^\s]+)\z/i, 1] + return render_error("unauthorized", "A bearer token is required", status: :unauthorized) unless token + + @current_control_api_key = ControlAPIKey.authenticate(token) + render_error("unauthorized", "The bearer token is invalid, expired, or revoked", status: :unauthorized) unless @current_control_api_key + end + + def require_scope!(scope) + return if current_control_api_key&.allows?(scope) + + render_error("forbidden", "This API key does not have the required scope", status: :forbidden) + end + + def organization_scope!(organization) + return true if current_control_api_key.platform_admin? + return true if current_control_api_key.organization_id == organization.id + + render_error("not_found", "The requested resource could not be found", status: :not_found) + false + end + + def scoped_organization!(uuid = params[:organization_uuid] || params[:uuid]) + organization = Organization.present.find_by!(uuid: uuid) + organization_scope!(organization) ? organization : nil + end + + def render_data(data, status: :ok, meta: nil) + body = { data: data, meta: meta || {}, error: nil } + response.set_header("X-Request-Id", request_id) + render json: body, status: status + end + + def render_error(code, message, status:, details: {}) + response.set_header("X-Request-Id", request_id) + render json: { data: nil, meta: {}, error: { code: code, message: message, details: details } }, status: status + end + + def pagination_for(scope) + page = [params.fetch(:page, 1).to_i, 1].max + per_page = params.fetch(:per_page, DEFAULT_PAGE_SIZE).to_i.clamp(1, MAX_PAGE_SIZE) + [scope.offset((page - 1) * per_page).limit(per_page), { page: page, per_page: per_page }] + end + + def audit!(organization:, resource:, action:, before: {}, after: {}) + AuditEvent.create!(actor_uuid: current_control_api_key.uuid, organization_uuid: organization.uuid, + resource_type: resource.class.name, resource_uuid: resource.uuid, action: action, + request_id: request_id, source_ip: request.remote_ip, + before_metadata: safe_metadata(before), after_metadata: safe_metadata(after), occurred_at: Time.current) + end + + attr_reader :request_id + + def set_request_id + @request_id = request.headers["X-Request-Id"].presence || SecureRandom.uuid + end + + def enforce_rate_limit! + key = "control-api-rate-limit/#{current_control_api_key.uuid}/#{Time.current.to_i / RATE_LIMIT_WINDOW.to_i}" + count = Rails.cache.increment(key, 1, expires_in: RATE_LIMIT_WINDOW) + return if count <= RATE_LIMIT + + render_error("rate_limited", "Too many requests", status: :too_many_requests) + end + + def record_not_found + render_error("not_found", "The requested resource could not be found", status: :not_found) + end + + def parameter_missing(exception) + render_error("invalid_request", "A required parameter is missing", status: :bad_request, details: { parameter: exception.param }) + end + + def unpermitted_parameters(exception) + render_error("invalid_request", "The request contains unsupported parameters", status: :unprocessable_content, details: { parameters: exception.params }) + end + + def record_invalid(exception) + render_error("validation_failed", "The request could not be processed", status: :unprocessable_content, details: exception.record.errors.to_hash) + end + + def safe_metadata(value) + filtered = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters).filter(value.to_h) + JSON.parse(JSON.generate(filtered)) + end + + end + end +end diff --git a/app/controllers/api/v2/credentials_controller.rb b/app/controllers/api/v2/credentials_controller.rb new file mode 100644 index 000000000..eb1770a15 --- /dev/null +++ b/app/controllers/api/v2/credentials_controller.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module API + module V2 + class CredentialsController < BaseController + + before_action { @organization = scoped_organization!(params[:organization_uuid]) } + before_action { @server = @organization&.servers&.present&.find_by!(uuid: params[:server_uuid]) } + before_action(only: :rotate) do + @credential = @server&.credentials&.find_by!(uuid: params[:uuid]) + end + before_action(only: :destroy) { @credential = @server&.credentials&.find_by(uuid: params[:uuid]) } + + def index + require_scope!("servers:read") + return if performed? || @server.nil? + + scope, meta = pagination_for(@server.credentials.order(:name)) + render_data(scope.map { |credential| serialize(credential) }, meta: meta) + end + + def create + require_scope!("credentials:write") + return if performed? || @server.nil? + + with_idempotency do + credential = @server.credentials.create!(credential_params) + audit!(organization: @organization, resource: credential, action: "credential.created", after: serialize(credential)) + render_data(serialize(credential, include_secret: true), status: :created) + end + end + + def destroy + require_scope!("credentials:write") + return if performed? || @server.nil? + + with_idempotency do + return render_error("not_found", "The requested resource could not be found", status: :not_found) unless @credential + + audit!(organization: @organization, resource: @credential, action: "credential.revoked", before: serialize(@credential)) + @credential.destroy! + head :no_content + end + end + + def rotate + require_scope!("credentials:write") + return if performed? || @credential.nil? + return render_error("credential_rotation_unsupported", "SMTP-IP credentials cannot be rotated", status: :unprocessable_content) if @credential.type == "SMTP-IP" + + with_idempotency do + replacement = @server.credentials.create!(type: @credential.type, name: @credential.name, hold: @credential.hold, options: @credential.options) + audit!(organization: @organization, resource: @credential, action: "credential.rotated", before: serialize(@credential), after: serialize(replacement)) + @credential.destroy! + render_data(serialize(replacement, include_secret: true), status: :created) + end + end + + private + + def credential_params + params.permit(:type, :name, :key, :hold) + end + + def serialize(credential, include_secret: false) + data = { uuid: credential.uuid, name: credential.name, type: credential.type, hold: credential.hold, last_used_at: credential.last_used_at, + key_hint: credential.key.present? ? "…#{credential.key.last(4)}" : nil, created_at: credential.created_at } + data[:key] = credential.key if include_secret + data + end + + end + end +end diff --git a/app/controllers/api/v2/domains_controller.rb b/app/controllers/api/v2/domains_controller.rb new file mode 100644 index 000000000..5fef74ea7 --- /dev/null +++ b/app/controllers/api/v2/domains_controller.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module API + module V2 + class DomainsController < BaseController + + before_action { @organization = scoped_organization!(params[:organization_uuid]) } + before_action { @server = @organization&.servers&.present&.find_by!(uuid: params[:server_uuid]) } + before_action(only: [:show, :destroy, :verify, :check_dns]) do + @domain = @server&.domains&.find_by!(uuid: params[:uuid]) + end + + def index + require_scope!("domains:read") + return if performed? || @server.nil? + + scope, meta = pagination_for(@server.domains.order(:name)) + render_data(scope.map { |domain| serialize(domain) }, meta: meta) + end + + def show + require_scope!("domains:read") + return if performed? || @domain.nil? + + render_data(serialize(@domain)) + end + + def create + require_scope!("domains:write") + return if performed? || @server.nil? + + with_idempotency do + domain = @server.domains.create!(domain_params) + audit!(organization: @organization, resource: domain, action: "domain.created", after: serialize(domain)) + render_data(serialize(domain), status: :created) + end + end + + def destroy + require_scope!("domains:write") + return if performed? || @domain.nil? + + with_idempotency do + @domain.destroy! + audit!(organization: @organization, resource: @domain, action: "domain.deleted") + head :no_content + end + end + + def verify + require_scope!("domains:write") + return if performed? || @domain.nil? + + with_idempotency do + @domain.verify_with_dns + audit!(organization: @organization, resource: @domain, action: "domain.verify_requested", after: serialize(@domain)) + render_data(serialize(@domain)) + end + end + + def check_dns + require_scope!("domains:write") + return if performed? || @domain.nil? + + with_idempotency do + @domain.check_dns(:manual) + audit!(organization: @organization, resource: @domain, action: "domain.dns_checked", after: serialize(@domain)) + render_data(serialize(@domain)) + end + end + + private + + def domain_params + params.permit(:name, :verification_method, :outgoing, :incoming, :use_for_any).tap do |attributes| + attributes[:verification_method] ||= "DNS" + end + end + + def serialize(domain) + { + uuid: domain.uuid, name: domain.name, verified: domain.verified?, verification_method: domain.verification_method, + dns_records: { + verification: { type: "TXT", name: domain.name, value: domain.dns_verification_string }, + spf: { type: "TXT", name: domain.name, value: domain.spf_record }, + dkim: { type: "TXT", name: "#{domain.dkim_record_name}.#{domain.name}", value: domain.dkim_record }, + return_path: { type: "CNAME", name: domain.return_path_domain, value: Postal::Config.dns.return_path_domain }, + mx: Array(Postal::Config.dns.mx_records) + }, + dns_status: { checked_at: domain.dns_checked_at, spf: status(domain, :spf), dkim: status(domain, :dkim), mx: status(domain, :mx), return_path: status(domain, :return_path) } + } + end + + def status(domain, type) + { status: domain.public_send("#{type}_status"), error: domain.public_send("#{type}_error") } + end + + end + end +end diff --git a/app/controllers/api/v2/health_controller.rb b/app/controllers/api/v2/health_controller.rb new file mode 100644 index 000000000..e6ee90ce3 --- /dev/null +++ b/app/controllers/api/v2/health_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module API + module V2 + class HealthController < BaseController + + def show + require_scope!("usage:read") + return if performed? + + render_data({ status: "ok" }) + end + + def server + organization = scoped_organization!(params[:organization_uuid]) + return if performed? || organization.nil? + + require_scope!("usage:read") + return if performed? + + server = organization.servers.present.find_by!(uuid: params[:uuid]) + total, unverified, bad_dns = server.domain_stats + render_data({ status: server.suspended? ? "suspended" : "ok", queue_size: server.queue_size, + domains: { total: total, unverified: unverified, dns_errors: bad_dns }, rolling_send_limit: server.throughput_stats }) + end + + end + end +end diff --git a/app/controllers/api/v2/organizations_controller.rb b/app/controllers/api/v2/organizations_controller.rb new file mode 100644 index 000000000..85b23bb10 --- /dev/null +++ b/app/controllers/api/v2/organizations_controller.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module API + module V2 + class OrganizationsController < BaseController + + before_action only: [:show, :update, :destroy, :suspend, :unsuspend] do + @organization = scoped_organization!(params[:uuid]) + end + + def index + require_scope!("organizations:read") + return if performed? + + if current_control_api_key.platform_admin? + scope = Organization.present.order(:created_at) + else + scope = Organization.present.where(id: current_control_api_key.organization_id).order(:created_at) + end + records, meta = pagination_for(scope) + render_data(records.map { |organization| serialize(organization) }, meta: meta.merge(total: scope.count)) + end + + def show + require_scope!("organizations:read") + return if performed? || @organization.nil? + + render_data(serialize(@organization)) + end + + def create + require_scope!("organizations:write") + return if performed? + return render_error("forbidden", "Only platform API keys can create organizations", status: :forbidden) unless current_control_api_key.platform_admin? + + with_idempotency do + organization = nil + Organization.transaction do + owner = find_or_create_owner!(owner_params) + organization = Organization.create!(organization_params.merge(owner: owner)) + OrganizationUser.where(organization: organization, user: owner).first_or_create!.update!(admin: true, all_servers: true) + end + audit!(organization: organization, resource: organization, action: "organization.created", after: serialize(organization)) + render_data(serialize(organization), status: :created) + end + end + + def update + require_scope!("organizations:write") + return if performed? || @organization.nil? + + before = serialize(@organization) + @organization.update!(organization_params.except(:external_customer_id)) + audit!(organization: @organization, resource: @organization, action: "organization.updated", before: before, after: serialize(@organization)) + render_data(serialize(@organization)) + end + + def destroy + require_scope!("organizations:write") + return if performed? || @organization.nil? + + with_idempotency do + @organization.soft_destroy + audit!(organization: @organization, resource: @organization, action: "organization.deleted") + head :no_content + end + end + + def suspend + lifecycle(:suspend) + end + + def unsuspend + lifecycle(:unsuspend) + end + + private + + def lifecycle(operation) + require_scope!("organizations:write") + return if performed? || @organization.nil? + + with_idempotency do + before = serialize(@organization) + operation == :suspend ? @organization.suspend(params[:reason].to_s) : @organization.unsuspend + audit!(organization: @organization, resource: @organization, action: "organization.#{operation}ed", before: before, after: serialize(@organization)) + render_data(serialize(@organization)) + end + end + + def organization_params + params.permit(:name, :permalink, :time_zone, :external_customer_id, :plan_code, + :monthly_outbound_limit, :quota_warning_percent, :quota_action) + end + + def owner_params + params.require(:owner).permit(:email_address, :first_name, :last_name) + end + + def find_or_create_owner!(attributes) + User.find_by(email_address: attributes[:email_address]) || User.create!(attributes.merge(password: SecureRandom.urlsafe_base64(48))) + rescue ActiveRecord::RecordNotUnique + User.find_by!(email_address: attributes[:email_address]) + end + + def serialize(organization) + { + uuid: organization.uuid, name: organization.name, permalink: organization.permalink, time_zone: organization.time_zone, + status: organization.status.downcase, suspension_reason: organization.suspension_reason, + external_customer_id: organization.external_customer_id, plan_code: organization.plan_code, + monthly_outbound_limit: organization.monthly_outbound_limit, quota_warning_percent: organization.quota_warning_percent, + quota_action: organization.quota_action, created_at: organization.created_at, updated_at: organization.updated_at + } + end + + end + end +end diff --git a/app/controllers/api/v2/servers_controller.rb b/app/controllers/api/v2/servers_controller.rb new file mode 100644 index 000000000..7c1e3ab0b --- /dev/null +++ b/app/controllers/api/v2/servers_controller.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module API + module V2 + class ServersController < BaseController + + before_action { @organization = scoped_organization!(params[:organization_uuid]) } + before_action only: [:show, :update, :destroy, :suspend, :unsuspend] do + @server = @organization&.servers&.present&.find_by!(uuid: params[:uuid]) + end + + rescue_from ControlPlane::ProvisionServer::ProvisioningError do + render_error("provisioning_failed", "The mail server could not be provisioned", status: :unprocessable_content) + end + + def index + require_scope!("servers:read") + return if performed? || @organization.nil? + + scope = @organization.servers.present.order(:created_at) + records, meta = pagination_for(scope) + render_data(records.map { |server| serialize(server) }, meta: meta.merge(total: scope.count)) + end + + def show + require_scope!("servers:read") + return if performed? || @server.nil? + + render_data(serialize(@server)) + end + + def create + require_scope!("servers:write") + return if performed? || @organization.nil? + + with_idempotency do + server = ControlPlane::ProvisionServer.new(organization: @organization, attributes: server_params).call + audit!(organization: @organization, resource: server, action: "server.created", after: serialize(server)) + render_data(serialize(server), status: :created) + end + end + + def update + require_scope!("servers:write") + return if performed? || @server.nil? + + before = serialize(@server) + @server.update!(server_params) + audit!(organization: @organization, resource: @server, action: "server.updated", before: before, after: serialize(@server)) + render_data(serialize(@server)) + end + + def destroy + require_scope!("servers:write") + return if performed? || @server.nil? + + @server.soft_destroy + audit!(organization: @organization, resource: @server, action: "server.deleted") + head :no_content + end + + def suspend + lifecycle(:suspend) + end + + def unsuspend + lifecycle(:unsuspend) + end + + private + + def lifecycle(operation) + require_scope!("servers:write") + return if performed? || @server.nil? + + with_idempotency do + before = serialize(@server) + operation == :suspend ? @server.suspend(params[:reason].to_s) : @server.unsuspend + audit!(organization: @organization, resource: @server, action: "server.#{operation}ed", before: before, after: serialize(@server)) + render_data(serialize(@server)) + end + end + + def server_params + params.permit(:name, :permalink, :mode, :send_limit, :message_retention_days, :raw_message_retention_days, + :raw_message_retention_size, :allow_sender, :privacy_mode, :postmaster_address) + end + + def serialize(server) + { + uuid: server.uuid, name: server.name, permalink: server.permalink, mode: server.mode, status: server.status.downcase, + suspension_reason: server.actual_suspension_reason, hourly_send_limit: server.send_limit, + retention: { messages_days: server.message_retention_days, raw_messages_days: server.raw_message_retention_days, raw_message_size: server.raw_message_retention_size }, + tracking: { allow_sender: server.allow_sender, privacy_mode: server.privacy_mode }, created_at: server.created_at, updated_at: server.updated_at + } + end + + end + end +end diff --git a/app/controllers/api/v2/usage_controller.rb b/app/controllers/api/v2/usage_controller.rb new file mode 100644 index 000000000..606dd7b49 --- /dev/null +++ b/app/controllers/api/v2/usage_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module API + module V2 + class UsageController < BaseController + + before_action { @organization = scoped_organization!(params[:organization_uuid] || params[:uuid]) } + before_action do + @server = @organization&.servers&.present&.find_by!(uuid: params[:uuid]) if action_name == "server" + end + + rescue_from ArgumentError do |exception| + render_error("invalid_date_range", exception.message, status: :unprocessable_content) + end + + def organization + report + end + + def server + report + end + + private + + def report + require_scope!("usage:read") + return if performed? || @organization.nil? || (action_name == "server" && @server.nil?) + + servers = @server ? [@server] : @organization.servers.present.where(suspended_at: nil).to_a + data = ControlPlane::UsageReport.new(servers: servers, + from: parse_time(:from, 30.days.ago), to: parse_time(:to, Time.current), + granularity: params.fetch(:granularity, "daily")).call + data[:bounce_rate] = data[:totals][:outgoing].positive? ? (data[:totals][:bounces] / data[:totals][:outgoing].to_f * 100).round(2) : 0 + data[:queue_size] = servers.sum(&:queue_size) + data[:domains] = domain_health(servers) + data[:quota] = quota_status + render_data(data) + end + + def parse_time(key, fallback) + return fallback unless params[key].present? + + Time.iso8601(params[key]) + rescue ArgumentError + raise ArgumentError, "#{key} must be an ISO-8601 timestamp" + end + + def quota_status + limit = @organization.monthly_outbound_limit + accepted = UsageRollup.where(organization: @organization, server_id: 0, period_start: Time.current.utc.to_date.beginning_of_month, + granularity: "monthly", metric: ControlPlane::OutboundQuota::METRIC).pick(:value).to_i + { plan_code: @organization.plan_code, monthly_outbound_limit: limit, outbound_accepted_count: accepted, + percent_used: limit ? (accepted / limit.to_f * 100).round(2) : nil, action: @organization.quota_action } + end + + def domain_health(servers) + total, unverified, dns_errors = servers.each_with_object([0, 0, 0]) do |server, values| + server.domain_stats.each_with_index { |value, index| values[index] += value } + end + { total: total, unverified: unverified, dns_errors: dns_errors } + end + + end + end +end diff --git a/app/controllers/api/v2/webhooks_controller.rb b/app/controllers/api/v2/webhooks_controller.rb new file mode 100644 index 000000000..e7ba2919b --- /dev/null +++ b/app/controllers/api/v2/webhooks_controller.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "uri" + +module API + module V2 + class WebhooksController < BaseController + + before_action { @organization = scoped_organization!(params[:organization_uuid]) } + before_action { @server = @organization&.servers&.present&.find_by!(uuid: params[:server_uuid]) } + before_action(only: [:show, :update, :destroy]) do + @webhook = @server&.webhooks&.find_by!(uuid: params[:uuid]) + end + + def index + require_scope!("servers:read") + return if performed? || @server.nil? + + scope, meta = pagination_for(@server.webhooks.order(:name)) + render_data(scope.map { |webhook| serialize(webhook) }, meta: meta) + end + + def show + require_scope!("servers:read") + return if performed? || @webhook.nil? + + render_data(serialize(@webhook)) + end + + def create + require_scope!("servers:write") + return if performed? || @server.nil? + + with_idempotency do + validate_destination!(webhook_params[:url]) + webhook = @server.webhooks.create!(webhook_create_params) + audit!(organization: @organization, resource: webhook, action: "webhook.created", after: serialize(webhook)) + render_data(serialize(webhook), status: :created) + end + rescue Postal::HTTP::BlockedDestinationError, URI::InvalidURIError, SocketError + render_error("unsafe_webhook_url", "The webhook URL is not permitted", status: :unprocessable_content) + end + + def update + require_scope!("servers:write") + return if performed? || @webhook.nil? + + with_idempotency do + validate_destination!(webhook_params.fetch(:url, @webhook.url)) + @webhook.update!(webhook_params) + audit!(organization: @organization, resource: @webhook, action: "webhook.updated", after: serialize(@webhook)) + render_data(serialize(@webhook)) + end + rescue Postal::HTTP::BlockedDestinationError, URI::InvalidURIError, SocketError + render_error("unsafe_webhook_url", "The webhook URL is not permitted", status: :unprocessable_content) + end + + def destroy + require_scope!("servers:write") + return if performed? || @webhook.nil? + + with_idempotency do + @webhook.destroy! + audit!(organization: @organization, resource: @webhook, action: "webhook.deleted") + head :no_content + end + end + + private + + def webhook_params + params.permit(:name, :url, :all_events, :enabled, events: []) + end + + def webhook_create_params + webhook_params.tap do |attributes| + attributes[:all_events] = true if attributes[:all_events].nil? && attributes[:events].blank? + end + end + + def validate_destination!(url) + raise URI::InvalidURIError if url.blank? + + uri = URI.parse(url) + raise URI::InvalidURIError unless uri.is_a?(URI::HTTPS) && uri.host.present? + + Postal::HTTP::AddressGuard.safe_connect_address(uri.host) + end + + def serialize(webhook) + { uuid: webhook.uuid, name: webhook.name, url: webhook.url, all_events: webhook.all_events, events: webhook.events, enabled: webhook.enabled, last_used_at: webhook.last_used_at } + end + + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d2c36cda5..24498f7ed 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -62,10 +62,13 @@ def append_info_to_payload(payload) end def url_with_return_to(url) - if params[:return_to].blank? || !params[:return_to].starts_with?("/") + return_to = params[:return_to] + if return_to.blank? || + !return_to.start_with?("/") || + return_to.start_with?("//", "/\\") url_for(url) else - params[:return_to] + return_to end end diff --git a/app/controllers/concerns/api/v2/idempotency.rb b/app/controllers/concerns/api/v2/idempotency.rb new file mode 100644 index 000000000..c50f2a4ae --- /dev/null +++ b/app/controllers/concerns/api/v2/idempotency.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module API + module V2 + module Idempotency + + private + + def with_idempotency + key = request.headers["Idempotency-Key"].to_s + return yield if key.blank? + return render_error("invalid_idempotency_key", "Idempotency-Key must be at most 255 characters", status: :bad_request) if key.length > 255 + + digest = Digest::SHA256.hexdigest([request.request_method, request.path, request.raw_post].join("\n")) + record, created = reserve_idempotency_record(key, digest) + return replay_idempotency_record(record, digest) unless created + + yield + record.update!(response_status: response.status, response_body: response.body) + rescue StandardError + record&.destroy! if created && record&.persisted? && record.response_status.nil? + raise + end + + def reserve_idempotency_record(key, digest) + record = current_control_api_key.idempotency_records.create!( + key: key, request_method: request.request_method, request_path: request.path, payload_digest: digest + ) + [record, true] + rescue ActiveRecord::RecordNotUnique + [current_control_api_key.idempotency_records.find_by!(key: key), false] + end + + def replay_idempotency_record(record, digest) + return render_error("idempotency_conflict", "Idempotency-Key was reused with a different request", status: :conflict) if record.payload_digest != digest + return render_error("idempotency_in_progress", "A request with this Idempotency-Key is still being processed", status: :conflict) if record.response_status.nil? + + return head(record.response_status) if record.response_body.blank? + + render json: JSON.parse(record.response_body), status: record.response_status + end + + end + end +end diff --git a/app/controllers/legacy_api/messages_controller.rb b/app/controllers/legacy_api/messages_controller.rb index 6d78c1618..239eee3cd 100644 --- a/app/controllers/legacy_api/messages_controller.rb +++ b/app/controllers/legacy_api/messages_controller.rb @@ -15,12 +15,9 @@ class MessagesController < BaseController # OR an error if the message does not exist. # def message - if api_params["id"].blank? - render_parameter_error "`id` parameter is required but is missing" - return - end + message = find_message + return if performed? - message = @current_credential.server.message(api_params["id"]) message_hash = { id: message.id, token: message.token } expansions = api_params["_expansions"] @@ -111,12 +108,9 @@ def message # OR an error if the message does not exist. # def deliveries - if api_params["id"].blank? - render_parameter_error "`id` parameter is required but is missing" - return - end + message = find_message + return if performed? - message = @current_credential.server.message(api_params["id"]) deliveries = message.deliveries.map do |d| { id: d.id, @@ -136,5 +130,37 @@ def deliveries id: api_params["id"] end + private + + # Look up the message referenced by the request's `id` parameter. + # + # The legacy API only ever identifies a message by its integer ID. The + # request body is parsed as JSON, so without validation a JSON object or + # array supplied for `id` would arrive as a Ruby Hash/Array and be passed + # straight through to the message database as a raw set of SQL conditions. + # We therefore reject anything that is not a simple scalar before it can + # reach the database and coerce the value to an integer ID. + # + # Renders an error and returns nil when the parameter is missing or is not + # a scalar; otherwise returns the matched message (raising NotFound when no + # message matches, which the actions rescue). + # + # @return [Postal::MessageDB::Message, nil] + def find_message + id = api_params["id"] + + if id.blank? + render_parameter_error "`id` parameter is required but is missing" + return + end + + unless id.is_a?(String) || id.is_a?(Integer) + render_parameter_error "`id` parameter must be a string or integer" + return + end + + @current_credential.server.message(id.to_i) + end + end end diff --git a/app/controllers/legacy_api/send_controller.rb b/app/controllers/legacy_api/send_controller.rb index 48adedf09..14f463706 100644 --- a/app/controllers/legacy_api/send_controller.rb +++ b/app/controllers/legacy_api/send_controller.rb @@ -62,7 +62,11 @@ def message message.credential = @current_credential if message.valid? result = message.create_messages - render_success message_id: message.message_id, messages: result + if result + render_success message_id: message.message_id, messages: result + else + render_error "OrganizationMonthlyQuotaExceeded", message: "The organization monthly outbound quota has been reached" + end else render_error message.errors.first, message: ERROR_MESSAGES[message.errors.first] end @@ -113,7 +117,16 @@ def raw # Store the result ready to return result = { message_id: nil, messages: {} } if api_params["rcpt_to"].is_a?(Array) - api_params["rcpt_to"].uniq.each do |rcpt_to| + recipients = api_params["rcpt_to"].uniq + quota_decision = nil + if recipients.any? + quota_decision = ControlPlane::OutboundQuota.reserve!(@current_credential.server, count: recipients.size) + if quota_decision.suspend? + render_error "OrganizationMonthlyQuotaExceeded", message: "The organization monthly outbound quota has been reached" + return + end + end + recipients.each do |rcpt_to| message = @current_credential.server.message_db.new_message message.rcpt_to = rcpt_to message.mail_from = api_params["mail_from"] @@ -123,7 +136,12 @@ def raw message.domain_id = authenticated_domain.id message.credential_id = @current_credential.id message.bounce = api_params["bounce"] ? true : false - message.save + if quota_decision&.hold? + message.save(queue_on_create: false) + message.create_delivery("Held", details: "Organization monthly outbound quota (#{quota_decision.limit}) has been reached.") + else + message.save + end result[:message_id] = message.message_id if result[:message_id].nil? result[:messages][rcpt_to] = { id: message.id, token: message.token } end diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index ea0e940ce..9d59f08e4 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -89,6 +89,18 @@ def deliveries end def html_raw + override_content_security_policy_directives( + default_src: %w('none'), + script_src: %w('none'), + style_src: %w('unsafe-inline'), + img_src: %w(* data:), + font_src: %w(*), + frame_ancestors: %w('self'), + form_action: %w('none'), + base_uri: %w('none') + ) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "no-referrer" render html: @message.html_body_without_tracking_image.html_safe end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c6acf4563..17d124217 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -3,6 +3,7 @@ module ApplicationHelper def format_delivery_details(server, text) + text = h(text) text.gsub!(//) do id = ::Regexp.last_match(1).to_i link_to("message ##{id}", organization_server_message_path(server.organization, server, id), class: "u-link") @@ -32,7 +33,7 @@ def domain_options_for_select(server, selected_domain = nil, options = {}) s << "" server_domains.each do |domain| selected = domain == selected_domain ? "selected='selected'" : "" - s << "" + s << "" end s << "" end @@ -42,7 +43,7 @@ def domain_options_for_select(server, selected_domain = nil, options = {}) s << "" organization_domains.each do |domain| selected = domain == selected_domain ? "selected='selected'" : "" - s << "" + s << "" end s << "" end @@ -59,7 +60,7 @@ def endpoint_options_for_select(server, selected_value = nil, options = {}) http_endpoints.each do |endpoint| value = "#{endpoint.class}##{endpoint.uuid}" selected = value == selected_value ? "selected='selected'" : "" - s << "" + s << "" end s << "" end @@ -70,7 +71,7 @@ def endpoint_options_for_select(server, selected_value = nil, options = {}) smtp_endpoints.each do |endpoint| value = "#{endpoint.class}##{endpoint.uuid}" selected = value == selected_value ? "selected='selected'" : "" - s << "" + s << "" end s << "" end @@ -81,7 +82,7 @@ def endpoint_options_for_select(server, selected_value = nil, options = {}) address_endpoints.each do |endpoint| value = "#{endpoint.class}##{endpoint.uuid}" selected = value == selected_value ? "selected='selected'" : "" - s << "" + s << "" end s << "" end @@ -93,7 +94,7 @@ def endpoint_options_for_select(server, selected_value = nil, options = {}) selected = (selected_value == mode ? "selected='selected'" : "") text = t("route_modes.#{mode.underscore}") - s << "" + s << "" end s << "" end diff --git a/app/lib/smtp_server/client.rb b/app/lib/smtp_server/client.rb index 2a70667c9..cf601b19a 100644 --- a/app/lib/smtp_server/client.rb +++ b/app/lib/smtp_server/client.rb @@ -487,6 +487,17 @@ def finished end end + credential_recipients = @recipients.select { |recipient| recipient.first == :credential } + if credential_recipients.any? + @quota_decision = ControlPlane::OutboundQuota.reserve!(@credential.server, count: credential_recipients.size) + if @quota_decision.suspend? + transaction_reset + @state = :welcomed + increment_error_count("organization-monthly-quota-exceeded") + return "552 Organization monthly outbound quota has been reached" + end + end + @recipients.each do |recipient| type, rcpt_to, server, options = recipient @@ -503,7 +514,12 @@ def finished message.scope = "outgoing" message.domain_id = authenticated_domain&.id message.credential_id = @credential.id - message.save + if @quota_decision&.hold? + message.save(queue_on_create: false) + message.create_delivery("Held", details: "Organization monthly outbound quota (#{@quota_decision.limit}) has been reached.") + else + message.save + end when :bounce increment_message_count("bounce") diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb new file mode 100644 index 000000000..d520b4b30 --- /dev/null +++ b/app/models/audit_event.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AuditEvent < ApplicationRecord + + serialize :before_metadata, type: Hash + serialize :after_metadata, type: Hash + + validates :resource_type, :action, :occurred_at, presence: true + +end diff --git a/app/models/control_api_key.rb b/app/models/control_api_key.rb new file mode 100644 index 000000000..3b0eeb658 --- /dev/null +++ b/app/models/control_api_key.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class ControlAPIKey < ApplicationRecord + + include HasUUID + + TOKEN_PREFIX = "postal_cp_" + TOKEN_BYTES = 32 + SCOPES = %w[ + platform:admin organizations:read organizations:write servers:read servers:write + domains:read domains:write credentials:write usage:read + ].freeze + + belongs_to :organization, optional: true + has_many :idempotency_records, dependent: :destroy + + serialize :scopes, type: Array + + validates :name, :scopes, presence: true + validates :token_digest, presence: true, uniqueness: true + validate :scopes_are_supported + validate :organization_scope_cannot_be_platform_admin + + scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) } + + def self.issue!(name:, scopes:, organization: nil, expires_at: nil) + token = "#{TOKEN_PREFIX}#{SecureRandom.urlsafe_base64(TOKEN_BYTES, false)}" + key = create!(name: name, scopes: scopes, organization: organization, expires_at: expires_at, token_digest: digest(token)) + [key, token] + end + + def self.authenticate(token) + return if token.blank? + + digest = digest(token) + candidate = active.find_by(token_digest: digest) + return unless candidate + return unless ActiveSupport::SecurityUtils.secure_compare(candidate.token_digest, digest) + + candidate.tap { |key| key.update_column(:last_used_at, Time.current) } + end + + def self.digest(token) + Digest::SHA256.hexdigest(token.to_s) + end + + def active? + revoked_at.nil? && (expires_at.nil? || expires_at > Time.current) + end + + def allows?(scope) + scopes.include?("platform:admin") || scopes.include?(scope) + end + + def platform_admin? + scopes.include?("platform:admin") && organization_id.nil? + end + + private + + def scopes_are_supported + invalid = scopes.to_a - SCOPES + errors.add(:scopes, "contain unsupported values") if invalid.any? + end + + def organization_scope_cannot_be_platform_admin + return unless organization_id && scopes.to_a.include?("platform:admin") + + errors.add(:scopes, "cannot include platform:admin for an organization-scoped key") + end + +end diff --git a/app/models/http_endpoint.rb b/app/models/http_endpoint.rb index 8cd9044f3..393b78536 100644 --- a/app/models/http_endpoint.rb +++ b/app/models/http_endpoint.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "uri" + # == Schema Information # # Table name: http_endpoints @@ -38,6 +40,7 @@ class HTTPEndpoint < ApplicationRecord validates :name, presence: true validates :url, presence: true + validate :url_must_be_http_or_https validates :encoding, inclusion: { in: ENCODINGS } validates :format, inclusion: { in: FORMATS } validates :timeout, numericality: { greater_than_or_equal_to: 5, less_than_or_equal_to: 60 } @@ -56,4 +59,17 @@ def update_routes routes.each { |r| r.update(endpoint: nil, mode: "Reject") } end + private + + def url_must_be_http_or_https + return if url.blank? + + uri = URI.parse(url) + return if uri.is_a?(URI::HTTP) && uri.host.present? + + errors.add(:url, "must be an HTTP or HTTPS URL") + rescue URI::InvalidURIError + errors.add(:url, "must be a valid HTTP or HTTPS URL") + end + end diff --git a/app/models/idempotency_record.rb b/app/models/idempotency_record.rb new file mode 100644 index 000000000..5820c9584 --- /dev/null +++ b/app/models/idempotency_record.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class IdempotencyRecord < ApplicationRecord + + belongs_to :control_api_key + + validates :key, :request_method, :request_path, :payload_digest, presence: true + +end diff --git a/app/models/organization.rb b/app/models/organization.rb index 60841dc45..7c3b58214 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -35,6 +35,9 @@ class Organization < ApplicationRecord validates :name, presence: true validates :permalink, presence: true, format: { with: /\A[a-z0-9-]*\z/ }, uniqueness: { case_sensitive: false }, exclusion: { in: RESERVED_PERMALINKS } validates :time_zone, presence: true + validates :quota_action, inclusion: { in: %w[monitor hold suspend] }, allow_nil: true + validates :quota_warning_percent, numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: 100 }, allow_nil: true + validates :monthly_outbound_limit, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true default_value :time_zone, -> { "UTC" } default_value :permalink, -> { Organization.find_unique_permalink(name) if name } @@ -71,6 +74,14 @@ def suspended? suspended_at.present? end + def suspend(reason) + update!(suspended_at: Time.current, suspension_reason: reason) + end + + def unsuspend + update!(suspended_at: nil, suspension_reason: nil) + end + def user_assignment(user) @user_assignments ||= {} @user_assignments[user.id] ||= organization_users.where(user: user).first diff --git a/app/models/outgoing_message_prototype.rb b/app/models/outgoing_message_prototype.rb index 8584b72c5..5064c6a9a 100644 --- a/app/models/outgoing_message_prototype.rb +++ b/app/models/outgoing_message_prototype.rb @@ -32,6 +32,7 @@ def initialize(server, ip, source_type, attributes) end attr_reader :message_id + attr_reader :quota_decision def from_address Postal::Helpers.strip_name_from_address(@from) @@ -74,9 +75,12 @@ def all_addresses def create_messages if valid? + @quota_decision = ControlPlane::OutboundQuota.reserve!(@server, count: all_addresses.size) + return false if @quota_decision.suspend? + all_addresses.each_with_object({}) do |address, hash| if address = Postal::Helpers.strip_name_from_address(address) - hash[address] = create_message(address) + hash[address] = create_message(address, quota_decision: @quota_decision) end end else @@ -183,7 +187,7 @@ def raw_message end end - def create_message(address) + def create_message(address, quota_decision: nil) message = @server.message_db.new_message message.scope = "outgoing" message.rcpt_to = address @@ -194,7 +198,12 @@ def create_message(address) message.credential_id = credential&.id message.received_with_ssl = true message.bounce = @bounce - message.save + if quota_decision&.hold? + message.save(queue_on_create: false) + message.create_delivery("Held", details: "Organization monthly outbound quota (#{quota_decision.limit}) has been reached.") + else + message.save + end { id: message.id, token: message.token } end diff --git a/app/models/usage_rollup.rb b/app/models/usage_rollup.rb new file mode 100644 index 000000000..b6d4cb78f --- /dev/null +++ b/app/models/usage_rollup.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class UsageRollup < ApplicationRecord + + belongs_to :organization + belongs_to :server, optional: true + + validates :period_start, :granularity, :metric, presence: true + validates :value, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + +end diff --git a/app/services/control_plane/outbound_quota.rb b/app/services/control_plane/outbound_quota.rb new file mode 100644 index 000000000..ab18ea5bb --- /dev/null +++ b/app/services/control_plane/outbound_quota.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module ControlPlane + class OutboundQuota + + Decision = Struct.new(:action, :limit, :used, :requested, keyword_init: true) do + def allow? + action == :allow + end + + def hold? + action == :hold + end + + def suspend? + action == :suspend + end + end + + METRIC = "outbound_accepted" + + class << self + + def reserve!(server, count: 1) + new(server, count).reserve! + end + + end + + def initialize(server, count) + @server = server + @organization = server.organization + @count = count.to_i + end + + def reserve! + raise ArgumentError, "count must be positive" unless @count.positive? + + @organization.with_lock do + rollup = UsageRollup.lock.find_or_create_by!(organization: @organization, server_id: 0, + period_start: Time.current.utc.to_date.beginning_of_month, + granularity: "monthly", metric: METRIC) + used = rollup.value + limit = @organization.monthly_outbound_limit + unless limit && used + @count > limit + rollup.update!(value: used + @count) + next Decision.new(action: :allow, limit: limit, used: used + @count, requested: @count) + end + + case @organization.quota_action + when "hold" + rollup.update!(value: used + @count) + Decision.new(action: :hold, limit: limit, used: used + @count, requested: @count) + when "suspend" + @organization.suspend("Monthly outbound quota of #{limit} reached") unless @organization.suspended? + Decision.new(action: :suspend, limit: limit, used: used, requested: @count) + else + rollup.update!(value: used + @count) + Decision.new(action: :allow, limit: limit, used: used + @count, requested: @count) + end + end + end + + end +end diff --git a/app/services/control_plane/provision_server.rb b/app/services/control_plane/provision_server.rb new file mode 100644 index 000000000..b40347172 --- /dev/null +++ b/app/services/control_plane/provision_server.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module ControlPlane + class ProvisionServer + + class ProvisioningError < StandardError; end + + def initialize(organization:, attributes:) + @organization = organization + @attributes = attributes + end + + def call + server = @organization.servers.build(@attributes) + server.provision_database = false + server.validate! + + Server.transaction { server.save! } + begin + server.message_db.provisioner.provision + rescue StandardError => _e + cleanup(server) + raise ProvisioningError, "Mail server provisioning failed" + end + server + end + + private + + def cleanup(server) + server.message_db.provisioner.drop + rescue StandardError + nil + ensure + server.provision_database = false + server.destroy! + end + + end +end diff --git a/app/services/control_plane/usage_report.rb b/app/services/control_plane/usage_report.rb new file mode 100644 index 000000000..7cdd12143 --- /dev/null +++ b/app/services/control_plane/usage_report.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module ControlPlane + class UsageReport + + MAX_RANGE = 93.days + GRANULARITIES = %w[hourly daily monthly].freeze + + def initialize(servers:, from:, to:, granularity:) + @servers = servers + @from = from + @to = to + @granularity = granularity + end + + def call + validate! + series = {} + totals = Hash.new(0) + @servers.each do |server| + server.message_db.statistics.get(@granularity.to_sym, metrics, @to, periods).each do |time, values| + bucket = (series[time.iso8601] ||= Hash.new(0)) + metrics.each do |metric| + bucket[metric] += values[metric].to_i + totals[metric] += values[metric].to_i + end + end + end + { totals: totals, series: series.map { |time, values| values.merge(time: time) }, rolling: rolling } + end + + private + + def metrics + [:outgoing, :incoming, :bounces, :held, :spam] + end + + def periods + case @granularity + when "hourly" then ((@to - @from) / 1.hour).ceil + when "daily" then ((@to - @from) / 1.day).ceil + else (((@to.to_date.year * 12) + @to.to_date.month) - ((@from.to_date.year * 12) + @from.to_date.month) + 1) + end + end + + def rolling + outgoing = @servers.sum { |server| server.message_db.live_stats.total(60, types: [:outgoing]) } + limit = @servers.sum { |server| server.send_limit.to_i } + { outgoing: outgoing, limit: limit.positive? ? limit : nil, percent_used: limit.positive? ? (outgoing / limit.to_f * 100).round(2) : nil } + end + + def validate! + raise ArgumentError, "granularity is invalid" unless GRANULARITIES.include?(@granularity) + raise ArgumentError, "from must be before to" if @from >= @to + raise ArgumentError, "date range is too large" if @to - @from > MAX_RANGE + end + + end +end diff --git a/app/views/messages/html.html.haml b/app/views/messages/html.html.haml index 357f2cb50..826f7e176 100644 --- a/app/views/messages/html.html.haml +++ b/app/views/messages/html.html.haml @@ -14,4 +14,4 @@ This means that we no longer store the raw data for this e-mail or the e-mail didn't include a HTML part. - else - %iframe{:width => "100%", :height => "100%", :src => html_raw_organization_server_message_path(organization, @server, @message.id)} + %iframe{:width => "100%", :height => "100%", :sandbox => "allow-popups allow-popups-to-escape-sandbox", :referrerpolicy => "no-referrer", :src => html_raw_organization_server_message_path(organization, @server, @message.id)} diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index 615869a88..7fd2be747 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -7,4 +7,5 @@ # notations and behaviors. Rails.application.config.filter_parameters += [ :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, + :authorization, :credential, :key, :dkim_private_key, ] diff --git a/config/routes.rb b/config/routes.rb index 11ef1798c..a3a1c0156 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,31 @@ # frozen_string_literal: true Rails.application.routes.draw do + namespace :api do + namespace :v2 do + get "health", to: "health#show" + resources :organizations, param: :uuid, only: [:create, :index, :show, :update, :destroy] do + post :suspend, on: :member + post :unsuspend, on: :member + get :usage, on: :member, controller: "usage", action: :organization + resources :servers, param: :uuid, controller: "servers", only: [:create, :index, :show, :update, :destroy] do + post :suspend, on: :member + post :unsuspend, on: :member + get :usage, on: :member, controller: "usage", action: :server + get :health, on: :member, controller: "health", action: :server + resources :domains, param: :uuid, controller: "domains", only: [:create, :index, :show, :destroy] do + post :verify, on: :member + post "check-dns", on: :member, action: :check_dns + end + resources :credentials, param: :uuid, controller: "credentials", only: [:create, :index, :destroy] do + post :rotate, on: :member + end + resources :webhooks, param: :uuid, controller: "webhooks", only: [:create, :index, :show, :update, :destroy] + end + end + end + end + # Legacy API Routes match "/api/v1/send/message" => "legacy_api/send#message", via: [:get, :post, :patch, :put] match "/api/v1/send/raw" => "legacy_api/send#raw", via: [:get, :post, :patch, :put] diff --git a/db/migrate/20260830000001_create_control_api_keys.rb b/db/migrate/20260830000001_create_control_api_keys.rb new file mode 100644 index 000000000..db1bb3b01 --- /dev/null +++ b/db/migrate/20260830000001_create_control_api_keys.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateControlAPIKeys < ActiveRecord::Migration[7.0] + + def change + create_table :control_api_keys do |t| + t.string :uuid, null: false + t.string :name, null: false + t.string :token_digest, null: false + t.text :scopes, null: false + t.references :organization, type: :integer, foreign_key: true + t.datetime :last_used_at + t.datetime :expires_at + t.datetime :revoked_at + t.timestamps + end + + add_index :control_api_keys, :uuid, unique: true + add_index :control_api_keys, :token_digest, unique: true + end + +end diff --git a/db/migrate/20260830000002_create_control_plane_audit_and_idempotency_records.rb b/db/migrate/20260830000002_create_control_plane_audit_and_idempotency_records.rb new file mode 100644 index 000000000..83b5e982c --- /dev/null +++ b/db/migrate/20260830000002_create_control_plane_audit_and_idempotency_records.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class CreateControlPlaneAuditAndIdempotencyRecords < ActiveRecord::Migration[7.0] + + def change + create_table :audit_events do |t| + t.string :actor_uuid + t.string :organization_uuid + t.string :resource_type, null: false + t.string :resource_uuid + t.string :action, null: false + t.string :request_id + t.string :source_ip + t.text :before_metadata + t.text :after_metadata + t.datetime :occurred_at, null: false + t.timestamps + end + + add_index :audit_events, [:organization_uuid, :occurred_at] + add_index :audit_events, :request_id + + create_table :idempotency_records do |t| + t.references :control_api_key, null: false, foreign_key: true + t.string :key, null: false + t.string :request_method, null: false + t.string :request_path, null: false + t.string :payload_digest, null: false + t.integer :response_status + t.text :response_body + t.timestamps + end + + add_index :idempotency_records, [:control_api_key_id, :key], unique: true, name: "index_idempotency_records_on_key_and_control_api_key" + end + +end diff --git a/db/migrate/20260830000003_add_control_plane_quota_fields_to_organizations.rb b/db/migrate/20260830000003_add_control_plane_quota_fields_to_organizations.rb new file mode 100644 index 000000000..4a148399e --- /dev/null +++ b/db/migrate/20260830000003_add_control_plane_quota_fields_to_organizations.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddControlPlaneQuotaFieldsToOrganizations < ActiveRecord::Migration[7.0] + + def change + add_column :organizations, :external_customer_id, :string + add_column :organizations, :plan_code, :string + add_column :organizations, :monthly_outbound_limit, :bigint + add_column :organizations, :quota_warning_percent, :integer, default: 80, null: false + add_column :organizations, :quota_action, :string, default: "monitor", null: false + add_index :organizations, :external_customer_id + end + +end diff --git a/db/migrate/20260830000004_create_usage_rollups.rb b/db/migrate/20260830000004_create_usage_rollups.rb new file mode 100644 index 000000000..24eef6908 --- /dev/null +++ b/db/migrate/20260830000004_create_usage_rollups.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateUsageRollups < ActiveRecord::Migration[7.0] + + def change + create_table :usage_rollups do |t| + t.references :organization, type: :integer, null: false, foreign_key: true + t.integer :server_id, null: false, default: 0 + t.date :period_start, null: false + t.string :granularity, null: false + t.string :metric, null: false + t.bigint :value, null: false, default: 0 + t.timestamps + end + + add_index :usage_rollups, [:organization_id, :server_id, :period_start, :granularity, :metric], unique: true, name: "index_usage_rollups_on_metric_period" + end + +end diff --git a/db/migrate/20260830000005_make_user_email_addresses_unique.rb b/db/migrate/20260830000005_make_user_email_addresses_unique.rb new file mode 100644 index 000000000..32ef9069d --- /dev/null +++ b/db/migrate/20260830000005_make_user_email_addresses_unique.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class MakeUserEmailAddressesUnique < ActiveRecord::Migration[7.0] + + def change + remove_index :users, :email_address + add_index :users, :email_address, unique: true, length: 255 + end + +end diff --git a/db/schema.rb b/db/schema.rb index 9ab08fdb8..37cf471be 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_03_11_205229) do +ActiveRecord::Schema[7.0].define(version: 2026_08_30_000005) do create_table "additional_route_endpoints", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.integer "route_id" t.string "endpoint_type" @@ -28,6 +28,23 @@ t.datetime "updated_at", precision: nil, null: false end + create_table "audit_events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| + t.string "actor_uuid" + t.string "organization_uuid" + t.string "resource_type", null: false + t.string "resource_uuid" + t.string "action", null: false + t.string "request_id" + t.string "source_ip" + t.text "before_metadata" + t.text "after_metadata" + t.datetime "occurred_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["organization_uuid", "occurred_at"], name: "index_audit_events_on_organization_uuid_and_occurred_at" + t.index ["request_id"], name: "index_audit_events_on_request_id" + end + create_table "authie_sessions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "token" t.string "browser_id" @@ -74,6 +91,22 @@ t.string "uuid" end + create_table "control_api_keys", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| + t.string "uuid", null: false + t.string "name", null: false + t.string "token_digest", null: false + t.text "scopes", null: false + t.integer "organization_id" + t.datetime "last_used_at" + t.datetime "expires_at" + t.datetime "revoked_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["organization_id"], name: "index_control_api_keys_on_organization_id" + t.index ["token_digest"], name: "index_control_api_keys_on_token_digest", unique: true + t.index ["uuid"], name: "index_control_api_keys_on_uuid", unique: true + end + create_table "domains", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.integer "server_id" t.string "uuid" @@ -130,6 +163,20 @@ t.integer "priority" end + create_table "idempotency_records", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| + t.bigint "control_api_key_id", null: false + t.string "key", null: false + t.string "request_method", null: false + t.string "request_path", null: false + t.string "payload_digest", null: false + t.integer "response_status" + t.text "response_body" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["control_api_key_id", "key"], name: "index_idempotency_records_on_key_and_control_api_key", unique: true + t.index ["control_api_key_id"], name: "index_idempotency_records_on_control_api_key_id" + end + create_table "ip_pool_rules", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "uuid" t.string "owner_type" @@ -178,6 +225,12 @@ t.datetime "deleted_at" t.datetime "suspended_at" t.string "suspension_reason" + t.string "external_customer_id" + t.string "plan_code" + t.bigint "monthly_outbound_limit" + t.integer "quota_warning_percent", default: 80, null: false + t.string "quota_action", default: "monitor", null: false + t.index ["external_customer_id"], name: "index_organizations_on_external_customer_id" t.index ["permalink"], name: "index_organizations_on_permalink", length: 8 t.index ["uuid"], name: "index_organizations_on_uuid", length: 8 end @@ -316,6 +369,19 @@ t.index ["uuid"], name: "index_user_invites_on_uuid", length: 12 end + create_table "usage_rollups", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| + t.integer "organization_id", null: false + t.integer "server_id", default: 0, null: false + t.date "period_start", null: false + t.string "granularity", null: false + t.string "metric", null: false + t.bigint "value", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["organization_id", "server_id", "period_start", "granularity", "metric"], name: "index_usage_rollups_on_metric_period", unique: true + t.index ["organization_id"], name: "index_usage_rollups_on_organization_id" + end + create_table "users", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "uuid" t.string "first_name" @@ -332,7 +398,7 @@ t.boolean "admin", default: false t.string "oidc_uid" t.string "oidc_issuer" - t.index ["email_address"], name: "index_users_on_email_address", length: 8 + t.index ["email_address"], name: "index_users_on_email_address", unique: true, length: 255 t.index ["uuid"], name: "index_users_on_uuid", length: 8 end diff --git a/doc/config/environment-variables.md b/doc/config/environment-variables.md index 940424e04..56fc70a5e 100644 --- a/doc/config/environment-variables.md +++ b/doc/config/environment-variables.md @@ -18,6 +18,7 @@ This document contains all the environment variables which are available for thi | `POSTAL_SIGNING_KEY_PATH` | String | Path to the private key used for signing | $config-file-root/signing.key | | `POSTAL_SMTP_RELAYS` | Array of strings | An array of SMTP relays in the format of smtp://host:port | [] | | `POSTAL_TRUSTED_PROXIES` | Array of strings | An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses) | [] | +| `POSTAL_ALLOWED_REQUEST_DESTINATIONS` | Array of strings | Hostnames or IP/CIDR ranges that outbound webhook and HTTP endpoint requests are permitted to reach even when they resolve to a private, loopback, link-local or otherwise reserved address. All other such destinations are blocked to prevent SSRF. | [] | | `POSTAL_QUEUED_MESSAGE_LOCK_STALE_DAYS` | Integer | The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried. | 1 | | `POSTAL_BATCH_QUEUED_MESSAGES` | Boolean | When enabled queued messages will be de-queued in batches based on their destination | true | | `WEB_SERVER_DEFAULT_PORT` | Integer | The default port the web server should listen on unless overriden by the PORT environment variable | 5000 | diff --git a/doc/config/yaml.yml b/doc/config/yaml.yml index f3a735a9f..4e0406d38 100644 --- a/doc/config/yaml.yml +++ b/doc/config/yaml.yml @@ -29,6 +29,8 @@ postal: smtp_relays: [] # An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses) trusted_proxies: [] + # Hostnames or IP/CIDR ranges that outbound webhook and HTTP endpoint requests are permitted to reach even when they resolve to a private, loopback, link-local or otherwise reserved address. All other such destinations are blocked to prevent SSRF. + allowed_request_destinations: [] # The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried. queued_message_lock_stale_days: 1 # When enabled queued messages will be de-queued in batches based on their destination diff --git a/docs/api/v2-openapi.yml b/docs/api/v2-openapi.yml new file mode 100644 index 000000000..cc739d4bc --- /dev/null +++ b/docs/api/v2-openapi.yml @@ -0,0 +1,102 @@ +openapi: 3.1.0 +info: + title: Postal Control API + version: v2 + description: Management control plane for Postal organizations, servers, domains, credentials, webhooks, and usage. +servers: + - url: https://postal.example +security: + - bearerAuth: [] +paths: + /api/v2/organizations: + get: + summary: List organizations + responses: { '200': { $ref: '#/components/responses/Envelope' } } + post: + summary: Create an organization and owner + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OrganizationInput' } } } } + responses: { '201': { $ref: '#/components/responses/Envelope' }, '422': { $ref: '#/components/responses/Error' } } + /api/v2/organizations/{uuid}: + parameters: [{ $ref: '#/components/parameters/OrganizationUuid' }] + get: { summary: Get an organization, responses: { '200': { $ref: '#/components/responses/Envelope' } } } + patch: { summary: Update an organization, responses: { '200': { $ref: '#/components/responses/Envelope' } } } + delete: { summary: Soft-delete an organization, responses: { '204': { description: Deleted } } } + /api/v2/organizations/{uuid}/suspend: + post: { summary: Suspend organization, responses: { '200': { $ref: '#/components/responses/Envelope' } } } + /api/v2/organizations/{uuid}/unsuspend: + post: { summary: Reactivate organization, responses: { '200': { $ref: '#/components/responses/Envelope' } } } + /api/v2/organizations/{organization_uuid}/servers: + parameters: [{ $ref: '#/components/parameters/OrganizationUuidNested' }] + get: { summary: List servers, responses: { '200': { $ref: '#/components/responses/Envelope' } } } + post: { summary: Provision server synchronously, responses: { '201': { $ref: '#/components/responses/Envelope' } } } + /api/v2/organizations/{organization_uuid}/servers/{uuid}: + parameters: [{ $ref: '#/components/parameters/OrganizationUuidNested' }, { $ref: '#/components/parameters/ResourceUuid' }] + get: { summary: Get server, responses: { '200': { $ref: '#/components/responses/Envelope' } } } + patch: { summary: Update server, responses: { '200': { $ref: '#/components/responses/Envelope' } } } + delete: { summary: Soft-delete server, responses: { '204': { description: Deleted } } } + /api/v2/organizations/{organization_uuid}/servers/{server_uuid}/domains: + get: { summary: List sending domains, responses: { '200': { $ref: '#/components/responses/Envelope' } } } + post: { summary: Add sending domain, responses: { '201': { $ref: '#/components/responses/Envelope' } } } + /api/v2/organizations/{organization_uuid}/servers/{server_uuid}/domains/{uuid}: + get: { summary: Get domain DNS instructions, responses: { '200': { $ref: '#/components/responses/Envelope' } } } + delete: { summary: Delete domain, responses: { '204': { description: Deleted } } } + /api/v2/organizations/{organization_uuid}/servers/{server_uuid}/domains/{uuid}/verify: + post: { summary: Verify DNS ownership, responses: { '200': { $ref: '#/components/responses/Envelope' } } } + /api/v2/organizations/{organization_uuid}/servers/{server_uuid}/domains/{uuid}/check-dns: + post: { summary: Check SPF, DKIM, MX and return-path DNS, responses: { '200': { $ref: '#/components/responses/Envelope' } } } + /api/v2/organizations/{organization_uuid}/servers/{server_uuid}/credentials: + get: { summary: List masked credential metadata, responses: { '200': { $ref: '#/components/responses/Envelope' } } } + post: { summary: Create credential; secret returned once, responses: { '201': { $ref: '#/components/responses/Envelope' } } } + /api/v2/organizations/{organization_uuid}/servers/{server_uuid}/credentials/{uuid}: + delete: { summary: Revoke credential, responses: { '204': { description: Revoked } } } + /api/v2/organizations/{organization_uuid}/servers/{server_uuid}/credentials/{uuid}/rotate: + post: { summary: Rotate credential; replacement secret returned once, responses: { '201': { $ref: '#/components/responses/Envelope' } } } + /api/v2/organizations/{organization_uuid}/servers/{server_uuid}/webhooks: + get: { summary: List webhooks, responses: { '200': { $ref: '#/components/responses/Envelope' } } } + post: { summary: Create HTTPS webhook, responses: { '201': { $ref: '#/components/responses/Envelope' } } } + /api/v2/organizations/{organization_uuid}/usage: + get: { summary: Get aggregate organization usage, responses: { '200': { $ref: '#/components/responses/Envelope' } } } + /api/v2/organizations/{organization_uuid}/servers/{uuid}/usage: + get: { summary: Get server usage, responses: { '200': { $ref: '#/components/responses/Envelope' } } } + /api/v2/organizations/{organization_uuid}/servers/{uuid}/health: + get: { summary: Get server queue and domain health, responses: { '200': { $ref: '#/components/responses/Envelope' } } } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: ControlApiKey } + parameters: + OrganizationUuid: { name: uuid, in: path, required: true, schema: { type: string } } + OrganizationUuidNested: { name: organization_uuid, in: path, required: true, schema: { type: string } } + ResourceUuid: { name: uuid, in: path, required: true, schema: { type: string } } + schemas: + OrganizationInput: + type: object + required: [name, owner] + properties: + name: { type: string } + permalink: { type: string } + time_zone: { type: string } + external_customer_id: { type: string } + plan_code: { type: string } + owner: + type: object + required: [email_address, first_name, last_name] + properties: + email_address: { type: string, format: email } + first_name: { type: string } + last_name: { type: string } + Envelope: + type: object + required: [data, error] + properties: { data: {}, meta: {}, error: { type: [object, 'null'] } } + Error: + type: object + properties: + data: { type: 'null' } + error: { type: object, required: [code, message], properties: { code: { type: string }, message: { type: string }, details: { type: object } } } + responses: + Envelope: + description: Successful response envelope + content: { application/json: { schema: { $ref: '#/components/schemas/Envelope' } } } + Error: + description: Error envelope + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } diff --git a/docs/api/v2.md b/docs/api/v2.md new file mode 100644 index 000000000..356837c62 --- /dev/null +++ b/docs/api/v2.md @@ -0,0 +1,67 @@ +# Postal control API v2 + +`/api/v2` is Postal's management control plane. It does not replace the existing +`/api/v1` email-submission API or SMTP submission. + +## Authentication + +Send a control key only in the Authorization header: + +```sh +curl https://postal.example/api/v2/organizations \ + -H 'Authorization: Bearer postal_cp_…' +``` + +Keys are digest-only at rest and their clear-text value is shown only at issue +time. Bootstrap the first operator key with `bin/rails postal:control_api:bootstrap_key`. +Tenant-scoped keys cannot read or mutate a different organization. Use +`Idempotency-Key` for create and lifecycle retries; a reused key with a changed +body returns `409`. + +## Onboarding flow + +1. `POST /api/v2/organizations` (platform key). +2. `POST /api/v2/organizations/{organization_uuid}/servers`. +3. `POST .../servers/{server_uuid}/domains` with DNS verification. +4. Read the domain response's `dns_records` and configure the customer's DNS. +5. `POST .../domains/{domain_uuid}/verify`, then `check-dns`. +6. `POST .../credentials`; persist the returned `key` immediately because it is + not available from credential listing. +7. Submit mail with that credential through existing `/api/v1/send/message` or SMTP. +8. Read `GET .../usage`; `outgoing` means processed by Postal's outgoing worker, + not necessarily delivered. +9. Suspend/unsuspend the server with its lifecycle endpoints. + +## DNS and credential safety + +Domain responses provide verification, SPF, DKIM public, return-path, and MX +records plus last check statuses. They never include `dkim_private_key`. +Credential lists expose only a final-four-character hint. Rotation returns a new +secret once and revokes the replaced credential immediately; clients should +deploy the replacement before calling rotation. + +## Errors, metrics, and operations + +Every response is `{data, meta, error}` and all errors use non-2xx status codes. +Common codes are `unauthorized`, `forbidden`, `not_found`, `validation_failed`, +`invalid_date_range`, `unsafe_webhook_url`, `provisioning_failed`, and +`idempotency_conflict`. Usage ranges are capped at 93 days and support hourly, +daily, and monthly aggregation. `bounces`, `held`, and `spam` follow Postal's +message-database statistics semantics. + +Monthly quotas count accepted outbound recipients for the current calendar month +across every server in an organization. `monitor` records excess volume, `hold` +stores excess messages as held, and `suspend` suspends the organization and +rejects the submission. The same quota gate is used by HTTP and SMTP submission. + +Webhooks require HTTPS and resolve through Postal's existing outbound address +guard, which blocks localhost, private, link-local, and metadata destinations +unless an operator has explicitly allowlisted them. + +## Migration and rollback + +Run `bin/rails db:migrate` using the repository's pinned Ruby and Bundler. +Rollback with `bin/rails db:rollback STEP=3` only after disabling `/api/v2` +traffic, because it removes control keys, audit/idempotency history, and quota +configuration. Never log or export bearer tokens, credential keys, passwords, +or DKIM private keys. diff --git a/lib/postal/config_schema.rb b/lib/postal/config_schema.rb index e3c6415d5..871d094a7 100644 --- a/lib/postal/config_schema.rb +++ b/lib/postal/config_schema.rb @@ -92,6 +92,14 @@ module Postal transform { |ip| IPAddr.new(ip) } end + string :allowed_request_destinations do + array + description "Hostnames or IP/CIDR ranges that outbound webhook and HTTP " \ + "endpoint requests are permitted to reach even when they resolve " \ + "to a private, loopback, link-local or otherwise reserved address. " \ + "All other such destinations are blocked to prevent SSRF." + end + integer :queued_message_lock_stale_days do description "The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried." default 1 diff --git a/lib/postal/http.rb b/lib/postal/http.rb index 8a32d9b58..4f17febd6 100644 --- a/lib/postal/http.rb +++ b/lib/postal/http.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "net/https" +require "resolv" require "uri" module Postal @@ -47,19 +48,24 @@ def self.request(method, url, options = {}) request["User-Agent"] = options[:user_agent] || "Postal/#{Postal.version}" - connection = Net::HTTP.new(uri.host, uri.port) - - if uri.scheme == "https" - connection.use_ssl = true - connection.verify_mode = OpenSSL::SSL::VERIFY_PEER - ssl = true - else - ssl = false - end + timeout = options[:timeout] || 60 + ssl = uri.scheme == "https" begin - timeout = options[:timeout] || 60 Timeout.timeout(timeout) do + connect_address = AddressGuard.safe_connect_address(uri.host) + + connection = Net::HTTP.new(uri.host, uri.port) + # Pin the connection to the address we validated above so that the socket + # cannot be redirected to a different (e.g. internal) address via a DNS + # rebinding race between the check and the connection. + connection.ipaddr = connect_address + + if uri.scheme == "https" + connection.use_ssl = true + connection.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + result = connection.request(request) { code: result.code.to_i, @@ -68,6 +74,13 @@ def self.request(method, url, options = {}) secure: ssl } end + rescue BlockedDestinationError => e + { + code: -4, + body: e.message, + headers: {}, + secure: ssl + } rescue OpenSSL::SSL::SSLError { code: -3, @@ -75,7 +88,7 @@ def self.request(method, url, options = {}) headers: {}, secure: ssl } - rescue SocketError, Errno::ECONNRESET, EOFError, Errno::EINVAL, Errno::ENETUNREACH, Errno::EHOSTUNREACH, Errno::ECONNREFUSED => e + rescue Resolv::ResolvError, SocketError, SystemCallError, EOFError => e { code: -2, body: e.message, diff --git a/lib/postal/http/address_guard.rb b/lib/postal/http/address_guard.rb new file mode 100644 index 000000000..ed863d2ab --- /dev/null +++ b/lib/postal/http/address_guard.rb @@ -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] + 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] + 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 diff --git a/lib/postal/http/blocked_destination_error.rb b/lib/postal/http/blocked_destination_error.rb new file mode 100644 index 000000000..c08a4446e --- /dev/null +++ b/lib/postal/http/blocked_destination_error.rb @@ -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 diff --git a/lib/postal/message_db/database.rb b/lib/postal/message_db/database.rb index 52f456986..415a88deb 100644 --- a/lib/postal/message_db/database.rb +++ b/lib/postal/message_db/database.rb @@ -70,7 +70,7 @@ def new_message(attributes = {}) # Return the total size of all stored messages # def total_size - query("SELECT SUM(size) AS size FROM `#{database_name}`.`raw_message_sizes`").first["size"] || 0 + query("SELECT SUM(size) AS size FROM #{escape_identifier(database_name)}.`raw_message_sizes`").first["size"] || 0 end # @@ -151,11 +151,11 @@ def select(table, options = {}) if options[:count] sql_query << " COUNT(id) AS count" elsif options[:fields] - sql_query << (" " + options[:fields].map { |f| "`#{f}`" }.join(", ")) + sql_query << (" " + options[:fields].map { |f| escape_identifier(f) }.join(", ")) else sql_query << " *" end - sql_query << " FROM `#{database_name}`.`#{table}`" + sql_query << " FROM #{escape_identifier(database_name)}.#{escape_identifier(table)}" if options[:where].present? sql_query << (" " + build_where_string(options[:where], " AND ")) end @@ -163,7 +163,7 @@ def select(table, options = {}) direction = (options[:direction] || "ASC").upcase raise Postal::Error, "Invalid direction #{options[:direction]}" unless %w[ASC DESC].include?(direction) - sql_query << " ORDER BY `#{options[:order]}` #{direction}" + sql_query << " ORDER BY #{escape_identifier(options[:order])} #{direction}" end if options[:limit] @@ -211,7 +211,7 @@ def select_with_pagination(table, page, options = {}) # Will return the total number of affected rows. # def update(table, attributes, options = {}) - sql_query = "UPDATE `#{database_name}`.`#{table}` SET" + sql_query = "UPDATE #{escape_identifier(database_name)}.#{escape_identifier(table)} SET" sql_query << " #{hash_to_sql(attributes)}" if options[:where] sql_query << (" " + build_where_string(options[:where])) @@ -227,8 +227,8 @@ def update(table, attributes, options = {}) # Will return the ID of the new item. # def insert(table, attributes) - sql_query = "INSERT INTO `#{database_name}`.`#{table}`" - sql_query << (" (" + attributes.keys.map { |k| "`#{k}`" }.join(", ") + ")") + sql_query = "INSERT INTO #{escape_identifier(database_name)}.#{escape_identifier(table)}" + sql_query << (" (" + attributes.keys.map { |k| escape_identifier(k) }.join(", ") + ")") sql_query << (" VALUES (" + attributes.values.map { |v| escape(v) }.join(", ") + ")") with_mysql do |mysql| query_on_connection(mysql, sql_query) @@ -243,8 +243,8 @@ def insert_multi(table, keys, values) if values.empty? nil else - sql_query = "INSERT INTO `#{database_name}`.`#{table}`" - sql_query << (" (" + keys.map { |k| "`#{k}`" }.join(", ") + ")") + sql_query = "INSERT INTO #{escape_identifier(database_name)}.#{escape_identifier(table)}" + sql_query << (" (" + keys.map { |k| escape_identifier(k) }.join(", ") + ")") sql_query << " VALUES " sql_query << values.map { |v| "(" + v.map { |r| escape(r) }.join(", ") + ")" }.join(", ") query(sql_query) @@ -260,7 +260,7 @@ def insert_multi(table, keys, values) # Will return the total number of affected rows. # def delete(table, options = {}) - sql_query = "DELETE FROM `#{database_name}`.`#{table}`" + sql_query = "DELETE FROM #{escape_identifier(database_name)}.#{escape_identifier(table)}" sql_query << (" " + build_where_string(options[:where], " AND ")) with_mysql do |mysql| query_on_connection(mysql, sql_query) @@ -351,32 +351,41 @@ def build_where_string(attributes, joiner = ", ") def hash_to_sql(hash, joiner = ", ") hash.map do |key, value| + column = escape_identifier(key) if value.is_a?(Array) && value.all? { |v| v.is_a?(Integer) } - "`#{key}` IN (#{value.join(', ')})" + "#{column} IN (#{value.join(', ')})" elsif value.is_a?(Array) escaped_values = value.map { |v| escape(v) }.join(", ") - "`#{key}` IN (#{escaped_values})" + "#{column} IN (#{escaped_values})" elsif value.is_a?(Hash) sql = [] value.each do |operator, inner_value| case operator when :less_than - sql << "`#{key}` < #{escape(inner_value)}" + sql << "#{column} < #{escape(inner_value)}" when :greater_than - sql << "`#{key}` > #{escape(inner_value)}" + sql << "#{column} > #{escape(inner_value)}" when :less_than_or_equal_to - sql << "`#{key}` <= #{escape(inner_value)}" + sql << "#{column} <= #{escape(inner_value)}" when :greater_than_or_equal_to - sql << "`#{key}` >= #{escape(inner_value)}" + sql << "#{column} >= #{escape(inner_value)}" end end sql.empty? ? "1=1" : sql.join(joiner) else - "`#{key}` = #{escape(value)}" + "#{column} = #{escape(value)}" end end.join(joiner) end + # Escape a value for safe use as a MySQL identifier (e.g. a column or + # table name). Identifiers are wrapped in backticks and any backtick + # within the identifier is doubled so it cannot break out of the quoting + # and inject arbitrary SQL. + def escape_identifier(identifier) + "`" + identifier.to_s.gsub("`", "``") + "`" + end + end end end diff --git a/lib/tasks/control_api_keys.rake b/lib/tasks/control_api_keys.rake new file mode 100644 index 000000000..2cd4de490 --- /dev/null +++ b/lib/tasks/control_api_keys.rake @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +namespace :postal do + namespace :control_api do + desc "Create the first platform control API key" + task :bootstrap_key, [:name] => :environment do |_task, args| + if ControlAPIKey.active.where(organization_id: nil).where("scopes LIKE ?", "%platform:admin%").exists? + abort "A platform control API key already exists. Create additional keys through an approved operator workflow." + end + + key, token = ControlAPIKey.issue!(name: args[:name].presence || "bootstrap", scopes: ["platform:admin"]) + puts "Control API key #{key.uuid} created. Store this token now; it will not be shown again:" + puts token + end + end +end diff --git a/lib/tracking_middleware.rb b/lib/tracking_middleware.rb index bee3a6378..d66473637 100644 --- a/lib/tracking_middleware.rb +++ b/lib/tracking_middleware.rb @@ -48,25 +48,11 @@ def dispatch_image_request(request, server_token, message_token) Sentry.capture_exception(e) if defined?(Sentry) end - source_image = request.params["src"] - case source_image - when nil + if request.params["src"].nil? headers = {} headers["Content-Type"] = "image/png" headers["Content-Length"] = TRACKING_PIXEL.bytesize.to_s [200, headers, [TRACKING_PIXEL]] - when /\Ahttps?:\/\// - response = Postal::HTTP.get(source_image, timeout: 3) - return [404, {}, ["Not found"]] unless response[:code] == 200 - - headers = {} - headers["Content-Type"] = response[:headers]["content-type"]&.first - headers["Last-Modified"] = response[:headers]["last-modified"]&.first - headers["Cache-Control"] = response[:headers]["cache-control"]&.first - headers["Etag"] = response[:headers]["etag"]&.first - headers["Content-Length"] = response[:body].bytesize.to_s - [200, headers, [response[:body]]] - else [400, {}, ["Invalid/missing source image"]] end diff --git a/script/test_owner_email_uniqueness.sh b/script/test_owner_email_uniqueness.sh new file mode 100644 index 000000000..57d621851 --- /dev/null +++ b/script/test_owner_email_uniqueness.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if ! command -v docker >/dev/null 2>&1; then + echo "Docker is required to run this test." >&2 + exit 1 +fi + +image="${POSTAL_IMAGE:-postal-control-api-ci}" + +POSTAL_IMAGE="$image" docker compose run --rm postal \ + bundle exec rspec spec/requests/api/v2/organizations_spec.rb diff --git a/spec/apis/legacy_api/messages/deliveries_spec.rb b/spec/apis/legacy_api/messages/deliveries_spec.rb index 2daf95f6b..cfc69cc29 100644 --- a/spec/apis/legacy_api/messages/deliveries_spec.rb +++ b/spec/apis/legacy_api/messages/deliveries_spec.rb @@ -64,6 +64,23 @@ end end + # Regression test for GHSA-x2hq-rfpg-3xr5 (see message_spec.rb). A JSON + # object supplied for `id` must be rejected before reaching the database + # rather than being interpreted as a raw set of SQL conditions. + context "when the message ID is a JSON object (SQL injection attempt)" do + it "rejects it with a parameter error and never reaches the database" do + expect_any_instance_of(Server).not_to receive(:message) + post "/api/v1/messages/deliveries", + headers: { "x-server-api-key" => credential.key, + "content-type" => "application/json" }, + params: { id: { "id`=0 OR SLEEP(5)#" => "x" } }.to_json + expect(response.status).to eq 200 + parsed_body = JSON.parse(response.body) + expect(parsed_body["status"]).to eq "parameter-error" + expect(parsed_body["data"]["message"]).to match(/must be a string or integer/) + end + end + context "when the message ID exists" do let(:server) { create(:server) } let(:credential) { create(:credential, server: server) } diff --git a/spec/apis/legacy_api/messages/message_spec.rb b/spec/apis/legacy_api/messages/message_spec.rb index 202b66b50..c40d756dc 100644 --- a/spec/apis/legacy_api/messages/message_spec.rb +++ b/spec/apis/legacy_api/messages/message_spec.rb @@ -63,6 +63,56 @@ end end + # Regression tests for GHSA-x2hq-rfpg-3xr5. The request body is parsed as + # JSON, so a JSON object/array supplied for `id` would otherwise arrive as + # a Ruby Hash/Array and be passed straight through to the message database + # as a raw set of SQL conditions (blind SQL injection). These must be + # rejected before reaching the database. + context "when the message ID is a JSON object (SQL injection attempt)" do + it "rejects it with a parameter error and never reaches the database" do + expect_any_instance_of(Server).not_to receive(:message) + post "/api/v1/messages/message", + headers: { "x-server-api-key" => credential.key, + "content-type" => "application/json" }, + params: { id: { "id`=0 OR SLEEP(5)#" => "x" } }.to_json + expect(response.status).to eq 200 + parsed_body = JSON.parse(response.body) + expect(parsed_body["status"]).to eq "parameter-error" + expect(parsed_body["data"]["message"]).to match(/must be a string or integer/) + end + end + + context "when the message ID is a JSON array" do + it "rejects it with a parameter error" do + post "/api/v1/messages/message", + headers: { "x-server-api-key" => credential.key, + "content-type" => "application/json" }, + params: { id: [1, 2, 3] }.to_json + expect(response.status).to eq 200 + parsed_body = JSON.parse(response.body) + expect(parsed_body["status"]).to eq "parameter-error" + expect(parsed_body["data"]["message"]).to match(/must be a string or integer/) + end + end + + context "when the message ID is provided as a numeric string" do + let(:message) { MessageFactory.outgoing(server) } + + it "is coerced to an integer and looks the message up" do + post "/api/v1/messages/message", + headers: { "x-server-api-key" => credential.key, + "content-type" => "application/json" }, + params: { id: message.id.to_s }.to_json + expect(response.status).to eq 200 + parsed_body = JSON.parse(response.body) + expect(parsed_body["status"]).to eq "success" + expect(parsed_body["data"]).to match({ + "id" => message.id, + "token" => message.token + }) + end + end + context "when the message ID exists" do let(:server) { create(:server) } let(:credential) { create(:credential, server: server) } diff --git a/spec/apis/legacy_api/send/quota_spec.rb b/spec/apis/legacy_api/send/quota_spec.rb new file mode 100644 index 000000000..2f9ce389e --- /dev/null +++ b/spec/apis/legacy_api/send/quota_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Legacy HTTP send quota enforcement", type: :request do + let(:organization) { create(:organization, monthly_outbound_limit: 1, quota_action: "hold") } + let(:server) { create(:server, organization: organization) } + let(:credential) { create(:credential, server: server, type: "API") } + let(:domain) { create(:domain, owner: server) } + let(:decision) { ControlPlane::OutboundQuota::Decision.new(action: :hold, limit: 1, used: 2, requested: 1) } + + it "uses the shared organization quota gate and holds mail when it says hold" do + expect(ControlPlane::OutboundQuota).to receive(:reserve!).with(server, count: 1).and_return(decision) + + post "/api/v1/send/message", headers: { "X-Server-API-Key" => credential.key, "Content-Type" => "application/json" }, + params: { to: ["recipient@example.net"], from: "sender@#{domain.name}", plain_body: "test" }.to_json + + expect(response).to have_http_status(:ok) + message_id = JSON.parse(response.body).dig("data", "messages", "recipient@example.net", "id") + expect(server.message(message_id)).to be_held + expect(QueuedMessage.where(server: server)).to be_empty + end +end diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb new file mode 100644 index 000000000..4beb3de98 --- /dev/null +++ b/spec/helpers/application_helper_spec.rb @@ -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'">) } + + 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 ") + + # 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