Skip to content

security: rate limiting & SSRF allowlists (sessions throttle, OTP rate limit, provider base_url)#1520

Open
dgilperez wants to merge 8 commits intowe-promise:mainfrom
dgilperez:security/pr5-ratelimit-ssrf
Open

security: rate limiting & SSRF allowlists (sessions throttle, OTP rate limit, provider base_url)#1520
dgilperez wants to merge 8 commits intowe-promise:mainfrom
dgilperez:security/pr5-ratelimit-ssrf

Conversation

@dgilperez
Copy link
Copy Markdown

@dgilperez dgilperez commented Apr 19, 2026

Summary

Rack::Attack-based throttling for login and API OTP, plus SSRF allowlists on the Mercury and Lunchflow provider base_url configuration.

Part of the security hardening originally bundled in #1104 (closed), now split by functional area per @jjmata's feedback. Companion PRs: #1516 #1517 #1518 #1519 #1521.

Findings addressed (3)

ID Severity CWE Fix
MED CWE-307 Sessions throttle on POST /sessions via Rack::Attack (config/initializers/rack_attack.rb)
F-06 MED CWE-307 Per-email OTP attempt rate limit on API login via Rack::Attack
F-08 MED CWE-918 SSRF via provider base_url — allowlists in app/models/mercury_item.rb and app/models/lunchflow_item.rb

Configuration

New optional env vars (sensible defaults in code):

Env var Default Purpose
RACK_ATTACK_SESSION_LIMIT 10 POST /sessions attempts per IP
RACK_ATTACK_SESSION_PERIOD_SECONDS 60
RACK_ATTACK_OTP_LIMIT 5 API MFA OTP attempts per email
RACK_ATTACK_OTP_PERIOD_SECONDS 300

Allowlists exposed as public constants MercuryItem::ALLOWED_BASE_URLS and LunchflowItem::ALLOWED_BASE_URLS for tests / future extension. Mercury allowlist uses the current https://api-sandbox.mercury.com/api/v1 (the old branch's sandbox.mercury.com URL was stale).

Tests

  • New integration tests in test/integration/rack_attack_test.rb (throttle registration) and model tests in mercury_item_test.rb / lunchflow_item_test.rb (URL validation).
  • Full suite: 3232 runs, 0 failures.
  • bin/rubocop clean, bin/brakeman 0 warnings.

Out of scope

Related

Summary by CodeRabbit

  • New Features

    • Added throttling for web and API login flows to reduce brute-force/OTP abuse.
  • Bug Fixes / Security

    • Hardened outbound base-URL handling for external integrations with allowlists, safer defaults, warnings for rejected URLs, and stricter validation on saved values.
  • Tests

    • Added integration and unit tests covering throttling behavior and base-URL allowlist validation.

Adds an IP-based Rack::Attack throttle on the Rails web session endpoint
(POST /sessions) to slow down brute-force and password-spraying attacks.
This complements the OAuth token endpoint throttle already in place for
API-based login flows.

Limit and period are configurable via:
- RACK_ATTACK_SESSION_LIMIT (default: 10)
- RACK_ATTACK_SESSION_PERIOD_SECONDS (default: 60)

Adds a coverage test asserting the throttle is registered.
Previously the mobile/API login endpoint (POST /api/v1/auth/login) accepted
unlimited OTP attempts, while the web MFA flow enforced a 5-attempt / 5-minute
lockout (CWE-307). This adds a Rack::Attack throttle keyed on the normalized
email address of the login attempt so attackers cannot trivially rotate IPs
to bypass the lockout.

Defaults mirror web MFA (5 attempts per 5 minutes) and are configurable via:
- RACK_ATTACK_OTP_LIMIT (default: 5)
- RACK_ATTACK_OTP_PERIOD_SECONDS (default: 300)

The throttle is applied at the middleware layer so it short-circuits before
the controller runs, which is simpler and more robust than an in-controller
Rails.cache counter.

Adds a coverage test asserting the throttle is registered.
The Mercury and Lunchflow provider settings allow users to override the
provider base_url via a free-form text field. Without validation, a
malicious (or compromised) user could redirect outbound credentialed
requests at internal services — cloud metadata endpoints
(169.254.169.254), localhost, or internal DNS — resulting in
Server-Side Request Forgery.

Restrict `effective_base_url` to a known-good allowlist per provider.
Unknown values are rejected (logged under [SECURITY]) and the default
production endpoint is used instead. For Mercury both the production
and the current `api-sandbox.mercury.com` endpoint are allowed, since
the sandbox is a documented legitimate value in the Mercury settings
panel.

Adds tests covering default fallback, sandbox allowance (Mercury), and
rejection of metadata-style URLs for both providers.
@brin-security-scanner brin-security-scanner Bot added contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis. labels Apr 19, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a BaseUrlAllowlistable model concern for configurable outbound base-URL allowlists (used by LunchflowItem and MercuryItem) and introduces Rack::Attack throttles for web session creation and OTP-based API login with request-body parsing for normalized throttle keys.

Changes

Cohort / File(s) Summary
Base URL allowlist concern
app/models/concerns/base_url_allowlistable.rb
New concern providing allowed_base_urls(*urls), sets frozen ALLOWED_BASE_URLS, adds effective_base_url fallback to first allowlisted URL, registers base_url inclusion validation (allow_blank: true), and logs a single [SECURITY] warn when rejecting non-allowlisted values.
Models using allowlist
app/models/lunchflow_item.rb, app/models/mercury_item.rb
Removed per-model effective_base_url methods; added include BaseUrlAllowlistable and allowed_base_urls declarations with model-specific approved endpoints.
Rack::Attack throttles & request parsing
config/initializers/rack_attack.rb
Added throttle: POST /sessions by IP (env-configurable limit/period) and throttle: POST /api/v1/auth/login OTP attempts keyed by normalized email. Added LoginRequestFields helper to extract email/otp_code from form params or JSON bodies and rewind request body; uses existing throttled_responder.
Rack::Attack integration tests
test/integration/rack_attack_test.rb
New tests asserting throttle rules exist and exercising runtime throttling behavior until 429; tests enable Rack::Attack temporarily and restore cache store and enabled flag in ensure blocks.
Model tests — allowlist behavior
test/models/lunchflow_item_test.rb, test/models/mercury_item_test.rb
New unit tests: default fallback, allowlisted pass-through, rejection logging + fallback to first allowlisted URL, and save-time validation failure for non-allowlisted base_url values (assert error messages).
Concern tests
test/models/concerns/base_url_allowlistable_test.rb
New tests for allowed_base_urls DSL: cannot configure twice; errors for empty/invalid inputs (non-strings, blank, nil).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Client as Client
  participant RackAttack as Rack::Attack\n(LoginRequestFields)
  participant Cache as Throttle Cache
  participant App as Rails App
  participant Logger as Rails.logger

  Client->>RackAttack: POST /api/v1/auth/login (body: email, otp_code)
  RackAttack->>RackAttack: read/parse body, rewind
  RackAttack->>Cache: check/increment throttle key (email-normalized)
  alt under limit
    RackAttack->>App: forward request
    App->>Logger: normal auth logs
    App-->>Client: 200 / auth response
  else over limit
    RackAttack->>Logger: log throttled event
    RackAttack-->>Client: 429 Too Many Requests
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • jjmata

Poem

🐰 I hop through code with gentle paws,

I guard the URLs and mend the flaws,
Throttles watch logins through the night,
Warnings whisper when a link’s not right,
Hops secure — I nibble till it's tight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately summarizes the main changes: rate limiting (sessions throttle, OTP rate limit) and SSRF allowlists for provider base URLs, which are the core objectives of the PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dc09bb504b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread config/initializers/rack_attack.rb Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
app/models/mercury_item.rb (1)

174-190: Reject invalid base_url at save time, not only on read.

