第 1 章 托管式应用与数据平台(基础:Vercel与Supabase)

学习目标

  • 区分并配置Vercel的部署环境(Preview与Production)隔离原则,以及环境变量的暴露边界(NEXT_PUBLIC_前缀变量对比仅限服务端使用的变量)。
  • 启用部署保护(Deployment Protection)将预览环境对外部网络锁定,并安全地设计绕过密钥(x-vercel-protection-bypass),以便自动化测试与Webhook请求能够顺利通过。
  • 将无服务器函数地域(如首尔icn1)贴近数据库所在位置进行部署,并阐明执行时长上限(maxDuration)、Fluid Compute以及流式传输对AI响应延迟与运行成本的影响。
  • 理解Supabase Postgres连接池组件Supavisor的事务模式(端口6543)与会话模式(端口5432)之间的差异,并根据工作负载特性选择恰当的连接配置。
  • 解释行级安全(Row Level Security, RLS)中USING子句与WITH CHECK子句在运作机制上的区别,编写坚守anon密钥与service_role密钥信任边界的安全策略。
  • 设计Supabase Auth的重定向URL允许列表(涵盖Web源站与移动端深层链接Deep Link),以及私有Storage存储桶基于签名URL(Signed URL)的访问控制机制。

引言

有一支创业团队在周末黑客松上借助编码智能体开发出了一款出色的AI文档摘要Web应用。他们把在本地电脑上运行良好的代码推送到GitHub并与Vercel绑定,不到一分钟就生成了一个全球可访问的HTTPS地址;紧接着又接入了Supabase项目,一举完成了用户登录、数据库读写与文件上传功能。团队成员欢呼雀跃,并在周一早上通过社交媒体高调发布了产品上线的消息。

然而上线仅两个小时,三起严重故障便接连爆发。第一,一名普通用户打开浏览器开发者工具的Network(网络)面板与JavaScript打包文件(Bundle),竟原封不动地翻出了该团队付费购买的LLM API密钥以及Supabase最高管理员密钥。原因在于开发人员为了在前端框架里图省事地读取变量,给所有环境变量都加上了NEXT_PUBLIC_前缀。第二,当同时在线用户数涌入约200人时,文档摘要界面先是卡顿了3秒以上,随后便抛出remaining connection slots are reserved for non-replication superuser connections(剩余连接槽位已保留给非复制超级用户连接)的数据库连接耗尽错误并彻底瘫痪。这是因为无服务器函数运行在默认的美国东部(iad1),而数据库却位于首尔(icn1),不仅跨洋往返延迟极高,而且每个函数实例都直接占用了数据库直连端口(端口5432)且迟迟不释放。第三,尚处于研发阶段、计划下个月才推出的付费功能分支预览(Preview)URL被搜索引擎爬虫收录并流传到了外部社区,导致任何人都能直接打开并试用尚未做完权限校验的实验性功能。

诸如Vercel与Supabase之类的托管式平台虽然能将基础设施的搭建周期从数天缩短至几分钟,但这绝不意味着“保持默认配置就万事大吉”。恰恰相反,正因为部署极其便捷,哪怕只是一个环境变量前缀、一个端口号或者一个RLS复选框的疏忽,都会直接演变为生产环境的安全防线崩溃。本章将依次剖析九项核心原理与架构设计准则,帮助你将托管式应用与数据平台从“玩具级原型”真正提升至“生产级实战”水准。

核心概念

Vercel环境隔离:Preview与Production的边界

在托管式应用平台上,最先需要立起的支柱就是环境隔离(Environment Separation)。当Vercel与Git代码仓库绑定后,会将运行环境明确划分为三类:在开发者本地笔记本电脑上运行的Development、针对每个功能分支或拉取请求(Pull Request, PR)自动构建生成的Preview,以及当代码合并至主分支(如main)或被显式提升(Promote)时绑定至正式客户域名的Production。

打个比方,Production就像是正式接待顾客的餐厅营业大厅,而Preview则是用于试做新菜品、测试后厨动线的试菜厨房。如果试菜厨房与营业大厅共用同一个收银保险柜、同一个食材仓库和同一套点餐账本,会发生什么后果?厨师在试菜厨房测试“取消订单退款”功能时,可能会误把营业大厅里真实顾客的付款给退掉;或者成千上万条自动化测试产生的假订单会混入正式账本,把财务数据彻底搞乱。

因此,实施环境隔离必须严格遵守以下四条铁律。

隔离维度 Preview环境 Production环境 未隔离时引发的生产事故
域名 按分支与提交哈希生成的专属预览URL 面向客户的正式服务域名(如app.example.com) 未经验证的实验代码直接暴露给真实客户
数据库与存储 研发或预发(Staging)专用DB,或隔离的测试项目 存放真实客户数据的生产数据库 模式迁移实验或E2E测试破坏甚至清空生产数据
外部支付与消息密钥 沙盒(Sandbox/测试)密钥、拦截外部发送模式 正式(Live)支付密钥、真实短信与邮件发送密钥 测试期间向真实客户群发通知,或产生真实扣款
认证与开发者后门 仅限受控测试账号登录(且必须置于部署保护之后) 彻底禁用测试登录与开发期权限授予API 攻击者利用开发期赋权接口直接获取管理员权限

尤为关键的是,在Vercel控制台中注册环境变量时,可以为每个变量单独勾选其生效的目标环境(Production、Preview、Development)。如果在录入生产数据库连接字符串(DATABASE_URL)或正式支付私钥时随手把三个环境的复选框全部勾上,那么任何能够提交PR并触发Preview构建的开发人员,都可以通过打印控制台日志读取生产机密,或者让预览代码直接连上生产数据库。生产环境的机密值必须且只能注入到Production环境中,而Preview环境则应当注入独立的预发环境凭证。此外,当特定Preview分支需要专属隔离配置时,还可以利用按分支生效的环境变量(Branch-specific Environment Variables)来进一步收紧实验范围。

环境变量与NEXT_PUBLIC_的暴露边界

以Next.js为代表的现代Web应用框架,允许在同一个代码仓库中同时编写运行于服务端的逻辑与下发至用户浏览器执行的客户端代码。在这种混合架构下,无论新手还是资深工程师,最容易踩中的安全雷区就是环境变量随客户端打包产物泄露。

出于安全考量,构建工具默认会将所有环境变量视为仅限服务端使用(Server-only)。也就是说,像process.env.OPENAI_API_KEY或process.env.SUPABASE_SERVICE_ROLE_KEY这样的变量,只能在服务端组件(Server Component)、API路由处理器(Route Handler)或服务端动作(Server Action)等Vercel服务端运行时环境中读取,绝不会被打包进发送给浏览器的JavaScript文件中。

然而,一旦变量名称以NEXT_PUBLIC_开头(例如NEXT_PUBLIC_SITE_URL、NEXT_PUBLIC_SUPABASE_URL),情况就会发生根本性逆转。构建工具会在构建阶段(Build-time)查找该变量的实际字符串值,并将其原封不动地硬编码内联(Inlining)到客户端JavaScript打包文件内部。

// 仅限服务端执行的代码 (Route Handler / Server Action) — 安全
// 此代码仅在服务端运行,不会包含在发送给浏览器的打包产物中。
const apiKey = process.env.LLM_SECRET_API_KEY;

// 客户端组件 ("use client") — 向全世界公开!
// 在构建阶段,下方代码会被直接替换为诸如 "https://proj.supabase.co" 的真实字符串字面量,
// 任何人都可以在浏览器开发者工具(Sources/Network面板)中直接看到它。
const publicUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;

在此必须牢记两个核心特性。

  1. 带有NEXT_PUBLIC_前缀的值绝不是秘密。 哪怕你把变量名起得再像机密(比如叫NEXT_PUBLIC_SECRET_KEY),它的值也和直接写在网页HTML源码里的明文没有任何区别,全世界任何访问者都能直接读取。因此,LLM API密钥、数据库连接字符串、service_role密钥、Webhook签名校验密钥以及加密私钥,绝对禁止添加NEXT_PUBLIC_前缀。
  2. NEXT_PUBLIC_变量的值在构建阶段(Build-time)即被固化。 仅限服务端使用的环境变量可以在服务端处理请求时读取,但被内联进客户端打包产物的NEXT_PUBLIC_变量值早在构建时就已经被烘焙进静态JS文件并分发到了CDN节点上。因此,在控制台中修改了NEXT_PUBLIC_变量的值之后,必须触发重新部署(Redeploy)以重新执行构建,浏览器端才会生效。

注意

在实际开发中,有时开发人员在客户端组件里读取服务端变量得到undefined,为了图省事快速消除报错,便直接给原有的机密变量加上NEXT_PUBLIC_前缀。这种做法无异于因为打不开保险柜,就把保险柜钥匙直接挂在公司大门外的公告栏上。凡是客户端功能(如调用LLM、发起支付确认)需要使用机密密钥的场景,都必须重构代码结构,改为由客户端调用服务端API路由或Server Action,再由服务端代为发起请求。

部署保护(Deployment Protection)与自动化绕过设计

每次提交PR时,Preview部署都会生成一个带有独立子域名的专属URL(例如my-app-git-feat-new-ui-team.vercel.app)。只要知道这个地址,互联网上的任何人都可以直接访问该公开端点。如果不开启部署保护,那些尚未完成安全审查的新功能、内部管理后台草稿或正式发布前的营销页面不仅会直接暴露给外部人员,甚至还可能被搜索引擎爬虫抓取并编入公开索引。

Vercel的**部署保护(Deployment Protection)**功能可以在Preview部署(以及生产环境默认的.vercel.app域名)前方竖起一道身份认证关卡。

  • Vercel Authentication:仅允许登录了同一个Vercel团队账号的内部成员访问Preview URL。
  • Password Protection:仅向知晓额外访问密码的外部合作伙伴或外部评审人员开放页面。
  • Trusted IPs:仅允许来自企业办公网固定公网IP或VPN网关网段的访问请求。

然而,当非人类的自动化工具需要访问Preview URL时,这道关卡就会带来新的矛盾。例如,当GitHub Actions工作流启动Playwright针对刚刚部署好的Preview环境执行端到端(E2E)自动化测试、外部支付或消息平台的沙盒测试Webhook回调Preview服务器的/api/webhook接口、或者后端CI流水线发起健康检查时,这些请求都会被Vercel Authentication登录页面(返回HTTP 401/403或重定向至登录页)拦截,导致自动化流程全部失败。

此时,既不需要也不应该把部署保护彻底关掉,标准解法是启用面向自动化的保护绕过(Protection Bypass for Automation)。在项目设置中生成专属的绕过密钥(VERCEL_AUTOMATION_BYPASS_SECRET)后,自动化客户端只需在发起HTTP请求时附带以下请求头即可。

GET /api/health HTTP/1.1
Host: my-app-git-feat-team.vercel.app
x-vercel-protection-bypass: <签发的绕过密钥值>
x-vercel-set-bypass-cookie: true
  • 当请求头中的x-vercel-protection-bypass值与项目配置的绕过密钥完全匹配时,Vercel边缘节点会自动跳过身份认证界面,将请求直接放行至后端函数。
  • 如果是像Playwright这样基于真实浏览器的E2E测试,在打开首屏页面后还需要连续加载数十个子资源并跳转多个页面,只需在首个请求中同时附带x-vercel-set-bypass-cookie: true(在跨域名或跨站点测试场景下可设为samesitenone)请求头,Vercel就会在浏览器中种下一枚绕过Cookie,从而使后续所有页面与资源请求均能顺畅通过。
  • 对于不支持自定义HTTP请求头的第三方外部Webhook服务,虽然可以受限地使用查询参数方式(?x-vercel-protection-bypass=<密钥>),但由于URL极易残留在各类访问日志中,应当优先采用HTTP请求头方式,并且必须在应用代码内同时严格校验Webhook请求体的HMAC加密签名。

函数地域部署、执行时长上限与Fluid Compute及流式传输

静态资源(图片、CSS样式表、打包后的JS文件)会被自动复制到遍布全球的CDN边缘节点,由距离用户最近的节点瞬间返回。但是,负责查询数据库并调用外部LLM API的**无服务器函数(Serverless Function)**究竟应该部署在哪里呢?

许多开发者误以为“函数也应该离用户越近越快”,或者干脆不做任何配置,任由函数停留在Vercel默认的美国东部(iad1,华盛顿特区)地域。然而,如果后端的数据库(Supabase Postgres)位于首尔(AWS ap-northeast-2,对应Vercel地域代码icn1),这种跨洋分离就会引发灾难性的延迟叠加。

