成为全栈·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 后端裁决。

为什么不让浏览器直接请求后端

关注点 浏览器直连后端 同源 BFF
API 地址 暴露并进入客户端配置 API_ORIGIN 留在服务端
Cookie 受跨站与域名策略影响 浏览器只处理当前站点 Cookie
CORS 后端需要允许前台 origin 浏览器请求同源
私有缓存头 依赖每个上游响应正确设置 代理统一补 private, no-store
协议适配 客户端各处处理 集中转换 Cookie 和响应

BFF 增加了一跳和一处运行成本,换来的是协议集中与前端部署边界。是否采用它,取决于系统现状,而不是"Next.js 项目必须有 BFF"。

动态路径首先要阻止越界

Route Handler 捕获路径片段,但不能把任意字符串直接拼给上游:

ts 复制代码
const { path } = await params

if (path.some((part) => part === '..' || part === '.' || /[\\/]/.test(part))) {
  return NextResponse.json(
    { code: 4000, message: '无效路径' },
    { status: 400 },
  )
}

const target = new URL(
  `/api/v1/${path.map(encodeURIComponent).join('/')}`,
  API_ORIGIN,
)

目标 origin 只能来自服务端配置,浏览器不能传一个完整 URL 让代理代为访问。否则 BFF 可能变成开放代理或服务端请求伪造入口。

查询串必须完整保留

列表、搜索、分页和排序都依赖 query。只转发 pathname 会造成一种很难察觉的故障:接口返回 200,但永远是默认第 1 页。

ts 复制代码
const target = new URL(`/api/v1/${encodedPath}`, API_ORIGIN)
target.search = request.nextUrl.search

这里直接复制经过 URL 解析的查询串,而不是手工遍历后再次编码。测试时至少要覆盖重复参数、中文关键词、空值和带符号排序字段。

请求头采用允许列表,而不是照单全收

ts 复制代码
const headers = new Headers()

for (const key of ['accept', 'content-type', 'authorization', 'user-agent']) {
  const value = request.headers.get(key)
  if (value) headers.set(key, value)
}

headers.set('accept-encoding', 'identity')

Host、Content-Length、Connection 等逐跳或传输相关头不应从浏览器原样送给上游。请求体由运行时重新构造,长度也应由底层计算。

方法与 body 则按 HTTP 语义转发:

ts 复制代码
const upstream = await fetch(target, {
  method: request.method,
  headers,
  body: ['GET', 'HEAD'].includes(request.method)
    ? undefined
    : await request.arrayBuffer(),
  cache: 'no-store',
  redirect: 'manual',
  signal: AbortSignal.timeout(15_000),
})

使用 arrayBuffer() 能保留 JSON、表单和附件二进制内容,不要无条件 request.json()。代理还设置超时,避免上游失联长期占住 Worker。

同一主机可能还运行旧前台。新应用使用独立名称 codex_refresh,发往后端时再转换成后端认识的 refreshToken:

ts 复制代码
export const backendCookie = (cookies: string): string => {
  const token = cookies
    .split(';')
    .map((item) => item.trim())
    .find((item) => item.startsWith('codex_refresh='))
    ?.slice('codex_refresh='.length)

  return token ? `refreshToken=${token}` : ''
}

代理不应把浏览器的所有 Cookie 送给后端。允许列表减少无关会话泄漏,也避免两个前端的刷新令牌互相覆盖。

后端返回时再改回前台名称,并调整当前环境需要的属性:

ts 复制代码
export const frontendCookie = (cookie: string, hostname: string): string => {
  let result = 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, '')
  }

  return result
}

生产 HTTPS 仍保留 Secure。只在字面量回环主机放宽,不能因为"开发环境"就对任意 HTTP 域名删掉安全属性。

Expires=Wed, 21 Oct... 自身含逗号,直接对 set-cookie 字符串执行 split(',') 会破坏日期。运行时提供独立 Cookie 列表时,应逐条追加:

ts 复制代码
const outgoing = new Headers()

upstream.headers.forEach((value, key) => {
  if (!blocked.includes(key.toLowerCase()) && key.toLowerCase() !== 'set-cookie') {
    outgoing.set(key, value)
  }
})

for (const cookie of upstream.headers.getSetCookie()) {
  outgoing.append('set-cookie', frontendCookie(cookie, request.nextUrl.hostname))
}

