Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve the upgrade path of Strong Parameters #28734

Merged
merged 9 commits into from
Apr 19, 2017

Conversation

rafaelfranca
Copy link
Member

Some changes were made in this PR to improve the upgrade path of Strong Parameters not inheriting from hash anymore.

Raise exception when calling to_h in an unpermitted Parameters

Before we returned either an empty hash or only the always permitted parameters (:controller and :action by default).

The previous behavior was dangerous because in order to get the attributes users usually fallback to use to_unsafe_h that could potentially introduce security issues.

The to_unsafe_h API is also not good since Parameters is a object that quacks like a Hash but not in all cases since to_h would return an empty hash and users were forced to check if to_unsafe_h is defined or if the instance is a ActionController::Parameters in order to work with it. This end up coupling a lot of libraries and parts of the application with something that is from the controller layer.

Add ActionController::Parameters#to_hash to implicit conversion

Now methods that implicit convert objects to a hash will be able to work without requiring the users to change their implementation.

This method will return a Hash instead of a HashWithIndefirentAccess to mimic the same implementation of HashWithIndefirentAccess#to_hash.

Implement ActionController::Parameters#to_query and #to_param

Previously it was raising an error because it may be unsafe to use those methods in a unpermitted parameter. Now we delegate to to_h that already raise an error when the Parameters instance is not permitted.

This also fix a bug when using #to_query in a hash that contains a ActionController::Parameters instance and was returning the name of the class in the string.

Backport

The plan is to backport this to 5.1 and part of it to 5.0.

Backport to 5.0

The plan is to backport the first and the last change fully.

The #to_hash change I want to just undeprecate it for now since it is already calling to_hash in the internal array and the result includes all the parameters in the case were they are not permitted.

Also implement respond_to_missing since we have a method_missing.

cc @maartenvg @nicolaslupien @trevorturk

@kirs
Copy link
Member

kirs commented Apr 12, 2017

I'm really excited to see to_h raising an error if it's not permitted. That would save hours of work when upgrading Shopify to strong parameters.

I have concerns about backporting the last commit to 5.0. The change makes sense in the context of to_h raising an error, but that won't be the case for 5.0. Do I miss the point?

@rafaelfranca
Copy link
Member Author

The plan is to to_h raise an error in 5.0 (so backporting the first and last change). An error is better than silently raising making the Parameters empty since now users can know their application will break.

@trevorturk
Copy link
Contributor

I agree the error is good -- it's much better than silently not working. Good job!

# # => ActionController::ForbiddenParameters: converting a unpermitted parameters to hash is not allowed
class ForbiddenParameters < StandardError
def initialize
super("converting an unpermitted parameters to hash is not allowed")
Copy link
Member

Choose a reason for hiding this comment

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

"unable to convert unpermitted parameters to hash", maybe? "not allowed" sounds like we're being difficult.

# params = ActionController::Parameters.new(a: "123", b: "456")
# params.to_h
# # => ActionController::ForbiddenParameters: converting a unpermitted parameters to hash is not allowed
class ForbiddenParameters < StandardError
Copy link
Member

Choose a reason for hiding this comment

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

Forbidden doesn't sound quite right.. how about UnfilteredParameters?

@@ -257,7 +312,7 @@ def to_h
# params.to_unsafe_h
# # => {"name"=>"Senjougahara Hitagi", "oddity" => "Heavy stone crab"}
def to_unsafe_h
convert_parameters_to_hashes(@parameters, :to_unsafe_h)
permit!.to_h
Copy link
Member

Choose a reason for hiding this comment

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

I don't think this should mutate the original params object.. otherwise params.to_unsafe_h in one place that's being careful some other way could leave a later params.to_h exposed to danger.

Copy link
Member Author

Choose a reason for hiding this comment

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

oh. That is right. I was wondering why we implemented like that and now I know it. We are missing some test coverage about this case. I'll add. Do you have preference between dup the paramters and mutate it or the implementation like it was before?

Copy link
Member

Choose a reason for hiding this comment

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

If it's just a simple dup that seems fine.. if it would need to be a deep dup, I think it's probably worth the effort of the more complicated version.

@kirs
Copy link
Member

kirs commented Apr 13, 2017

The plan is to to_h raise an error in 5.0

That's quite a breaking change to push to stable version. As a developer I would be extremely surprised by this introduced between 5.0.2 and 5.0.3. This will also make the upgrade to 5.0.3 harder, resulting to lower adoption rate.

At least this should be a config variable, with raise disabled by default.

# safe_params = params.permit(:name)
# safe_params.to_hash # => {"name"=>"Senjougahara Hitagi"}
def to_hash
to_h.to_hash
Copy link

Choose a reason for hiding this comment

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

This will make our code cleaner, thanks! 👍

@matthewd
Copy link
Member

@kirs that's a good point.. but on the other hand, is anyone going to be knowingly using the current behaviour? It seems a rather long-winded way to spell {}.

The low-impact alternative would be to introduce a warning, and optionally offer the raise as a non-default config option. I'm dubious that the exception would cause people problems (as opposed to identifying places they didn't realise were silently eating input), but as 5.0 is fairly mature at this point -- and about to become legacy -- maybe we should go with the more conservative option anyway.

I'm not sure whether this should go through the deprecation infrastructure (gives people more control etc, even though it's not strictly a deprecation), or straight to Kernel#warn.

@rafaelfranca
Copy link
Member Author

@kirs that's a good point.. but on the other hand, is anyone going to be knowingly using the current behaviour? It seems a rather long-winded way to spell {}.

Yeah... if people is getting this exception after upgrading to 5.0.3 it means that they had a bug that they didn't anticipated in their code and in my opinion an exception is better than leaving the bug hidden.

We can go in the deprecation path just to be safe with an option to raise on this deprecation.

@trevorturk
Copy link
Contributor

Yes the deprecation path might be best, like we have new_rails_defaults etc. I think raising would have been a better choice to start, but I agree at this point it may be surprising to see new exceptions when you upgrade, so opt-in would be ideal.

@rafaelfranca
Copy link
Member Author

Fixed all the points for this branch and 5.1. I'll open a new PR with the changes to 5.0

Copy link
Contributor

@maclover7 maclover7 left a comment

Choose a reason for hiding this comment

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

Left a few comments about the docs :)

end
end

# Returns a safe <tt>Hash</tt> representation of this parameter
Copy link
Contributor

Choose a reason for hiding this comment

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

the parameters

#
# params = ActionController::Parameters.new({
# name: 'David',
# nationality: 'Danish'})
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe have }) be on a new line, like above?

#
# params = ActionController::Parameters.new({
# name: 'David',
# nationality: 'Danish'})
Copy link
Contributor

Choose a reason for hiding this comment

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

same comment -- maybe have }) be on a new line, like above?

# # => ActionController::UnfilteredParameters: unable to convert unpermitted parameters to hash
class UnfilteredParameters < StandardError
def initialize
super("unable to convert unpermitted parameters to hash")
Copy link
Member

Choose a reason for hiding this comment

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

It would be friendly to the developer to suggest how they're supposed to fix the error (by using permit)

Copy link
Member Author

Choose a reason for hiding this comment

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

An exception don't need to explain what users have to do. It depends on the context and since we don't know what is the context we can't recommend something. But, it needs to be clear about what it means. Is the exception message and documentation clear about what it means?

Copy link
Member

Choose a reason for hiding this comment

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

+1 exceptions should clearly state what's wrong, but leave speculation about how the developer might fix it to someone/something else

@rafaelfranca
Copy link
Member Author

@maclover7 thanks! Done

Copy link
Contributor

@kaspth kaspth left a comment

Choose a reason for hiding this comment

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

Like this a lot! 👍

@@ -43,6 +43,18 @@ def initialize(params) # :nodoc:
end
end

# Raised when a Parameters instance is not marked as permitted and
# an operation to transform to hash is called.
Copy link
Contributor

Choose a reason for hiding this comment

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

"to transform it to a hash."

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

#
# safe_params = params.permit(:name)
# safe_params.to_h # => {"name"=>"Senjougahara Hitagi"}
def to_h
if permitted?
convert_parameters_to_hashes(@parameters, :to_h)
else
slice(*self.class.always_permitted_parameters).permit!.to_h
Copy link
Contributor

Choose a reason for hiding this comment

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

How do we honor always_permitted_parameters now? Should it be deprecated?

Copy link
Member Author

Choose a reason for hiding this comment

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

It is still needed when you permit let say [:user][:name] in a controller and the action_on_unpermitted_parameters is either :log or :raise. that way :controller and :action will not raise.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, it's handled in unpermitted_parameters!. Never mind 😊

# name: 'David',
# nationality: 'Danish'
# })
# params.to_query('user')
Copy link
Contributor

Choose a reason for hiding this comment

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

Should the new documentation here use double quoted strings and no hash braces on the new call to fit our Rubocop rules?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

assert_equal({ "controller" => "users", "action" => "create" }, params.to_h)
assert_instance_of Hash, params.to_hash
assert_not_kind_of ActionController::Parameters, params.to_hash
assert_equal({ "crab" => "Senjougahara Hitagi" }, params.to_hash)
Copy link
Contributor

Choose a reason for hiding this comment

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

Wouldn't Ruby implicitly call to_hash on the params here? Should we test an implicit case?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

Copy link
Contributor

@kaspth kaspth left a comment

Choose a reason for hiding this comment

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

Had one last comment, otherwise 👍

Before we returned either an empty hash or only the always permitted
parameters (:controller and :action by default).

The previous behavior was dangerous because in order to get the
attributes users usually fallback to use to_unsafe_h that could
potentially introduce security issues.

The to_unsafe_h API is also not good since Parameters is a object that
quacks like a Hash but not in all cases since to_h would return an empty
hash and users were forced to check if to_unsafe_h is defined or if the
instance is a ActionController::Parameters in order to work with it.
This end up coupling a lot of libraries and parts of the application
with something that is from the controller layer.
Now methods that implicit convert objects to a hash will be able to work
without requiring the users to change their implementation.

This method will return a Hash instead of a HashWithIndefirentAccess
to mimic the same implementation of HashWithIndefirentAccess#to_hash.
Previously it was raising an error because it may be unsafe to use those
methods in a unpermitted parameter. Now we delegate to to_h that already
raise an error when the Parameters instance is not permitted.

This also fix a bug when using `#to_query` in a hash that contains a
`ActionController::Parameters` instance and was returning the name of the
class in the string.
We are talking about a list of parameters even so we need to use plural.
Even if we were talking about the instance of the Parameters object we
would have to use the capital and monospaced font.
@matthewd
Copy link
Member

There's another stand-alone instance of the existing message in

raise ArgumentError, "Attempting to generate a button from non-sanitized request parameters!" \
" Whitelist and sanitize passed parameters to be secure."

@rafaelfranca
Copy link
Member Author

Good catch. Fixed

Since this protection is now in Parameters we can use it instead of
reimplementing again.
@rafaelfranca rafaelfranca merged commit 3d1154f into rails:master Apr 19, 2017
rafaelfranca added a commit that referenced this pull request Apr 19, 2017
Improve the upgrade path of Strong Parameters
@rafaelfranca
Copy link
Member Author

Backported to 5-1-stable in a217fd0.

PR for 5-0-stable #28803.

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

Successfully merging this pull request may close these issues.

6 participants