Files
sure/app/controllers/api/v1/valuations_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

219 lines
6.0 KiB
Ruby

# frozen_string_literal: true
class Api::V1::ValuationsController < Api::V1::BaseController
before_action :ensure_read_scope, only: [ :show ]
before_action :ensure_write_scope, only: [ :create, :update ]
before_action :set_valuation, only: [ :show, :update ]
def show
render :show
rescue => e
Rails.logger.error "ValuationsController#show error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "An unexpected error occurred"
}, status: :internal_server_error
end
def create
unless valuation_account_id.present?
render json: {
error: "validation_failed",
message: "Account ID is required",
errors: [ "Account ID is required" ]
}, status: :unprocessable_entity
return
end
unless valuation_params[:amount].present?
render json: {
error: "validation_failed",
message: "Amount is required",
errors: [ "Amount is required" ]
}, status: :unprocessable_entity
return
end
unless valuation_params[:date].present?
render json: {
error: "validation_failed",
message: "Date is required",
errors: [ "Date is required" ]
}, status: :unprocessable_entity
return
end
account = current_resource_owner.family.accounts.find(valuation_account_id)
create_success = false
error_payload = nil
ActiveRecord::Base.transaction do
result = account.create_reconciliation(
balance: valuation_params[:amount],
date: valuation_params[:date]
)
unless result.success?
error_payload = {
error: "validation_failed",
message: "Valuation could not be created",
errors: [ result.error_message ]
}
raise ActiveRecord::Rollback
end
@entry = account.entries.valuations.find_by!(date: valuation_params[:date])
@valuation = @entry.entryable
if valuation_params.key?(:notes)
unless @entry.update(notes: valuation_params[:notes])
error_payload = {
error: "validation_failed",
message: "Valuation could not be created",
errors: @entry.errors.full_messages
}
raise ActiveRecord::Rollback
end
end
create_success = true
end
unless create_success
render json: error_payload, status: :unprocessable_entity
return
end
render :show, status: :created
rescue ActiveRecord::RecordNotFound
render json: {
error: "not_found",
message: "Account or valuation entry not found"
}, status: :not_found
rescue => e
Rails.logger.error "ValuationsController#create error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "An unexpected error occurred"
}, status: :internal_server_error
end
def update
if valuation_params[:date].present? || valuation_params[:amount].present?
unless valuation_params[:date].present? && valuation_params[:amount].present?
render json: {
error: "validation_failed",
message: "Both amount and date are required when updating reconciliation",
errors: [ "Amount and date must both be provided" ]
}, status: :unprocessable_entity
return
end
update_success = false
error_payload = nil
updated_entry = nil
ActiveRecord::Base.transaction do
result = @entry.account.update_reconciliation(
@entry,
balance: valuation_params[:amount],
date: valuation_params[:date]
)
unless result.success?
error_payload = {
error: "validation_failed",
message: "Valuation could not be updated",
errors: [ result.error_message ]
}
raise ActiveRecord::Rollback
end
updated_entry = @entry.account.entries.valuations.find_by!(date: valuation_params[:date])
if valuation_params.key?(:notes)
unless updated_entry.update(notes: valuation_params[:notes])
error_payload = {
error: "validation_failed",
message: "Valuation could not be updated",
errors: updated_entry.errors.full_messages
}
raise ActiveRecord::Rollback
end
end
update_success = true
end
unless update_success
render json: error_payload, status: :unprocessable_entity
return
end
@entry = updated_entry
@valuation = @entry.entryable
render :show
else
if valuation_params.key?(:notes)
unless @entry.update(notes: valuation_params[:notes])
render json: {
error: "validation_failed",
message: "Valuation could not be updated",
errors: @entry.errors.full_messages
}, status: :unprocessable_entity
return
end
end
@entry.reload
@valuation = @entry.entryable
render :show
end
rescue => e
Rails.logger.error "ValuationsController#update error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "An unexpected error occurred"
}, status: :internal_server_error
end
private
def set_valuation
@entry = current_resource_owner.family
.entries
.where(entryable_type: "Valuation")
.find(params[:id])
@valuation = @entry.entryable
rescue ActiveRecord::RecordNotFound
render json: {
error: "not_found",
message: "Valuation not found"
}, status: :not_found
end
def ensure_read_scope
authorize_scope!(:read)
end
def ensure_write_scope
authorize_scope!(:write)
end
def valuation_account_id
params.dig(:valuation, :account_id)
end
def valuation_params
params.require(:valuation).permit(:amount, :date, :notes)
end
end