登录可能同时设置刷新 Cookie 和其他状态 Cookie,注销也可能通过过期 Cookie 清除会话。丢掉第二个 Set-Cookie 会产生"登录看似成功,刷新却掉线"或"退出后还能恢复"的问题。

刷新令牌不应再出现在响应 JSON

后端为了兼容其他客户端,登录、注册和刷新响应可能同时返回 refresh token。Web 前台已经使用 HttpOnly Cookie,就应从 JSON 中移除:

ts 复制代码
if (
  path[0] === 'auth' &&
  ['login', 'register', 'refresh'].includes(path[1]) &&
  upstream.ok
) {
  const envelope = await upstream.json()
  if (envelope.data) delete envelope.data.refreshToken

  return NextResponse.json(envelope, {
    status: upstream.status,
    headers: outgoing,
  })
}

这让浏览器 JavaScript 只拿到 access token,refresh token 的读取和轮换都停留在 Cookie 与服务端代理边界。

跨站写入要检查请求来源

SameSite=Lax 可以降低一部分 CSRF 风险,但服务端仍应拒绝明确的跨站写请求。当前代理对 POST、PUT、PATCH、DELETE 检查 Sec-Fetch-Site 与 Origin:

ts 复制代码
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method)) {
  const source = request.headers.get('origin')
  let sourceHost = ''

  try {
    sourceHost = source ? new URL(source).host : ''
  } catch {
    sourceHost = 'invalid'
  }

  if (
    request.headers.get('sec-fetch-site') === 'cross-site' ||
    (source && sourceHost !== request.headers.get('host'))
  ) {
    return NextResponse.json(
      { code: 4000, message: '请求来源无效' },
      { status: 403 },
    )
  }
}

Origin 比较的是主机部分,并显式处理非法 URL。这个保护不代替后端鉴权,也不意味着所有无 Origin 请求都可信;它是在 Cookie 自动携带的前提下增加一道同源写入约束。

私有响应必须禁止缓存

ts 复制代码
outgoing.set('cache-control', 'private, no-store')

return new NextResponse(upstream.body, {
  status: upstream.status,
  headers: outgoing,
})

上游 fetch 自身使用 cache: 'no-store',下游响应再声明 private, no-store。前者防止 Next.js 复用上游结果,后者约束浏览器与中间缓存。会员资料、收藏和通知不能沿用公开文章的 60 秒缓存。

附件上传不需要在代理中理解文件内容

只要代理保留 Content-Type 并按字节转发 body,multipart boundary 仍由浏览器请求携带:

ts 复制代码
const body = ['GET', 'HEAD'].includes(request.method)
  ? undefined
  : await request.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 保持一致
bash 复制代码
curl -i 'http://localhost:3000/api/v1/articles?page=2'
curl -i -X POST '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 的价值正来自这种克制。

延伸阅读


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
gudufy6 小时前
Blazor 安全设计:一个 Blazor Admin 后台需要防哪些攻击?
csrf·ssrf·越权·easyadminblazor·blazor安全
FungLeo1 天前
成为全栈·Next.js 网站前台篇·评论系统:叠楼、回复、删除与内容审核如何落到前台
react.js·内容审核·next.js·评论系统·树形数据·成为全栈
Alice-YUE1 天前
React 渲染机制速通:Render 树、Fiber 树与更新调度
前端·react.js·前端框架·react·fiber·前端性能·渲染机制
传奇开心果编程2 天前
【现代声明式UI学与练】第1课 从命令式UI到声明式UI
学习·flutter·ui·swiftui·react·android jetpack
frjc2 天前
前端技术选型:主流方案对比与落地建议
vue·react·angular
szial3 天前
网络爬虫与 CDP 实战(二):浏览器能访问,Python 却被拒绝?拆开登录态与 CSRF
爬虫·python·csrf
cindershade4 天前
一条请求的接力赛:用运行时责任重画 RSC 边界
性能优化·next.js
zach5 天前
前端落地实战:Next.js 项目 Nginx + PM2 生产部署全流程(附踩坑总结)
前端·pm2·next.js
zach5 天前
Vue/React SPA 打包部署后,子路由刷新 401 未登录问题彻底解决
前端·nginx·next.js