Skip to content

Commit d61a69c

Browse files
authored
Merge pull request rails#54643 from etiennebarrie/dont-escape-json-when-unnecessary
Don't always escape JSON when calling `render json:`
2 parents dd44466 + 622b144 commit d61a69c

File tree

7 files changed

+102
-2
lines changed

7 files changed

+102
-2
lines changed

actionpack/CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
1+
* The JSON renderer doesn't escape HTML entities or Unicode line separators anymore.
2+
3+
Using `render json:` will no longer escape `<`, `>`, `&`, `U+2028` and `U+2029` characters that can cause errors
4+
when the resulting JSON is embedded in JavaScript, or vulnerabilities when the resulting JSON is embedded in HTML.
5+
6+
Since the renderer is used to return a JSON document as `application/json`, it's typically not necessary to escape
7+
those characters, and it improves performance.
8+
9+
Escaping will still occur when the `:callback` option is set, since the JSON is used as JavaScript code in this
10+
situation (JSONP).
11+
12+
You can use the `:escape` option or set `config.action_controller.escape_json_responses` to `true` to restore the
13+
escaping behavior.
14+
15+
```ruby
16+
class PostsController < ApplicationController
17+
def index
18+
render json: Post.last(30), escape: true
19+
end
20+
end
21+
```
22+
23+
*Étienne Barrié*, *Jean Boussier*
24+
125
* Load lazy route sets before inserting test routes
226

327
Without loading lazy route sets early, we miss `after_routes_loaded` callbacks, or risk

actionpack/lib/action_controller/metal/renderers.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ module Renderers
2929

3030
included do
3131
class_attribute :_renderers, default: Set.new.freeze
32+
class_attribute :escape_json_responses, instance_accessor: false, default: true
3233
end
3334

3435
# Used in ActionController::Base and ActionController::API to include all
@@ -86,7 +87,7 @@ def self.remove(key)
8687
remove_possible_method(method_name)
8788
end
8889

89-
def self._render_with_renderer_method_name(key)
90+
def self._render_with_renderer_method_name(key) # :nodoc:
9091
"_render_with_renderer_#{key}"
9192
end
9293

@@ -140,7 +141,7 @@ def render_to_body(options)
140141
_render_to_body_with_renderer(options) || super
141142
end
142143

143-
def _render_to_body_with_renderer(options)
144+
def _render_to_body_with_renderer(options) # :nodoc:
144145
_renderers.each do |name|
145146
if options.key?(name)
146147
_process_options(options)
@@ -153,6 +154,7 @@ def _render_to_body_with_renderer(options)
153154

154155
add :json do |json, options|
155156
json_options = options.except(:callback, :content_type, :status)
157+
json_options[:escape] ||= false if !self.class.escape_json_responses? && options[:callback].blank?
156158
json = json.to_json(json_options) unless json.kind_of?(String)
157159

158160
if options[:callback].present?

actionpack/lib/action_controller/railtie.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,5 +133,18 @@ class Railtie < Rails::Railtie # :nodoc:
133133
ActionController::TestCase.executor_around_each_request = app.config.active_support.executor_around_test_case
134134
end
135135
end
136+
137+
initializer "action_controller.escape_json_responses_deprecated_warning" do
138+
config.after_initialize do
139+
ActiveSupport.on_load(:action_controller) do
140+
if ActionController::Base.escape_json_responses
141+
ActionController.deprecator.warn(<<~MSG.squish)
142+
Setting action_controller.escape_json_responses = true is deprecated and will have no effect in Rails 8.2.
143+
Set it to `false` or use `config.load_defaults(8.1)`.
144+
MSG
145+
end
146+
end
147+
end
148+
end
136149
end
137150
end

actionpack/test/controller/render_json_test.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require "abstract_unit"
44
require "controller/fake_models"
55
require "active_support/logger"
6+
require "active_support/core_ext/object/with"
67

78
class RenderJsonTest < ActionController::TestCase
89
class JsonRenderable
@@ -46,6 +47,14 @@ def render_json_hello_world_with_callback
4647
render json: ActiveSupport::JSON.encode(hello: "world"), callback: "alert"
4748
end
4849

