JSON gem API 的安全隐患与改进方案

本文深入探讨了Ruby JSON gem中存在的API设计问题,包括create_additions选项的安全风险、重复键处理的隐患、全局配置的弊端,并提出了基于JSON::Coder的改进方案,旨在提升代码的安全性和可维护性。

JSON gem API 的问题与改进

在我开始优化 Ruby JSON 系列文章时提到过,性能并不是我申请成为新 gem 维护者的原因。实际原因是该 gem 有许多我认为不太好的 API,其中一些甚至非常危险。

作为 gem 用户,很容易对弃用和破坏性变更感到烦恼。这会产生噪音并增加额外工作,因此我完全理解人们可能会遭受弃用疲劳。虽然偶尔会遇到主要是表面性的弃用,不值得它们引起的变动(这也让我很烦恼),但大多数时候弃用是有充分理由的,只是很少传达给用户,更少被讨论,所以让我们来讨论一次。

我想回顾一些我已经实施或可能很快实施的 API 变更和弃用,这是一个解释变更价值的好机会,也可以更广泛地讨论 API 设计。

处理 Ruby 中的弃用

在深入讨论已弃用的 API 之前,我想提一下如何在现代 Ruby 中有效处理弃用。

从 Ruby 2.7 开始,使用 Kernel#warn 发出的警告消息被分类,其中一个可用类别是 :deprecated。默认情况下,弃用警告被静默;要显示它们,必须启用 :deprecated 类别,如下所示:

1
Warning[:deprecated] = true

强烈建议在测试套件中这样做,以至于 Rails 和 Minitest 会默认这样做。但是,如果您使用 RSpec,则必须在 spec_helper.rb 文件中自己设置,因为我们试图让 RSpec 也这样做已经超过四年了,但没有成功。但我仍然希望最终会发生。

关于 Ruby 的 Kernel#warn 方法的另一个有用的事情是,在底层,它调用 Warning.warn 方法,允许您重新定义它并自定义其行为。例如,您可以将警告转换为错误,如下所示:

1
2
3
4
5
module Warning
  def warn(message, ...)
    raise message
  end
end

这样做既确保警告不会被错过,又有助于跟踪它们,因为您会得到一个带有完整回溯的异常,而不是一个指向单个调用点的警告,这可能无助于您找到问题。这是我在大多数自己的项目中使用的模式,并且我也将其包含在 Rails 自己的测试套件中。

对于较大的项目,始终保持无弃用可能很复杂,还有更复杂的 deprecation_toolkit gem。

create_additions 选项

现在,让我们从说服我请求维护权的 API 开始。

您知道 JSON.loadJSON.parse 之间的区别吗?不止一个,但主要区别是默认启用的一组选项不同,特别是有一个非常危险的选项:create_additions: true

这个选项非常糟糕,以至于 Rubocop 的默认规则集出于安全原因完全禁止 JSON.load,并且它已经涉及多个安全漏洞。让我们深入探讨它的作用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
require "json"

class Point
  class << self
    def json_create(data)
      new(data["x"], data["y"])
    end
  end

  def initialize(x, y)
    @x = x
    @y = y
  end
end

document = <<~'JSON'
  {
    "json_class": "Point",
    "x": 123.456,
    "y": 789.321
  }
JSON

p JSON.parse(document)
# => {"json_class" => "Point", "x" => 123.456, "y" => 789.321}

p JSON.load(document)
# => #<Point:0x00000001007f6d08 @x=123.456, @y=789.321>

所以 create_additions: true 解析选项的作用是,当它注意到具有特殊键 "json_class" 的对象时,它会解析常量并在其上调用 #json_create

本身,这并不是一个安全漏洞,因为只有具有 .json_create 方法的类才能以这种方式实例化。但如果您长期使用 Ruby,这可能会让您想起与 YAML 等 gem 类似的漏洞问题。

这就是这类鸭子类型 API 的问题:它们太全局了。您可能有一段使用 JSON.load 的代码本身是完全安全的,但如果它嵌入到一个应用程序中,该应用程序还加载了其他一些代码,定义了您未预料到的一些 .json_create 方法,您可能会遇到未预见的漏洞。

但即使您没有定义任何 json_create 方法,gem 也会始终在 String 上定义一个:

1
2
3
>> require "json"
>> JSON.load('{"json_class": "String", "raw": [112, 119, 110, 101, 100]}')
=> "pwned"

