成为全栈·Next.js 网站前台篇·同源 BFF 代理:API、附件、Cookie 与跨站写入保护
Route Handler 代理不是把一个 URL 原样转发到另一个 URL。它还要限制目标地址、保留查询串、正确处理多个 Set-Cookie、隔离私有缓存,并拒绝明确的跨站写入。
前言
当前前台和 M1 后端是两个独立应用。浏览器如果直接请求后端,要面对跨域配置、Cookie 域名和后端地址暴露;refresh Cookie 还可能与同一主机上的旧前台冲突。
于是 Next.js 提供/api/v1/[...path]同源入口:浏览器只和当前站点通信,Route Handler 再访问真实后端。看起来像几十行“转发器”,实际却站在浏览器、Next.js 和后端三套 HTTP 语义之间。
这篇不把 BFF 说成另一套业务后端。它的职责是适配边界:转发请求、转换 Cookie、收窄安全范围。文章、评论、权限和状态机仍由 M1 后端裁决。
为什么不让浏览器直接请求后端
| 关注点 | 浏览器直连后端 | 同源 BFF |
|---|---|---|
| API 地址 | 暴露并进入客户端配置 | API_ORIGIN留在服务端 |
| Cookie | 受跨站与域名策略影响 | 浏览器只处理当前站点 Cookie |
| CORS | 后端需要允许前台 origin | 浏览器请求同源 |
| 私有缓存头 | 依赖每个上游响应正确设置 | 代理统一补private, no-store |
| 协议适配 | 客户端各处处理 | 集中转换 Cookie 和响应 |
BFF 增加了一跳和一处运行成本,换来的是协议集中与前端部署边界。是否采用它,取决于系统现状,而不是“Next.js 项目必须有 BFF”。
动态路径首先要阻止越界
Route Handler 捕获路径片段,但不能把任意字符串直接拼给上游:
const{path}=awaitparamsif(path.some((part)=>part==='..'||part==='.'||/[\\/]/.test(part))){returnNextResponse.json({code:4000,message:'无效路径'},{status:400},)}consttarget=newURL(`/api/v1/${path.map(encodeURIComponent).join('/')}`,API_ORIGIN,)目标 origin 只能来自服务端配置,浏览器不能传一个完整 URL 让代理代为访问。否则 BFF 可能变成开放代理或服务端请求伪造入口。
查询串必须完整保留
列表、搜索、分页和排序都依赖 query。只转发 pathname 会造成一种很难察觉的故障:接口返回 200,但永远是默认第 1 页。
consttarget=newURL(`/api/v1/${encodedPath}`,API_ORIGIN)target.search=request.nextUrl.search这里直接复制经过 URL 解析的查询串,而不是手工遍历后再次编码。测试时至少要覆盖重复参数、中文关键词、空值和带符号排序字段。
请求头采用允许列表,而不是照单全收
constheaders=newHeaders()for(constkeyof['accept','content-type','authorization','user-agent']){constvalue=request.headers.get(key)if(value)headers.set(key,value)}headers.set('accept-encoding','identity')Host、Content-Length、Connection等逐跳或传输相关头不应从浏览器原样送给上游。请求体由运行时重新构造,长度也应由底层计算。
方法与 body 则按 HTTP 语义转发:
constupstream=awaitfetch(target,{method:request.method,headers,body:['GET','HEAD'].includes(request.method)?undefined:awaitrequest.arrayBuffer(),cache:'no-store',redirect:'manual',signal:AbortSignal.timeout(15_000),})使用arrayBuffer()能保留 JSON、表单和附件二进制内容,不要无条件request.json()。代理还设置超时,避免上游失联长期占住 Worker。
Cookie 转换只转发当前应用需要的一枚
同一主机可能还运行旧前台。新应用使用独立名称codex_refresh,发往后端时再转换成后端认识的refreshToken:
exportconstbackendCookie=(cookies:string):string=>{consttoken=cookies.split(';').map((item)=>item.trim()).find((item)=>item.startsWith('codex_refresh='))?.slice('codex_refresh='.length)returntoken?`refreshToken=${token}`:''}代理不应把浏览器的所有 Cookie 送给后端。允许列表减少无关会话泄漏,也避免两个前端的刷新令牌互相覆盖。
后端返回时再改回前台名称,并调整当前环境需要的属性:
exportconstfrontendCookie=(cookie:string,hostname:string):string=>{letresult=cookie.replace(/^refreshToken=/,'codex_refresh=').replace(/;\s*SameSite=[^;]+/i,'; SameSite=Lax')if(['localhost','127.0.0.1','[::1]'].includes(hostname)){result=result.replace(/;\s*Secure/gi,'')}returnresult}生产 HTTPS 仍保留 Secure。只在字面量回环主机放宽,不能因为“开发环境”就对任意 HTTP 域名删掉安全属性。
多个 Set-Cookie 不能用逗号随便拆
Expires=Wed, 21 Oct...自身含逗号,直接对set-cookie字符串执行split(',')会破坏日期。运行时提供独立 Cookie 列表时,应逐条追加:
constoutgoing=newHeaders()upstream.headers.forEach((value,key)=>{if(!blocked.includes(key.toLowerCase())&&key.toLowerCase()!=='set-cookie'){outgoing.set(key,value)}})for(constcookieofupstream.headers.getSetCookie()){outgoing.append('set-cookie',frontendCookie(cookie,request.nextUrl.hostname))}登录可能同时设置刷新 Cookie 和其他状态 Cookie,注销也可能通过过期 Cookie 清除会话。丢掉第二个Set-Cookie会产生“登录看似成功,刷新却掉线”或“退出后还能恢复”的问题。
刷新令牌不应再出现在响应 JSON
后端为了兼容其他客户端,登录、注册和刷新响应可能同时返回 refresh token。Web 前台已经使用 HttpOnly Cookie,就应从 JSON 中移除:
if(path[0]==='auth'&&['login','register','refresh'].includes(path[1])&&upstream.ok){constenvelope=awaitupstream.json()if(envelope.data)deleteenvelope.data.refreshTokenreturnNextResponse.json(envelope,{status:upstream.status,headers:outgoing,})}这让浏览器 JavaScript 只拿到 access token,refresh token 的读取和轮换都停留在 Cookie 与服务端代理边界。
跨站写入要检查请求来源
SameSite=Lax 可以降低一部分 CSRF 风险,但服务端仍应拒绝明确的跨站写请求。当前代理对 POST、PUT、PATCH、DELETE 检查Sec-Fetch-Site与 Origin:
if(!['GET','HEAD','OPTIONS'].includes(request.method)){constsource=request.headers.get('origin')letsourceHost=''try{sourceHost=source?newURL(source).host:''}catch{sourceHost='invalid'}if(request.headers.get('sec-fetch-site')==='cross-site'||(source&&sourceHost!==request.headers.get('host'))){returnNextResponse.json({code:4000,message:'请求来源无效'},{status:403},)}}Origin 比较的是主机部分,并显式处理非法 URL。这个保护不代替后端鉴权,也不意味着所有无 Origin 请求都可信;它是在 Cookie 自动携带的前提下增加一道同源写入约束。
私有响应必须禁止缓存
outgoing.set('cache-control','private, no-store')returnnewNextResponse(upstream.body,{status:upstream.status,headers:outgoing,})上游 fetch 自身使用cache: 'no-store',下游响应再声明private, no-store。前者防止 Next.js 复用上游结果,后者约束浏览器与中间缓存。会员资料、收藏和通知不能沿用公开文章的 60 秒缓存。
附件上传不需要在代理中理解文件内容
只要代理保留Content-Type并按字节转发 body,multipart boundary 仍由浏览器请求携带:
constbody=['GET','HEAD'].includes(request.method)?undefined:awaitrequest.arrayBuffer()不要读取formData()后再手工重建,除非 BFF 确实需要检查或变换字段。当前代理只做透明传输,文件大小、MIME、权限和存储规则由后端上传接口负责。
不过这会让附件经过 Next.js Worker 多走一跳。大文件场景更适合后端签发直传地址,前端直接上传对象存储;那属于新的契约和安全模型,不能由当前代理悄悄演变。
BFF 验收矩阵
| 场景 | 预期结果 |
|---|---|
/articles?page=2&sort=-publishedAt | 上游收到完整 query |
路径含..或斜线片段 | 400,不访问上游 |
| 登录返回两个 Set-Cookie | 两条分别保留并转换 |
| 登录响应含 refreshToken | 浏览器 JSON 中已删除 |
| 跨站 POST | 403 |
| 同源 GET | 正常转发,不要求 Origin |
| 上游超时 | 502 统一错误信封 |
| 会员资料响应 | Cache-Control: private, no-store |
| multipart 附件 | 字节和 Content-Type 保持一致 |
curl-i'http://localhost:3000/api/v1/articles?page=2'curl-i-XPOST'http://localhost:3000/api/v1/auth/refresh'\-H'Origin: https://attacker.example'\-H'Sec-Fetch-Site: cross-site'浏览器验收还要覆盖登录、刷新页面恢复、退出后不能恢复、附件上传和两个账号切换。单独验证代理返回 200,不能证明 Cookie 生命周期完整。
适用边界
当前 BFF 是协议适配层,不应加入文章审核、评论权限或会员状态机。业务规则进入两处后,管理后台和其他客户端会得到不同结果。
若代理未来承担聚合多个服务、服务端会话或响应裁剪,应把它当正式后端组件治理,增加可观测性、限流和独立契约,而不是继续把所有逻辑堆进一个 Route Handler。
小结
同源代理真正难的地方不在fetch(target),而在 HTTP 细节:目标地址是否受控、查询串是否保留、哪些头可以转发、多个 Cookie 是否完整、私有响应是否缓存、跨站写入是否被拒绝。
把这些边界集中以后,浏览器得到简单的同源接口,后端继续拥有最终业务规则。BFF 的价值正来自这种克制。
延伸阅读
- C 端认证:内存令牌、HttpOnly Cookie 与会话代次
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer