在 Scorched 框架中,加密会话并不是直接把 session 哈希塞进 cookie,而是经历了一次完整的数据转换链路:内存对象到字节序列、再到密文、最后变成可传输文本。中间这个“内存对象到字节序列”的步骤由序列化器完成,它是加密会话插件的核心组件之一。如果序列化器设计不当,轻则出现会话数据丢失或类型错乱,重则引入反序列化漏洞。Scorched::Plugins::Session::Encrypted::Serializer 正是这个环节的抽象入口,本文从它在链路中的位置讲起,再对比常见序列化方案并给出安全配置。

序列化器在加密会话链路中的位置
当控制器中执行 session[:user_id] = 42 这样的赋值操作时,数据起初只存在于内存中的 session 对象里。HTTP 响应返回浏览器后,服务端必须让后续请求还能识别这个用户,而 cookie 是常用的载体。cookie 的值只能是字符串,所以直接存储 Ruby 对象不可行。加密会话插件的处理顺序通常为:先调用序列化器,把 session 哈希转换成字符串;接着用对称密钥加密该字符串,得到二进制密文;最后进行 Base64 编码,因为 cookie 头对二进制内容不友好,Base64 可以保证密文在传输中不被破坏。
读取流程则完全相反:请求到达时,插件先取出 cookie 值并进行 Base64 解码,然后解密得到明文字符串,最后调用序列化器的反序列化方法把字符串还原为 Ruby 对象。序列化器在这里承担了两个看似简单但极容易出错的接口:dump 负责从对象到字符串,load 负责从字符串到对象。如果 dump 与 load 的格式约定不一致,或者不支持某些数据类型,就会出现会话数据无法恢复的问题。
从职责上看,序列化器不负责加密,也不负责 cookie 属性配置,它只解决数据形态转换问题。但正因为加密发生在序列化之后,序列化器的输出会直接影响加密效率与最终体积。比如序列化结果越紧凑,加密前的字符串越短,cookie 体积就越小。自定义序列化器时需要严格实现 dump 和 load,并保证两者逻辑互逆。
require 'json'
module Scorched
module Plugins
module Session
module Encrypted
module Serializer
module JSONSerializer
def self.dump(data)
JSON.generate(data)
end
def self.load(raw)
JSON.parse(raw, symbolize_names: true)
end
end
end
end
end
end
end
上面的代码就是一个最小化的 JSON 序列化器。它把 session 哈希通过 JSON.generate 变成字符串,再通过 JSON.parse 还原。使用 symbolize_names: true 是为了让键名从字符串自动转为符号,这样存取体验与普通 Ruby 哈希更接近。这段代码虽然没有直接调用加密逻辑,但已经完整承担了序列化器的职责。
常见序列化格式对比与选型
Ruby 生态中最常见的序列化方案有三种:Marshal、JSON 和 MessagePack。它们各有优劣,不能简单地说谁更好,而要根据会话数据的实际情况来选择。Marshal 是 Ruby 内置的序列化工具,可以对绝大多数 Ruby 对象进行二进制序列化,类型保真度最高,包括 Time、自定义类实例、符号等都能还原。但 Marshal 有一个严重问题:反序列化不可信数据时可能触发任意代码执行漏洞。即使加密层能降低篡改风险,一旦密钥泄露或存在加密绕过,Marshal 反序列化会使攻击者直接获得远程代码执行能力,因此生产环境普遍不推荐将会话序列化交给 Marshal。
JSON 是语言无关的文本格式,安全性比 Marshal 高得多,因为 JSON.parse 只返回基础类型,不会实例化任意类。代价是类型能力有限:Ruby 的符号会变成字符串,哈希键需要额外处理;Time 对象必须手动转成字符串或整数时间戳;自定义类实例无法直接序列化。对大多数 Web 会话来说,存储的数据无非是字符串、数字、布尔和哈希数组,JSON 完全够用,而且可读性更好,方便调试。
MessagePack 则是一种二进制序列化格式,输出体积通常比 JSON 更小,解析速度也更快,适合对 cookie 体积敏感的场景。它支持的类型比 JSON 多一些,比如二进制数据和时间戳扩展类型,但同样不会像 Marshal 那样随意实例化类。如果会话数据体量较大,既想保持紧凑又想避免 Marshal 的安全风险,可以考虑 MessagePack。
| 格式 | 类型保真度 | 安全性 | 输出体积 | 跨语言 |
|---|---|---|---|---|
| Marshal | 高,几乎任意对象 | 低,存在反序列化漏洞 | 中 | 否 |
| JSON | 低,仅基础类型 | 高,不会实例化类 | 中 | 是 |
| MessagePack | 中,比 JSON 多一些类型 | 高,不会实例化类 | 小 | 是 |
如果会话中需要存储 Time 对象,JSON 方案通常会把时间序列化成 ISO8601 字符串或 Unix 时间戳,读取时再手动转换。MessagePack 自带时间扩展类型,使用起来稍方便。综合来看,大部分 Scorched 应用使用 JSON 序列化器已经完全足够,只有在明确需要二进制紧凑性或时间类型原生支持时,才考虑 MessagePack。
require 'msgpack'
module Scorched
module Plugins
module Session
module Encrypted
module Serializer
module MessagePackSerializer
def self.dump(data)
MessagePack.pack(data)
end
def self.load(raw)
MessagePack.unpack(raw)
end
end
end
end
end
end
end
这段 MessagePack 序列化器与 JSON 版本结构相同,只是把核心方法换成了 MessagePack.pack 和 MessagePack.unpack。切换序列化格式时只需替换 dump 和 load 内部实现,对上层控制器代码无任何影响,这正是序列化器抽象的价值所在。
加密会话序列化器的安全配置实践
安全配置的第一步是优先选择 JSON 或 MessagePack,彻底避开 Marshal。有些开发者为了省事,直接使用 Ruby 内置的 Marshal.dump 和 Marshal.load,原因是它能序列化任意对象。但加密会话的数据来源是客户端 cookie,即使经过加密,也应假设密文可能被离线破解或密钥可能泄露。一旦攻击者能够向 Marshal.load 输入构造好的恶意序列化数据,就可能导致命令执行。因此在安全敏感场景中,不要使用 Marshal 作为会话序列化器。
密钥管理同样重要。加密会话的密钥必须从环境变量或专用密钥管理服务中读取,绝不能硬编码在源码中。密钥长度和算法也要符合现代标准,优先使用 AES-256-GCM 这类带完整性校验的加密模式,确保密文一旦被篡改就能在解密阶段被发现。同时 cookie 本身应设置 httponly 和 secure 属性,避免 JavaScript 读取会话标识,并强制仅在 HTTPS 连接中传输。
另一项容易被忽略的实践是控制会话数据体积。浏览器对单个 cookie 的大小通常限制在 4KB 左右,而序列化、加密、Base64 编码每一步都会增加开销,尤其是 Base64 会让密文体积膨胀约三分之一。如果序列化后的会话字符串本身就很长,最终很可能超过 cookie 限制,导致会话写入失败或者数据被截断。应尽量只把轻量级的标识信息放入会话,例如用户 ID、角色、过期时间,而把大块数据放在服务端存储中。
class App < Scorched::Controller
plugin Scorched::Plugins::Session::Encrypted,
secret: ENV.fetch('SESSION_SECRET'),
serializer: Scorched::Plugins::Session::Encrypted::Serializer::JSONSerializer,
cookie_options: { httponly: true, secure: true }
get '/' do
session[:user_id] ||= nil
'ok'
end
end
上面的配置示例使用了环境变量读取密钥,并指定 JSON 序列化器,同时设置了 httponly 和 secure 两个 cookie 属性。对于 cookie_options 中的 secure,在本地开发环境如果只有 HTTP 可用,可以临时关闭,但部署到生产时必须开启。序列化器与加密层解耦后,未来更换序列化格式也只需修改一个配置参数,不会影响业务逻辑。