这里再次,您可能需要找到一些特定情况来利用这一点,但您可能可以看到这个技巧如何用于绕过某种验证检查。

那么我计划怎么做呢?几件事。

首先,我弃用了隐式的 create_additions: true 选项。如果您使用 JSON.load 来实现该功能,将发出弃用警告,要求使用 JSON.unsafe_load

1
2
3
4
5
6
require "json"
Warning[:deprecated] = true
JSON.load('{"json_class": "String", "raw": [112, 119, 110, 101, 100]}')
# /tmp/j.rb:3: warning: JSON.load implicit support for `create_additions: true`
# is deprecated and will be removed in 3.0,
# use JSON.unsafe_load or explicitly pass `create_additions: true`

话虽如此,考虑到这个功能有多么不稳定,我也在考虑将其提取到另一个 gem 中。这曾经是不可能的,因为它深深嵌入到 C 和 Java 解析器中,但我最近重构了它,使用解析器暴露的回调成为纯 Ruby 代码。

现在您可以向 JSON.load 提供一个 Proc,解析器将为每个解析的值调用它,允许您用一个值替换另一个值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
cb = ->(obj) do
  case obj
  when String
    obj.upcase
  else
    obj
  end
end

p JSON.load('["a", {"b": 1}]', cb)
# => ["A", {"B" => 1}]

在此更改之前,JSON.load 已经接受一个 Proc,但其返回值被忽略。好处是这个回调现在也作为处理富对象序列化的更安全和灵活的方式。例如,您可以实现类似这样的东西:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
types = {
  "range" => MyRangeType
}
cb = ->(obj) do
  case obj
  when Hash
    if type = types[obj["__type"]]
      type.load(obj)
    else
      obj
    end
  else
    obj
  end
end

虽然这需要用户编写更多代码,但它提供了对反序列化的更严格控制,但更重要的是,它不再是全局的。如果一个库使用此功能来反序列化受信任的数据,其回调永远不会被另一个库调用,就像旧的 Class#json_create API 那样。

明显的解决方案是遵循与 YAML 相同的路线,使用其 permitted_classes 参数,但在我看来,这不会解决问题的根源,并且会使得 API 使用起来非常不愉快。相反,我相信这个 Proc 接口提供了与以前相同的功能,但方式更灵活和安全。

我认为这是一个明显的弃用案例,因为它很少需要,有安全影响,并且会让用户感到惊讶。

重复键的解析

我最近弃用的解析器的另一个行为是重复键的处理。考虑以下代码:

1
p JSON.parse('{"a": 1, "a": 2}')["a"]

您认为它应该返回什么?您可以争论第一个键或最后一个键应该获胜,或者这应该导致解析错误。

不幸的是,JSON 有点像一个“后指定”格式,因为它最初是一个极其简单的文档。关于“对象”的所有内容如下:

对象是一组无序的名称/值对。对象以 { 开始,以 } 结束。每个名称后跟 :,名称/值对之间用 , 分隔。

就是这样,这是规范的范围,如您所见,没有提到如果解析器遇到重复键应该做什么。后来,各种标准化机构试图基于现有的实现来指定 JSON。

因此,我们现在有 IETF 的 STD 90,也称为 RFC 8259,其中指出:

许多实现仅报告最后一个名称/值对。其他实现报告错误或无法解析对象,一些实现报告所有名称/值对,包括重复项。

换句话说,它承认大多数实现返回最后看到的对,但没有规定任何特定行为。还有 ECMA-404 标准:

JSON 语法不对用作名称的字符串施加任何限制,不要求名称字符串唯一,也不对名称/值对的顺序分配任何意义。这些都是语义考虑,可以由 JSON 处理器或定义 JSON 用于数据交换的特定用途的规范来定义。

这几乎是规范语言的等价于:🤷‍♂️。

未指定格式的问题是有时可以被利用,经典的例子是 HTTP 请求走私。虽然这不是一种利用,但 Hacker One 发生了一个安全问题,部分原因是这种行为。

从技术上讲,错误在 JSON 生成端,但如果 JSON 的 gem 解析器没有静默接受重复键,他们会在开发早期发现它。

这就是为什么从版本 2.13.0 开始,JSON.parse 现在接受一个新的 allow_duplicate_key: 关键字参数,如果未明确允许,遇到重复键时会发出弃用警告:

