diff --git a/Gemfile b/Gemfile index de23ef7d8..bb6973d4a 100644 --- a/Gemfile +++ b/Gemfile @@ -38,7 +38,7 @@ gem "sidekiq-unique-jobs" # Monitoring gem "vernier" -gem "rack-mini-profiler" +gem "rack-mini-profiler", group: :development gem "sentry-ruby" gem "sentry-rails" gem "sentry-sidekiq" diff --git a/app/controllers/csp_reports_controller.rb b/app/controllers/csp_reports_controller.rb new file mode 100644 index 000000000..47d4e722b --- /dev/null +++ b/app/controllers/csp_reports_controller.rb @@ -0,0 +1,35 @@ +# Receives CSP violation reports from browsers. +# +# The Content Security Policy initializer sets `report_uri "/csp-violation-report"`. +# Browsers POST a small JSON payload describing each blocked resource. We just log +# the report — operators can forward these logs to Sentry or another aggregator. +# +# Inherits from ActionController::Base (not ApplicationController) to avoid auth, +# CSRF, Pundit, and other before_actions. CSP reports must be accepted from any +# origin, with no authentication, and the browser discards non-2xx responses. +class CspReportsController < ActionController::Base + MAX_BODY_BYTES = 8_192 + + def create + body = request.body.read(MAX_BODY_BYTES) + report = parse_report(body) + + Rails.logger.warn("[CSP] violation: #{report.to_json}") if report.present? + + head :no_content + end + + private + def parse_report(body) + return {} if body.blank? + JSON.parse(body) + rescue StandardError => e + # Catch broadly (JSON::ParserError, Encoding::CompatibilityError, etc.). + # Scrub to valid UTF-8 so String#truncate can't raise on malformed bytes, + # and fold the exception class into the log so a poorly-behaving client + # is still diagnosable without us 500-ing. + safe = body.to_s.dup.force_encoding(Encoding::UTF_8).scrub("") + Rails.logger.warn("[CSP] parse failed: #{e.class}") if defined?(Rails.logger) + { raw: safe.truncate(512), parse_error: e.class.name } + end +end diff --git a/config/environments/production.rb b/config/environments/production.rb index fc7120a0a..b1649ca05 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -18,7 +18,7 @@ # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files). - # config.require_master_key = true + config.require_master_key = true # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. # config.public_file_server.enabled = false @@ -102,6 +102,26 @@ config.active_record.dump_schema_after_migration = false # Enable DNS rebinding protection and other `Host` header attacks. + # Accept either APP_DOMAIN (single host) or ALLOWED_HOSTS (comma-separated list). + # If neither is set, `config.hosts` stays empty (allow all) to preserve backward + # compatibility with existing self-hosted deploys — a [SECURITY] warning is logged + # at boot (see cors.rb for the matching allow-list warning) to nudge operators. + if ENV["APP_DOMAIN"].present? + config.hosts = [ ENV["APP_DOMAIN"] ] + else + # Parse before deciding to assign — `ENV["ALLOWED_HOSTS"].present?` is + # true for junk like "," or " " which split+strip+reject down to []. + # Assigning config.hosts = [] means "deny every host", which is worse + # than falling through to the warn-and-leave-unset branch. + parsed_hosts = ENV["ALLOWED_HOSTS"].to_s.split(",").map(&:strip).reject(&:empty?) + if parsed_hosts.any? + config.hosts = parsed_hosts + else + # Use `config.logger` rather than `Rails.logger`: this block runs during + # environment configuration, before `Rails.logger` is finalized. + config.logger&.warn("[SECURITY] APP_DOMAIN and ALLOWED_HOSTS not set — DNS rebinding protection disabled") + end + end # config.hosts = [ # "example.com", # Allow requests from example.com # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 834aa2118..99e3b1345 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,26 +1,91 @@ # Be sure to restart your server when you modify this file. - -# Define an application-wide content security policy. -# See the Securing Rails Applications Guide for more information: -# https://guides.rubyonrails.org/security.html#content-security-policy-header - -# Rails.application.configure do -# config.content_security_policy do |policy| -# policy.default_src :self, :https -# policy.font_src :self, :https, :data -# policy.img_src :self, :https, :data -# policy.object_src :none -# policy.script_src :self, :https -# policy.script_src :self, :https, 'https://us.i.posthog.com' -# policy.style_src :self, :https -# # Specify URI for violation reports -# # policy.report_uri "/csp-violation-report-endpoint" -# end # -# # Generate session nonces for permitted importmap, inline scripts, and inline styles. -# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } -# config.content_security_policy_nonce_directives = %w(script-src style-src) +# Content Security Policy — report-only mode. +# Violations are logged to /csp-violation-report but nothing is blocked yet. +# Once violations are reviewed and the policy is tuned, switch to enforcement +# by removing config.content_security_policy_report_only = true. # -# # Report violations without enforcing the policy. -# # config.content_security_policy_report_only = true -# end +# Known external services that need allowlisting: +# - PostHog: https://us.i.posthog.com, https://us-assets.i.posthog.com +# - Plaid: https://cdn.plaid.com +# - Stripe: https://js.stripe.com, https://hooks.stripe.com +# - Pusher: wss://*.pusher.com (ActionCable / Hotwire) +# - OpenAI: (server-side only, no browser resources needed) + +Rails.application.configure do + config.content_security_policy do |policy| + policy.default_src :self + policy.font_src :self, :data + policy.img_src :self, :https, :data, :blob + policy.object_src :none + + # Baseline hardening directives — all browsers respect these and they cost + # nothing: no arbitrary , forms only to self, no framing. + policy.base_uri :self + policy.form_action :self + policy.frame_ancestors :none + + managed_mode = Rails.application.config.app_mode.managed? + + # Scripts: self + Plaid + Stripe (+ PostHog in managed mode) + importmap inline (nonce-controlled) + script_src = [ + :self, + "https://cdn.plaid.com", + "https://js.stripe.com" + ] + + # Connections: self + external APIs used client-side + connect_src = [ + :self, + "https://hooks.stripe.com" + ] + + if managed_mode + # Derive PostHog hosts from the same env var config/initializers/posthog.rb + # uses, so a managed deploy pointed at a different PostHog region doesn't + # have CSP silently blocking the very traffic that initializer enables. + # + # Region handling: the assets host mirrors the API host with the first + # subdomain labelled "-assets" (us.i.posthog.com → us-assets.i.posthog.com, + # eu.i.posthog.com → eu-assets.i.posthog.com). Operators on self-hosted + # PostHog with a different layout can override via POSTHOG_ASSETS_HOST. + posthog_host = ENV.fetch("POSTHOG_HOST", "https://us.i.posthog.com") + posthog_assets_host = ENV.fetch("POSTHOG_ASSETS_HOST") do + uri = URI.parse(posthog_host) + assets_host = uri.host.to_s.sub(/\A([^.]+)/, '\1-assets') + "#{uri.scheme}://#{assets_host}" + end + + script_src += [ posthog_host, posthog_assets_host ] + + connect_src += [ + posthog_host, + posthog_assets_host, + "wss://*.pusher.com" + ] + end + + policy.script_src(*script_src) + + # Styles: self + unsafe_inline needed for Tailwind/inline styles + policy.style_src :self, :unsafe_inline + + policy.connect_src(*connect_src) + + # Frames: Plaid and Stripe use iframes + policy.frame_src "https://cdn.plaid.com", + "https://js.stripe.com" + + policy.report_uri "/csp-violation-report" + end + + # Nonces for inline scripts/styles managed by importmap and Hotwire. + # Per-response random nonce — a session-id nonce is constant for the lifetime of + # the session and provides no CSP guarantee once an attacker observes one script tag. + config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) } + config.content_security_policy_nonce_directives = %w[script-src] + + # REPORT-ONLY: violations are logged, nothing is blocked. + # Remove this line to enforce the policy. + config.content_security_policy_report_only = true +end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 8ffb294d4..885cfa5de 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -2,35 +2,52 @@ # CORS configuration for API access from mobile clients (Flutter) and other external apps. # -# This enables Cross-Origin Resource Sharing for the /api, /oauth, and /sessions endpoints, -# allowing the Flutter mobile client and other authorized clients to communicate -# with the Rails backend. +# Allowed origins configured via ALLOWED_ORIGINS env var (comma-separated). +# Falls back to APP_DOMAIN if set, otherwise denies cross-origin requests. +# +# Examples: +# ALLOWED_ORIGINS=https://app.example.com,https://staging.example.com +# APP_DOMAIN=app.example.com +# +# Security: wildcard origins (*) are intentionally not used. + +def allowed_origins + if ENV["ALLOWED_ORIGINS"].present? + ENV["ALLOWED_ORIGINS"].split(",").map(&:strip).reject(&:empty?) + elsif ENV["APP_DOMAIN"].present? + [ "https://#{ENV['APP_DOMAIN']}" ] + else + Rails.logger.warn("[SECURITY] ALLOWED_ORIGINS and APP_DOMAIN not set — CORS will deny all cross-origin requests") + [] + end +end Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do - # Allow requests from any origin for API endpoints - # Mobile apps and development environments need flexible CORS - origins "*" + origins(*allowed_origins) - # API endpoints for mobile client and third-party integrations resource "/api/*", headers: :any, methods: %i[get post put patch delete options head], expose: %w[X-Request-Id X-Runtime], + max_age: 86400, + credentials: true + + resource "/oauth/token", + headers: :any, + methods: %i[post options], max_age: 86400 - # OAuth endpoints for authentication flows - resource "/oauth/*", + resource "/oauth/revoke", headers: :any, - methods: %i[get post put patch delete options head], - expose: %w[X-Request-Id X-Runtime], + methods: %i[post options], max_age: 86400 - # Session endpoints for webview-based authentication resource "/sessions/*", headers: :any, methods: %i[get post delete options head], expose: %w[X-Request-Id X-Runtime], - max_age: 86400 + max_age: 86400, + credentials: true end end diff --git a/config/initializers/mini_profiler.rb b/config/initializers/mini_profiler.rb index 6a79f19ea..301677634 100644 --- a/config/initializers/mini_profiler.rb +++ b/config/initializers/mini_profiler.rb @@ -1,4 +1,7 @@ -Rails.application.configure do - Rack::MiniProfiler.config.skip_paths = [ "/design-system", "/assets", "/cable", "/manifest", "/favicon.ico", "/hotwire-livereload", "/logo-pwa.png" ] - Rack::MiniProfiler.config.max_traces_to_show = 50 +# rack-mini-profiler is only loaded in development +if defined?(Rack::MiniProfiler) + Rails.application.configure do + Rack::MiniProfiler.config.skip_paths = [ "/design-system", "/assets", "/cable", "/manifest", "/favicon.ico", "/hotwire-livereload", "/logo-pwa.png" ] + Rack::MiniProfiler.config.max_traces_to_show = 50 + end end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 1338491a4..91753f29e 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,12 +1,24 @@ require "sidekiq/web" if Rails.env.production? + sidekiq_username = ENV["SIDEKIQ_WEB_USERNAME"] + sidekiq_password = ENV["SIDEKIQ_WEB_PASSWORD"] + + if sidekiq_username.blank? || sidekiq_password.blank? + Rails.logger.warn("[SECURITY] SIDEKIQ_WEB_USERNAME and SIDEKIQ_WEB_PASSWORD not set — Sidekiq Web UI will be inaccessible") + end + Sidekiq::Web.use(Rack::Auth::Basic) do |username, password| - configured_username = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_USERNAME", "sure")) - configured_password = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_PASSWORD", "sure")) + next false if sidekiq_username.blank? || sidekiq_password.blank? - ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) & - ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password) + ActiveSupport::SecurityUtils.secure_compare( + ::Digest::SHA256.hexdigest(username), + ::Digest::SHA256.hexdigest(sidekiq_username) + ) && + ActiveSupport::SecurityUtils.secure_compare( + ::Digest::SHA256.hexdigest(password), + ::Digest::SHA256.hexdigest(sidekiq_password) + ) end end diff --git a/config/routes.rb b/config/routes.rb index 544d1c8f0..1a6a9206c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -121,7 +121,7 @@ delete :disable end - mount Lookbook::Engine, at: "/design-system" + mount Lookbook::Engine, at: "/design-system" if Rails.env.development? if Rails.env.development? mount Rswag::Api::Engine => "/api-docs" @@ -538,6 +538,10 @@ # MCP server endpoint for external AI assistants (JSON-RPC 2.0) post "mcp", to: "mcp#handle" + # Receive CSP violation reports from browsers (report_uri set in + # config/initializers/content_security_policy.rb). + post "csp-violation-report" => "csp_reports#create" + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check diff --git a/docs/hosting/docker.md b/docs/hosting/docker.md index 96ec28065..7146893b9 100644 --- a/docs/hosting/docker.md +++ b/docs/hosting/docker.md @@ -95,6 +95,20 @@ SECRET_KEY_BASE="replacemewiththegeneratedstringfromthepriorstep" POSTGRES_PASSWORD="replacemewithyourdesireddatabasepassword" ``` +#### Production hardening (recommended) + +The following variables tighten security when running outside a local network. Set them if your instance is reachable from the public internet. + +| Variable | Purpose | +|---|---| +| `RAILS_MASTER_KEY` | Decrypts Rails encrypted credentials. Required — the app refuses to boot in production without it (or a `config/master.key` file). | +| `SIDEKIQ_WEB_USERNAME` + `SIDEKIQ_WEB_PASSWORD` | Basic-auth credentials guarding `/sidekiq`. The Web UI is mounted only when both are set. | +| `ALLOWED_ORIGINS` | Comma-separated CORS allow-list for browser/mobile clients (e.g. `https://app.example.com,https://staging.example.com`). | +| `APP_DOMAIN` | Single-host fallback for CORS when `ALLOWED_ORIGINS` is unset, and DNS-rebinding `config.hosts` allow-list. | +| `ALLOWED_HOSTS` | Comma-separated alternative to `APP_DOMAIN` for DNS-rebinding protection when you serve multiple hostnames. | + +If none of `ALLOWED_ORIGINS` / `APP_DOMAIN` is set, cross-origin requests are rejected and a `[SECURITY]` warning is emitted at boot. Likewise if `APP_DOMAIN` / `ALLOWED_HOSTS` is missing, DNS-rebinding protection is disabled with a boot warning. + #### Using HTTPS Assuming you want to access your instance from the internet, you should have secured your URL address with an SSL certificate. diff --git a/test/controllers/csp_reports_controller_test.rb b/test/controllers/csp_reports_controller_test.rb new file mode 100644 index 000000000..8933ebf2f --- /dev/null +++ b/test/controllers/csp_reports_controller_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +class CspReportsControllerTest < ActionDispatch::IntegrationTest + test "accepts a CSP report and returns 204" do + payload = { "csp-report" => { "violated-directive" => "script-src", "blocked-uri" => "https://evil.example.com/x.js" } } + + post "/csp-violation-report", + params: payload.to_json, + headers: { "Content-Type" => "application/csp-report" } + + assert_response :no_content + end + + test "accepts an empty body and returns 204" do + post "/csp-violation-report" + assert_response :no_content + end + + test "accepts malformed JSON without raising" do + post "/csp-violation-report", + params: "not-json", + headers: { "Content-Type" => "application/csp-report" } + + assert_response :no_content + end + + test "truncates bodies larger than MAX_BODY_BYTES without raising" do + # Send a valid-JSON payload whose byte length exceeds MAX_BODY_BYTES. The + # controller reads at most MAX_BODY_BYTES, producing a truncated (and + # therefore unparseable) body — must still return 204 and not 500. + oversized = "A" * (CspReportsController::MAX_BODY_BYTES + 1024) + payload = { "csp-report" => { "blocked-uri" => oversized } }.to_json + + post "/csp-violation-report", + params: payload, + headers: { "Content-Type" => "application/csp-report" } + + assert_response :no_content + end + + test "accepts invalid-UTF-8 bytes without raising" do + # A hostile client can send bytes that make String#truncate blow up if we + # don't scrub. Must still return 204. + post "/csp-violation-report", + params: "\xC3\x28".b, # invalid 2-byte UTF-8 sequence + headers: { "Content-Type" => "application/csp-report" } + + assert_response :no_content + end +end diff --git a/test/integration/cors_test.rb b/test/integration/cors_test.rb index e16d98d3c..b4456f8ba 100644 --- a/test/integration/cors_test.rb +++ b/test/integration/cors_test.rb @@ -2,71 +2,84 @@ require "test_helper" +# CORS is now driven by ALLOWED_ORIGINS / APP_DOMAIN env vars (see F-02). +# The test environment does not set either, so Rack::Cors is loaded with an +# empty origin allowlist and MUST NOT echo back any Origin. class CorsTest < ActionDispatch::IntegrationTest + EVIL_ORIGIN = "http://evil.example.com" + + def assert_cors_header_absent(msg = nil) + assert_nil response.headers["Access-Control-Allow-Origin"], msg + end + test "rack cors is configured in middleware stack" do middleware_classes = Rails.application.middleware.map(&:klass) assert_includes middleware_classes, Rack::Cors, "Rack::Cors should be in middleware stack" end - test "cors headers are returned for api endpoints" do - get "/api/v1/usage", headers: { "Origin" => "http://localhost:3000" } - - assert_equal "*", response.headers["Access-Control-Allow-Origin"] - assert response.headers["Access-Control-Expose-Headers"].present? + test "cors does not reflect wildcard origin for api endpoints" do + get "/api/v1/usage", headers: { "Origin" => EVIL_ORIGIN } + assert_cors_header_absent end - test "cors preflight request is handled for api endpoints" do - # Simulate a preflight OPTIONS request + test "cors preflight does not allow arbitrary origin for api endpoints" do options "/api/v1/transactions", headers: { - "Origin" => "http://localhost:3000", + "Origin" => EVIL_ORIGIN, "Access-Control-Request-Method" => "POST", "Access-Control-Request-Headers" => "Content-Type, Authorization" } - - assert_response :ok - assert_equal "*", response.headers["Access-Control-Allow-Origin"] - assert response.headers["Access-Control-Allow-Methods"].present? - assert_includes response.headers["Access-Control-Allow-Methods"], "POST" + assert_cors_header_absent end - test "cors headers are returned for oauth endpoints" do + test "cors does not reflect wildcard origin for oauth token endpoint" do post "/oauth/token", params: { grant_type: "authorization_code", code: "test" }, - headers: { "Origin" => "http://localhost:3000" } - - assert_equal "*", response.headers["Access-Control-Allow-Origin"] + headers: { "Origin" => EVIL_ORIGIN } + assert_cors_header_absent end - test "cors preflight request is handled for oauth endpoints" do + test "cors preflight does not allow arbitrary origin for oauth token endpoint" do options "/oauth/token", headers: { - "Origin" => "http://localhost:3000", + "Origin" => EVIL_ORIGIN, "Access-Control-Request-Method" => "POST", "Access-Control-Request-Headers" => "Content-Type" } + assert_cors_header_absent + end - assert_response :ok - assert_equal "*", response.headers["Access-Control-Allow-Origin"] + test "cors does not reflect wildcard origin for oauth revoke endpoint" do + post "/oauth/revoke", + params: { token: "test-token" }, + headers: { "Origin" => EVIL_ORIGIN } + assert_cors_header_absent end - test "cors headers are returned for session endpoints" do + test "cors preflight does not allow arbitrary origin for oauth revoke endpoint" do + options "/oauth/revoke", + headers: { + "Origin" => EVIL_ORIGIN, + "Access-Control-Request-Method" => "POST", + "Access-Control-Request-Headers" => "Content-Type" + } + assert_cors_header_absent + end + + test "cors does not reflect wildcard origin for session endpoints" do post "/sessions", params: { email: "test@example.com", password: "password" }, - headers: { "Origin" => "http://localhost:3000" } - - assert_equal "*", response.headers["Access-Control-Allow-Origin"] + headers: { "Origin" => EVIL_ORIGIN } + assert_cors_header_absent end - test "cors preflight request is handled for session endpoints" do + test "cors preflight does not allow arbitrary origin for session endpoints" do options "/sessions/new", headers: { - "Origin" => "http://localhost:3000", + "Origin" => EVIL_ORIGIN, "Access-Control-Request-Method" => "GET", "Access-Control-Request-Headers" => "Content-Type" } - - assert_response :ok - assert_equal "*", response.headers["Access-Control-Allow-Origin"] + assert_cors_header_absent end end