effective_base_url is a good last-line defense, but since base_url is user-writable and has no AR validation, an invalid value (e.g., http://169.254.169.254/...) is still persisted to the DB. Consequences:

  • The stored value silently diverges from the effective value — a user looking at settings sees the bad URL but API calls transparently hit the default.
  • Rails.logger.warn("[SECURITY] Rejected Mercury base_url: ...") fires on every effective_base_url call (incl. each sync/API hit), producing potentially noisy repeated warnings for a single bad record.
  • The user never gets form-level feedback that their input was rejected.

Adding an AR validation surfaces the error in the settings UI and keeps the runtime check as defense-in-depth. As per coding guidelines: "Use ActiveRecord validations for complex validations and business logic."

🛡️ Proposed fix
   validates :name, presence: true
   validates :token, presence: true, on: :create
+  validates :base_url, inclusion: { in: ->(_) { ALLOWED_BASE_URLS } }, allow_blank: true

The same change should be applied in app/models/lunchflow_item.rb against its ALLOWED_BASE_URLS.

Note: the constant must be declared above the validates line if you move this to the top of the class, or keep the lambda form shown above so the constant is resolved lazily.

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

In `@app/models/mercury_item.rb` around lines 174 - 190, Add an ActiveRecord
validation on the user-writable base_url so invalid values are rejected at save
time in addition to the existing runtime guard: declare or reference
ALLOWED_BASE_URLS and add a validates :base_url, inclusion: { in:
ALLOWED_BASE_URLS, allow_blank: true } (or use a lambda resolving the constant
if you move it below the validation) so the settings UI surfaces errors; keep
the existing effective_base_url method as a defense-in-depth and apply the same
change to lunchflow_item.rb for its ALLOWED_BASE_URLS constant.
app/models/lunchflow_item.rb (1)

156-171: Allowlist + fallback pattern duplicated with MercuryItem — consider extraction.

The ALLOWED_BASE_URLS constant + effective_base_url method is an exact structural duplicate of the one introduced in app/models/mercury_item.rb. If more provider items adopt the same SSRF hardening (SimpleFin, EnableBanking, Plaid, etc.), consider extracting this into a small concern (e.g., BaseUrlAllowlisting) that takes the allowlist as a class attribute. Also see the separate comment on app/models/mercury_item.rb about adding an AR validation so invalid base_url values can't silently persist — the same concern applies here.

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

In `@app/models/lunchflow_item.rb` around lines 156 - 171, This duplicates the
ALLOWED_BASE_URLS constant and effective_base_url method found in MercuryItem;
extract the shared logic into a small ActiveSupport::Concern (e.g.,
BaseUrlAllowlisting) that exposes a class-level attribute for the allowlist and
an instance method effective_base_url, then include that concern into
LunchflowItem (and MercuryItem) and move ALLOWED_BASE_URLS into the class
attribute for each model; also add an ActiveRecord validation (e.g., validates
:base_url, inclusion: { in: allowed list } or a custom validator) to prevent
invalid base_url values persisting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@config/initializers/rack_attack.rb`:
- Around line 59-65: The OTP throttle block (throttle("api/otp_attempts/email",
...)) is currently reading request.params which misses JSON request bodies and
may call downcase/strip on non-strings; update the block to safely extract the
email from either form params or a parsed JSON body (e.g., parse
request.body.read with JSON.parse rescue nil, and ensure you rewind body) and
normalize only when the extracted email is a String (guard before calling
downcase/strip); ensure the throttle key uses that normalized string (or nil) so
mobile JSON submissions are correctly rate-limited and tests are added to assert
429 behavior for JSON OTP submissions.

In `@test/integration/rack_attack_test.rb`:
- Around line 24-34: Extend the existing tests in
test/integration/rack_attack_test.rb to perform behavioral checks rather than
only inspecting Rack::Attack.throttles: write a test that issues 10 POST
/sessions requests from the same IP (using the same remote_ip header) and
asserts they succeed, then issue an 11th POST and assert a
429/:too_many_requests response; similarly, write a test that issues 5 POST
/api/v1/auth/login requests with the same email and otp_code and asserts
success, then a 6th that asserts 429/:too_many_requests; mirror the assertion
style used in test/controllers/api/v1/base_controller_test.rb and reuse the
existing throttles checks ("sessions/create" and "api/otp_attempts/email") but
add these request-based assertions to validate behavior.

---

Nitpick comments:
In `@app/models/lunchflow_item.rb`:
- Around line 156-171: This duplicates the ALLOWED_BASE_URLS constant and
effective_base_url method found in MercuryItem; extract the shared logic into a
small ActiveSupport::Concern (e.g., BaseUrlAllowlisting) that exposes a
class-level attribute for the allowlist and an instance method
effective_base_url, then include that concern into LunchflowItem (and
MercuryItem) and move ALLOWED_BASE_URLS into the class attribute for each model;
also add an ActiveRecord validation (e.g., validates :base_url, inclusion: { in:
allowed list } or a custom validator) to prevent invalid base_url values
persisting.

In `@app/models/mercury_item.rb`:
- Around line 174-190: Add an ActiveRecord validation on the user-writable
base_url so invalid values are rejected at save time in addition to the existing
runtime guard: declare or reference ALLOWED_BASE_URLS and add a validates
:base_url, inclusion: { in: ALLOWED_BASE_URLS, allow_blank: true } (or use a
lambda resolving the constant if you move it below the validation) so the
settings UI surfaces errors; keep the existing effective_base_url method as a
defense-in-depth and apply the same change to lunchflow_item.rb for its
ALLOWED_BASE_URLS constant.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5db97550-6c2a-483d-9fbf-52cc14f26fa5

📥 Commits

Reviewing files that changed from the base of the PR and between b32e9db and dc09bb5.

📒 Files selected for processing (6)
  • app/models/lunchflow_item.rb
  • app/models/mercury_item.rb
  • config/initializers/rack_attack.rb
  • test/integration/rack_attack_test.rb
  • test/models/lunchflow_item_test.rb
  • test/models/mercury_item_test.rb

Comment thread config/initializers/rack_attack.rb
Comment thread test/integration/rack_attack_test.rb
Inline:
- OTP throttle for /api/v1/auth/login now extracts `email`/`otp_code` from
  either form params OR a JSON body (mobile clients POST JSON, which Rack::Attack
  does NOT see via `request.params` — it runs before Rails parses the body).
  Body is rewound so downstream middleware still reads it. Helper module
  LoginRequestFields keeps the logic tidy and testable.
- New behavioral integration tests: after N posts at the configured limit,
  the N+1 request returns 429. Covers both `/sessions` (form-encoded) and the
  JSON path on `/api/v1/auth/login` (regression for the body-parse fix above).
  Rack::Attack is toggled on per-test and the cache store is reset in ensure
  blocks so other tests aren't affected.

Nits:
- Extracted ALLOWED_BASE_URLS + effective_base_url into a
  BaseUrlAllowlistable concern used by MercuryItem and LunchflowItem.
  Eliminates duplication and makes it trivial to apply the same hardening
  to other provider items later.
- Added an AR `inclusion` validation on `base_url` (via the concern) so
  invalid values are rejected at save time. The settings UI now surfaces
  form-level errors instead of silently persisting a bad URL and only
  catching it at read time. The runtime fallback in effective_base_url is
  kept as defense-in-depth (values written via rake, console, or direct
  DB updates still get logged and coerced).

Adjusted the existing mercury/lunchflow test expectations to match the
concern-emitted log message ("Rejected MercuryItem base_url: ..." rather
than "Rejected Mercury base_url: ...") — the class name is more grep-able.
@dgilperez
Copy link
Copy Markdown
Author

Thanks — addressed all four points.

OTP throttle misses JSON bodies (rack_attack.rb:59): Good catch — request.params doesn't include the JSON body because Rack::Attack runs before Rails parses it. Added a small LoginRequestFields.read(request, field) helper that tries form params first, falls back to parsing the JSON body, and rewinds request.body so downstream middleware still reads it. Type-guards on the extracted values so we never call downcase/strip on non-strings.

Behavioral rate-limit tests (test/integration/rack_attack_test.rb): Added two tests that actually trigger the throttle by toggling Rack::Attack.enabled = true for the duration (disabled in test env by default) and resetting Rack::Attack.cache.store in ensure blocks so other tests aren't affected. One covers POST /sessions (form-encoded), one covers POST /api/v1/auth/login with a JSON body — the latter is a direct regression test for the body-parse fix above.

AR validation on base_url (mercury_item.rb, lunchflow_item.rb): Agreed. validates :base_url, inclusion: { in: ALLOWED_BASE_URLS, allow_blank: true } is now applied via the new concern (see below) so invalid values are rejected at save time — the settings UI surfaces the error instead of silently accepting a bad URL. Runtime effective_base_url is kept as defense-in-depth for values that arrive via rake/console/direct DB updates; it logs a [SECURITY] warning and coerces to the canonical URL.

Duplication extracted to a concern: Moved ALLOWED_BASE_URLS + effective_base_url into app/models/concerns/base_url_allowlistable.rb. Both MercuryItem and LunchflowItem now do:

include BaseUrlAllowlistable
allowed_base_urls "https://api.mercury.com/api/v1", "https://api-sandbox.mercury.com/api/v1"

One declaration registers the ALLOWED_BASE_URLS constant, the inclusion validation, and the runtime helper. Any future provider item with the same SSRF concern can opt in with one line.

Side note: the log line now includes the class name ([SECURITY] Rejected MercuryItem base_url: ...) rather than a hand-crafted label — more grep-friendly and removes a footgun where the log string could drift from the model. Adjusted the existing mercury/lunchflow tests accordingly.

All changes in commit 55dea37d. Full suite: 3242 runs, 0 failures. Rubocop + brakeman clean.

— wolfgang 🤖

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
app/models/concerns/base_url_allowlistable.rb (1)

26-32: Possible divergence between ALLOWED_BASE_URLS and the registered validator on repeated calls.

const_set is guarded by const_defined?(..., false), so the constant is set only on the first invocation, but validates :base_url, inclusion: { in: allowed } is registered unconditionally using the local allowed. If allowed_base_urls is ever called twice (code reload/class reopen, accidental re-declaration, subclass adding URLs), the constant will keep the original list while a second validator with a different list gets appended — and effective_base_url reads the (stale) constant via const_get. The two layers can silently disagree.

Either guard both together, or have the validator reference the constant so they can't diverge:

♻️ Proposed refactor
     def allowed_base_urls(*urls)
       allowed = urls.flatten.freeze
-      const_set(:ALLOWED_BASE_URLS, allowed) unless const_defined?(:ALLOWED_BASE_URLS, false)
-      validates :base_url, inclusion: { in: allowed }, allow_blank: true
+      if const_defined?(:ALLOWED_BASE_URLS, false)
+        raise ArgumentError, "#{name}.allowed_base_urls already configured"
+      end
+      const_set(:ALLOWED_BASE_URLS, allowed)
+      validates :base_url, inclusion: { in: -> (_) { const_get(:ALLOWED_BASE_URLS) } }, allow_blank: true
     end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/concerns/base_url_allowlistable.rb` around lines 26 - 32, The
ALLOWED_BASE_URLS constant is only set once but the inclusion validator in
allowed_base_urls(*urls) is registered every call using the local allowed
variable, causing potential divergence with const_get(:ALLOWED_BASE_URLS) used
elsewhere; fix by making the validator reference the constant instead of the
local allowed (or guard both const_set and validates behind the same
const_defined? check) so that validates :base_url, inclusion: { in: -> {
const_get(:ALLOWED_BASE_URLS) } }, allow_blank: true (or only add the validator
when the constant is not already defined) to keep ALLOWED_BASE_URLS and the
validator in sync when allowed_base_urls is invoked multiple times.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/models/concerns/base_url_allowlistable.rb`:
- Around line 26-32: The ALLOWED_BASE_URLS constant is only set once but the
inclusion validator in allowed_base_urls(*urls) is registered every call using
the local allowed variable, causing potential divergence with
const_get(:ALLOWED_BASE_URLS) used elsewhere; fix by making the validator
reference the constant instead of the local allowed (or guard both const_set and
validates behind the same const_defined? check) so that validates :base_url,
inclusion: { in: -> { const_get(:ALLOWED_BASE_URLS) } }, allow_blank: true (or
only add the validator when the constant is not already defined) to keep
ALLOWED_BASE_URLS and the validator in sync when allowed_base_urls is invoked
multiple times.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e0286bc9-b954-4607-8c95-d5edf5990672

📥 Commits

Reviewing files that changed from the base of the PR and between dc09bb5 and 55dea37.

📒 Files selected for processing (7)
  • app/models/concerns/base_url_allowlistable.rb
  • app/models/lunchflow_item.rb
  • app/models/mercury_item.rb
  • config/initializers/rack_attack.rb
  • test/integration/rack_attack_test.rb
  • test/models/lunchflow_item_test.rb
  • test/models/mercury_item_test.rb
🚧 Files skipped from review as they are similar to previous changes (6)
  • test/models/mercury_item_test.rb
  • test/models/lunchflow_item_test.rb
  • config/initializers/rack_attack.rb
  • app/models/mercury_item.rb
  • test/integration/rack_attack_test.rb
  • app/models/lunchflow_item.rb

CodeRabbit follow-up: `const_set` was guarded by `const_defined?` but
`validates` was unconditional. A second call to `allowed_base_urls`
(class reload, subclass, accidental re-declaration) would silently
leave `ALLOWED_BASE_URLS` as the first invocation's list while appending
a second `inclusion` validator bound to a snapshot of the second list —
so `effective_base_url` (const_get) and the validator could disagree
about what's legal.

Two changes:
- `allowed_base_urls` now raises ArgumentError on a second call. Fail
  loud at boot instead of latent drift.
- The validator resolves the allow-list via `const_get` on each call
  instead of closing over the local `allowed`, so even if someone
  mutates the constant out-of-band (rake task, console), `effective_base_url`
  and the inclusion check stay in lockstep.

New concern test asserts the double-configuration raises.
@dgilperez
Copy link
Copy Markdown
Author

Good catch on the drift.

Two changes:

  1. allowed_base_urls raises on a second call. The constant was const_set-guarded but validates wasn't — a second invocation (class reload, subclass, accidental re-declaration) would silently keep the first list in ALLOWED_BASE_URLS while appending a second validator with the new list. Now fails loudly at boot with ArgumentError: TestItem.allowed_base_urls already configured.

  2. Validator resolves the allow-list via const_get on each call, not a closure over the local allowed. Belt and suspenders: even if someone mutates ALLOWED_BASE_URLS out-of-band (rake, console), the inclusion check and effective_base_url stay in lockstep.

New test in test/models/concerns/base_url_allowlistable_test.rb asserts the double-configuration raises.

Commit cbb0e42d. Full suite: 3243 runs, 0 failures. Rubocop + brakeman clean.

— wolfgang 🤖

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/models/concerns/base_url_allowlistable.rb (1)

27-33: Fail fast on empty or malformed allowlists.

This macro is the security boundary for provider URLs; a zero-entry or blank/non-string allowlist currently registers successfully and later makes effective_base_url fall back to nil. Reject misconfiguration at declaration time.

🛡️ Proposed hardening
     def allowed_base_urls(*urls)
       if const_defined?(:ALLOWED_BASE_URLS, false)
         raise ArgumentError,
           "#{name}.allowed_base_urls already configured — call it exactly once per class"
       end
 
-      const_set(:ALLOWED_BASE_URLS, urls.flatten.freeze)
+      allowed_urls = urls.flatten
+      unless allowed_urls.any? && allowed_urls.all? { |url| url.is_a?(String) && url.present? }
+        raise ArgumentError,
+          "#{name}.allowed_base_urls requires at least one non-blank URL string"
+      end
+
+      const_set(:ALLOWED_BASE_URLS, allowed_urls.map { |url| url.dup.freeze }.freeze)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/concerns/base_url_allowlistable.rb` around lines 27 - 33, The
allowed_base_urls macro should validate its input and fail fast: in the
allowed_base_urls method (which sets the ALLOWED_BASE_URLS constant) flatten the
provided urls, ensure every entry is a non-blank String (e.g.
respond_to?(:strip) and stripped length > 0), discard nils, and raise
ArgumentError if the resulting array is empty or any element is
non-string/blank; only then const_set(:ALLOWED_BASE_URLS, cleaned_urls.freeze).
Also keep the existing duplicate-call guard that raises if ALLOWED_BASE_URLS is
already defined.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/models/concerns/base_url_allowlistable.rb`:
- Line 17: Update the inline comment that currently claims a "single boot-time
[SECURITY] log" to accurately reflect that effective_base_url logs an
invalid-value event each time it's called at runtime; locate the comment near
the effective_base_url method in the BaseUrlAllowlistable concern and change the
wording to indicate per-call/runtime logging of invalid values rather than a
one-time boot log.

---

Nitpick comments:
In `@app/models/concerns/base_url_allowlistable.rb`:
- Around line 27-33: The allowed_base_urls macro should validate its input and
fail fast: in the allowed_base_urls method (which sets the ALLOWED_BASE_URLS
constant) flatten the provided urls, ensure every entry is a non-blank String
(e.g. respond_to?(:strip) and stripped length > 0), discard nils, and raise
ArgumentError if the resulting array is empty or any element is
non-string/blank; only then const_set(:ALLOWED_BASE_URLS, cleaned_urls.freeze).
Also keep the existing duplicate-call guard that raises if ALLOWED_BASE_URLS is
already defined.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 330f6744-2d51-4ec4-8f06-43ebf0a81dc8

📥 Commits

Reviewing files that changed from the base of the PR and between 55dea37 and cbb0e42.

📒 Files selected for processing (2)
  • app/models/concerns/base_url_allowlistable.rb
  • test/models/concerns/base_url_allowlistable_test.rb

Comment thread app/models/concerns/base_url_allowlistable.rb Outdated
CodeRabbit round 3: `allowed_base_urls` trusted its input. An empty call,
a nil or blank entry, or a non-String entry (e.g. symbol, URI object) was
silently accepted and later made `effective_base_url` return nil or blow
up at validation time. Reject misconfiguration where it's most
debuggable: at boot.

Raises ArgumentError with a descriptive message when the flattened list
is empty, contains non-Strings, or contains blank strings. Deep-freezes
each URL so they can't be mutated through the constant later.

Also corrected an inline comment that misrepresented the [SECURITY]
logging as a "single boot-time" event — it's per-call at runtime.

New tests cover the four rejection paths (empty, :symbol, "", nil).
@dgilperez
Copy link
Copy Markdown
Author

Third round addressed.

Validate input at declaration time (base_url_allowlistable.rb:27): Agreed. allowed_base_urls now raises ArgumentError at boot if the flattened list is empty, contains a non-String (symbol, URI object, etc.), or contains blank strings. Fail-loud at declaration is far easier to debug than a silent nil fallback from effective_base_url discovered in production logs. Deep-freezes each URL so the constant can't be mutated later.

Inline comment correction (base_url_allowlistable.rb:17): Fixed. The [SECURITY] log fires per call to effective_base_url, not once at boot — updated the concern's doc comment to reflect reality.

New tests for the four rejection paths: empty args, :symbol entry, "" blank entry, nil entry.

Commit fa923558. Full suite: 3247 runs, 0 failures. Rubocop + brakeman clean.

— wolfgang 🤖

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/models/concerns/base_url_allowlistable.rb`:
- Around line 20-21: The comment incorrectly calls the validator a "DB-level
validation"; update the header comment in the BaseUrlAllowlistable concern to
call it an "ActiveRecord validation" (or "Rails/ActiveRecord inclusion
validator") and clarify that direct DB writes can bypass it and are only
mitigated by the runtime fallback; edit the descriptive lines that currently
read "DB-level validation and the runtime helper" to reflect "ActiveRecord
validation and the runtime helper" and ensure the wording mentions that DB
writes are still possible and covered only by the runtime fallback.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5aaf2609-6e45-45ed-8c15-fe061d14895d

📥 Commits

Reviewing files that changed from the base of the PR and between cbb0e42 and fa92355.

📒 Files selected for processing (2)
  • app/models/concerns/base_url_allowlistable.rb
  • test/models/concerns/base_url_allowlistable_test.rb
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/models/concerns/base_url_allowlistable_test.rb

Comment thread app/models/concerns/base_url_allowlistable.rb Outdated
CodeRabbit round 4: the concern's doc comment called the inclusion
validator a 'DB-level validation', which is misleading — it's an
ActiveRecord validation that can be bypassed by any path that writes
without going through AR (update_columns, raw SQL, a rake task using
direct DB access). Corrected the wording and spelled out what each
layer actually catches.
@dgilperez
Copy link
Copy Markdown
Author

Fair — the comment was misleading. Updated to call it an "ActiveRecord inclusion validation" and spelled out what each layer actually catches vs. bypasses:

  • The AR validation catches bad input at the UI / AR-write boundary.
  • The runtime effective_base_url helper guards values written by paths that bypass AR: direct SQL (UPDATE provider_items ...), rake tasks using update_columns, or console sessions skipping validation.
  • Neither is a full substitute for the other.

Commit fe0511da.

— wolfgang 🤖

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
app/models/concerns/base_url_allowlistable.rb (1)

30-49: Add declaration-time validation for SSRF hardening boundary.

allowed_base_urls currently accepts any nonblank string. Since effective_base_url falls back to ALLOWED_BASE_URLS.first, a misconfigured declaration could set an unsafe URL as the canonical fallback. Add validation to reject non-absolute/non-HTTPS URLs at declaration time to fail closed.

Proposed hardening
+require "uri"
+
 # Shared F-08 SSRF hardening for provider items whose operators can configure
 # an outbound `base_url` from the UI. Without validation a user could point
 # server-side requests at internal endpoints (169.254.169.254 metadata,
@@
       unless allowed.any? && allowed.all? { |url| url.is_a?(String) && url.present? }
         raise ArgumentError,
           "#{name}.allowed_base_urls requires at least one non-blank URL string (got #{allowed.inspect})"
       end
+
+      invalid_url = allowed.find { |url| !absolute_https_url?(url) }
+      if invalid_url
+        raise ArgumentError,
+          "#{name}.allowed_base_urls requires absolute HTTPS URLs without userinfo, query, or fragment (got #{invalid_url.inspect})"
+      end
 
       const_set(:ALLOWED_BASE_URLS, allowed.map { |url| url.dup.freeze }.freeze)
@@
         allow_blank: true
     end
+
+    private
+
+    def absolute_https_url?(url)
+      uri = URI.parse(url)
+      uri.is_a?(URI::HTTPS) &&
+        uri.host.present? &&
+        uri.userinfo.blank? &&
+        uri.query.blank? &&
+        uri.fragment.blank?
+    rescue URI::InvalidURIError
+      false
+    end
   end

Current declarations in MercuryItem, LunchflowItem all conform to the proposed validation rules (absolute HTTPS URLs with API paths).

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

In `@app/models/concerns/base_url_allowlistable.rb` around lines 30 - 49, The
allowed_base_urls declaration currently only checks for non-blank strings;
update the allowed_base_urls method to validate each URL at declaration time by
parsing and ensuring it is an absolute HTTPS URL (e.g. use URI parsing to
require a scheme of "https" and a present host/path as appropriate) and raise
ArgumentError with a clear message if any URL is not absolute or not HTTPS; keep
the const_set of ALLOWED_BASE_URLS and the existing validates :base_url logic
but reject invalid declarations early so effective_base_url (which may fall back
to ALLOWED_BASE_URLS.first) can never be set to a non-HTTPS or non-absolute
value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/models/concerns/base_url_allowlistable.rb`:
- Around line 30-49: The allowed_base_urls declaration currently only checks for
non-blank strings; update the allowed_base_urls method to validate each URL at
declaration time by parsing and ensuring it is an absolute HTTPS URL (e.g. use
URI parsing to require a scheme of "https" and a present host/path as
appropriate) and raise ArgumentError with a clear message if any URL is not
absolute or not HTTPS; keep the const_set of ALLOWED_BASE_URLS and the existing
validates :base_url logic but reject invalid declarations early so
effective_base_url (which may fall back to ALLOWED_BASE_URLS.first) can never be
set to a non-HTTPS or non-absolute value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f2362092-34ef-463a-8499-4be027edd5cb

📥 Commits

Reviewing files that changed from the base of the PR and between fa92355 and fe0511d.

📒 Files selected for processing (1)
  • app/models/concerns/base_url_allowlistable.rb

CodeRabbit round 5: the first entry in ALLOWED_BASE_URLS becomes the
canonical fallback returned by effective_base_url when an invalid value
is encountered. A misconfigured declaration (http://, relative path,
embedded credentials) would undermine the whole F-08 SSRF defense by
making a non-HTTPS or internal URL the implicit escape hatch.

Added declaration-time validation:
- Must be absolute HTTPS
- Must have a host
- Must NOT carry userinfo (e.g., https://admin:secret@host), a query,
  or a fragment
- Must parse as a valid URI

7 new unit tests cover each rejection path + a positive case.
@dgilperez
Copy link
Copy Markdown
Author

Good defense-in-depth catch. The first entry in ALLOWED_BASE_URLS becomes the canonical fallback effective_base_url returns when an invalid value is encountered, so a misconfigured declaration would undermine the whole SSRF defense.

Added declaration-time validation via a tiny absolute_https_url? helper on the concern. Each entry must:

  • Parse as a valid URI (rejects "https://exa mple.com")
  • Be absolute HTTPS (rejects http://, rejects relative paths)
  • Have a host
  • NOT carry userinfo (rejects https://admin:secret@internal.example.com)
  • NOT carry a query or fragment (prevents ?secret=… / #… sneaking into the allowlist)

7 new unit tests covering each rejection path + a positive case.

Commit 717da1bd. Full suite unchanged: 3247 runs, 0 failures. Rubocop clean.

— wolfgang 🤖

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant