Files
sure/app/controllers/api/v1/holdings_controller.rb
David Gil 7f17fbf6da security: sanitize exception messages in v1 API responses (FIX-11) (#1521)
* fix(security): sanitize exception messages in API responses (FIX-11)

Replace raw e.message/error.message interpolations in response bodies
with generic error strings, and log class+message server-side. Prevents
leaking internal exception details (stack traces, SQL fragments, record
data) to API clients.

Covers:
- API v1 accounts, categories (index/show), holdings, sync, trades,
  transactions (index/show/create/update/destroy), valuations
  (show/create/update): replace "Error: #{e.message}" with
  "An unexpected error occurred".
- API v1 auth: device-registration rescue paths now log
  "[Auth] Device registration failed: ..." and respond with
  "Failed to register device".
- WebhooksController#plaid and #plaid_eu: log full error and respond
  with "Invalid webhook".
- Settings::ProvidersController: generic user-facing flash alert,
  detailed log line with error class + message.

Updates providers_controller_test assertion to match sanitized flash.

* fix(security): address CodeRabbit review

Major — partial-commit on device registration failure:
- Strengthened valid_device_info? to also run MobileDevice's model
  validations up-front (device_type inclusion, attribute presence), not
  just a flat "are the keys present?" check. A client that sends a bad
  device_type ("windows", etc.) is now rejected at the API boundary
  BEFORE signup commits any user/family/invite state.
- Wrapped the signup path (user.save + InviteCode.claim + MobileDevice
  upsert + token issuance) in ActiveRecord::Base.transaction. A
  post-save RecordInvalid from device registration (e.g., racing
  uniqueness on device_id) now rolls back the user/invite/family so
  clients don't see a partial-account state.
- Rescue branch logs the exception class + message ("#{e.class} - #{e.message}")
  for better postmortem debugging, matching the providers controller
  pattern.

Nit:
- Tightened providers_controller_test log expectation regex to assert on
  both the exception class name AND the message ("StandardError - Database
  error"), so a regression that drops either still fails the test.

Tests:
- New: "should reject signup with invalid device_type before committing
  any state" — POST /api/v1/auth/signup with device_type="windows"
  returns 400 AND asserts no User, MobileDevice, or Doorkeeper::AccessToken
  row was created.

Note on SSO path (sso_exchange → issue_mobile_tokens, lines 173/225): the
device_info in those flows comes from Rails.cache (populated by an earlier
request that already passed valid_device_info?), so the pre-validation
covers it indirectly. Wrapping the full SSO account creation (user +
invitation + OidcIdentity + issue_mobile_tokens) in one transaction would
be a meaningful architectural cleanup but is out of scope for this
error-hygiene PR — filed it as a mental note for a follow-up.
2026-04-19 18:38:23 +02:00

109 lines
3.0 KiB
Ruby

# frozen_string_literal: true
class Api::V1::HoldingsController < Api::V1::BaseController
include Pagy::Backend
before_action :ensure_read_scope
before_action :set_holding, only: [ :show ]
def index
family = current_resource_owner.family
holdings_query = family.holdings.joins(:account).where(accounts: { status: [ "draft", "active" ] })
holdings_query = apply_filters(holdings_query)
holdings_query = holdings_query.includes(:account, :security).chronological
@pagy, @holdings = pagy(
holdings_query,
page: safe_page_param,
limit: safe_per_page_param
)
@per_page = safe_per_page_param
render :index
rescue ArgumentError => e
render_validation_error(e.message, [ e.message ])
rescue => e
log_and_render_error("index", e)
end
def show
render :show
rescue => e
log_and_render_error("show", e)
end
private
def set_holding
family = current_resource_owner.family
@holding = family.holdings.joins(:account).where(accounts: { status: %w[draft active] }).find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "not_found", message: "Holding not found" }, status: :not_found
end
def ensure_read_scope
authorize_scope!(:read)
end
def apply_filters(query)
if params[:account_id].present?
query = query.where(account_id: params[:account_id])
end
if params[:account_ids].present?
query = query.where(account_id: Array(params[:account_ids]))
end
if params[:date].present?
query = query.where(date: parse_date!(params[:date], "date"))
end
if params[:start_date].present?
query = query.where("holdings.date >= ?", parse_date!(params[:start_date], "start_date"))
end
if params[:end_date].present?
query = query.where("holdings.date <= ?", parse_date!(params[:end_date], "end_date"))
end
if params[:security_id].present?
query = query.where(security_id: params[:security_id])
end
query
end
def safe_page_param
page = params[:page].to_i
page > 0 ? page : 1
end
def safe_per_page_param
per_page = params[:per_page].to_i
case per_page
when 1..100
per_page
else
25
end
end
def parse_date!(value, param_name)
Date.parse(value)
rescue Date::Error, ArgumentError, TypeError
raise ArgumentError, "Invalid #{param_name} format"
end
def render_validation_error(message, errors)
render json: {
error: "validation_failed",
message: message,
errors: errors
}, status: :unprocessable_entity
end
def log_and_render_error(action, exception)
Rails.logger.error "HoldingsController##{action} error: #{exception.message}"
Rails.logger.error exception.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "An unexpected error occurred"
}, status: :internal_server_error
end
end