50+
def render_json_unsafe_chars_with_callback
51+
render json: { hello: "\u2028\u2029<script>" }, callback: "alert"
52+
end
53+
54+
def render_json_unsafe_chars_without_callback
55+
render json: { hello: "\u2028\u2029<script>" }
56+
end
57+
4958
def render_json_with_custom_content_type
5059
render json: ActiveSupport::JSON.encode(hello: "world"), content_type: "text/javascript"
5160
end
@@ -106,6 +115,20 @@ def test_render_json_with_callback
106115
assert_equal "text/javascript", @response.media_type
107116
end
108117

118+
def test_render_json_with_callback_escapes_js_chars
119+
get :render_json_unsafe_chars_with_callback, xhr: true
120+
assert_equal '/**/alert({"hello":"\\u2028\\u2029\\u003cscript\\u003e"})', @response.body
121+
assert_equal "text/javascript", @response.media_type
122+
end
123+
124+
def test_render_json_with_new_default_and_without_callback_does_not_escape_js_chars
125+
TestController.with(escape_json_responses: false) do
126+
get :render_json_unsafe_chars_without_callback
127+
assert_equal %({"hello":"\u2028\u2029<script>"}), @response.body
128+
assert_equal "application/json", @response.media_type
129+
end
130+
end
131+
109132
def test_render_json_with_custom_content_type
110133
get :render_json_with_custom_content_type, xhr: true
111134
assert_equal '{"hello":"world"}', @response.body

guides/source/configuring.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Below are the default values associated with each target version. In cases of co
6060

6161
#### Default Values for Target Version 8.1
6262

63+
- [`config.action_controller.escape_json_responses`](#config-action-controller-escape-json-responses): `false`
6364
- [`config.yjit`](#config-yjit): `!Rails.env.local?`
6465

6566
#### Default Values for Target Version 8.0
@@ -1964,6 +1965,21 @@ The default value depends on the `config.load_defaults` target version:
19641965
Configures the [`ParamsWrapper`](https://api.rubyonrails.org/classes/ActionController/ParamsWrapper.html). This can be called at
19651966
the top level, or on individual controllers.
19661967

1968+
#### `config.action_controller.escape_json_responses`
1969+
1970+
Configures the JSON renderer to escape HTML entities and Unicode characters that are invalid in JavaScript.
1971+
1972+
This is useful if you relied on the JSON response having those characters escaped to embed the JSON document in
1973+
\<script> tags in HTML.
1974+
1975+
This is mainly for compatibility when upgrading Rails applications, otherwise you can use the `:escape` option for
1976+
`render json:` in specific controller actions.
1977+
1978+
| Starting with version | The default value is |
1979+
| --------------------- | -------------------- |
1980+
| (original) | `true` |
1981+
| 8.1 | `false` |
1982+
19671983
### Configuring Action Dispatch
19681984

19691985
#### `config.action_dispatch.cookies_serializer`

railties/lib/rails/application/configuration.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,10 @@ def load_defaults(target_version)
355355
# redefine methods (e.g. mocking), hence YJIT isn't generally
356356
# faster in these environments.
357357
self.yjit = !Rails.env.local?
358+
359+
if respond_to?(:action_controller)
360+
action_controller.escape_json_responses = false
361+
end
358362
else
359363
raise "Unknown version #{target_version.to_s.inspect}"
360364
end

railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_1.rb.tt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,21 @@
88
#
99
# Read the Guide for Upgrading Ruby on Rails for more info on each option.
1010
# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
11+
12+
###
13+
# Skips escaping HTML entities and line separators. When set to `false`, the
14+
# JSON renderer no longer escapes these to improve performance.
15+
#
16+
# Example:
17+
# class PostsController < ApplicationController
18+
# def index
19+
# render json: { key: "\u2028\u2029<>&" }
20+
# end
21+
# end
22+
#
23+
# Renders `{"key":"\u2028\u2029\u003c\u003e\u0026"}` with the previous default, but `{"key":"

<>&"}` with the config
24+
# set to `false`.
25+
#
26+
# Applications that want to keep the escaping behavior can set the config to `true`.
27+
#++
28+
# Rails.configuration.action_controller.escape_json_responses = false

0 commit comments

Comments
 (0)