让我们对比一下当一名位于首尔的用户发起请求时,两种部署架构下的网络耗时差异。

部署架构 请求流转路径与单次往返耗时 单次请求执行5次DB查询时的累计网络延迟
函数iad1(美东) + DB首尔 首尔用户 → 美东函数(180ms) → 首尔DB连续5次查询往返(5 × 180ms = 900ms) → 首尔用户(180ms) 约1,260ms以上(随着查询次数增加呈倍数暴涨)
函数icn1(首尔) + DB首尔 首尔用户 → 首尔函数(15ms) → 首尔DB连续5次查询往返(5 × 3ms = 15ms) → 首尔用户(15ms) 约45ms(数据库与计算函数处于同一地理区域)

在处理一次Web请求的过程中,用户浏览器与无服务器函数之间的网络往返只有区区一次;但函数与数据库之间的网络往返却会因为会话认证核验、读取用户资料、检查权限、写入操作记录等步骤,在单次请求内部按串行瀑布流(Waterfall)方式连续发生数次乃至十几次。因此,无服务器函数的部署地域必须严格固定在距离主数据库(Primary DB)最近的地域(例如韩国数据库对应icn1)。仅仅是在vercel.json中配置一行regions: ["icn1"],就能立刻消除数百毫秒乃至上秒级的跨洋通信延迟。

在AI应用程序中,除了地域部署之外,函数执行时长上限(maxDuration)、流式传输(Streaming)以及Fluid Compute同样至关重要。

  1. 执行时长上限(maxDuration):普通的增删改查(CRUD)API通常在1秒内即可返回,但调用深度推理模型(Reasoning Model)或执行多步工具调用的智能体(Agent),往往需要30秒乃至数分钟才能生成完整回答。如果不按路由显式声明export const maxDuration = 60;(或在当前套餐允许的范围内调高上限),当模型还在努力生成答案时,函数就会因为触发默认超时而被平台强制终止,导致用户只看到报错白屏,而已消耗的昂贵Token费用却打了水漂。
  2. 流式传输(Streaming,SSE / ReadableStream):一旦LLM生成出第一个Token就立即通过流式响应推送给浏览器,不仅能大幅缩短首字节到达时间(TTFB)与用户的体感等待延迟,还能保持HTTP连接处于活跃传输状态,避免被中间代理网关判定为空闲连接而掐断。
  3. Fluid Compute(流体计算):在传统的无服务器函数模型中,每个请求都会独占一个函数实例;当该函数调用OpenAI或Anthropic API并苦苦等待长达数十秒的流式输出(纯网络I/O等待)时,整个实例的CPU与内存都被白白占着并按时长计费。Vercel的Fluid Compute充分发挥了Node.js与Python运行时的异步I/O特性,允许同一个已预热的(Warm)函数实例在等待外部网络I/O响应的间隙,并发(Concurrency)接收并处理其他到达的请求。这不仅显著降低了冷启动(Cold Start)发生的频率,还大幅削减了I/O等待期间的闲置计算费用。此外,当主响应已经发送给用户,但还需要在后台完成审计日志写入或Token用量统计时,可以使用waitUntil() API在不拖慢用户响应的前提下优雅地完成异步收尾工作。

Supabase Postgres与Supavisor连接池:6543对比5432

PostgreSQL在架构传统上采用的是一种偏重的连接模型:每当有一个客户端建立连接(Connection),数据库服务器操作系统就会为其派生(Fork)一个独立的后台进程(Process)并分配专属内存。因此,单台PostgreSQL数据库能够同时维持的直接连接数量(max_connections)受到实例规格的严格限制,通常只有几十个到几百个。

在传统的常驻型服务器(例如2台EC2虚拟机)架构下,每台服务器在启动时只需建立包含10个连接的固定连接池并循环复用(总计仅占用20个连接),自然相安无事。但在以Vercel为代表的无服务器环境下,一旦遇到流量高峰,函数实例会在瞬间横向扩容至几十乃至数百个;如果每个并发实例都试图直接与PostgreSQL进行TCP+TLS握手并建立直连,数据库连接上限就会在短短几秒钟内被彻底耗尽。

为了解决这一结构性矛盾,Supabase在数据库前方部署了名为Supavisor的云原生连接池(Connection Pooler)。Supavisor负责在前端接纳成千上万个轻量级客户端连接,而在后端与真正的PostgreSQL之间仅维持少数几个物理连接来复用转发SQL查询。Supavisor根据端口号的不同提供两种截然不同的工作模式,准确区分这两者是考试与工程实践的核心考点。

对比维度 事务模式(Transaction Mode) 会话模式(Session Mode) 直接连接(Direct Connection)
默认端口 6543(Supavisor) 5432(Supavisor连接池主机) 5432(DB专属直连主机,默认IPv6)
后端连接占用粒度 仅在单个事务(BEGIN~COMMIT)执行期间借用连接,事务一结束立即归还至连接池 从客户端建立连接起直到主动断开连接为止,1:1独占后端连接 不经过连接池,1:1直接连接到Postgres后台进程
适用工作负载 无服务器函数(Vercel、Edge、Lambda)、高并发Web API 常驻型后端服务器、必须依赖会话级状态(SET、临时表)的长耗时任务 数据库模式迁移(CREATE/ALTER)、pg_dump备份、数据库管理工具
会话级特性限制 由于每次事务分配到的底层物理连接可能不同,会话级的PREPARE(预编译语句)、SET、LISTEN/NOTIFY以及会话临时表均无法跨事务保持 所有会话级特性均可正常工作(但并发连接槽位消耗速度与直连一样快) 支持PostgreSQL的全部原生功能