1
2
3
4
5
6
7
8
9
require "json"
Warning[:deprecated] = true

p JSON.parse('{"a": 1, "a": 2}')
# => {"a" => 2}

# /tmp/j.rb:4: warning: detected duplicate key "a" in JSON object.
# This will raise an error in json 3.0 unless enabled via `allow_duplicate_key: true`
#at line 1 column 1

如警告消息中所述,我计划在下一个主要版本中将默认行为更改为错误,但当然,对于罕见的需要重复键的情况,总是可以明确允许。

这里再次,我认为这个弃用是合理的,因为重复键很少见,但也几乎总是一个错误,因此我期望很少有人需要更改任何东西,而那些需要更改的人可能会发现他们应用程序中以前未注意到的错误。

to_jsonto_s 方法

在您惊恐地倒吸一口冷气之前,别担心,我不计划弃用 Object#to_json 方法,永远不会。它太广泛了,以至于这永远不可接受。

但这并不意味着这个 API 是好的,也不意味着不应该做任何事情。在 json gem API 的核心,有一个概念,即对象可以通过响应 to_json 方法来定义自己应该如何序列化为 JSON。

乍一看,这似乎是一个完美的 API,它是一个对象可以实现的接口,相当经典的面向对象设计。这是一个更改 Time 对象序列化方式的示例。

默认情况下,json 会在它不知道如何处理的对象上调用 #to_s

1
2
>> puts JSON.generate({ created_at: Time.now })
{"created_at":"2025-08-02 13:03:32 +0200"}

但我们可以指示它使用 ISO8601 / RFC 3339 格式序列化 Time:

1
2
3
4
5
6
7
8
class Time
  def to_json(...)
    iso8601(3).to_json(...)
  end
end

>> puts JSON.generate({ created_at: Time.now })
{"created_at":"2025-08-02T13:05:04.160+02:00"}

这看起来都很好,但问题在于,就像 .json_create 方法一样,这是一个全局行为。一个应用程序可能需要在不同上下文中以不同方式序列化日期。

更糟糕的是,在库的上下文中,比如需要以特定方式序列化 Time 的 API 客户端,使用这个 API 并不真正可能,您不能假设更改这样的全局行为是可接受的,因为您对将在其中运行的应用程序一无所知。

所以对我来说,这里有两个问题。首先,使用 #to_s 作为回退适用于少数类型,如日期,但对于绝大多数其他对象来说真的没有帮助:

1
2
>> puts JSON.generate(Object.new)
"#<Object:0x000000011ce214a0>"

我真的想不出一个情况下这是您想要的行为。如果 JSON.generate 最终在对象上调用 to_s,我敢打赌在 99% 的情况下,开发人员不打算序列化该对象,或者忘记在其上实现 #to_json。无论哪种方式,引发错误并要求提供显式方法来序列化该未知对象会更有用。

第二个是应该可以本地自定义给定类型的序列化,而不是全局。

此外,返回一个字符串作为 JSON 片段也不好,因为它意味着递归调用生成器,并允许生成无效文档:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Broken
  def to_json
    to_s
  end
end

>> Broken.new.to_json
=> "#<Broken:0x0000000123054050>"
>> JSON.parse(Broken.new.to_json)
#> JSON::ParserError: unexpected character: '#<Broken:0x000000011c9377a0>'
# > at line 1 column 1 

这就是新的 JSON::Coder API 旨在解决的问题。

默认情况下,JSON::Coder 只接受序列化具有直接 JSON 等效类型的类型,所以 Hash、Array、String / Symbol、Integer、Float、true、false 和 nil。任何没有直接 JSON 等效类型的类型都会产生错误:

1
2
3
4
5
>> MY_JSON = JSON::Coder.new
>> MY_JSON.dump({a: 1})
=> "{\"a\":1}"
>> MY_JSON.dump({a: Time.new})
#> JSON::GeneratorError: Time not allowed in JSON

但它确实允许您提供一个 Proc 来定义所有其他类型的序列化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
MY_JSON = JSON::Coder.new do |obj|
  case obj
  when Time
    obj.iso8601(3)
  else
    obj # return `obj` to fail serialization
  end
end

>> MY_JSON.dump({a: Time.new})
=> "{\"a\":\"2025-08-02T14:03:15.091+02:00\"}"

