关于 spree-security
面向 Spree 6 扩展开发者的安全技能,涵盖密钥、API 访问、员工会话及面向顾客的接口,并介绍降低凭据、支付和跨店数据风险的实用控制措施。
主要功能
- 01密钥与加密密钥管理
- 02API 密钥范围控制
- 03员工会话保护
- 04按店铺限制记录访问
- 05质量赋值控制
适用场景
- 01为客户及网关数据配置加密密钥
- 02用 API 密钥范围限制集成权限
- 03检查自定义控制器是否暴露跨店数据
- 04设置 API 资源可写属性
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-security安装后在 Claude Code 中输入/spree-security确认已加载。
详细介绍
Spree 安全
Spree 沿用 Rails 的安全模型,并增加了电子商务攻击面:支付流程、客户个人身份信息、员工凭据和出站 Webhook。本技能涵盖开发者扩展 Spree 6 时必须妥善处理的事项。有关谁可以做什么(角色、权限键、作用域、店面所有权、SSO),请参阅 spree-auth-permissions。
四行威胁模型
- Store API:面向互联网,由不受信任的客户端调用。风险:购物车/订单上的 IDOR、通过商品目录内容造成的 XSS、滥用购物车/身份验证端点。
- Admin API + 仪表板:员工凭据可能遭到钓鱼,集成可能泄露密钥。风险:权限范围过大的密钥、自定义控制器中的跨店泄露、角色权限不断扩大。
- 支付:涉及资金和 PCI。风险:卡数据进入日志或数据库、网关 Webhook 未经验证、退款滥用。
- 出站流量:Webhook 和集成。风险:SSRF、接收方未经验证。
密钥
- 将它们保存在 Rails 加密凭据或环境变量中,绝不要放进代码库。
VITE_*变量会编译进仪表板资源包,因此绝不要在其中放入密钥。 - **密钥泄露:**先在提供商处轮换,然后更新凭据/环境变量并部署,接着清理 Git 历史记录(
git filter-repo)。如果先清理历史记录,泄露的密钥在轮换前仍然有效。 - **每个环境中的
secret_key_base都必须保持稳定。**机密 API 密钥会使用它作为密钥生成 HMAC-SHA256 摘要,因此轮换它会使所有sk_密钥失效。它也是 JWT 签名的最后备用密钥。使用SPREE_JWT_SECRET_KEY(或凭据中的jwt_secret_key)设置专用 JWT 密钥。 - **Active Record 加密。**Spree 仅在配置了密钥时才会加密
Spree::WebhookEndpoint#secret_key和Spree::GatewayCustomer#profile_id(确定性加密),以及Spree::UserIdentity#access_token/#refresh_token(OAuth 令牌);如果未配置,这些值将以明文存储,初始模板会在启动时记录警告。设置以下三个环境变量:ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY、ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY、ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT。create-spree-app会将一组开发环境密钥写入.env;spree encryption init会为较早项目的.env添加密钥(绝不覆盖现有密钥,也没有--force;之后使用spree update重新创建容器,若已弹出则使用spree dev——spree restart会保留旧环境变量);spree encryption init --print或bin/rails db:encryption:init会打印一组新的生产环境密钥。每个环境使用单独的一组密钥,并将其备份到密钥管理器中。- 初始模板的
config/application.rb会将环境变量(若无则回退到active_record_encryption凭据项)复制到config.active_record.encryption中。这很重要:webhook 密钥和网关客户的encrypts调用会检查Rails.configuration.active_record.encryption,因此,如果密钥只存在于凭据中而缺少这段代码,这两列就会以明文存储。使用较早初始模板创建的应用需要添加这段代码(参见spree-upgrade-5-to-6)。 - **数据加密后,绝不要更改或丢失密钥。**Rails 可以轮换主密钥,但无法轮换 webhook 密钥和网关客户 ID 使用的确定性密钥/盐值。
- 为现有数据启用加密:身份令牌仍可读取(
support_unencrypted_data: true),并会在下次写入时加密。但 webhook 密钥和网关客户 ID 不会这样处理——设置config.active_record.encryption.support_unencrypted_data = true和extend_queries = true,携带密钥部署,运行[Spree::WebhookEndpoint, Spree::GatewayCustomer, Spree::UserIdentity].each { |m| m.find_each(&:encrypt) },然后移除这两项设置。
- **支付方式和集成偏好设置未加密。**网关凭据存储在序列化的
preferences列中,因此请将数据库及其备份视为存有有效密钥。通过仪表板(Settings → Payments)输入有效的网关密钥,不要将其写入种子数据。
spree/agent-skills 插件附带一个钩子,可在 Agent 写入符合已知格式的密钥(Stripe 有效密钥、AWS 密钥、PAT)时发出警告。它只是预警机制,不能替代审查。
API 密钥
pk_* Publishable. Safe in browser and mobile code. Store API only.
sk_* Secret. Server-to-server only. Never ship it in a bundle, app or VITE_* variable.
- **最低限度的权限范围。**使用
spree api-key create --type secret --scopes read_orders,write_fulfillments。不要向集成授予write_all。权限范围列表见spree-api-v3/references/scopes.md。 - 权限范围和渠道绑定均不可更改。如需轮换或更改访问权限,请创建新密钥、部署新密钥,然后撤销旧密钥(Settings → API keys,或使用
spree api-key revoke)。 - 使用机密密钥进行的写入操作会归属到该密钥(
*_type: api_key),因此你可以通过 actor 字段和WebhookDelivery日志审计泄露密钥造成的影响。 - 将店面的可发布密钥绑定到其渠道,这样购物者就无法通过发送
X-Spree-Channel切换渠道。 - 泄露
pk_只是麻烦:限制速率并轮换。泄露sk_则是安全漏洞:撤销密钥,然后进行审计。
仪表板与员工会话
React 仪表板(@spree/dashboard,由 spree_dashboard 在 /dashboard 提供)不保存 API 密钥:
- 员工登录后,JWT 仅保存在内存中(默认有效期为 5 分钟,由
admin_jwt_expiration配置)。刷新令牌是一个作用域限定为/api/v3/admin/auth的 HttpOnly 签名 Cookie。JavaScript 无法读取它,而且每次刷新时都会轮换。 - 使用 HTTPS 时,Cookie 的属性为
SameSite=None; Secure。使用纯 HTTP 时,会回退为SameSite=Lax(仅限开发环境)。 - 同源(默认设置:Rails 在
/dashboard提供仪表板)无需配置 CORS 或 Cookie。 - 跨源(仪表板托管在 CDN 上):两端都必须使用 HTTPS,并且在 Settings → Allowed Origins(
Spree::AllowedOrigin,/api/v3/admin/allowed_origins)中添加仪表板来源。起始模板中的config/initializers/cors.rb会针对/api/v3/admin/*查询该表,并设置credentials: true。Cookie 的 CSRF 防护依赖 SameSite 和严格的来源允许列表,因此列表应准确且简短。 - UI 中的限制不等于授权。 仪表板根据
GET /api/v3/admin/me返回的权限键隐藏按钮,但 API 端的授权关卡才负责强制执行权限。任何仪表板插件操作都必须调用声明了scoped_resource的端点(参见spree-auth-permissions)。该关卡之后的记录级拒绝由Spree.ability_class决定——自定义子类可以进一步限制员工访问,但绝不会放宽权限键关卡;密钥(Spree::ApiKeyAbility)则绕过该关卡。 - 员工 SSO:注册
OidcStrategy,并可选择从Spree.admin_authentication_strategies中移除:email。绝不会根据 IdP 自动创建员工账号。 - 员工管理内置了防护措施(详见
spree-auth-permissions):只有管理员可以授予或移除admin角色,且每个商店至少保留一名管理员;邀请必须指定角色;邀请接受链接来自单独的写入权限受控端点(绝不会来自列表),重新发送邀请会轮换令牌;员工密码重置链接只会指向仪表板来源;登录、当前密码确认和接受邀请共用一个锁定机制(Spree::Authentication::Lockout)——你添加的任何密码检查也应通过该机制进行。 - 为每位员工创建独立账号,并分配最小权限角色。不要共用
admin@登录账号。
Mission Control(/jobs)
作业界面受 HTTP Basic 认证保护。生产环境中请设置 MISSION_CONTROL_USER 和 MISSION_CONTROL_PASSWORD;未设置时,仪表板会保持锁定。起始模板的本地默认值(spree / spree123)仅适用于开发和测试环境。切勿将它们复制到生产环境配置文件中。
Store API:IDOR 与跨商店数据泄漏
- 购物车和订单可由其所有者(JWT)或持有访客令牌(
X-Spree-Token)的人读取。任何持有该令牌的人都能读取对应的购物车或订单,因此绝不要记录令牌,也不要将令牌放入要分享的 URL 中。 - 带前缀的 ID 不是秘密(它们是顺序键的编码)。每次查找都必须进行授权;绝不要依赖 ID 难以猜测。
- 在自定义 Store 控制器中,通过
storefront_access_policy.scope(Model.for_store(current_store))或current_user.<association>读取数据。绝不要使用Model.find(params[:id])。 - 在自定义 Admin 控制器中,使用继承的
scope(按商店限定范围)并声明scoped_resource。多商店应用共用一个数据库,因此在控制器中调用Spree::Order.find会造成跨商店数据泄漏。 - 写入请求体中的每个 ID 也都是一次查找。 通过
current_store(在卖家端接口上还要通过卖家)解析引用的 ID(stock_location_id、reason_id、自定义字段的父记录、翻译目标、价格列表中的变体、促销规则中的商品),并从路由中获取父记录,绝不要从查询参数或请求体参数中获取。核心代码对自身端点会执行这些操作;自定义控制器若直接将参数中的*_id赋值给记录,就会再次造成跨商店数据泄漏。 - 在开发和测试环境中,
Spree::StoreScopeGuard会标记对商店所有表(任何包含store_id的spree_*表)执行的SELECT,前提是这些查询既未按商店限定范围,也未按 ID 筛选。它会监视每个 API v3 请求,以及任何设置Spree::Current.store的工作单元——包括任务、webhook 控制器、控制台脚本和规格测试——直到Spree::Current重置。模式:SPREE_STORE_SCOPE_GUARD/Spree::Config[:store_scope_guard]=log(默认)、raise(Spree 自身的 API 测试套件)、off;在生产环境中绝不会启用。使用Spree::StoreScopeGuard.skip { … }包裹有意执行的全局查询。请认真对待它的警告。
批量赋值
API v3 控制器根据 resource_permitted_attributes(自定义控制器)和各模型的 additional_permitted_attributes 构建允许列表。不存在 Spree::PermittedAttributes 模块。不要覆盖 permitted_params——这会丢弃扩展添加的内容。
# Make an extension column writable on the existing endpoints (never `<<`, the array is frozen):
Spree::Product.additional_permitted_attributes += [:brand_id]
Spree::Address.additional_permitted_attributes += [{ tag_ids: [] }]
# Custom controllers list their own:
def resource_permitted_attributes = %i[name rating body]
绝不要使用 params.permit!。绝不要在面向客户的端点中允许所有权或权限列(customer_id、store_id、role_ids、status)。请在服务器端设置这些值。
注入和 XSS
- **SQL:**使用参数化的
where('x > ?', v)或哈希条件,绝不使用字符串插值。Ransack 是安全的,因为它只会筛选允许列表中的属性。未知的q[...]谓词会被静默丢弃。使用Spree.ransack.add_attribute(Model, :attr)暴露新筛选条件,绝不要将机密或摘要列入允许列表。筛选条件相当于是/否预言机,因此 Store 和 Seller API 的允许列表比员工端更严格(storefront_ransackable_associations、以:store/:seller为键的private_ransackable_attributes/private_ransackable_scopes);扩大店面允许列表时,要确保私有数据不在其中。继承 Store/SellerResourceController的控制器会自动传入该参数(ransack_auth_object);面向客户的控制器中手动编写的查询则应自行调用ransack(params, auth_object: :store)。 - 富文本:商品、类别、集合和卖家描述会在保存时由
Spree::RichTextSanitizer清理(通过has_spree_rich_text/sanitizes_rich_text)。允许列表涵盖仪表板 Tiptap 编辑器生成的内容:p br hr h1–h6 strong em s u code pre blockquote ul ol li a img,并会移除data:/javascript:URL。API 将description作为纯文本返回,将description_html作为已清理的标记返回。- 如需有意扩大允许列表:在初始化器中使用
Spree::RichTextSanitizer.allowed_tags += %w[table thead tbody tr th td]。 - 跳过回调的写入(
update_columns、update_all、原始 SQL、绕过模型的批量导入)不会经过清理。请自行调用Spree::RichTextSanitizer.sanitize(html)。 - 在模型中新增富文本列:
include Spree::SanitizableRichText+has_spree_rich_text :body。翻译属性也需要在Translation类上使用sanitizes_rich_text。
- 如需有意扩大允许列表:在初始化器中使用
- **店面:**仅对这些经服务器清理的字段使用框架提供的原始 HTML 转义入口(例如 React 的
dangerouslySetInnerHTML)来渲染*_html。其他所有内容都要转义。 - **CSV 导出:**写入供员工用电子表格打开的文件前,应将以
=、+、-或@开头的单元格内容处理为安全值。
CORS 和 CSP
# config/initializers/cors.rb: storefront on another origin (the admin block comes from the starter)
allow do
origins 'https://shop.example.com'
resource '/api/v3/store/*', headers: :any, methods: %i[get post patch put delete options]
end
- 绝不要同时使用
origins '*'和credentials: true。管理端和跨域卖家面板必须使用 Allowed Origins 表。 - 在渲染 HTML 的应用上设置 CSP。对于 Next.js 店面,这意味着要在 Next 应用上设置,而不是 Rails。Rails 端主要提供 JSON 和仪表板。
Webhooks
出站(Spree → 你的接收端)。每次投递都会携带 X-Spree-Webhook-Signature = 使用端点密钥对 "#{timestamp}.#{raw_body}" 计算的十六进制 HMAC-SHA256,以及 X-Spree-Webhook-Timestamp 和 X-Spree-Webhook-Event。接收端必须:
- 在解析 JSON 之前,使用原始请求体字节进行验证。
- 使用恒定时间函数进行比较(
ActiveSupport::SecurityUtils.secure_compare、crypto.timingSafeEqual)。 - 拒绝时间戳早于约 5 分钟的请求(防止重放)。
- 保证幂等。
Spree::WebhookDeliveryJob会在非 2xx 响应、超时和连接错误时通过退避机制重试(总共 5 次,事件id不变);手动重新投递也会再次发送同一事件。
@spree/sdk/webhooks 导出 verifyWebhookSignature(rawBody, signature, timestamp, secret, tolerance = 300)。请参阅 spree-events-webhooks。
SSRF。 除开发环境外,投递会经过 SsrfFilter,该过滤器会阻止指向私有、环回和链路本地地址的请求。开发环境会绕过该过滤器,因此接收端可以使用 localhost。不要将 Rails.env.development? 分支复制到其他代码路径。除开发环境外,webhooks_verify_ssl 默认开启;请保持开启。创建 Webhook 端点需要 write_webhooks,该权限与 settings 有意分开。
write_webhooks/read_webhooks 不授予的权限。 customer.password_reset_requested 携带有效的重置令牌:它只会发送到明确订阅该事件的端点(绝不会发送到订阅 * 或 customer.* 的端点);订阅该事件,或更改接收该事件的端点地址,也需要 write_customers。投递日志会将凭据(购物车 token、重置/验证令牌、下载 URL、支付会话密钥、礼品卡 code)存储为 [REDACTED];Admin API 只会向有权读取底层记录的调用方返回投递的 payload(否则为 null)。
入站(网关 → Spree,例如 /api/v3/webhooks/payments/...):仅凭带前缀的 ID 查找记录(请求无法指定商店),请求会在对应支付方式/集成所属的商店中运行,提供商会使用该记录自身的密钥验证签名——无效时返回 401。签名是唯一的身份验证方式。如果你编写自定义提供商,请在执行操作前验证签名(参阅 spree-providers、spree-payments)。
数据隐私(GDPR)
- 数据主体请求是
Spree::DataRequest记录。客户通过POST /api/v3/store/customers/me/data_requests { kind: "access" | "erasure" }创建请求(擦除需要current_password)。员工使用GET /api/v3/admin/customers/:id/export和POST /api/v3/admin/customers/:id/anonymize。导出在后台生成,并通过电子邮件发送带有效期的签名链接。 - 擦除意味着匿名化,由
Spree::Customers::Anonymize工作流执行。该流程会清理账户、地址簿、订单地址快照、已保存的卡、身份信息和会话,并从同意记录中移除电子邮件、IP 和用户代理(记录本身会保留——参见下文“同意”)。财务记录会保留(订单、付款、税费明细、商品项,以及用于税务管辖的国家、州和截断后的邮政编码),并发布customer.anonymized。 - 如果你添加了存储个人数据的表,必须在同一次变更中扩展匿名化处理。 Core 中有一个架构保护规范;如果某个个人数据列未纳入处理范围,该规范就会失败。
- 钩子:
Rails.application.config.after_initialize do Spree.hooks.register('customers.anonymize.validate', 'MyApp::LegalHold') # workflow.reject!('Under legal hold') Spree.hooks.register('data_requests.fulfill.extend_payload', 'MyApp::LoyaltyExport') # return a Hash to merge end - 同意:
Spree::ConsentRecord记录接受事件(目的、来源、时间、文档摘要)。客户还具有email_marketing_consent_updated_at/_source。擦除后,同意记录会保留(目的、来源和时间戳作为证明),但电子邮件、IP 和用户代理会被清除;如果客户曾选择加入营销,还会追加一条email_marketing撤回记录(来源为anonymization)。Cookie 同意由店面负责处理。 - 员工通过
read_customers/write_customers访问DataRequest和ConsentRecord。
PCI 范围
Spree 从不存储或传输 PAN。银行卡数据通过支付网关托管字段,从浏览器直接发送到支付网关(通过 spree_stripe 使用 Stripe Elements / Payment Element、Adyen Drop-in、PayPal)。Spree 只接收令牌和支付会话。Spree::CreditCard 保存品牌、末 4 位、有效期和支付网关引用,从不保存完整卡号或 CVC。
- 绝不要添加银行卡数据列,也不要通过你的 API 代理原始银行卡字段。如果你认为有此需要,请改用支付网关令牌化。
- 仅使用托管字段时,通常适用 SAQ A。自行收集银行卡数据则适用 SAQ D。
- 参数过滤已涵盖
number、verification_value等参数。对于自定义敏感参数名,请扩展Rails.application.config.filter_parameters。
速率限制
API 内置了限流:按可发布密钥 + IP 限制(300 次/分钟)、按密钥限制(600 次/分钟),并对登录、注册、刷新和密码重置按 IP 限制。计数器存储在 Rails.cache 中,因此多进程部署需要共享存储(Solid Cache 或 Redis)。在前方部署 CDN 或 WAF,以防范流量型滥用。参阅 spree-api-v3 中的表格。
依赖项卫生
在 CI 中对店面和仪表板运行 bundle audit、brakeman 和 pnpm audit。此插件不提供 CI 工作流。
部署清单
- 在 credentials/env 中设置密钥。不要将密钥放在
VITE_*、代码仓库或种子数据中。 - 确保
secret_key_base稳定;设置SPREE_JWT_SECRET_KEY;设置三个ACTIVE_RECORD_ENCRYPTION_*密钥(仅限生产环境,且应备份),并将其读入config.active_record.encryption。 - 设置
config.force_ssl = true;API、仪表板和店面均启用 HTTPS。 - Allowed Origins 仅列出实际的仪表板/卖家面板来源。店面 CORS 显式列出允许的来源。
- 设置
MISSION_CONTROL_USER/MISSION_CONTROL_PASSWORD。 - 集成使用权限范围最小的密钥,并根据用途为每个密钥命名。
write_all仅用于紧急情况。 - 员工均使用个人账户和最低权限角色。如果组织有 IdP,则强制使用 SSO。
- Webhook 接收端验证 HMAC、检查时间戳,并确保操作具备幂等性。
- 自定义控制器限定到店铺范围(Admin)或所有权范围(Store)。
- 匿名化处理涵盖你添加的所有个人数据表。
- 为速率限制使用共享缓存存储。在前方部署 CDN/WAF。
- 加密数据库备份,并存储在远离数据库的位置。
深入阅读
- PCI:
node_modules/@spree/docs/dist/developer/security/pci_compliance.md - 数据隐私:
node_modules/@spree/docs/dist/developer/core-concepts/data-privacy.md - 仪表板部署与身份验证:
node_modules/@spree/docs/dist/developer/dashboard/deployment.md - Admin API 身份验证:
node_modules/@spree/docs/dist/api-reference/admin-api/authentication.md - 后台作业 / Mission Control:
node_modules/@spree/docs/dist/developer/deployment/background_jobs.md - Rails 安全指南:https://guides.rubyonrails.org/security.html
- 相关技能:
spree-auth-permissions、spree-api-v3、spree-events-webhooks、spree-payments、spree-deployment。