在无服务器应用程序中使用Supavisor时,必须严格遵守以下三条工程铁律。

  1. 运行时应用流量一律使用端口6543(事务模式)。 哪怕瞬间启动了数百个并发函数实例,每个实例也只在实际执行SQL查询的那几毫秒内借用后端数据库连接,用完立即归还,从而彻底根除连接耗尽故障。
  2. 在ORM或数据库驱动中禁用Prepared Statement缓存,或开启兼容模式。 在事务模式下,第一条查询执行PREPARE stmt_1时借用的后端物理连接,与第二条查询被分配到的后端物理连接往往不是同一个,直接复用会导致抛出prepared statement "s1" does not exist错误(例如:在Prisma中需在连接字符串末尾附加?pgbouncer=true,而在postgres.js驱动中需显式配置prepare: false)。
  3. 数据库模式迁移(migrate)必须拆分至会话模式或直接连接(端口5432)。 绝大多数数据库迁移工具为了防止两个迁移进程同时运行破坏表结构,都会依赖PostgreSQL的会话级咨询锁(pg_advisory_lock)或执行耗时较长的DDL操作。因此,标准做法是将供运行时使用的DATABASE_URL(端口6543)与仅供迁移脚本使用的DIRECT_URL(端口5432)分开配置。此外,如果确实需要在事务模式内部注入配置上下文,切勿使用会残留在整个会话中的普通SET命令,而必须使用仅在当前事务内部生效、且在事务提交时自动重置的SET LOCAL(例如SET LOCAL role、SET LOCAL app.user_id = ...),以防状态泄露并污染下一个借用该连接的其他用户请求。

行级安全(RLS):USING与WITH CHECK的确切作用机制

Supabase会在PostgreSQL数据库之上自动生成REST与GraphQL API(基于PostgREST),使浏览器或移动App能够直接对数据进行读写。这项功能虽然极大地提升了开发效率,但也意味着数据库表本身直接成为了面向互联网的第一道防线。如果创建了数据表却没有开启行级安全(Row Level Security,简称RLS)(即未执行ALTER TABLE ... ENABLE ROW LEVEL SECURITY),那么任何持有公开anon密钥的人都可以随意读取、修改甚至删除该表中的所有数据行。

一旦为数据表启用了RLS,该表就会立即进入**默认拒绝(Default Deny)**状态。也就是说,凡是没有通过策略(Policy)显式放行的操作,哪怕一行数据也查不出来、写不进去(唯一的例外是表的所有者或拥有BYPASSRLS最高权限的service_role角色)。

在编写RLS策略时,最容易让人混淆的就是**USING子句与WITH CHECK子句之间的职责分工。用通俗的比喻来说,USING回答的是“允许从仓库里把哪些现有货物拿出来看或拿出来改(过滤存量行)”,而WITH CHECK回答的则是“新放进仓库或者修改后放回仓库的货物是否符合规定(校验新行状态)”**。

ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;

-- 查询 (SELECT):仅允许查看归属于自己的数据行。
CREATE POLICY "docs_select_own" ON public.documents
  FOR SELECT TO authenticated
  USING ( (SELECT auth.uid()) = owner_id );

-- 插入 (INSERT):仅允许以自己的名义插入新数据行。
CREATE POLICY "docs_insert_own" ON public.documents
  FOR INSERT TO authenticated
  WITH CHECK ( (SELECT auth.uid()) = owner_id );

-- 更新 (UPDATE):只能选中自己的现有行 (USING),且修改后的新状态仍必须归属于自己 (WITH CHECK)。
CREATE POLICY "docs_update_own" ON public.documents
  FOR UPDATE TO authenticated
  USING ( (SELECT auth.uid()) = owner_id )
  WITH CHECK ( (SELECT auth.uid()) = owner_id );

-- 删除 (DELETE):仅允许删除归属于自己的现有数据行。
CREATE POLICY "docs_delete_own" ON public.documents
  FOR DELETE TO authenticated
  USING ( (SELECT auth.uid()) = owner_id );

我们可以用下表清晰地梳理出这两个子句在不同SQL命令下的具体运作方式。

SQL命令 USING子句的作用(检查存量行) WITH CHECK子句的作用(检查新行或修改后的行) 常见的策略设计失误
SELECT 仅将条件求值为真(true)的行包含在查询结果中(为假的行会被静默隐藏) 不可使用(查询操作不会产生新数据行) 忘记开启RLS,或直接写成USING (true)导致敏感表全表公开
INSERT 不可使用(插入前尚不存在可供比对的存量行) 若待插入的新行不满足条件,则直接抛出权限错误并拒绝写入 写成WITH CHECK (true),导致攻击者可冒用他人的owner_id伪造插入记录
UPDATE 先筛选出允许被作为修改目标的存量数据行 对修改完成后的新行状态再次进行校验,确认其是否依然满足安全约束 只写了USING却未限制可修改的列,或允许将行的所有权转让给他人
DELETE 筛选出允许被执行删除操作的存量数据行 不可使用(删除后不再保留数据行) 误以为只要配了SELECT策略就能自动阻止删除,或漏写删除过滤条件

参考

在编写UPDATE策略时,如果只写了USING子句而省略了WITH CHECK子句,PostgreSQL默认会将USING子句的条件原样复制一份作为WITH CHECK条件来执行。但在实际业务中,修改前后的约束条件往往并不相同——例如“只允许修改处于草稿状态(status = 'draft')的本人文章(USING),且修改后的状态只能是草稿或已提交(status IN ('draft', 'submitted')),绝不允许用户自己直接把状态改成已审批通过(approved)(WITH CHECK)”。因此,养成显式分别编写USING与WITH CHECK两个子句的习惯最为安全。此外,像(SELECT auth.uid()) = owner_id这样将auth.uid()包裹在标量子查询中,并为owner_id列建立索引,可以让PostgreSQL查询优化器在每次查询中仅对函数求值一次(转化为InitPlan),从而大幅提升大表查询性能。

anon密钥与service_role密钥的信任边界

在每个Supabase项目中,都存在两类职责截然不同的API密钥(对应新版密钥体系中的Publishable Key与Secret Key)。

  1. anon密钥(Publishable Key,公开密钥):专为嵌入浏览器前端或移动App客户端而设计的公开密钥。当用户尚未登录时,请求以PostgreSQL的anon角色执行;当用户通过Supabase Auth完成登录并获得JWT访问令牌后,请求则切换为authenticated角色。无论处于哪种状态,所有查询都严格受到数据库RLS策略的管辖与限制。换言之,anon密钥暴露在客户端代码中是完全正常的设计,系统的安全性并不依赖于隐藏这把密钥,而是完全取决于RLS策略是否严密无死角。
  2. service_role密钥(Secret Key,最高权限私钥):专为后端管理任务、接收处理第三方Webhook或运行定时批量统计任务而提供的超级权限私钥。一旦使用该密钥发起请求,PostgreSQL内置的**BYPASSRLS权限就会生效,彻底无视数据表上的所有RLS策略**,能够任意读取、写入和删除任何一行数据。

因此,service_role密钥无异于数据库的Root主控钥匙,必须遵守以下铁律:

  • 绝对禁止将其放入浏览器前端(如加上NEXT_PUBLIC_前缀)、打包进移动App安装包二进制文件,或提交至公开的Git代码仓库。
  • 即便是在受控的后端服务器内部使用,在处理普通用户发起的日常业务请求路径上,也切忌图省事全局共用一个service_role客户端实例。一旦使用了service_role,RLS这张底线安全网就会彻底失效;只要开发人员在编写业务代码时哪怕漏写了一个WHERE user_id = ?过滤条件,就会立刻把其他客户的私密数据直接返回给当前请求者(即典型的“糊涂代理人”Confused Deputy漏洞)。
  • 处理用户请求的服务端代码应当传递当前用户的会话令牌,使用受RLS约束的客户端(或事务级本地上下文)执行查询;而service_role密钥则必须被严格限制在诸如支付Webhook入账、系统级定时清理任务(Cron)等没有用户会话且必须由系统充当执行主体的最小化隔离路径中。

Supabase Auth回调允许列表与移动端及Web端认证流

当应用接入社交账号登录(Google、Apple、GitHub)或使用邮箱魔法链接(Magic Link)与密码重置链接时,完成身份验证的用户会携带令牌或授权码重定向回到应用所指定的回调地址(redirectTo)。

如果攻击者能够篡改登录请求中的redirectTo=https://evil.example.com/callback参数,而认证服务器又不加校验地照单全收(即开放重定向Open Redirect漏洞),那么一旦受害者完成正常登录,其珍贵的访问令牌或一次性授权码就会直接被发送给攻击者控制的服务器。

