Wednesday, June 21, 2017

`form_for` is bypassing model accessors. How to make it stop?

Leave a Comment

I set these methods to automatically encrypt values.

class User < ApplicationRecord   def name=(val)     super val.encrypt   end   def name     (super() || '').decrypt   end 

When I try to submit the form and there is an error (missing phone), then the name attribute shows up garbled.

<input class="form-control" type="text" value="Mg8IS1LB2A1efAeZJxIDJMSroKcq6WueyY4ZiUX+hfI=" name="user[name]" id="user_name"> 

It works when the validations succeeds. It also works in the console when I go line-by-line through my controller #update.

irb(main):015:0> u = User.find 1 irb(main):016:0> u.name => "Sue D. Nym" irb(main):017:0> u.phone => "212-555-1234" irb(main):018:0> u.update name: 'Sue D. Nym', phone: ''    (10.0ms)  BEGIN    (1.0ms)  ROLLBACK => false irb(main):020:0> u.save => false irb(main):029:0> u.errors.full_messages.join ',' => "Phone can't be blank" irb(main):031:0> u.build_image unless u.image => nil irb(main):033:0> u.name => "Sue D. Nym" 
users_controller.rb
  def update     @user = User.find current_user.id     @user.update user_params     if @user.save       flash.notice = "Profile Saved"       redirect_to :dashboard     else       flash.now.alert = @user.errors.full_messages.join ', '       @user.build_image unless @user.image       render :edit     end   end 

The view is somehow getting the encrypted value without going through #name, and only after a validation failure.


I reduced the controller to the absolute minimum and it fails immediately after #update. However, it's working on the console!

  def update     @user = User.find current_user.id     @user.update user_params     render :edit     return 

I reduced my view to the absolute minimum and it shows the name, but only outside of form_for. I don't know why yet.

edit.haml
=@user.name =form_for @user, html: { multipart: true } do |f|   =f.text_field :name 
HTML source
<span>Sue D. Nym</span> <form class="edit_user" id="edit_user_1" enctype="multipart/form-data" action="/users/1" accept-charset="UTF-8" method="post">   <input name="utf8" type="hidden" value="✓"><input type="hidden" name="_method" value="patch"><input type="hidden" name="authenticity_token" value="C/ScTxfENNxCKgzG0qAlPElOKI7nOYxZimQ7BsB64wIWQ9El4+vOAfxX3qHL08rbr0sxRiJnzQti13e4DAgkfQ==">     <input type="text" value="sER9cjwa6Ov5weXjEQN2KJYoTOXtVBytpX/cI/aPrFs=" name="user[name]" id="user_name"> </form> 

I noticed attributes still returned encrypted values so I tried adding this but form_for still manages to obtain the encrypted value and put it in the form!

  def attributes     attr_hash = super()     attr_hash["name"] = name     attr_hash   end 

Rails 5.0.2

3 Answers

Answers 1

While you can work around this by overloading name_before_type_case, I think this is actually the wrong place to be doing this kind of transformation.

Based on your example, the requirements here appear to be:

  1. plaintext while in memory
  2. encrypted at rest

So if we move the encrytion/decryption transformation to the Ruby-DB boundary, this logic becomes much cleaner & reusable.

Rails 5 introduced a helpful Attributes API for dealing with this exact scenario. Since you have provided no details about how your encryption routine is implemented, I'm going to use Base64 in my example code to demonstrate a text transformation.

class EncryptedTextType < ActiveRecord::Type::Text   # this is called when saving to the DB   def serialize(value)     Base64.encode64(value) unless value.nil?   end    # called when loading from DB   def deserialize(value)     Base64.decode64(cast value) unless value.nil?   end end  ActiveRecord::Type.register(:encrypted, EncryptedTextType) 

Now, you can specify this attribute as encrypted in the model:

class User < ApplicationRecord   attribute :name, :encrypted end 

The name attribute will be transparently encrypted & decrypted during roundtrips to the DB. This also means that you can apply the same transform to as many attributes as you like without rewriting the same code.

Answers 2

Why are you exposing it as name at all ?

class User < ApplicationRecord     def decrypted_name=(val)        name = val.encrypt     end      def decrypted_name        name.decrypt     end end 

Then you use @model.decrypted_name instead of @model.name as name is encrypted, and such saved in DB.

edit.haml =@user.decrypted_name =form_for @user, html: { multipart: true } do |f|   =f.text_field :decrypted_name 

And name if it is encrypted should not be handled directly but with this decrypted_name accessor.

Answers 3

I found this similar question: How do input field methods (text_area, text_field, etc.) get attribute values from a record within a form_for block?

I added

  def name_before_type_cast     (super() || '').decrypt   end 

And now it works!

Here is the full solution:

  @@encrypted_fields = [:name, :phone, :address1, :address2, :ssn, ...]   @@encrypted_fields.each do |m|     setter = (m.to_s+'=').to_sym     getter = m     getter_btc = (m.to_s+'_before_type_cast').to_sym     define_method(setter) do |v|       super v.encrypt     end     define_method(getter) do       (super() || '').decrypt     end     define_method(getter_btc) do       (super() || '').decrypt     end   end 

Some docs: http://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/BeforeTypeCast.html

If You Enjoyed This, Take 5 Seconds To Share It

0 comments:

Post a Comment