Skip to content

security: input validation & injection fixes (XSS, constantize, open redirects, CSV, SQL)#1517

Open
dgilperez wants to merge 11 commits intowe-promise:mainfrom
dgilperez:security/pr1-input-validation
Open

security: input validation & injection fixes (XSS, constantize, open redirects, CSV, SQL)#1517
dgilperez wants to merge 11 commits intowe-promise:mainfrom
dgilperez:security/pr1-input-validation

Conversation

@dgilperez
Copy link
Copy Markdown

@dgilperez dgilperez commented Apr 19, 2026

Summary

Addresses all input-validation / output-escaping findings from the 2026-03 pentest: XSS in markdown renderers, unsafe constantize on user input, open-redirect vectors, CSV formula injection, and SQL interpolation in reports.

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

Findings addressed (11)

ID Severity CWE Fix
FIX-05 / HIGH-04 HIGH CWE-79 Stored XSS via Redcarpet — filter_html: true + sanitize() allowlist in application_helper.rb
FIX-06 HIGH CWE-79 Stored XSS via GitHub release notes html_safe in pages/changelog.html.erb
FIX-07 HIGH CWE-470 Unsafe constantize in import/mappings_controller.rb — allowlist
FIX-08 MED CWE-601 Open redirect via store_return_to — path sanitization in concerns/store_location.rb
F-07 HIGH CWE-470 Unsafe constantize in account_import.rbALLOWED_ACCOUNTABLE_TYPES allowlist
F-09 MED CWE-1236 CSV formula injection in family/data_exporter.rbsanitize_csv escapes leading =+-@\t\r\n
F-10 MED CWE-601 Open redirect in concerns/accountable_resource.rbsafe_return_to_path helper
CRIT-02 CRIT CWE-89 SQL interpolation in reports ORDER BY — hash-based sanitized ordering
HIGH-03 HIGH CWE-89 Unescaped LIKE wildcards in API transactions search — sanitize_sql_like
HIGH-05 HIGH CWE-79 Raw HTML in guides markdown renderer — filter_html: true

Tests

  • New: test/models/account_import_test_security.rb (ALLOWED_ACCOUNTABLE_TYPES), test/models/family/data_exporter_test.rb (sanitize_csv cases).
  • Full suite: 3228 runs, 0 failures.
  • bin/rubocop clean, bin/brakeman 0 warnings.

Out of scope

Related

Summary by CodeRabbit

Bug Fixes

  • Enhanced search security by properly escaping user input to prevent unexpected wildcard behavior.
  • Improved redirect handling to prevent open redirect vulnerabilities when returning to previous pages.
  • Added content sanitization to Markdown rendering and changelog display with safety filters to prevent unintended HTML execution.
  • Implemented type validation during imports to prevent instantiation of unsupported or untrusted types.
  • Protected CSV exports from injection attacks by escaping special characters in user-generated fields.
  • Improved SQL query construction for sorting to use safer parameterized methods.

Tests

  • Added security tests for type validation during data imports.
  • Added tests for CSV injection prevention in data exports.

…, HIGH-04)

Redcarpet by default allows raw HTML in markdown input. Combined with
.html_safe on the rendered output, any user-supplied markdown (e.g. AI
chat responses, notes) could inject arbitrary HTML/JS resulting in
stored XSS.

- Add filter_html: true to the Redcarpet renderer so raw HTML tags
  embedded in markdown are escaped (FIX-05).
- Replace .html_safe with Rails' sanitize() helper using an allowlist
  of safe tags and attributes as defense-in-depth (HIGH-04).
The changelog page renders GitHub release notes via .html_safe, which
trusts whatever the upstream GitHub API returns. A compromised release
author or malicious release body could inject arbitrary HTML/JS,
resulting in stored XSS for every user viewing /changelog.

Replace .html_safe with Rails' sanitize() helper constrained to an
allowlist of tags and attributes appropriate for release notes.
…IX-07)

mappable_class and mapping_class called .constantize on user-supplied
params (:mappable_type, :type), which permits attackers to instantiate
any constant loaded in the Rails app. This is a remote code execution
primitive via gadget classes and can trigger autoloading of unintended
constants.

