Skip to content
15 changes: 14 additions & 1 deletion app/models/lunchflow_item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,20 @@ def credentials_configured?
api_key.present?
end

# F-08: SSRF hardening. The `base_url` column is user-writable via the
# Lunchflow settings panel; without validation a malicious user could point
# outbound requests at internal services (169.254.169.254, localhost,
# internal DNS, etc.). Restrict to a known-good Lunchflow endpoint.
ALLOWED_BASE_URLS = [
"https://lunchflow.app/api/v1"
].freeze

def effective_base_url
base_url.presence || "https://lunchflow.app/api/v1"
url = base_url.presence || ALLOWED_BASE_URLS.first
unless ALLOWED_BASE_URLS.include?(url)
Rails.logger.warn("[SECURITY] Rejected Lunchflow base_url: #{url.inspect}")
return ALLOWED_BASE_URLS.first
end
url
end
end
16 changes: 15 additions & 1 deletion app/models/mercury_item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,21 @@ def credentials_configured?
token.present?
end

# F-08: SSRF hardening. The `base_url` column is user-writable via the
# Mercury settings panel; without validation a malicious user could point
# outbound requests at internal services (169.254.169.254, localhost,
# internal DNS, etc.). Restrict to a known-good set of Mercury endpoints.
ALLOWED_BASE_URLS = [
"https://api.mercury.com/api/v1",
"https://api-sandbox.mercury.com/api/v1"
].freeze

def effective_base_url
base_url.presence || "https://api.mercury.com/api/v1"
url = base_url.presence || ALLOWED_BASE_URLS.first
unless ALLOWED_BASE_URLS.include?(url)
Rails.logger.warn("[SECURITY] Rejected Mercury base_url: #{url.inspect}")
return ALLOWED_BASE_URLS.first
end
url
end
end
26 changes: 26 additions & 0 deletions config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ class Rack::Attack
request.ip if request.path.start_with?("/admin/")
end

# Throttle web session creation (login) to slow down brute-force/password-spraying.
# NOTE: this is the Rails web session endpoint, not the OAuth token endpoint.
# Configurable via ENV: RACK_ATTACK_SESSION_LIMIT (default: 10),
# RACK_ATTACK_SESSION_PERIOD_SECONDS (default: 60).
throttle("sessions/create",
limit: ENV.fetch("RACK_ATTACK_SESSION_LIMIT", 10).to_i,
period: ENV.fetch("RACK_ATTACK_SESSION_PERIOD_SECONDS", 60).to_i.seconds
) do |request|
request.ip if request.post? && request.path == "/sessions"
end

# Determine limits based on self-hosted mode
self_hosted = Rails.application.config.app_mode.self_hosted?

Expand All @@ -39,6 +50,21 @@ class Rack::Attack
request.ip if request.path.start_with?("/api/")
end

# F-06: Per-user OTP rate limiting on API login (mirrors web MFA: 5 attempts / 5 min).
# Without this, the mobile/API login endpoint accepted unlimited OTP attempts
# while the web flow enforced a 5-attempt / 5-minute lockout. Throttling by
# normalized email means attackers can't trivially rotate IPs to bypass it.
# Configurable via ENV: RACK_ATTACK_OTP_LIMIT (default: 5),
# RACK_ATTACK_OTP_PERIOD_SECONDS (default: 300).
throttle("api/otp_attempts/email",
limit: ENV.fetch("RACK_ATTACK_OTP_LIMIT", 5).to_i,
period: ENV.fetch("RACK_ATTACK_OTP_PERIOD_SECONDS", 300).to_i.seconds
) do |request|
if request.path == "/api/v1/auth/login" && request.post? && request.params["otp_code"].present?
request.params["email"]&.downcase&.strip
Comment thread
dgilperez marked this conversation as resolved.
Outdated
end
Comment thread
coderabbitai[bot] marked this conversation as resolved.
end

# Block requests that appear to be malicious
blocklist("block malicious requests") do |request|
# Block requests with suspicious user agents
Expand Down
12 changes: 12 additions & 0 deletions test/integration/rack_attack_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,16 @@ class RackAttackTest < ActionDispatch::IntegrationTest
throttles = Rack::Attack.throttles.keys
assert_includes throttles, "api/requests", "API requests should have rate limiting"
end

test "POST /sessions has rate limiting configured" do
# F-04/login-throttle: brute-force/password-spraying mitigation
throttles = Rack::Attack.throttles.keys
assert_includes throttles, "sessions/create", "Web session login should have rate limiting"
end

test "API OTP login has per-user rate limiting configured" do
# F-06: mirror web MFA (5 attempts / 5 min) for API login OTP submissions
throttles = Rack::Attack.throttles.keys
assert_includes throttles, "api/otp_attempts/email", "API OTP login should have per-user rate limiting"
end
Comment thread
coderabbitai[bot] marked this conversation as resolved.
end
23 changes: 23 additions & 0 deletions test/models/lunchflow_item_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
require "test_helper"

class LunchflowItemTest < ActiveSupport::TestCase
def setup
@lunchflow_item = lunchflow_items(:one)
end

test "effective_base_url returns default when base_url blank" do
@lunchflow_item.base_url = nil
assert_equal "https://lunchflow.app/api/v1", @lunchflow_item.effective_base_url
end

test "effective_base_url returns base_url when in allowlist" do
@lunchflow_item.base_url = "https://lunchflow.app/api/v1"
assert_equal "https://lunchflow.app/api/v1", @lunchflow_item.effective_base_url
end

test "effective_base_url rejects unknown base_url and falls back to default (F-08 SSRF)" do
@lunchflow_item.base_url = "http://169.254.169.254/latest/meta-data"
Rails.logger.expects(:warn).with(regexp_matches(/\[SECURITY\] Rejected Lunchflow base_url/))
assert_equal LunchflowItem::ALLOWED_BASE_URLS.first, @lunchflow_item.effective_base_url
end
end
11 changes: 11 additions & 0 deletions test/models/mercury_item_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ def setup
assert_equal "https://api.mercury.com/api/v1", @mercury_item.effective_base_url
end

test "effective_base_url rejects unknown base_url and falls back to default (F-08 SSRF)" do
@mercury_item.base_url = "http://169.254.169.254/latest/meta-data"
Rails.logger.expects(:warn).with(regexp_matches(/\[SECURITY\] Rejected Mercury base_url/))
assert_equal MercuryItem::ALLOWED_BASE_URLS.first, @mercury_item.effective_base_url
end

test "effective_base_url allows sandbox URL (F-08 SSRF allowlist)" do
@mercury_item.base_url = "https://api-sandbox.mercury.com/api/v1"
assert_equal "https://api-sandbox.mercury.com/api/v1", @mercury_item.effective_base_url
end

test "mercury_provider returns Provider::Mercury instance" do
provider = @mercury_item.mercury_provider
assert_instance_of Provider::Mercury, provider
Expand Down
Loading