关于 rails-security-multitenancy
面向安全敏感的 Rails 开发与多租户边界审查,整理了租户隔离、认证、授权和 SSRF 防护模式。内容以 Fizzy 和 Campfire 的实践为参考。
主要功能
- 01按租户与用户范围查询数据
- 02强化认证与请求限流
- 03防护用户提供的 URL
- 04隔离实时广播与会话
- 05覆盖 CSRF、缓存与授权
适用场景
- 01审查多租户 Rails 数据访问
- 02设计魔法链接与 API Token 认证
- 03保护 Webhook 和推送 URL
- 04检查租户间广播与存储授权
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ marckohlbrugge/ 37signals-skills --skill rails-security-multitenancy安装后在 Claude Code 中输入/rails-security-multitenancy确认已加载。
详细介绍
Rails 安全与多租户
适用于对安全敏感的 Rails 工作,以及租户边界审查。模式来自 Fizzy(基于路径的多租户 SaaS)和 Campfire(单租户、机器人 API)。
核心规则
- 所有租户数据访问都必须限定在租户/用户所有权边界内。
- 在涉及租户的流程中,绝不要信任裸用
Model.find(params[:id])的方式。 - 按租户/账户限定实时广播和流名称。
- 对身份验证和易受滥用的端点实施速率限制。
- 将用户提供的 URL 视为不可信输入。
- 无法确认访问权限时,默认拒绝(
head :forbidden)。
租户架构(基于路径,Fizzy 风格)
- 中间件从
PATH_INFO中提取账户前缀并写入SCRIPT_NAME,加载账户,然后在Current.with_account(account)中处理请求。URL 助手会自动保持租户上下文正确(包括 ActiveStorage 和通过script_name:生成的 webhook 负载 URL)。 - 三层身份验证:Identity → Session → 账户范围内的 User。 Session 关联到
Identity;每个请求都会解析Current.user = identity.users.find_by(account: Current.account)。在账户 A 中有效的 Session 绝不能在账户 B 中执行操作。在 ActionCable 的Connection#connect中应用相同的解析逻辑。 Current的 setter 会级联处理:赋值session时会解析identity,赋值identity时会为当前账户解析user。- 身份验证路由(登录、注册、魔法链接)会显式退出租户范围(
disallow_account_scope),并将请求从带租户前缀的 URL 重定向出去。 - 支持同时登录多个租户时,按路径范围限定会话 Cookie(
path: account.slug)。 - 定期任务在请求上下文之外运行:显式遍历租户(
with_each_tenant)。将Current.account序列化到任务负载中(参见 rails-jobs)。 - 使用
Rack::MockRequest单独测试租户中间件;集成测试则根据测试夹具账户设置default_url_options[:script_name]。
限定范围的查询(深度防御)
参数只能选择已获授权集合中的哪条记录,绝不能据此授予访问权限:
@card = Current.user.accessible_cards.find_by!(number: params[:card_id]) # access graph
@membership = Current.user.memberships.find_by!(room_id: params[:room_id]) # join model
@user = Current.account.users.find(params[:user_id]) # tenant association
- 即使是单租户代码,也要通过关联进行范围限定;错误的 ID 会自然返回 404。
- 公开分享使用不透明令牌(在
Publication记录上使用has_secure_token :key),绝不使用内部 ID。 - ActiveStorage:将 blob 关联到账户,并通过领域模型授权 blob/representation 控制器(
blob → attachment → record.accessible_to?(user));已发布内容使用显式的publicly_accessible?路径。 - 撤销访问权限时,通过范围受限的异步任务清理衍生数据(提及、通知、关注记录),不要留下跨边界的残留状态。
强化身份验证
- 魔法链接/验证码:只能使用一次(使用后销毁记录)、有效期短、使用
secure_compare比较,并通过已验证的待处理身份验证 Cookie 绑定到电子邮件。 - 防止枚举:未知电子邮件地址也要获得与真实地址相同的模拟流程/用户体验。
- API 令牌:按 HTTP 方法限定权限(只读令牌不能执行 POST);生成的密钥仅通过短期消息验证器(约 10 秒)展示一次,此后不再展示。
- 将机器人/自动化身份验证设为显式模式:仅对机器人密钥身份验证跳过 CSRF;默认在所有位置拒绝机器人(
deny_bots+allow_bot_access only:)。 - 使用 Rails 内置功能限制速率,并确保响应符合端点语义:
rate_limit to: 10, within: 15.minutes, only: :create,
with: -> { redirect_to ..., alert: "Try again in 15 minutes." }
- 限制会话记录的写入频率(每小时最多更新一次
last_active_at)。 - 除密码外,也要过滤敏感参数:消息正文、推送端点、令牌。
SSRF 防御基线
对于 webhook、推送端点、展开预览等任何受用户影响的 URL:
- 请求前解析 DNS 并验证目标 IP;阻止回环、私有、链路本地和 IPv4 映射的 IPv6 地址范围(链路本地地址范围 = 云元数据)。
- 将请求固定到已验证的 IP(
Net::HTTP.new(host, port, ipaddr: resolved_ip)),以防 DNS 重绑定。 - 创建时和执行时都要验证。
- 每次重定向跳转时重新解析并重新验证——重定向链是经典的绕过方式。
- 限制响应大小(预先检查 content-length,并限制分块读取大小),以防内存 DoS。
- 在适用的情况下,叠加允许列表(已知目标集合时):Web Push 需要允许的供应商主机后缀,并解析为公共 IP。
CSRF 和缓存
- 绝不对渲染表单或 CSRF 令牌的页面进行 HTTP 缓存。
- 在来源检查的基础上添加
Sec-Fetch-Site验证(same-origin/same-site);先以仅报告模式逐步启用,并将Sec-Fetch-Site添加到Vary标头中。 - 设置具有严格底线的 CSP(
object_src :none、base_uri :none、frame_ancestors :self);先使用仅报告模式和report_uri验证,再强制执行。 - 私有应用:发送
X-Robots-Tag: none。
授权默认做法
- 在模型上定义谓词方法(
card.editable_by?(user)、user.can_administer_board?(board));控制器进行检查,并返回head :forbidden。 - 使用声明式控制器宏(
allow_unauthenticated_access、ensure_can_administer),而不是散布条件判断。 - 将共享的防护逻辑集中到小型 concern 中。
滥用应对
- 封禁用户时进行级联处理:将其会话 IP 转换为封禁项,远程断开 ActionCable 连接,删除会话,并异步清除内容,同时向 UI 广播。
- 仅对变更请求(POST/PUT/PATCH/DELETE)强制执行 IP 封禁。
- 验证封禁目标为公共 IP——绝不允许封禁回环或私有地址范围。
- 断开已停用用户的连接:
ActionCable.server.remote_connections.where(current_user: user).disconnect(reconnect: false)。
入站 Webhook(接收)
- 验证签名(例如
Stripe::Webhook.construct_event),然后从来源 API 重新获取规范状态,而不是信任负载内容或顺序。
危险信号
- 未按租户限定范围的广播频道或流名称。
- 未经所有权检查,便从请求参数推断租户数据。
- 根据会话查找全局
User,却未按账户限定范围(混淆身份与用户)。 - 向未经验证的目标发送 webhook 或获取请求;跟随重定向时未重新验证。
- 绕过域授权的 ActiveStorage URL。
- 安全控制隐藏在各控制器临时添加的条件判断中。
- 管理员界面中永久可见的密钥。
- 未进行速率限制的认证端点。
评论
支持 Markdown