Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
3 changes: 2 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,7 @@
config.active_record.dump_schema_after_migration = false

# Enable DNS rebinding protection and other `Host` header attacks.
config.hosts = [ ENV["APP_DOMAIN"] ].compact if ENV["APP_DOMAIN"].present?
Comment thread
jjmata marked this conversation as resolved.
Outdated
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Exempt health checks from strict host authorization

Restricting config.hosts to only APP_DOMAIN will also block the built-in /up health endpoint whenever a load balancer or orchestrator probes with a different Host header (commonly an IP, internal DNS name, or localhost). In that deployment pattern, readiness/liveness checks start returning 403 and can take instances out of service even though the app is healthy; add a host authorization exclusion for /up (or allow probe hosts) when enabling this restriction.

Useful? React with 👍 / 👎.

# config.hosts = [
# "example.com", # Allow requests from example.com
# /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
Expand Down
91 changes: 68 additions & 23 deletions config/initializers/content_security_policy.rb
Original file line number Diff line number Diff line change
@@ -1,26 +1,71 @@
# 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

managed_mode = Rails.application.config.app_mode.managed?

# Scripts: self + (optional) PostHog + Plaid + Stripe + 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
script_src += [
"https://us.i.posthog.com",
"https://us-assets.i.posthog.com"
]

connect_src += [
"https://us.i.posthog.com",
"https://us-assets.i.posthog.com",
"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
config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
config.content_security_policy_nonce_directives = %w[script-src]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

# 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)
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
2 changes: 1 addition & 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
53 changes: 28 additions & 25 deletions test/integration/cors_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,71 +2,74 @@

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
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" }
test "cors does not reflect wildcard origin for api endpoints" do
get "/api/v1/usage", headers: { "Origin" => "http://evil.example.com" }

assert_equal "*", response.headers["Access-Control-Allow-Origin"]
assert response.headers["Access-Control-Expose-Headers"].present?
assert_not_equal "*", response.headers["Access-Control-Allow-Origin"]
assert_not_equal "http://evil.example.com", response.headers["Access-Control-Allow-Origin"]
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" => "http://evil.example.com",
"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"
# With an empty allowlist rack-cors should not echo the origin back.
assert_not_equal "*", response.headers["Access-Control-Allow-Origin"]
assert_not_equal "http://evil.example.com", response.headers["Access-Control-Allow-Origin"]
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" }
headers: { "Origin" => "http://evil.example.com" }

assert_equal "*", response.headers["Access-Control-Allow-Origin"]
assert_not_equal "*", response.headers["Access-Control-Allow-Origin"]
assert_not_equal "http://evil.example.com", response.headers["Access-Control-Allow-Origin"]
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" => "http://evil.example.com",
"Access-Control-Request-Method" => "POST",
"Access-Control-Request-Headers" => "Content-Type"
}

assert_response :ok
assert_equal "*", response.headers["Access-Control-Allow-Origin"]
assert_not_equal "*", response.headers["Access-Control-Allow-Origin"]
assert_not_equal "http://evil.example.com", response.headers["Access-Control-Allow-Origin"]
end

test "cors headers are returned for session endpoints" do
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" }
headers: { "Origin" => "http://evil.example.com" }

assert_equal "*", response.headers["Access-Control-Allow-Origin"]
assert_not_equal "*", response.headers["Access-Control-Allow-Origin"]
assert_not_equal "http://evil.example.com", response.headers["Access-Control-Allow-Origin"]
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" => "http://evil.example.com",
"Access-Control-Request-Method" => "GET",
"Access-Control-Request-Headers" => "Content-Type"
}

assert_response :ok
assert_equal "*", response.headers["Access-Control-Allow-Origin"]
assert_not_equal "*", response.headers["Access-Control-Allow-Origin"]
assert_not_equal "http://evil.example.com", response.headers["Access-Control-Allow-Origin"]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
end
end
Loading