Restrict both methods to an explicit allowlist of safe class names
(Category/Tag/Account and the four Import::*Mapping types) before
calling constantize.
store_return_to copied the params[:return_to] value straight into the
session, where later redirect_back_or_to would follow it — including
fully-qualified attacker-controlled URLs or protocol-relative //evil
paths. This enabled phishing via legitimate-looking links.

Only persist values that start with a single "/" (internal relative
paths) and reject anything else.
AccountImport#import! called .constantize on the user-supplied mapping
value from a CSV. Because CSV import mappings are user-editable this
allowed attackers with a valid family session to instantiate arbitrary
constants from the Rails environment.

Restrict to the nine legitimate Accountable subclasses and raise
ArgumentError otherwise.
CSV/NDJSON exports echoed user-supplied strings (account names, tag
names, notes, rule names, etc.) verbatim. A value starting with =, +,
-, @, or certain control characters is interpreted as a formula by
Excel/Google Sheets and could execute commands, exfiltrate data, or
phish the recipient on open (CWE-1236).

Add a sanitize_csv helper that prefixes any such value with a single
quote, and apply it to every user-controlled string across the CSV
and NDJSON export paths.
AccountableResource#create redirected to account_params[:return_to]
without validation, allowing attackers to craft account-creation links
that bounce through our domain to a phishing site.

Introduce safe_return_to_path which rejects absolute URLs, any value
with a scheme, and protocol-relative paths, falling back to the newly
created account on invalid input.
build_sorted_transactions concatenated the sort_direction string into
an ORDER BY fragment. While an allowlist check gates the value, any
future refactor could regress into SQL injection. Replace the raw SQL
string with Rails' hash-based order syntax, which delegates quoting
to the adapter and removes the injection primitive entirely.
…-03)

Api::V1::TransactionsController#apply_search interpolated the raw
search parameter into an ILIKE pattern. Values containing % or _
bypassed the intended literal substring match and let a client
enumerate or DoS the index with arbitrary pattern expansion.

Pipe the search term through ActiveRecord::Base.sanitize_sql_like so
% and _ are escaped before being wrapped in the wildcard template.
Settings::GuidesController rendered docs/onboarding/guide.md with a
default Redcarpet HTML renderer. Anyone with write access to that
path (including a future migration that makes guides user-editable)
could inject arbitrary HTML/JS and trigger XSS on the settings page.

Instantiate the renderer with filter_html: true and standard safe
link attributes as defense-in-depth.
@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

📝 Walkthrough

Walkthrough

Security hardening across controllers, models, helpers, views, and tests: input escaping for SQL LIKE, open-redirect/path validation, dynamic-constant allowlisting, CSV formula sanitization, HTML/Markdown sanitization, safer SQL ordering, and accompanying unit tests for these behaviors.

Changes

Cohort / File(s) Summary
SQL Search & Ordering
app/controllers/api/v1/transactions_controller.rb, app/controllers/reports_controller.rb
Escape user search input with ActiveRecord::Base.sanitize_sql_like for ILIKE; replace string-interpolated ORDER BY with validated direction symbols and Rails hash-style ordering.
Open-redirect / Return-to Guards
app/controllers/concerns/accountable_resource.rb, app/controllers/concerns/store_location.rb
Introduce safe_return_to_path and stricter store_return_to checks: require leading single /, reject // and disallow URLs with scheme/host or parse errors before using return targets.
Dynamic Constant Allowlisting
app/controllers/import/mappings_controller.rb, app/models/account_import.rb
Add frozen allowlists (ALLOWED_MAPPABLE_TYPES, ALLOWED_MAPPING_TYPES, ALLOWED_ACCOUNTABLE_TYPES) and validate params against them; resolve classes via safe helpers (Accountable.from_type), raising or returning nil on invalid input.
Markdown/HTML Sanitization
app/controllers/settings/guides_controller.rb, app/helpers/application_helper.rb, app/views/pages/changelog.html.erb
Enable filter_html in Redcarpet renderer, set link attributes (target="_blank", rel="noopener noreferrer"), and replace unconditional .html_safe with sanitize(...) using explicit allowed tags/attributes.
CSV Export Sanitization
app/models/family/data_exporter.rb
Add sanitize_csv helper to prefix strings starting with =, +, -, @, or leading newline/tab/carriage return with a single quote to prevent spreadsheet formula injection; apply to CSV fields (NDJSON left unchanged).
Tests Added
test/models/account_import_test_security.rb, test/models/family/data_exporter_test.rb
New security-focused tests: verify allowed accountable types mirror Accountable::TYPES and resolve via Accountable.from_type; test sanitize_csv behavior and ensure NDJSON output remains unchanged.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • jjmata
  • luckyPipewrench
  • sokie

