Skip to content

Commit f169aa0

Browse files
authored
Encourage the use of status codes for unsafe requests (#40)
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.
1 parent b19133a commit f169aa0

File tree

10 files changed

+258
-2
lines changed

10 files changed

+258
-2
lines changed

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
betterlint (1.9.0)
4+
betterlint (1.10.0)
55
rubocop (~> 1.62.0)
66
rubocop-performance (~> 1.21.0)
77
rubocop-rails (~> 2.24.0)

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,20 @@ This cop is capable of autocorrecting offenses, but it's not entirely safe. If y
210210
Betterment/HardcodedID:
211211
AutoCorrect: true
212212
```
213+
214+
### Betterment/RedirectStatus
215+
216+
The `redirect_to` method defaults to a `302 Found`, but when redirecting a POST/PUT/PATCH/DELETE
217+
to a GET location, the correct response code is `303 See Other`.
218+
219+
This cop requires you to explictly provide an HTTP status code when redirecting from the create,
220+
update, and destory actions. When autocorrecting, this will automatically add `status: :see_other`.
221+
222+
### Betterment/RenderStatus
223+
224+
The `render` method defaults to a `200 OK`. Calling `render` in the create, update, and destroy
225+
actions often indicates error handling (e.g. `422 Unprocessable Entity`).
226+
227+
This cop requires you to explicitly provide an HTTP status code when rendering a response in the
228+
create, update, and destroy actions. When autocorrecting, this will automatically add
229+
`status: :unprocessable_entity` or `status: :ok` depending on what you're rendering.

betterlint.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
55

66
Gem::Specification.new do |s|
77
s.name = "betterlint"
8-
s.version = "1.9.0"
8+
s.version = "1.10.0"
99
s.authors = ["Development"]
1010
s.email = ["[email protected]"]
1111
s.summary = "Betterment rubocop configuration"

config/default.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,18 @@ Betterment/UnsafeJob:
7676
Betterment/UnscopedFind:
7777
StyleGuide: '#bettermentunscopedfind'
7878

79+
Betterment/RedirectStatus:
80+
SafeAutoCorrect: false
81+
Description: Detect missing status codes when redirecting POST, PUT, PATCH, or DELETE responses
82+
Include:
83+
- app/controllers/**/*.rb
84+
85+
Betterment/RenderStatus:
86+
SafeAutoCorrect: false
87+
Description: Detect missing status codes when rendering POST, PUT, PATCH, or DELETE responses
88+
Include:
89+
- app/controllers/**/*.rb
90+
7991
FactoryBot/ConsistentParenthesesStyle:
8092
Enabled: false
8193

lib/rubocop/cop/betterment.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,5 @@
2020
require 'rubocop/cop/betterment/hardcoded_id'
2121
require 'rubocop/cop/betterment/vague_serialize'
2222
require 'rubocop/cop/betterment/fetch_boolean'
23+
require 'rubocop/cop/betterment/render_status'
24+
require 'rubocop/cop/betterment/redirect_status'
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'utils/response_status'
4+
5+
module RuboCop
6+
module Cop
7+
module Betterment
8+
class RedirectStatus < Base
9+
extend AutoCorrector
10+
11+
include Utils::ResponseStatus
12+
13+
MSG = <<~MSG.gsub(/\s+/, " ")
14+
Did you forget to specify an HTTP status code? The default is `status: :found`, which
15+
is usually inappropriate in this situation. Use `status: :see_other` when redirecting a
16+
POST, PUT, PATCH, or DELETE request to a GET resource.
17+
MSG
18+
19+
def on_def(node)
20+
each_offense(node, :redirect_to) do |responder|
21+
add_offense(responder) do |corrector|
22+
corrector.insert_after(responder, ", status: :see_other")
23+
end
24+
end
25+
end
26+
end
27+
end
28+
end
29+
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'utils/response_status'
4+
5+
module RuboCop
6+
module Cop
7+
module Betterment
8+
class RenderStatus < Base
9+
extend AutoCorrector
10+
11+
include Utils::ResponseStatus
12+
13+
MSG = <<~MSG.gsub(/\s+/, " ")
14+
Did you forget to specify an HTTP status code? The default is `status: :ok`, which might
15+
be inappropriate in this situation. Rendering after a POST, PUT, PATCH or DELETE request
16+
typically represents an error (e.g. `status: :unprocessable_entity`).
17+
MSG
18+
19+
def on_def(node)
20+
each_offense(node, :render) do |responder|
21+
add_offense(responder) do |corrector|
22+
corrector.insert_after(responder, ", status: #{infer_status(responder).inspect}")
23+
end
24+
end
25+
end
26+
27+
private
28+
29+
def infer_status(responder)
30+
case extract_template(responder).to_s
31+
when 'new', 'edit'
32+
:unprocessable_entity
33+
else
34+
:ok
35+
end
36+
end
37+
38+
# @!method extract_template(node)
39+
def_node_matcher :extract_template, <<~PATTERN
40+
(send nil? :render {(sym $_) (str $_)} ...)
41+
PATTERN
42+
end
43+
end
44+
end
45+
end
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# frozen_string_literal: true
2+
3+
module RuboCop
4+
module Cop
5+
module Betterment
6+
module Utils
7+
module ResponseStatus
8+
extend RuboCop::NodePattern::Macros
9+
10+
UNSAFE_ACTIONS = %i(create update destroy).freeze
11+
12+
private
13+
14+
def each_offense(node, responder_name, &block)
15+
if UNSAFE_ACTIONS.include?(node.method_name)
16+
on_missing_status(node, responder_name, &block)
17+
end
18+
end
19+
20+
# @!method on_missing_status(node)
21+
def_node_search :on_missing_status, <<~PATTERN
22+
(send nil? %1 ... !(hash <(pair (sym :status) _) ...>))
23+
PATTERN
24+
end
25+
end
26+
end
27+
end
28+
end
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
describe RuboCop::Cop::Betterment::RedirectStatus, :config do
6+
it 'adds an offense when redirecting without a status' do
7+
expect_offense(<<~RUBY)
8+
def create
9+
redirect_to '/'
10+
^^^^^^^^^^^^^^^ Did you forget to specify an HTTP status code? [...]
11+
end
12+
13+
def update
14+
redirect_to '/'
15+
^^^^^^^^^^^^^^^ Did you forget to specify an HTTP status code? [...]
16+
end
17+
RUBY
18+
19+
expect_correction(<<~RUBY)
20+
def create
21+
redirect_to '/', status: :see_other
22+
end
23+
24+
def update
25+
redirect_to '/', status: :see_other
26+
end
27+
RUBY
28+
end
29+
30+
it 'does not add offenses for valid usage' do
31+
expect_no_offenses(<<~RUBY)
32+
def index
33+
redirect_to '/'
34+
end
35+
36+
def show
37+
redirect_to '/'
38+
end
39+
40+
def new
41+
redirect_to '/'
42+
end
43+
44+
def edit
45+
redirect_to '/'
46+
end
47+
48+
def create
49+
redirect_to '/', status: :found
50+
redirect_to '/', status: :see_other
51+
end
52+
RUBY
53+
end
54+
end
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
describe RuboCop::Cop::Betterment::RenderStatus, :config do
6+
it 'adds an offense when rendering without a status' do
7+
expect_offense(<<~RUBY)
8+
def create
9+
render :new
10+
^^^^^^^^^^^ Did you forget to specify an HTTP status code? [...]
11+
render 'new'
12+
^^^^^^^^^^^^ Did you forget to specify an HTTP status code? [...]
13+
render :other
14+
^^^^^^^^^^^^^ Did you forget to specify an HTTP status code? [...]
15+
render 'other'
16+
^^^^^^^^^^^^^^ Did you forget to specify an HTTP status code? [...]
17+
render plain: 'OK'
18+
^^^^^^^^^^^^^^^^^^ Did you forget to specify an HTTP status code? [...]
19+
end
20+
21+
def update
22+
render :edit
23+
^^^^^^^^^^^^ Did you forget to specify an HTTP status code? [...]
24+
render 'edit'
25+
^^^^^^^^^^^^^ Did you forget to specify an HTTP status code? [...]
26+
end
27+
RUBY
28+
29+
expect_correction(<<~RUBY)
30+
def create
31+
render :new, status: :unprocessable_entity
32+
render 'new', status: :unprocessable_entity
33+
render :other, status: :ok
34+
render 'other', status: :ok
35+
render plain: 'OK', status: :ok
36+
end
37+
38+
def update
39+
render :edit, status: :unprocessable_entity
40+
render 'edit', status: :unprocessable_entity
41+
end
42+
RUBY
43+
end
44+
45+
it 'does not add offenses for valid usage' do
46+
expect_no_offenses(<<~RUBY)
47+
def index
48+
render :new
49+
end
50+
51+
def show
52+
render :new
53+
end
54+
55+
def new
56+
render :new
57+
end
58+
59+
def edit
60+
render :new
61+
end
62+
63+
def create
64+
render plain: "OK", status: :ok
65+
render :new, status: :unprocessable_entity
66+
end
67+
RUBY
68+
end
69+
end

0 commit comments

Comments
 (0)