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
Add redirect_code_for_unsafe_http_methods
config
#45393
base: main
Are you sure you want to change the base?
Add redirect_code_for_unsafe_http_methods
config
#45393
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like this!
@@ -85,7 +86,7 @@ def redirect_to(options = {}, response_options = {}) | |||
|
|||
allow_other_host = response_options.delete(:allow_other_host) { _allow_other_host } | |||
|
|||
self.status = _extract_redirect_to_status(options, response_options) | |||
self.status = _extract_redirect_to_status(request, options, response_options) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does request need to be a param? (Won't it always be present on a Metal?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wrote it this way partly to explicitly list the factors that affect the result, and partly for parity with _compute_redirect_to_location(request, options)
(which I assume is written that way for the same reason). But I don't have a strong preference.
@@ -11,6 +11,7 @@ class UnsafeRedirectError < StandardError; end | |||
|
|||
included do | |||
mattr_accessor :raise_on_open_redirects, default: false | |||
mattr_accessor :redirect_code_for_unsafe_http_methods, default: 302 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mattr_accessor
is rarely the right choice.
I think here might want to use class_attribute
, so that we can set the default on AC::Base
, and then refine on a per controller basis if needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think here might want to use
class_attribute
, so that we can set the default onAC::Base
, and then refine on a per controller basis if needed.
Excellent point! That would make it much easier to migrate an app one piece at a time.
mattr_accessor
is rarely the right choice.
For clarity, would you say that the order of preference is:
singleton_class.attr_accessor
- when the scope is exactly one classclass_attribute
- when the scope is a class hierarchy, allowing value inheritancemattr_accessor
- when the scope is global
Aside: do you feel like :raise_on_open_redirects
would be better as class_attribute
instead of mattr_accessor
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For clarity, would you say that the order of preference is
singleton_class.attr_accessor
- when it's exactly one class, or more frequently, when it's single global.class_attribute
- when the scope is a class hierarchy, allowing value inheritance, and it's not meant to be changed too much at runtime (class_attributes
writes are slow because it's basically adefine_method
).mattr_accessor
- honestly there's no reason to use it ever.
do you feel like
:raise_on_open_redirects
It could make sense yes. As you mentioned it could allow to migrate on a per controller basis.
@@ -284,6 +284,7 @@ def load_defaults(target_version) | |||
|
|||
if respond_to?(:action_controller) | |||
action_controller.allow_deprecated_parameters_hash_equality = false | |||
action_controller.redirect_code_for_unsafe_http_methods = 303 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hum, we should be very careful with this new default. We need to really check what the browser support etc might be.
There might also be a ton of API clients etc checking response.status == 302
. I do think it's cleaner, but I'm not sure we'll ever be able to switch the default for apps that upgrade (we could for new apps though).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to really check what the browser support etc might be.
If you mean support for HTTP status code 303, caniuse shows "unknown" for many older browser versions, but shows "supported" for the current version of all major browsers. I think "unknown" may be a quirk of how the data is aggregated. I found this Stack Overflow answer dated 2014 which indicates status code 303 is supported by Firefox 6+, Chrome 13+, Safari 5.1+, and — most importantly — Internet Explorer 6+.
There might also be a ton of API clients etc checking
response.status == 302
.
Sidenote: this PR was partly inspired by hotwired/turbo#84. I believe that checking status == 302
in the browser is not possible. hotwired/turbo#84 (comment) indicates that for the Fetch API, and another Stack Overflow answer indicates that for the XHR API. But it is indeed possible in non-browser clients.
I'm not sure we'll ever be able to switch the default for apps that upgrade (we could for new apps though).
How do you envision implementing that? Do you know of an example?
One way would be to modify the app generator templates, e.g. add a line to application_controller.rb.tt
. But that feels like configuration over convention.
Another way would be for rails app:update
to check if config.load_defaults
< 7.1, and, if so, generate an initializer that (re)sets redirect_code_for_unsafe_http_methods = 302
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But it is indeed possible in non-browser clients.
Yeah, that's what I meant. Web scrapers as well certainly.
How do you envision implementing that?
With a template, the question is which one. I was thinking application.rb.tt
, but it can be debated. I just don't think it can ever be in load_defaults
as it would break too many non-browser clients.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The version-specific settings in load_defaults
are specifically for new apps.
rails app:update
to [..] generate an initializer that (re)setsredirect_code_for_unsafe_http_methods = 302
.
That's what new_framework_defaults_*.rb
is.
This PR is already doing the thing you're describing. 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The version-specific settings in load_defaults are specifically for new apps.
I guess I wasn't clear. Yes load_defaults
is for new apps, but upgrading apps are expected to flip load_defauts
after a while.
Ensuring that flipping this config won't break any client is such an herculean task that I don't think we can expect users to do it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, fair enough.
I guess I've never anticipated that the average application would bump the load_defaults
line at all... but even assuming that's the case, I think it's generally true that in order to do so, you need to consider each of the generated "new defaults", and choose either to adopt them or to explicitly retain the "old" behaviour.
I definitely don't see this as a short-term transitional option that we'd remove later: broadly-cliented existing applications (👋🏻) will likely want to keep their old behaviour for the foreseeable future, as you say. But I'm not so sure that's more generally--or uniquely--true: acknowledging that any change can cause bumps for overly-particular clients (see e.g. our recent dropping of the response body in redirects, which had similar danger, but we didn't even option), I suspect that the vast majority of apps would be able to change this either without consequence, or with a handful of clients that needed to [and could] Just Catch Up.
I'm fine with even e.g. giving it an explicit call-out in the upgrading guide, if you think that might help? I guess my counterbalancing thought is just that ~all of the "new defaults" are things we've consciously decided are too radical of a change for us to drop them on existing applications, so I'm reluctant to give this so many warning lights that we implicitly suggest it's safe to flip others. 🤷🏻♂️
(But also, to my point in the other top-level comment... if we exclude POST, I suspect this gets a lot safer for a lot more apps to just roll with.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess I've never anticipated that the average application would bump the load_defaults line at all...
I may be wrong but for me the process was always that you'd carefully uncoment new_framework_defaults
, and then once it's all done you delete it and bump the load_defaults
(and merge any setting you don't want into application.rb
).
Otherwise you loose the consistency benefit of using a framework if all your apps have different defaults hence behave in different ways.
giving it an explicit call-out in the upgrading guide, if you think that might help?
I suppose, and a LOUD comment in the new_framework_defaults
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
a LOUD comment in the new_framework_defaults.
"Note that changing this setting on a previously-deployed application could easily break any existing non-browser HTTP clients that expect the old 302 response code."?
I think that's already scarier than the wording we used when we changed the cookie serializer, for example, and I'd argue that was much more dangerous (and harder to roll back)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, but the cookie it's your own app talking to itself. Here it's potentially 3rd party things you can't see the code of. If it breaks it may take a while before you realize it, hence why I'm much more worried.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
rails app:update
to [..] generate an initializer that (re)setsredirect_code_for_unsafe_http_methods = 302
.That's what
new_framework_defaults_*.rb
is.
Well, more like the inverse.
Currently, new_framework_defaults_*.rb
has # redirect_code_for_unsafe_http_methods = 303
, and users must uncomment it, and eventually delete it after bumping config.load_defaults
.
My suggestion was for something like legacy_framework_defaults.rb
, which would have redirect_code_for_unsafe_http_methods = 302
(uncommented), and users would possibly never delete it. And it would only be generated if config.load_defaults
was < 7.1 when rails app:update
was run.
But I would also be happy with a loud callout in new_framework_defaults_*.rb
, as you mentioned. In my opinion, either is preferable to generating extra config code for new apps.
I think this is probably worth specific discussion. The version of this change that I had pictured after seeing hotwired/turbo#84 (via #45383) had us retaining the legacy 302 status for "legacy HTTP actions", rather than safe/idempotent ones... which leaves us joining in the codification of existing non-conforming practice, but seems like it gives a decent reduction in potential surprise? |
One potential issue with excluding |
5df493d
to
badd696
Compare
4e8b978
to
2147697
Compare
When a client follows an HTTP 302 redirect, it will typically use the same HTTP method as the original request. This can cause issues when, for example, redirecting an XHR `DELETE` request after a `destroy`. (Note that many clients make an exception for `POST`, and will use `GET` to follow a 302 redirect in that case.) The solution so far has been for users to specify an explicit response code in such cases. For example: ```ruby def destroy Post.destroy(params[:id]) redirect_to root_path, status: 303 end ``` This commit adds a `redirect_code_for_unsafe_http_methods` configuration setting that allows users to specify a default HTTP response code to use when redirecting a request made with an [unsafe HTTP method][], such as `POST` or `DELETE`. For example, when set to 303, the explicit response code may be omitted: ```ruby def destroy Post.destroy(params[:id]) redirect_to root_path end ``` [unsafe HTTP method]: https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP
2147697
to
7384b65
Compare
…ode on redirect for the update action XHR requests other than GET or POST have issues when using 302 (e.g browsers trying to follow the redirect using the original request method resulting in double PATCH/PUT) This should be reverted when / if #45393 is merged
…ode on redirect for the update action XHR requests other than GET or POST have issues when using 302 (e.g browsers trying to follow the redirect using the original request method resulting in double PATCH/PUT) This should be reverted when / if #45393 is merged
I'd love to see this PR brought back to life. As part of rolling out Turbo Drive across a large app we are looking to monkey patch def redirect_to(options = {}, response_options = {})
if options.is_a?(Hash)
options[:status] = :see_other
else
response_options[:status] = :see_other
end
super(options, response_options)
end But given it's basically a requirement for using Hotwire properly, this feels like something Rails should support out of the box. That said, if making it a default is controversial, could we just add the config and consider the default in a separate PR? |
This PR checks that `render` and `redirect_to` are provided an explicit status code in response to [unsafe request methods](https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP) (e.g. POST/PUT/PATCH/DELETE). #### `redirect_to` When redirecting a POST/PUT/PATCH/DELETE to a GET location, we should use 303, rather than a 302. A 302 means that the request method should not be altered, but: > Many web browsers implemented this code in a manner that violated this standard, changing the request type of the new request to [GET](https://en.wikipedia.org/wiki/HTTP_GET_request), regardless of the type employed in the original request (e.g. [POST](https://en.wikipedia.org/wiki/POST_(HTTP))).[[1]](https://en.wikipedia.org/wiki/HTTP_302#cite_note-1) For this reason, HTTP/1.1 (RFC [2616](https://datatracker.ietf.org/doc/html/rfc2616)) added the new status codes [303](https://en.wikipedia.org/wiki/HTTP_303) and [307](https://en.wikipedia.org/wiki/HTTP_307) to disambiguate between the two behaviours, with 303 mandating the change of request type to GET, and 307 preserving the request type as originally sent. > -- https://en.wikipedia.org/wiki/HTTP_302 Sinatra's [`redirect` method](https://github.com/sinatra/sinatra/blob/5640495babcb4cfd69ba650b293660b7446402da/lib/sinatra/base.rb#L307-L321) automatically uses 303 for non-GET requests. [This PR](rails/rails#45393) aims to do this for Rails applications. When autocorrect is enabled, `status: :see_other` will be added to redirects used in the `create`, `update`, and `delete` actions. #### `render` Calling `render` in the `create`, `update`, and `destroy` actions is almost always associated with error handling, so a 4xx status should be used. In the vast majority of cases, we should be using 422 Unprocessable Entity. ```ruby if something.save redirect_to something, status: :see_other else render :new, status: :unprocessable_entity end ``` There are exceptions, though. For example, sometimes you want to show a confirmation page that says "We've received your request". In which case, 201 or 202 might be a more appropriate choice. When autocorrect is enabled, `status: :unprocessable_entity` will be added to renders used in the `create`, `update`, and `delete` actions. This is a pretty unreasonable thing to do, but could help people update these usages en-masse. For this reason, Autocorrect is disabled by default for this rule.
When a client follows an HTTP 302 redirect, it will typically use the same HTTP method as the original request. This can cause issues when, for example, redirecting an XHR
DELETE
request after adestroy
. (Note that many clients make an exception forPOST
, and will useGET
to follow a 302 redirect in that case.)The solution so far has been for users to specify an explicit response code in such cases. For example:
This commit adds a
redirect_code_for_unsafe_http_methods
configuration setting that allows users to specify a default HTTP response code to use when redirecting a request made with an unsafe HTTP method, such asPOST
orDELETE
. For example, when set to 303, the explicit response code may be omitted: