Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
35 changes: 35 additions & 0 deletions app/controllers/csp_reports_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
end
22 changes: 21 additions & 1 deletion config/environments/production.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# config.hosts = [
# "example.com", # Allow requests from example.com
# /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
Expand Down
111 changes: 88 additions & 23 deletions config/initializers/content_security_policy.rb
Original file line number Diff line number Diff line change
@@ -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 <base>, 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 ]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Expect: a route and controller/action that accepts CSP violation reports.
rg -n -C3 'csp-violation-report|csp.?violation|content_security_policy.*report'

Repository: we-promise/sure

Length of output: 4490


🏁 Script executed:

#!/bin/bash
# Search for routes that handle CSP violations
find . -name "routes.rb" -type f | head -5 | xargs cat | grep -n -i "csp\|violation"

Repository: we-promise/sure

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Search for controllers that might handle CSP reports
rg -n "def.*csp|def.*violation|def.*report" --type rb -i | grep -i "csp\|violation.*report"

Repository: we-promise/sure

Length of output: 85


🏁 Script executed:

#!/bin/bash
# Look for any references to the endpoint path in controllers/routes
rg -n "csp.violation|csp_violation" --type rb

Repository: we-promise/sure

Length of output: 85


🏁 Script executed:

#!/bin/bash
# Find routes.rb file
find . -name "routes.rb" -type f

Repository: we-promise/sure

Length of output: 77


🏁 Script executed:

#!/bin/bash
# Search for controllers with CSP or violation handling
find . -path "*/app/controllers/*.rb" -type f | xargs rg -l "csp|violation" -i

Repository: we-promise/sure

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Broader search for any action that might handle /csp-violation-report
rg -n "csp.violation|csp_violation" -i

Repository: we-promise/sure

Length of output: 961


🏁 Script executed:

#!/bin/bash
# Examine routes.rb for CSP report endpoint
cat ./config/routes.rb

Repository: we-promise/sure

Length of output: 16234


🏁 Script executed:

#!/bin/bash
# Search routes.rb specifically for the endpoint
rg "csp|violation" ./config/routes.rb -i

Repository: we-promise/sure

Length of output: 41


Wire the CSP report endpoint before relying on report-only telemetry.

report_uri points browsers at /csp-violation-report, but no route or controller exists to handle those requests. CSP violations will result in 404 errors and go uncollected, preventing the policy tuning that report-only mode is designed for.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@config/initializers/content_security_policy.rb` at line 61, policy.report_uri
is configured to send reports to "/csp-violation-report" but there is no
endpoint to receive them; add a route for POST "/csp-violation-report" and
implement a CspReportsController (e.g., CspReportsController#create) that
accepts JSON POST bodies, parses the report payload, logs or forwards the report
(and any identifying request metadata) and returns an empty 204 response so
browsers consider the report delivered; ensure the controller is CSRF-exempt or
uses appropriate skip_before_action for API requests and that the route accepts
JSON content-type.

end
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 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
43 changes: 30 additions & 13 deletions config/initializers/cors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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']}" ]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive CORS fallback origin scheme from SSL settings

The APP_DOMAIN fallback always builds an https:// origin, so deployments running without forced SSL (e.g., RAILS_FORCE_SSL=false or HTTP-only internal environments) will never match browser Origin: http://... headers. In those environments, cross-origin browser clients lose CORS access even though APP_DOMAIN is configured, so the fallback should account for HTTP (or allow both schemes) instead of hardcoding HTTPS.

Useful? React with 👍 / 👎.

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
9 changes: 6 additions & 3 deletions config/initializers/mini_profiler.rb
Original file line number Diff line number Diff line change
@@ -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
20 changes: 16 additions & 4 deletions config/initializers/sidekiq.rb
Original file line number Diff line number Diff line change
@@ -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

Expand Down
6 changes: 5 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/hosting/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions test/controllers/csp_reports_controller_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading