前端目的限制设计的核心实现与避坑指南

缤泽 Dev 安全 阅读 3,013
赞 13 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月接了个新活,给一个数据看板系统做权限收口。这系统之前是各个模块自己控制访问逻辑,结果用户反馈五花八门——有些人能看到不该看的数据,有些人明明有权限却打不开页面。老板说再这么下去要出事,得赶紧上一套统一的访问控制机制。

前端目的限制设计的核心实现与避坑指南

我一开始想直接上 RBAC,但一想不对劲:这系统里很多数据展示是有明确业务目的的,比如“风控分析”这个页面,只能在“处理异常交易”时打开,其他时候就算你是管理员也不该随意浏览。这时候光靠角色控制就不够了,得知道用户“为什么”要访问这个资源。

翻了一圈资料,最后盯上了“目的限制”(Purpose Limitation)这个思路。它原本是隐私保护里的概念,意思是收集和使用数据必须有明确、具体的用途。我把它挪用到了前端路由控制上——每个敏感页面访问都得带上上下文,说明你为啥要进这儿。

技术方案定下来:路由守卫 + 目的标记 + 本地校验。虽然不能完全防住高级攻击者,但至少能挡住大部分误操作和低级越权。

核心代码就这几行

实现起来其实不复杂,关键是在跳转前塞一个“目的标识”,然后在目标页加载时验证这个标识是否合法。整个流程走的是内存+短期存储结合的方式,避免持久化带来的泄露风险。

// 路由守卫
router.beforeEach((to, from, next) => {
  const purpose = sessionStorage.getItem('navigation_purpose')
  const expectedPurpose = to.meta.requiredPurpose

  if (expectedPurpose) {
    if (!purpose || purpose !== expectedPurpose) {
      console.warn(非预期访问尝试: ${to.name}, 要求目的=${expectedPurpose}, 当前=${purpose})
      next('/forbidden')
      return
    }
    // 用完即焚
    sessionStorage.removeItem('navigation_purpose')
  }
  next()
})

// 安全跳转方法
function navigateWithPurpose(targetRoute, purpose) {
  sessionStorage.setItem('navigation_purpose', purpose)
  router.push(targetRoute)
}

页面里调用也很简单:

// 在触发跳转的地方
navigateWithPurpose(
  { name: 'RiskAnalysis' },
  'fraud_investigation'
)

目标页面配置一下 meta:

{
  path: '/risk',
  name: 'RiskAnalysis',
  component: RiskAnalysisView,
  meta: { requiredPurpose: 'fraud_investigation' }
}

最大的坑:性能问题

刚开始上线那会儿一切正常,直到 QA 提了个 bug:从通知中心点进来经常被拦在外面。折腾了半天发现,是因为通知点击是通过 deep link 进来的,中间有个 loading 页要做鉴权跳转,等真正到目标页时,sessionStorage 里的 purpose 已经被清空了。

开始我以为加个 setTimeout 延迟清空就行,结果引发更严重的问题——如果用户快速点了两次,第二次的目的会被第一次残留的数据干扰,出现误判。

后来改成只在成功进入目标页后才清除,在 router.afterEach 里加了个白名单判断:

router.afterEach((to) => {
  const purpose = sessionStorage.getItem('navigation_purpose')
  if (purpose && to.meta.requiredPurpose === purpose) {
    sessionStorage.removeItem('navigation_purpose')
  }
})

但这样也有副作用:万一页面崩溃没走到 afterEach,purpose 就一直挂着。不过考虑到这种场景极少,而且最多影响一次后续访问,权衡之后决定接受这个小瑕疵。

谁也没想到的绕过方式

最离谱的是测试组搞出来的一个绕过路径:他们先正常发起一次合法跳转,卡在 loading 页的时候手动刷新,这时候 sessionStorage 还在,但路由状态重置了,就能直接进目标页。

这个问题到现在都没完美解决。理论上可以用 localStorage 配合时间戳+一次性 token 来搞,但那样复杂度飙升,还可能带来新的安全问题。最后我们折中了一下,在 loading 页加了个强制清理:

// 在全局 Loading 组件 mounted 时
if (window.performance.navigation.type === 1) {
  // 页面刷新
  sessionStorage.removeItem('navigation_purpose')
}

或者更粗暴一点,所有非 history.back() 的进入都清掉 purpose。虽然牺牲了部分体验,但至少堵住了明显的漏洞。

实际效果怎么样

上线一个月,日志显示拦截了大概 300 多次非预期访问,大多是开发调试时忘记带 purpose 或拼错字符串。真正涉及权限越界的有十几起,都是运营同学误点导致的,及时拦住避免了数据外泄。

做得好的地方是:轻量、无感改造、不影响现有登录体系。整个改动没动后端一行代码,纯前端实现,适合快速落地。

不足的地方也明显:对抗不了懂行的人。只要知道 requiredPurpose 是啥,完全可以手动 setItem 然后跳转。所以这玩意儿本质上是个“防君子不防小人”的机制,主要用来规范开发行为和防止误操作。

如果真要上强度,还得结合后端做目的级鉴权,前端只做第一道过滤。但现在项目排期紧,先这样顶着吧。

回顾与反思

回过头看,这个方案最大的价值不是技术多牛,而是推动团队建立了“访问目的”的意识。以前大家写跳转都是 router.push 就完事,现在至少会停下来想想:用户为什么需要看这个页面?有没有更安全的路径?

另外提醒一点:别把 purpose 当作加密字段用。我们最开始有人写了这样的代码:

navigateWithPurpose('/report', encrypt('user_id=123'))

简直是自找麻烦。purpose 必须是公开的枚举值,像 ‘fraud_investigation’、’customer_support’ 这种,永远不要在里面塞动态数据,否则就是变相传输敏感信息。

以上是我踩坑后的总结,希望对你有帮助

这套目的限制机制不是银弹,但在某些特定场景下确实有用。特别是那种“同一角色在不同情境下权限不同”的业务,光靠 RBAC 很难表达清楚,加上一层目的判断就灵活多了。

如果你也在做类似的权限系统,建议先从小范围试点开始,比如只保护最高危的两三个页面。等跑顺了再推广,别一上来就想全覆盖,容易把自己绕进去。

以上是我个人对这个方案的完整实践记录,有更优的实现方式欢迎评论区交流。这个方向还有很多可挖的点,比如结合用户行为日志做目的合理性分析,后续可能会继续分享。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论