为了彻底堵死这一漏洞,Supabase Auth强制要求配置以下两项设置:

  • Site URL:当请求未显式指定redirectTo参数时,默认跳回的生产环境主源站地址(例如https://aidan.agentcat.app)。
  • Redirect URLs(重定向允许列表,Allowlist):允许作为redirectTo目标的精确URL或通配符模式列表。如果请求跳转到未在此列表中注册的地址,Supabase Auth会直接拒绝该请求或强制将其跳回默认的Site URL。

对于同时运营Web端与原生移动App的服务,必须精细化地管理这份允许列表。

环境与平台 允许列表注册示例 配置注意事项
生产环境Web https://app.example.com/** 必须明确指定https://而非http://;仅当确认不存在任何可被攻击者接管的子域名时,才可审慎使用子域名通配符(https://*.example.com/**)
Preview预览部署Web https://*-myteam.vercel.app/** 切勿配置成https://*.vercel.app/**这种过于宽泛的规则,否则令牌可能被诱导发送至他人部署在Vercel上的恶意应用;务必限定在自身项目与团队前缀范围内
移动端原生App myapp://auth/callback 必须将自定义URL方案(Custom URL Scheme深层链接)或Universal Links / App Links按精确路径注册到列表中
本地开发环境 http://localhost:3000/** 严防本地开发地址被误设为生产环境的默认Site URL

此外,当服务端渲染(SSR)Web应用与移动App并存时,应当统一采用基于一次性授权码交换的**PKCE(Proof Key for Code Exchange)**认证流程。与直接将令牌暴露在URL哈希片段(#access_token=...)中的隐式流(Implicit Flow)不同,PKCE要求必须持有发起认证的客户端最初生成的code_verifier密钥,才能用一次性code换取最终的会话令牌,从而极大降低了授权码在传输途中被截获滥用的风险。

Supabase Storage:公开存储桶对比私有存储桶与签名URL(Signed URL)

AI服务经常需要处理用户上传的PDF文档、语音录音、身份证或资格证书扫描件以及模型生成的结果图片。Supabase Storage将文件本体保存在对象存储(兼容S3)中,而控制“谁有权访问哪个存储桶(bucket_id)下的哪条文件路径(name)”的权限逻辑,则完全由PostgreSQL中storage.objects系统表上的RLS策略来强制执行。

在创建存储桶(Bucket)时,首先必须做出一项关键抉择:将其设为公开存储桶(Public Bucket)还是私有存储桶(Private Bucket)。

  • 公开存储桶(public = true):只要知道存储桶内的文件路径(URL),无需携带任何身份认证请求头,也不会触发RLS查询,任何人都可以直接通过CDN高速下载该文件。这种模式只能用于完全没有任何保密要求的公开资产,例如网站Logo、公开课程封面图或允许公开分享的成绩徽章图片。切勿自以为“我把文件名设成了很长且难以猜出的UUID,所以把客户合同放进公开存储桶也很安全”——一旦该URL通过浏览器历史记录、Referer请求头或服务器日志泄露过哪怕一次,任何拿到链接的人就能永久无限制地查看该文件。
  • 私有存储桶(public = false):所有下载与上传请求都必须携带有效的身份认证信息,且必须通过storage.objects表的RLS策略核验。凡是客户上传的原始文档、包含个人姓名的专属成绩单PDF、考试期间抓拍的监考照片等仅限授权当事人查看的文件,无一例外都必须存放在私有存储桶中。

那么,如何将私有存储桶中的文件安全地提供给已登录用户下载,或者允许后台解析器(Parser)工作进程临时读取呢?工程上主要采用以下两种设计模式。

  1. 基于用户会话的RLS策略 + 规范化目录路径:将文件统一按{user_id}/{document_id}.pdf的规范格式存放,并在storage.objects表上配置形如(storage.foldername(name))[1] = (SELECT auth.uid()::text)的RLS策略,严格限制用户只能读写以自己UID命名的顶级文件夹内的对象。
  2. 限时有效的签名URL(Signed URL):先由服务端代码核实当前用户的访问权限,验证通过后调用诸如createSignedUrl(path, 60)的接口,动态签发一条内嵌了极短过期时间(例如60秒至5分钟)的加密签名URL并返回给客户端。一旦超过设定的过期时间戳,该URL就会自动失效;即便链接不慎外泄,也能将暴露的时间窗口压缩到最低限度。同样地,当需要让客户端上传几十兆的大体积文件而又不想经过服务端中转时,可以由服务端签发签名上传URL(createSignedUploadUrl),让浏览器直接将文件流上传至私有存储桶,既完美绕过了无服务器函数的请求体体积上限(例如Vercel函数4.5MB的请求体限制),又牢牢守住了权限防线。

深入阅读

无服务器冷启动、数据库TLS握手与事务级本地上下文

让我们深入到网络数据包(Packet)交互层面,看看为什么在无服务器函数与PostgreSQL建立连接时,跨地域物理距离与连接池模式的选择会产生如此致命的影响。

当一个新的无服务器函数实例被唤醒(冷启动Cold Start)并试图向远程PostgreSQL发起一条全新的数据库连接时,它必须按顺序完成以下五个步骤:

  1. 发起DNS查询,解析出数据库主机名对应的IP地址。
  2. 进行TCP三次握手(SYN → SYN-ACK → ACK),消耗1次网络往返(1 RTT)。
  3. 发送PostgreSQL SSLRequest协商报文并完成TLS 1.2/1.3加密握手,校验服务器证书并交换对称会话密钥,这需要再消耗1~2次网络往返(1~2 RTT)。
  4. 发送PostgreSQL启动数据包并完成SCRAM-SHA-256身份认证(质询-应答Challenge-Response),消耗2次网络往返(2 RTT)。
  5. 直到此时,才能真正发出第一条SELECT查询语句并等待返回结果(1 RTT)。

也就是说,仅仅为了执行第一条最简单的查询,前前后后至少需要经历5~6次网络往返(RTT)。如果函数部署在美东(iad1)而数据库位于首尔(icn1),单次跨洋RTT高达180ms,那么哪怕SQL查询本身命中索引只需1ms就能执行完,光是建立连接的过程就会白白耗费整整1秒钟(1,000ms)以上!反之,一旦将函数迁回首尔(icn1),使单次RTT缩短至2~3ms,并由前端的Supavisor预先与后端PostgreSQL保持好已完成握手的常驻连接池,建立连接的开销就会被瞬间压缩到几毫秒之内。

在此之上还叠加着一层至关重要的安全要求:在连接远程数据库时,为了防范中间人攻击(MITM),必须强制启用TLS加密并严格验证服务器证书(配置rejectUnauthorized: true并指定云厂商的根CA证书)。有些开发者遇到证书配置报错时,随手在代码里写上ssl: { rejectUnauthorized: false }来跳过检查,这等同于向攻击者宣告“就算你伪造了一台假冒数据库服务器,我也照样把账号密码和查询数据加密发给你”,在生产环境中是绝对不可触碰的高压线。

此外,当应用采用自建会话管理或服务端认证,需要将当前请求者的身份信息传递给PostgreSQL的RLS策略或触发器时,在Supavisor事务模式(端口6543)下必须格外小心。正如前文所述,如果在事务模式下执行普通的会话级SET app.current_user_id = 'u_123',该配置会一直残留在归还给连接池的物理连接上,导致紧接着借用同一条物理连接的另一名毫不相干的用户直接继承了前一个人的身份权限,造成极其严重的跨账号越权泄密事故(会话污染)。因此,在事务级连接池后方传递上下文时,必须将其包裹在同一个BEGIN ... COMMIT事务块内部,并严格使用**SET LOCAL(或调用第3个参数为true的set_config('...', '...', true))**,确保一旦当前事务结束,所注入的身份变量立即自动销毁。

多租户AI服务的RLS性能陷阱与绕过模式排查

虽然RLS是强有力的安全屏障,但如果策略条件表达式编写不当,一旦数据表记录超过10万行,数据库CPU使用率就可能瞬间飙升至100%,拖垮整个线上服务。这是因为PostgreSQL会将RLS策略条件作为一道强制性的安全屏障(Security Barrier),自动拼接到针对该表的每一条查询语句的WHERE条件中去执行。

下面对比两种最常见的RLS性能陷阱及其优化方案。

性能陷阱模式 低效的RLS写法示例 高效的RLS优化解法 底层执行原理差异
逐行重复调用函数 USING ( auth.uid() = user_id ) USING ( (SELECT auth.uid()) = user_id ) 用标量子查询包裹后,优化器不再对每一行重复调用函数,而是在单次查询开始时仅求值1次(InitPlan)并直接走索引扫描
多对多归属关系关联查询 USING ( team_id IN (SELECT team_id FROM members WHERE members.user_id = auth.uid()) ) 且未建索引 为members(user_id, team_id)创建复合索引 + 配合受控的SECURITY DEFINER辅助函数或JWT声明 若关联列缺少索引,每评估一行数据都会触发关联表的全表顺序扫描(Seq Scan),造成性能雪崩

在使用SECURITY DEFINER函数优化复杂权限查询时,必须保持高度警惕。PostgreSQL函数的默认安全模式是SECURITY INVOKER,即完全以调用者的身份权限运行并受RLS约束;而SECURITY DEFINER则会以函数创建者(通常是拥有最高权限的数据库管理员)的身份运行,从而直接绕过RLS检查。如果为了加速多租户归属判断而编写了一个SECURITY DEFINER函数,但该函数接收任意外部参数、直接返回表中的业务数据,并且对public或anon角色开放了执行权限(EXECUTE),那么攻击者只需发起一次RPC函数调用,就能彻底绕过RLS把全表数据洗劫一空。因此,编写SECURITY DEFINER函数时必须遵循三项安全加固准则:显式声明SET search_path = ''(防止模式搜索路径劫持)、将返回值严格限制为布尔值(boolean)或最小化的受控ID集合,并且及时使用REVOKE收回非必要角色的EXECUTE执行权限。

工作场景

瀚光教育:上线前夕揪出的环境变量泄露与Supavisor连接耗尽

教育科技初创公司“瀚光教育”使用Next.js(部署于Vercel)与Supabase(首尔地域)开发了一款为学生申论作文提供AI实时批改的Web服务。在正式上线前三天,团队组织了一场模拟作文大赛,邀请500名学生同时提交作文并接收实时流式批改,以此进行全链路压力测试。

压测开始仅仅3分钟,两个致命问题便暴露无遗。第一,负责安全巡检的工程师在浏览器里检索页面源码与_next/static/chunks/目录下的JavaScript打包文件时,赫然发现了NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY字样以及完整的service_role JWT密钥明文。原来,一名初级开发人员在编写学生分数汇总页面时遇到了RLS权限报错,为了图省事直接在前端组件里跑通统计查询,竟给service_role密钥加上了NEXT_PUBLIC_前缀。第二,当150名学生同时点击“开始AI批改”按钮时,页面响应先是卡顿了10秒以上,紧接着超过半数的请求纷纷抛出500错误(日志显示too many clients already以及FUNCTION_INVOCATION_TIMEOUT)并彻底中断。

基础设施负责人立即叫停测试,对系统架构进行了三项彻底改造:

  1. 吊销泄露密钥并重构信任边界:凡是在客户端打包产物中出现过哪怕一次的service_role密钥,一律按已公开泄露处理,立即在Supabase控制台重新生成(轮换)了私钥。客户端环境变量仅保留NEXT_PUBLIC_SUPABASE_URL与NEXT_PUBLIC_SUPABASE_ANON_KEY;将分数汇总逻辑彻底迁移至仅在服务端运行的Route Handler中,并且不再粗暴使用service_role,而是通过完善的RLS策略与经过身份核验的服务端查询来安全执行。
  2. 修正函数地域与执行时长上限:排查发现Vercel函数的部署地域仍保持在默认的美国东部(iad1),导致每次读写首尔Supabase数据库都要跨越太平洋往返通信。团队立即在vercel.json中配置"regions": ["icn1"]将函数迁回首尔,并为流式批改路由显式声明了export const maxDuration = 60;同时启用Fluid Compute。
  3. 切换至Supavisor事务模式:原先的DATABASE_URL直接指向了端口5432(会话模式),导致高并发启动的函数实例瞬间占满了所有连接槽位。团队将运行时连接字符串切换为端口6543(Supavisor事务模式)并关闭了驱动层的Prepared Statement缓存,仅将数据库模式迁移脚本分离出来单独使用DIRECT_URL(端口5432)。

次日重新进行的500人并发压力测试中,数据库活跃物理连接数平稳维持在15个左右,首句批改意见呈现在屏幕上的时间(TTFB)从原先的平均4.2秒骤降至0.8秒。

清流生物:利用RLS WITH CHECK与签名上传URL堵死私有实验笔记泄露漏洞

致力于利用AI分析新药候选物文献的“清流生物”运营着一款面向各大医药研究所提供独立工作区的B2B SaaS平台。每位研究员都会上传实验结果PDF文档(单个文件最大30MB)并撰写分析笔记。在早期版本上线后,平台收到了两类严重反馈。

第一,每当研究员尝试上传超过10MB的PDF文件时,Vercel API路由就会直接返回413 Payload Too Large错误。原因在于当时的架构是让浏览器先把文件上传给Vercel无服务器函数,再由函数中转上传至Supabase Storage,结果直接撞上了Vercel函数4.5MB的请求体大小上限。第二,在内部渗透测试中,安全人员登录A研究所的普通研究员账号后,在浏览器控制台中直接执行了一行supabase.from('notes').update({ lab_id: 'lab_B' }).eq('id', myNoteId),结果惊讶地发现自己的实验笔记竟然直接被转移到了B研究所的文档列表里。

平台工程团队深入剖析原因后,实施了如下架构整改。

问题领域 原有设计漏洞 整改后的安全架构
notes表的RLS策略 仅配置了FOR UPDATE USING (lab_id = current_user_lab()),未在WITH CHECK中限制不可变字段,或INSERT策略写成了WITH CHECK (true),允许跨租户篡改注入lab_id 在INSERT与UPDATE策略中均显式强制声明WITH CHECK (lab_id = current_user_lab()),并配合数据库触发器彻底禁止记录创建后修改lab_id字段
大体积PDF上传 浏览器 → Vercel函数(受限于4.5MB上限) → Supabase Storage中转 服务端先核验用户身份与研究所权限,通过后调用createSignedUploadUrl签发一次性令牌 → 由浏览器直接向Supabase Storage私有存储桶发起直传
PDF文件在线查阅 为了省事将Storage存储桶设为Public(公开),仅靠隐藏URL路径来防君子 将存储桶改为Private(私有),每次查阅时由服务端核实所属研究所权限后,动态签发有效期仅60秒的签名下载URL(createSignedUrl)

起初曾有人提议“干脆把Storage存储桶设成Public公开桶,好让前端直接传文件”,但因存在极高的核心研发数据泄露风险而被当场否决。最终,凭借“私有存储桶 + 服务端签发一次性签名上传URL”的组合方案,团队既优雅地绕过了无服务器函数的请求体体积限制,又完美守住了“唯有具备权限的研究员才能向所属研究所目录上传及读取文件”的多租户安全边界。

易错点

  1. “因为NEXT_PUBLIC_环境变量在Vercel控制台里是加密存储的,所以在浏览器里使用也很安全。” 控制台中的加密仅仅保护构建前的静态存储状态。一旦变量名带有NEXT_PUBLIC_前缀,构建工具就会在打包时将其明文字符串直接写进JavaScript文件中并分发给全世界用户的浏览器,因此绝对不能用于存放任何机密值。
  2. “我已经去Vercel控制台里修改了NEXT_PUBLIC_变量的值,所以线上网站会立刻生效。” 与仅限服务端使用的环境变量不同,被内联进客户端打包产物的变量值在构建阶段就已经固定死在静态文件里了。修改其值后,必须触发重新部署(Redeploy)生成新的构建产物,浏览器端才会更新。
  3. “无服务器函数应该像CDN一样同时部署到全球所有地域,这样访问速度才最快。” 如果函数在处理一次请求时需要向单地域数据库(例如位于首尔的主库)发起多次串行查询,那么当函数在远离数据库的地域执行时,函数与数据库之间的多次网络往返(RTT)延迟会成倍累加。因此,无服务器函数应当遵循贴近主数据库所在地域(如icn1)部署的原则。
  4. “5432是PostgreSQL的标准端口,所以在无服务器应用里直接连5432端口就行了。” 在无服务器函数这种短生命周期实例瞬间暴涨又缩容的环境中,如果使用会话模式或直接连接(端口5432),数据库连接槽位会瞬间耗尽。生产运行时流量必须使用Supavisor事务模式(端口6543),仅将数据库模式迁移独立拆分至端口5432执行。
  5. “只要在数据表上配好了严格的SELECT RLS策略,其他用户就无法修改或删除我的数据。” 启用RLS的数据表是按SQL命令类型分别评估策略的。SELECT策略只能控制查询可见性;如果在编写INSERT或UPDATE策略时图省事写成了WITH CHECK (true),攻击者依然可以冒用他人的所有者ID插入脏数据或篡改记录归属。
  6. “只要把文件名起成别人猜不到的超长UUID,把私密文档放进Public公开存储桶里也是安全的。” 靠路径难以猜测来维持安全(Security by Obscurity)根本不叫访问控制。公开存储桶的URL无需任何身份验证即可直接下载,一旦通过日志、浏览器历史记录或聊天分享泄露过一次,就没有任何办法撤回权限。敏感文件必须存放在私有存储桶中,并配合极短有效期的签名URL进行访问控制。

要点速览

核心主题 必须牢记的架构设计原则
环境隔离 Preview与Production必须彻底隔离数据库、存储桶与外部API密钥,生产环境机密仅允许注入Production环境
NEXT_PUBLIC_边界 构建时会被明文内联进客户端JS打包文件中;严禁用于私钥或service_role密钥,且修改后必须重新构建部署(Redeploy)才能生效
部署保护与自动化绕过 Preview部署应通过Vercel Authentication等机制锁定,CI E2E测试与Webhook则通过x-vercel-protection-bypass请求头安全放行
函数地域与执行模型 函数应部署在与主数据库相同的地域(如首尔icn1);针对耗时较长的LLM调用需配置maxDuration、流式响应与Fluid Compute
Supavisor 6543对比5432 无服务器运行时采用事务模式(端口6543,需注意禁用Prepared Statement),数据库模式迁移与会话锁操作则分离至会话/直连模式(端口5432)
RLS USING对比WITH CHECK USING负责过滤允许被读取、修改或删除的存量数据行,而WITH CHECK负责校验通过INSERT/UPDATE写入的新行状态
anon对比service_role anon密钥默认公开,由RLS充当安全防线;service_role会强制绕过所有RLS(BYPASSRLS),必须严格限制在服务端最小特权路径内
Auth重定向允许列表 为防范开放重定向与令牌窃取,需将Web源站与移动端深层链接(myapp://...)按精确允许列表管理,并统一采用PKCE认证流
Storage访问控制 敏感文件一律存入私有(Private)存储桶,在线查阅与大文件客户端直传均通过服务端签发短有效期签名URL(Signed URL)进行管控

查阅官方文档

自测

1-1. 在将Next.js应用程序部署至Vercel时,请解释带有NEXT_PUBLIC_前缀的环境变量与不带该前缀的环境变量在构建阶段及运行时分别是如何被不同处理的;如果管理员在控制台中修改了NEXT_PUBLIC_API_BASE的值,必须执行什么操作才能使改动在用户浏览器端生效?

1-2. 某Web服务的主数据库(Supabase Postgres)位于首尔地域,主要用户群体也分布在韩国境内。如果此时Vercel无服务器函数的地域仍保持在默认的美国东部(iad1),请从网络往返(RTT)的角度解释为什么在单次HTTP请求内部会导致响应延迟急剧飙升。

1-3. 请对比Supavisor的事务模式(端口6543)与会话模式(端口5432)在借用及归还后端数据库连接时机上的差异,并说明无服务器应用的运行时查询与数据库模式迁移(Migration)分别应当使用哪种模式及其原因。

1-4. 在PostgreSQL行级安全(RLS)策略中,请区分USING子句与WITH CHECK子句各自的求值时机,并举例说明当一条UPDATE策略仅正确编写了USING子句而WITH CHECK条件过于宽松时会引发怎样的安全漏洞。

1-5. 当Preview预览部署开启了Vercel Deployment Protection时,为了让运行在GitHub Actions中的自动化E2E测试脚本无需关闭部署保护就能连续访问并测试Preview URL下的多个页面,应当在HTTP请求中附带哪两个请求头?

1-6. 架构设计题:某系统需要安全地存储用户上传的个人合同PDF文件(单个文件最大25MB),且严格限制仅允许上传者本人查阅。为了在规避Vercel函数请求体大小上限的同时守住安全边界,请分别阐述应当如何设计:(1)Supabase Storage存储桶的公开/私有属性设置;(2)文件上传流程;(3)文件查阅下载流程。

实操练习

练习

编写环境变量与数据库连接字符串安全审计表(40分钟)

  1. 列出你正在运维或假想设计的一款AI Web服务的10个核心环境变量(例如:NEXT_PUBLIC_SITE_URL、NEXT_PUBLIC_SUPABASE_URL、NEXT_PUBLIC_SUPABASE_ANON_KEY、SUPABASE_SERVICE_ROLE_KEY、DATABASE_URL、DIRECT_URL、OPENAI_API_KEY、STRIPE_WEBHOOK_SECRET、VERCEL_AUTOMATION_BYPASS_SECRET、SESSION_SECRET)。
  2. 针对每个变量,在表格中填写以下五列内容:(甲)是否允许包含在客户端打包产物中;(乙)Preview环境与Production环境的值是否必须不同;(丙)一旦泄露将导致的危害场景;(丁)正确的调用位置(浏览器前端 / 服务端Route Handler / 迁移CLI工具 / CI运行器);(戊)修改变量值后是否需要重新构建(Rebuild)。
  3. 明确写出DATABASE_URL与DIRECT_URL中各自应当配置的端口号(6543对比5432)以及关键查询参数(如?pgbouncer=true等)。

完成标准: 在10个变量中,能够安全放入客户端打包产物的3个公开变量与严禁进入客户端的7个服务端机密变量界限分明;运行时连接池地址与模式迁移直连地址在端口号及用途上实现了彻底分离。

练习

设计多租户数据表的RLS策略与Storage签名URL访问流(50分钟)

  1. 编写定义projects (id, org_id, owner_id, title, status)表的SQL语句,并为该表启用行级安全(ENABLE ROW LEVEL SECURITY)。
  2. 面向authenticated角色,分别编写SELECT、INSERT、UPDATE、DELETE四条完整的RLS策略。要求将auth.uid()统一写为标量子查询(SELECT auth.uid())形式,并在UPDATE策略中同时显式声明USING与WITH CHECK子句,严禁用户将记录的所有者(owner_id)或所属组织(org_id)篡改为其他值。
  3. 写出向私有Storage存储桶project-files上传及读取文件时的4步交互时序,明确说明服务端API需要先校验哪些权限条件,随后分别调用哪个方法(createSignedUploadUrl、createSignedUrl)签发有效期为多少秒的临时URL。

完成标准: 四条SQL策略语法完全准确;在INSERT与UPDATE操作中利用WITH CHECK彻底堵死了冒用他人owner_id或org_id的越权漏洞;私有存储桶的文件上传与下载均受限时签名URL严密管控。