Poem

🐇 I hopped through rails and hid the traps,

Escaped the LIKEs and closed the gaps.
Paths now checked, constants are few,
CSV safe, and links askew—
A tidy patch from this small crew.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.04% 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 pull request title accurately and specifically describes the primary change: security fixes addressing multiple injection and validation vulnerabilities (XSS, constantize, open redirects, CSV, SQL).

✏️ 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: b2b7b512cf

ℹ️ 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 app/models/family/data_exporter.rb Outdated
Comment thread app/controllers/concerns/accountable_resource.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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/models/family/data_exporter.rb (1)

192-274: ⚠️ Potential issue | 🟠 Major

Do not apply CSV formula sanitization to NDJSON fields — it corrupts exported data.

sanitize_csv exists to neutralize spreadsheet formula interpretation when a value is written into a CSV cell. NDJSON is consumed as JSON, where a leading =, +, -, or @ has no special meaning, so prepending ' here is not a security win — it permanently mutates user-entered values (transaction name/notes, valuation name, rule name) inside all.ndjson.

Concrete impact:

  • A transaction named -1.5x leverage is exported as '-1.5x leverage in NDJSON.
  • Any round-trip (export → re-import via NDJSON) now silently drifts names/notes with every cycle, and the ' becomes part of the stored value.
  • serialize_rule_for_export is also reused from generate_ndjson (line 265), so rule names suffer the same corruption.

Keep sanitize_csv only in the CSV generators (which is where formula injection actually applies) and pass raw strings through for NDJSON.

🛠️ Suggested fix
             currency: transaction.entry.currency,
-            name: sanitize_csv(transaction.entry.name),
-            notes: sanitize_csv(transaction.entry.notes),
+            name: transaction.entry.name,
+            notes: transaction.entry.notes,
             excluded: transaction.entry.excluded,
             currency: entry.currency,