#to_json 方法相反,这里的 Proc 期望返回一个 JSON 原始对象,所以您不必担心 JSON 转义规则等,这更安全。但如果出于某种原因您需要,您仍然可以使用 JSON::Fragment

1
2
3
4
5
6
7
8
MY_JSON = JSON::Coder.new do |obj|
  case obj
  when SomeRecord
    JSON::Fragment.new(obj.json_blob)
  else
    obj # return `obj` to fail serialization
  end
end

使用这个新 API,gem 现在更容易以本地方式自定义 JSON 生成。

现在,正如我之前所说,我绝对不计划弃用 #to_json,甚至不计划弃用在未知对象上调用 #to_s 的行为。尽管我认为这是一个糟糕的 API,并且其替代方案优越得多,但 #to_json 方法从一开始就是 json gem 的核心,需要社区大量工作来迁移出去。

弃用 API 的决定应该始终权衡收益与成本。在这里,成本如此巨大,以至于我甚至无法考虑。

load_default_options / dump_default_options

我标记为弃用的另一组 API 是各种 _default_options 访问器。

1
2
3
4
5
>> puts JSON.dump("http://example.com")
"http://example.com"
>> JSON.dump_default_options[:script_safe] = true
>> puts JSON.dump("http://example.com")
"http:\/\/example.com"

概念很简单:您可以全局更改某些方法接收的默认选项。乍一看,这似乎是一种便利,它允许您设置一些选项,而不必在可能数十个不同的调用点传递它。

但就像 #to_json 和其他 API 一样,此更改适用于整个应用程序,包括一些可能不期望标准 JSON 方法行为不同的依赖项。

这不是假设,我个人遇到过一个 gem,它使用 JSON 来指纹一些对象图,例如:

1
2
3
def fingerprint
  Digest::SHA1.hexdigest(JSON.dump(some_object_graph))
end

该指纹方法在 gem 中得到了很好的测试,并且在几十个应用程序中运行良好,直到有一天有人报告了 gem 中的一个错误。经过一些调查,我发现相关的主机应用程序修改了 JSON.dump_default_options,导致指纹不同。

如果您想一想,这类全局设置与猴子补丁并没有太大不同:

1
2
3
4
5
6
JSON.singleton_class.prepend(Module.new {
  def dump(obj, proc = nil, opts = {})
    opts = opts.merge(script_safe: true)
    super
  end
})

绝大多数 Ruby 开发者非常清楚猴子补丁的潜在陷阱,有些人绝对讨厌它,但出于某种原因,这类全局配置 API 并没有受到那么多反对。

在某些情况下,它们是有意义的。例如,如果配置是针对应用程序或框架的(框架本质上是应用程序骨架),并不真正需要本地配置,全局配置更简单,更容易推理。

但在一个库中,可能被多个具有不同配置需求的其他库使用,它们就是一个问题。

有趣的是,这类 API 是 Ruby 3.5.0dev 中当前实验性命名空间功能的理由之一,这表明 json gem 并不是唯一有这个问题的地方。

这里再次,一个更好的解决方案是 JSON::Coder API,如果您想在整个代码库中集中 JSON 生成配置,您可以使用所需选项分配一个单例:

1
2
3
4
5
6
7
module MyLibrary
  JSON_CODER = JSON::Coder.new(script_safe: true)

  def do_things
    JSON_CODER.dump(...)
  end
end

作为库作者,您甚至可以允许用户替换为他们选择的配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
module MyLibrary
  class << self
    attr_accessor :json_coder
  end
  @json_coder = JSON::Coder.new(script_safe: true)

  def do_things
    MyLibrary.json_coder.dump(...)
  end
end

幸运的是,从我对 gem 使用情况的观察来看,这些 API 很少被使用,所以虽然它们不是主要障碍,但我认为成本与收益是正的。如果有人真的需要全局设置一个选项,他们可以猴子补丁 JSON,效果是一样的,至少更诚实。

结论

如前所述,弃用的决定不应轻率做出。对必须处理后果的用户要有同理心,没有什么比表面性的弃用更烦人的了。

但同样重要的是要认识到 API 何时容易出错甚至完全危险,弃用有时是纠正方向的必要之恶。

另外,您可能已经注意到,我在 json gem 中不喜欢的大多数

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计