ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

成为全栈·Next.js 网站前台篇·同源 BFF 代理:API、附件、Cookie 与跨站写入保护

成为全栈·Next.js 网站前台篇·同源 BFF 代理:API、附件、Cookie 与跨站写入保护 成为全栈·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 后端裁决。为什么不让浏览器直接请求后端关注点浏览器直连后端同源 BFFAPI 地址暴露并进入客户端配置API_ORIGIN留在服务端Cookie受跨站与域名策略影响浏览器只处理当前站点 CookieCORS后端需要允许前台 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},)}consttargetnewURL(/api/v1/${path.map(encodeURIComponent).join(/)},API_ORIGIN,)目标 origin 只能来自服务端配置浏览器不能传一个完整 URL 让代理代为访问。否则 BFF 可能变成开放代理或服务端请求伪造入口。查询串必须完整保留列表、搜索、分页和排序都依赖 query。只转发 pathname 会造成一种很难察觉的故障接口返回 200但永远是默认第 1 页。consttargetnewURL(/api/v1/${encodedPath},API_ORIGIN)target.searchrequest.nextUrl.search这里直接复制经过 URL 解析的查询串而不是手工遍历后再次编码。测试时至少要覆盖重复参数、中文关键词、空值和带符号排序字段。请求头采用允许列表而不是照单全收constheadersnewHeaders()for(constkeyof[accept,content-type,authorization,user-agent]){constvaluerequest.headers.get(key)if(value)headers.set(key,value)}headers.set(accept-encoding,identity)Host、Content-Length、Connection等逐跳或传输相关头不应从浏览器原样送给上游。请求体由运行时重新构造长度也应由底层计算。方法与 body 则按 HTTP 语义转发constupstreamawaitfetch(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发往后端时再转换成后端认识的refreshTokenexportconstbackendCookie(cookies:string):string{consttokencookies.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{letresultcookie.replace(/^refreshToken/,codex_refresh).replace(/;\s*SameSite[^;]/i,; SameSiteLax)if([localhost,127.0.0.1,[::1]].includes(hostname)){resultresult.replace(/;\s*Secure/gi,)}returnresult}生产 HTTPS 仍保留 Secure。只在字面量回环主机放宽不能因为“开发环境”就对任意 HTTP 域名删掉安全属性。多个 Set-Cookie 不能用逗号随便拆ExpiresWed, 21 Oct...自身含逗号直接对set-cookie字符串执行split(,)会破坏日期。运行时提供独立 Cookie 列表时应逐条追加constoutgoingnewHeaders()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){constenvelopeawaitupstream.json()if(envelope.data)deleteenvelope.data.refreshTokenreturnNextResponse.json(envelope,{status:upstream.status,headers:outgoing,})}这让浏览器 JavaScript 只拿到 access tokenrefresh token 的读取和轮换都停留在 Cookie 与服务端代理边界。跨站写入要检查请求来源SameSiteLax 可以降低一部分 CSRF 风险但服务端仍应拒绝明确的跨站写请求。当前代理对 POST、PUT、PATCH、DELETE 检查Sec-Fetch-Site与 Originif(![GET,HEAD,OPTIONS].includes(request.method)){constsourcerequest.headers.get(origin)letsourceHosttry{sourceHostsource?newURL(source).host:}catch{sourceHostinvalid}if(request.headers.get(sec-fetch-site)cross-site||(sourcesourceHost!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并按字节转发 bodymultipart boundary 仍由浏览器请求携带constbody[GET,HEAD].includes(request.method)?undefined:awaitrequest.arrayBuffer()不要读取formData()后再手工重建除非 BFF 确实需要检查或变换字段。当前代理只做透明传输文件大小、MIME、权限和存储规则由后端上传接口负责。不过这会让附件经过 Next.js Worker 多走一跳。大文件场景更适合后端签发直传地址前端直接上传对象存储那属于新的契约和安全模型不能由当前代理悄悄演变。BFF 验收矩阵场景预期结果/articles?page2sort-publishedAt上游收到完整 query路径含..或斜线片段400不访问上游登录返回两个 Set-Cookie两条分别保留并转换登录响应含 refreshToken浏览器 JSON 中已删除跨站 POST403同源 GET正常转发不要求 Origin上游超时502 统一错误信封会员资料响应Cache-Control: private, no-storemultipart 附件字节和 Content-Type 保持一致curl-ihttp://localhost:3000/api/v1/articles?page2curl-i-XPOSThttp://localhost:3000/api/v1/auth/refresh\-HOrigin: https://attacker.example\-HSec-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
返回列表