-            name: sanitize_csv(entry.name),
+            name: entry.name,
             created_at: entry.created_at,
     def serialize_rule_for_export(rule)
       {
-        name: sanitize_csv(rule.name),
+        name: rule.name,
         resource_type: rule.resource_type,

Note: serialize_rule_for_export is only called from generate_ndjson, so CSV rule-name sanitization in generate_rules_csv (line 131) is unaffected.

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

In `@app/models/family/data_exporter.rb` around lines 192 - 274, The NDJSON
exporter is incorrectly applying CSV formula sanitization (sanitize_csv) to JSON
output which mutates user data; remove sanitize_csv calls from the NDJSON
generation paths — specifically stop calling sanitize_csv on
transaction.entry.name and transaction.entry.notes in the "Transaction" block,
on entry.name in the "Valuation" block, and from serialize_rule_for_export (used
for "Rule" exports); leave sanitize_csv in CSV-generating methods (e.g.,
generate_rules_csv) untouched so CSV exports still neutralize formulas.
🧹 Nitpick comments (5)
test/models/family/data_exporter_test.rb (1)

347-363: LGTM — focused coverage for sanitize_csv.

Good use of send to exercise the private helper directly with tight, readable cases (formula prefixes, safe strings, non-string pass-through). Fixture usage and Minitest conventions align with the project's testing guidelines.

One nit: once the NDJSON misuse flagged in the production file is fixed, consider adding an assertion that NDJSON name/notes for a transaction whose name starts with =/- is preserved verbatim in all.ndjson — that locks in the intended separation between CSV and NDJSON handling.

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

In `@test/models/family/data_exporter_test.rb` around lines 347 - 363, Add a test
that verifies NDJSON output preserves verbatim values for fields that would be
CSV-sanitized: after fixing the NDJSON misuse in production, create or extend a
test that writes a transaction with name and notes starting with "=", "+" or "-"
and then asserts that the corresponding entries in the generated all.ndjson
contain the original name and notes strings untouched; reference the
sanitize_csv helper only to distinguish CSV behavior from NDJSON behavior and
assert on the NDJSON file's "name" and "notes" properties to lock in the
intended separation between CSV and NDJSON handling.
app/models/family/data_exporter.rb (1)

354-359: Minor: regex character class is correct, but consider documenting intent of \t\r\n.

The class [=+\-@\t\r\n] correctly escapes - and matches OWASP's recommended leading characters. One subtle gap worth noting: some guidance also treats a literal leading space before a formula character (e.g., " =1+1") as evasion. Not a blocker — current behavior matches the typical OWASP shortlist — but worth a brief comment on why tab/CR/LF are included (they prevent leading-whitespace bypasses in some parsers) so a future maintainer doesn't trim them.

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

In `@app/models/family/data_exporter.rb` around lines 354 - 359, The regex in
sanitize_csv currently matches leading formula characters including tab/CR/LF
but lacks an explanatory comment; update the sanitize_csv method to add a short
inline note explaining that the character class [=+\-@\t\r\n] intentionally
includes \t, \r, and \n to catch formula injection attempts that use leading
control whitespace as evasion, and mention that a literal leading space before a
formula (e.g., " =1+1") was deliberately not included (or document if you choose
to trim spaces) so future maintainers understand the tradeoff.
app/controllers/reports_controller.rb (1)

664-674: Redundant direction normalization.

sort_direction is whitelisted and uppercased on line 664, then immediately downcased and symbolized on line 666. Since the result flows into hash-based order(...) which is already safe from SQL injection, you can simplify to a single step:

♻️ Proposed simplification
-      sort_by = params[:sort_by] || "date"
-      # Whitelist sort_direction to prevent SQL injection
-      sort_direction = %w[asc desc].include?(params[:sort_direction]&.downcase) ? params[:sort_direction].upcase : "DESC"
-
-      direction = sort_direction.downcase.to_sym
+      sort_by = params[:sort_by] || "date"
+      # Whitelist sort_direction (hash-based order() also guards against SQL injection)
+      direction = %w[asc desc].include?(params[:sort_direction]&.downcase) ? params[:sort_direction].downcase.to_sym : :desc
       case sort_by
       when "date"
         transactions.order("entries.date" => direction)
       when "amount"
         transactions.order("entries.amount" => direction)
       else
         transactions.order("entries.date" => :desc)
       end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/reports_controller.rb` around lines 664 - 674, The code
redundantly uppercases then immediately downcases/symbolizes the sort direction;
simplify by producing a single normalized symbol used in the orders. Replace the
two-step normalization (sort_direction then direction) with one expression that
validates params[:sort_direction] against %w[asc desc] and yields a symbol (:asc
or :desc) to feed into transactions.order in the case branches (referencing
sort_by and transactions.order("entries.date"/"entries.amount")). Remove the
intermediate uppercase step and the extra variable to keep the input validation
and SQL-safety while reducing redundancy.
app/models/account_import.rb (1)

4-18: Reuse Accountable::TYPES / Accountable.from_type instead of duplicating the allowlist.

app/models/concerns/accountable.rb already defines the canonical allowlist and safe resolver:

TYPES = %w[Depository Investment Crypto Property Vehicle OtherAsset CreditCard Loan OtherLiability].freeze

def self.from_type(type)
  return nil unless TYPES.include?(type)
  type.constantize
end

ALLOWED_ACCOUNTABLE_TYPES here is an exact copy. When a new accountable subtype is eventually added to Accountable::TYPES, imports will silently fail with Invalid accountable type: … until someone remembers this second list. Delegating to Accountable keeps the security guarantee while eliminating the drift risk.

♻️ Suggested refactor
 class AccountImport < Import
   OpeningBalanceError = Class.new(StandardError)

-  ALLOWED_ACCOUNTABLE_TYPES = %w[
-    Depository Investment Crypto
-    Property Vehicle OtherAsset
-    CreditCard Loan OtherLiability
-  ].freeze
-
   def import!
     transaction do
       rows.each do |row|
         mapping = mappings.account_types.find_by(key: row.entity_type)
         type = mapping&.value
-        unless type.present? && ALLOWED_ACCOUNTABLE_TYPES.include?(type)
-          raise ArgumentError, "Invalid accountable type: #{type.inspect}"
-        end
-        accountable_class = type.constantize
+        accountable_class = Accountable.from_type(type)
+        raise ArgumentError, "Invalid accountable type: #{type.inspect}" unless accountable_class

If you prefer to keep AccountImport::ALLOWED_ACCOUNTABLE_TYPES as a public API (the security test references it), define it as ALLOWED_ACCOUNTABLE_TYPES = Accountable::TYPES so it stays in lockstep.

As per coding guidelines ("Place business logic in app/models/ folder … use Rails concerns and POROs for organization"), routing through the Accountable concern keeps the type-resolution logic in one place.

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

In `@app/models/account_import.rb` around lines 4 - 18, Replace the duplicated
allowlist and unsafe constantize with the canonical resolver in Accountable:
remove or redefine AccountImport::ALLOWED_ACCOUNTABLE_TYPES to use
Accountable::TYPES (e.g. ALLOWED_ACCOUNTABLE_TYPES = Accountable::TYPES) and in
import! stop doing type = mapping&.value; accountable_class = type.constantize;
instead call Accountable.from_type(mapping&.value) (or assign to a variable like
accountable_class = Accountable.from_type(mapping&.value)) and raise the same
ArgumentError if that returns nil so invalid types are still rejected; this
keeps the allowlist and resolution centralized in Accountable.
test/models/account_import_test_security.rb (1)

4-11: Test duplicates the constant instead of guarding against drift.

Asserting ALLOWED_ACCOUNTABLE_TYPES equals a hardcoded copy of the same list is tautological — any legitimate edit to the constant requires mirroring the change here, and the test still won't catch the real risk: AccountImport::ALLOWED_ACCOUNTABLE_TYPES silently drifting from Accountable::TYPES (the canonical accountable subtypes list in app/models/concerns/accountable.rb). If a new accountable subtype is added to Accountable::TYPES, imports for that type will start raising ArgumentError, "Invalid accountable type: …" until someone remembers to update this second list.

Prefer asserting parity with the canonical source:

♻️ Suggested change
   test "ALLOWED_ACCOUNTABLE_TYPES covers all expected types" do
-    expected = %w[
-      Depository Investment Crypto
-      Property Vehicle OtherAsset
-      CreditCard Loan OtherLiability
-    ]
-    assert_equal expected.sort, AccountImport::ALLOWED_ACCOUNTABLE_TYPES.sort
+    # Guard against drift from the canonical Accountable::TYPES list.
+    assert_equal Accountable::TYPES.sort,
+                 AccountImport::ALLOWED_ACCOUNTABLE_TYPES.sort
   end

This becomes unnecessary entirely if you consolidate on Accountable::TYPES in app/models/account_import.rb (see the related comment on that file).

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

In `@test/models/account_import_test_security.rb` around lines 4 - 11, The test
currently duplicates AccountImport::ALLOWED_ACCOUNTABLE_TYPES; change it to
assert parity with the canonical source by comparing
AccountImport::ALLOWED_ACCOUNTABLE_TYPES to Accountable::TYPES (e.g. sort and
assert equality or set equality) so the test fails if the constant drifts from
the canonical list; locate references to
AccountImport::ALLOWED_ACCOUNTABLE_TYPES and replace the hardcoded expected
array with a comparison to Accountable::TYPES in the test
"ALLOWED_ACCOUNTABLE_TYPES covers all expected types".
🤖 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/controllers/concerns/accountable_resource.rb`:
- Around line 103-112: safe_return_to_path currently allows protocol-relative
open redirects and only checks params[:return_to]; update it to read the
return_to value from both params[:return_to] and account_params[:return_to] (use
account_params if present), then reject any value that starts with '//'
(protocol-relative) or where URI.parse(return_to).host.present? or
uri.scheme.present?; only return the path when it begins with '/' (single slash)
and passes those host/scheme/leading-slash checks. Reference:
safe_return_to_path and account_params.

In `@app/helpers/application_helper.rb`:
- Around line 156-160: The sanitize allowlist currently omits img so markdown
images are stripped; update the sanitize call in application_helper.rb to
include "img" in the tags list and add safe image attributes (e.g. include src,
alt, title in the attributes array) so images render but only with constrained
attributes (leaving URL protocol filtering to Rails sanitizer), e.g. add "img"
to tags and add "src", "alt", "title" to attributes; also remove the
now-redundant .html_safe call in the provider form view where markdown output is
rendered.

In `@app/views/pages/changelog.html.erb`:
- Around line 24-26: The sanitize call rendering `@release_notes`[:body] in
app/views/pages/changelog.html.erb currently allows only attributes %w[href src
alt class id title], which strips GitHub's dir="auto" on block elements; update
the attributes list used in the sanitize(...) invocation (the attributes param
passed to sanitize) to include "dir" so bidi auto-detection (dir="auto") from
GitHub-rendered HTML is preserved while keeping other allowed attributes
unchanged.

---

Outside diff comments:
In `@app/models/family/data_exporter.rb`:
- Around line 192-274: The NDJSON exporter is incorrectly applying CSV formula
sanitization (sanitize_csv) to JSON output which mutates user data; remove
sanitize_csv calls from the NDJSON generation paths — specifically stop calling
sanitize_csv on transaction.entry.name and transaction.entry.notes in the
"Transaction" block, on entry.name in the "Valuation" block, and from
serialize_rule_for_export (used for "Rule" exports); leave sanitize_csv in
CSV-generating methods (e.g., generate_rules_csv) untouched so CSV exports still
neutralize formulas.

---

Nitpick comments:
In `@app/controllers/reports_controller.rb`:
- Around line 664-674: The code redundantly uppercases then immediately
downcases/symbolizes the sort direction; simplify by producing a single
normalized symbol used in the orders. Replace the two-step normalization
(sort_direction then direction) with one expression that validates
params[:sort_direction] against %w[asc desc] and yields a symbol (:asc or :desc)
to feed into transactions.order in the case branches (referencing sort_by and
transactions.order("entries.date"/"entries.amount")). Remove the intermediate
uppercase step and the extra variable to keep the input validation and
SQL-safety while reducing redundancy.

In `@app/models/account_import.rb`:
- Around line 4-18: Replace the duplicated allowlist and unsafe constantize with
the canonical resolver in Accountable: remove or redefine
AccountImport::ALLOWED_ACCOUNTABLE_TYPES to use Accountable::TYPES (e.g.
ALLOWED_ACCOUNTABLE_TYPES = Accountable::TYPES) and in import! stop doing type =
mapping&.value; accountable_class = type.constantize; instead call
Accountable.from_type(mapping&.value) (or assign to a variable like
accountable_class = Accountable.from_type(mapping&.value)) and raise the same
ArgumentError if that returns nil so invalid types are still rejected; this
keeps the allowlist and resolution centralized in Accountable.

In `@app/models/family/data_exporter.rb`:
- Around line 354-359: The regex in sanitize_csv currently matches leading
formula characters including tab/CR/LF but lacks an explanatory comment; update
the sanitize_csv method to add a short inline note explaining that the character
class [=+\-@\t\r\n] intentionally includes \t, \r, and \n to catch formula
injection attempts that use leading control whitespace as evasion, and mention
that a literal leading space before a formula (e.g., " =1+1") was deliberately
not included (or document if you choose to trim spaces) so future maintainers
understand the tradeoff.

In `@test/models/account_import_test_security.rb`:
- Around line 4-11: The test currently duplicates
AccountImport::ALLOWED_ACCOUNTABLE_TYPES; change it to assert parity with the
canonical source by comparing AccountImport::ALLOWED_ACCOUNTABLE_TYPES to
Accountable::TYPES (e.g. sort and assert equality or set equality) so the test
fails if the constant drifts from the canonical list; locate references to
AccountImport::ALLOWED_ACCOUNTABLE_TYPES and replace the hardcoded expected
array with a comparison to Accountable::TYPES in the test
"ALLOWED_ACCOUNTABLE_TYPES covers all expected types".

In `@test/models/family/data_exporter_test.rb`:
- Around line 347-363: Add a test that verifies NDJSON output preserves verbatim
values for fields that would be CSV-sanitized: after fixing the NDJSON misuse in
production, create or extend a test that writes a transaction with name and
notes starting with "=", "+" or "-" and then asserts that the corresponding
entries in the generated all.ndjson contain the original name and notes strings
untouched; reference the sanitize_csv helper only to distinguish CSV behavior
from NDJSON behavior and assert on the NDJSON file's "name" and "notes"
properties to lock in the intended separation between CSV and NDJSON handling.
🪄 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: a88a1a7c-cba9-4b2c-ab55-0be0feb9594d

📥 Commits

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

📒 Files selected for processing (12)
  • app/controllers/api/v1/transactions_controller.rb
  • app/controllers/concerns/accountable_resource.rb
  • app/controllers/concerns/store_location.rb
  • app/controllers/import/mappings_controller.rb
  • app/controllers/reports_controller.rb
  • app/controllers/settings/guides_controller.rb
  • app/helpers/application_helper.rb
  • app/models/account_import.rb
  • app/models/family/data_exporter.rb
  • app/views/pages/changelog.html.erb
  • test/models/account_import_test_security.rb
  • test/models/family/data_exporter_test.rb

Comment thread app/controllers/concerns/accountable_resource.rb Outdated
Comment thread app/helpers/application_helper.rb
Comment thread app/views/pages/changelog.html.erb Outdated
Major:
- data_exporter: remove sanitize_csv from NDJSON paths. NDJSON is JSON —
  leading =+-@ have no formula meaning there, and prepending "'" silently
  mutates user-entered transaction names/notes, valuation names, and rule
  names on every export→import round-trip.
- safe_return_to_path: reject protocol-relative URLs ("//evil.example.com"),
  also read return_to from account_params, and explicitly reject any URI
  with host or scheme.

Refactors / refinements:
- account_import: delegate ALLOWED_ACCOUNTABLE_TYPES to Accountable::TYPES and
  use Accountable.from_type instead of a duplicated constantize allowlist
  (prevents drift when new accountable subtypes are added).
- application_helper: add `img` tag and `src`/`alt`/`title` attrs to the
  markdown sanitize allowlist so images render (URL scheme filtering is
  already handled by Rails sanitizer).
- changelog view: add `dir` to sanitize attributes so GitHub's dir="auto"
  (bidi) is preserved on release notes.
- reports_controller: collapse the two-step sort_direction normalization
  (uppercase then downcase-to-sym) into a single symbol.
- data_exporter#sanitize_csv: comment why \t\r\n are in the class and note
  the CSV-only intent.

Tests:
- NDJSON round-trip test asserting formula-prefixed names/notes are preserved
  verbatim (locks in the main fix).
- account_import test now asserts parity with Accountable::TYPES instead of
  duplicating the list.
@dgilperez
Copy link
Copy Markdown
Author

Thanks — addressed all eight points.

Major — data_exporter NDJSON sanitize leak (data_exporter.rb:192-274): Fully agree. sanitize_csv was leaking into three NDJSON paths (transaction name/notes, valuation name, rule name via serialize_rule_for_export). Removed it from all three. Added a test that round-trips a transaction with =SUM(A1) as name and -1.5x leverage as notes through the exporter and asserts the NDJSON bytes match the input verbatim.

safe_return_to_path protocol-relative bypass (accountable_resource.rb:103): Good catch. Now rejects //… explicitly, rejects any URI with a present host or scheme, and reads return_to from account_params as a fallback source when the nested form carries it.

markdown img tag stripped (application_helper.rb:156): Agreed. Added img to the tag allowlist and src/alt/title to the attribute allowlist. URL protocol filtering is still enforced by the Rails sanitizer.

changelog dir="auto" stripped (changelog.html.erb:24): Added dir to the attributes allowlist so GitHub-rendered bidi blocks survive.

sort_direction redundant normalization (reports_controller.rb:664): Collapsed. Now a single %w[asc desc] whitelist → symbol.

sanitize_csv regex comment (data_exporter.rb:356): Added an explanatory comment covering the \t\r\n inclusion, the deliberate omission of literal leading space (matches OWASP shortlist), and a one-liner noting the helper is CSV-only, to make sure nobody reintroduces it in NDJSON paths.

AccountImport::ALLOWED_ACCOUNTABLE_TYPES drift (account_import.rb:4): Great spot. Changed it to alias Accountable::TYPES, and import! now calls Accountable.from_type directly. One canonical allowlist in Accountable + one resolver. No more secondary constantize call.

Test duplicates the list (account_import_test_security.rb:4): Now asserts parity with Accountable::TYPES (guards against drift), plus a test that Accountable.from_type rejects unknown / nil / empty input.

All changes in commit 268abe96. Full suite: 3234 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/controllers/concerns/accountable_resource.rb (1)

104-124: Consider extracting to a shared concern to eliminate duplication.

Near-identical return_to validators exist in app/controllers/sophtron_items_controller.rb (sanitize_return_to, lines 733–751) and app/controllers/mercury_items_controller.rb (lines 756–774), and app/controllers/concerns/store_location.rb#store_return_to implements a weaker variant that lacks the scheme/host checks enforced here. Extracting safe_return_to_path into a shared concern (e.g., SafeRedirect) and reusing it in those controllers — including StoreLocation — would remove duplication and close the inconsistency where session[:return_to] can be populated with values this helper would reject. Out of scope for this PR if preferred, but worth a follow-up.

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

In `@app/controllers/concerns/accountable_resource.rb` around lines 104 - 124,
Extract the duplicate return-to validation logic into a shared concern
(suggested name SafeRedirect) and replace the per-controller methods with calls
to it: move the implementation in safe_return_to_path into the concern and
expose a reusable method (e.g., safe_return_to_path or sanitize_return_to_path);
update app/controllers/concerns/store_location.rb to use this new method instead
of its weaker store_return_to, and replace sanitize_return_to in
sophtron_items_controller.rb and mercury_items_controller.rb to delegate to the
concern; ensure the concern preserves the same checks (reject protocol-relative
URLs, require leading "/", parse with URI and disallow scheme/host) so
session[:return_to] and all controllers use the consistent validator.
🤖 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/controllers/concerns/accountable_resource.rb`:
- Around line 104-124: Extract the duplicate return-to validation logic into a
shared concern (suggested name SafeRedirect) and replace the per-controller
methods with calls to it: move the implementation in safe_return_to_path into
the concern and expose a reusable method (e.g., safe_return_to_path or
sanitize_return_to_path); update app/controllers/concerns/store_location.rb to
use this new method instead of its weaker store_return_to, and replace
sanitize_return_to in sophtron_items_controller.rb and
mercury_items_controller.rb to delegate to the concern; ensure the concern
preserves the same checks (reject protocol-relative URLs, require leading "/",
parse with URI and disallow scheme/host) so session[:return_to] and all
controllers use the consistent validator.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 17d65a22-426b-4bf9-98e9-8a70f5258d03

📥 Commits

Reviewing files that changed from the base of the PR and between b2b7b51 and 268abe9.

📒 Files selected for processing (8)
  • app/controllers/concerns/accountable_resource.rb
  • app/controllers/reports_controller.rb
  • app/helpers/application_helper.rb
  • app/models/account_import.rb
  • app/models/family/data_exporter.rb
  • app/views/pages/changelog.html.erb
  • test/models/account_import_test_security.rb
  • test/models/family/data_exporter_test.rb
🚧 Files skipped from review as they are similar to previous changes (6)
  • app/views/pages/changelog.html.erb
  • test/models/family/data_exporter_test.rb
  • app/controllers/reports_controller.rb
  • app/models/family/data_exporter.rb
  • app/models/account_import.rb
  • test/models/account_import_test_security.rb

Comment thread app/helpers/application_helper.rb
@dgilperez
Copy link
Copy Markdown
Author

dgilperez commented Apr 19, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

1 similar comment
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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.

Development

Successfully merging this pull request may close these issues.

3 participants