框架深潜

Odoo Portal 分享链接真正危险的不是‘免登录’,而是 token 化访问到底只放开了什么:access_url、mail/view 与对象级边界讲透

Portal 链接表面上只是多了一个 access_token,但 portal.mixin 源码说明它本质上是在做对象级访问委托:谁能拿到 token、token 跟哪条记录绑定、mail/view 为什么要带 model/res_id、分页与附件为什么也要续传 token,这些才是分享链接是否安全的关键。

框架
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 4 阅读

先说结论

很多人看到 Odoo Portal 链接里的 access_token,第一反应是:

  • 这不就是“免登录参数”吗?

这个理解只对了一半。

/home/ubuntu/odoo-temp/addons/portal/models/portal_mixin.pycontrollers/portal.py 看,Odoo 真正做的不是“把登录去掉”,而是:

把某一条具体业务记录,包装成一个带对象级访问凭证的分享入口。

所以 Portal 的核心安全问题从来不是“用户有没有后台登录”,而是:

  • token 是不是只绑定当前对象;
  • 分享链接是否只放开当前记录必需的能力;
  • 跳页、附件、评论等后续动作有没有继续守住同一边界。

portal.mixin 说明 token 是“记录级”而不是“用户级”

portal.mixin 上有两个关键字段:

  • access_url
  • access_token

其中 _portal_ensure_token() 的实现非常直白:

  • 如果当前记录还没有 token,就写入一个 UUID;
  • 返回这条记录自己的 access_token

这透露出一个重要事实:

access token 的宿主是记录本身

也就是说,它不是一个“用户登录票据”,而是:

  • 这张报价单自己的 token;
  • 这张发票自己的 token;
  • 这条项目分享记录自己的 token。

这和常见的 session cookie、JWT、OAuth access token 完全不是同一层东西。

它更像:

某个对象对外暴露的受控分享钥匙。

所以一旦你把它误当成“简化登录”的工具,就很容易在定制里把边界做大。

_get_share_url() 暴露了分享链接的真实组成

_get_share_url() 非常值得细读。

它支持:

  • redirect=False:直接去对象自己的 access_url
  • redirect=True:先跳 /mail/view
  • pid + hash:为某个 partner 生成额外认证参数
  • signup_partner=True:允许被邀请人更容易建账号
  • share_token=True:拼上 access_token

这说明分享链接不是“一个 URL + 一个 token”这么简单,而是一个分层入口

第一层:到哪条记录

由:

  • self.access_url
  • model + res_id

决定。

第二层:凭什么看

由:

  • access_token
  • 或 partner 级 hash

决定。

第三层:是否顺带触发开户 / 门户身份承接

由:

  • signup_partner

这类参数决定。

也就是说,Portal 分享链接的安全性,不在于“有没有参数”,而在于每个参数都只放开自己那一层语义。

为什么 redirect=True 要走 /mail/view

很多人会忽略 _get_share_url()redirect=True 的设计。

此时它不会直接返回 access_url,而是返回:

  • /mail/view?model=...&res_id=...&access_token=...

这背后反映的是一个很成熟的边界思路:

即使是分享访问,也最好通过一个显式的“受控入口”来重新校验对象与 token 是否匹配。

/mail/view 这种路由不是多此一举,它像一个前置闸门:

  • 先验证你访问的是哪条记录;
  • 再看 token 是否足够;
  • 再决定该引导你进门户页、邮件线程页还是其他具体页面。

如果开发者在自定义里图省事,直接把后台记录 URL 拼出去,反而最容易把边界做散。

为什么 _get_share_url() 里会先 check_access('read')

源码里有个特别关键的小动作:

  • 在生成 access_token 之前,先 self.check_access('read')

这说明 Odoo 并不是让任何能拿到 recordset 的代码都能随意产出分享链接。

这一步的真实含义是:

  • 你先得对这条记录本来就有读取权;
  • 然后系统才允许你替这条记录生成对外访问入口。

也就是说,分享链接生成本身就是受权限约束的动作

这点非常重要,因为很多定制项目喜欢写一个通用按钮:

  • 点一下就吐一个外链。

如果这一步不先守住原始 read 权限,最后就很可能变成“低权限用户替高价值对象批量生成分享链接”。

get_portal_url() 为什么把 token 继续带到 suffix / download / report

get_portal_url() 会在 URL 上继续追加:

  • access_token
  • report_type
  • download=true
  • 额外 query string
  • anchor

这一步经常被当成“方便拼参数”,但它的真正意义是:

对象级访问边界必须能沿着后续动作延续,而不是只在第一跳生效。

例如:

  • 你先打开门户页;
  • 接着点下载 PDF;
  • 再点另一个报告格式;
  • 再翻到带锚点的局部区域;

如果只有第一页验证 token,后续派生 URL 不带 token,访问体验会断; 如果后续派生 URL 完全不再校验 token,安全边界又会散掉。

所以标准实现选择的是:

  • 在对象级 URL 家族里,把同一条记录的 token 继续传递下去。

分页与相邻记录为什么也要续传 token

controllers/portal.py 里的 get_records_pager() 同样很有代表性。

当当前对象有 access_url 时,它生成上一条 / 下一条链接,会自动拼:

  • ?access_token=...

这说明官方意识到一个特别容易被忽略的问题:

浏览动作也会把访问边界带散

如果当前页有 token,但“上一条 / 下一条”不带 token,会出现:

  • 当前页能看;
  • 点相邻记录立刻 403;
  • 或者开发者为了省事,把相邻页做成更宽松的公开查询。

标准实现没有这么做,而是明确要求:

  • 你在浏览哪条对象,就为哪条对象续传其 own token。

这再次证明 portal token 是对象级的,不是一次登录后通吃整个列表的会话钥匙。

分享 token 与 partner hash 不是一回事

_get_share_url() 里如果传了 pid,还会生成:

  • pid
  • hash = self._sign_token(pid)

这说明 Odoo 区分了两类凭证:

对象级 token

证明:

  • 你拿着这条记录的分享钥匙。

partner 级 hash

证明:

  • 这个链接是朝某个具体 partner 语义发出去的;
  • 某些 portal chatter 或邀请链路可以据此认定更细的身份上下文。

如果把这两层混在一起,就会在定制里犯两个典型错误:

  • 以为有 access_token 就等于知道来访者是谁;
  • 以为有 partner hash 就能对整条记录无限授权。

其实它们分别回答的是不同问题:

  • 能不能进这个对象?
  • 来访者更像是哪一类被邀请对象?

Portal 真正危险的不是免登录,而是能力扩张

从源码看,官方设计非常克制:

  • token 是记录级;
  • 分享链接生成要过原始 read 检查;
  • 跳转与后续派生 URL 都尽量围绕当前对象;
  • partner 级身份补充与对象级 token 分开处理。

所以大多数安全事故并不是标准模块“天生不安全”,而是定制把能力放大了,比如:

  • 用一个 token 顺手开放了对象列表;
  • 下载接口没继续校验对象 token;
  • 生成链接时不检查原始 read 权限;
  • 把 portal token 当成长期用户身份令牌来复用。

实战里最该怎么排查

如果用户反馈“为什么这个链接没登录也能看”或“分享页翻页后行为怪”,建议按这个顺序查:

  1. 先确认当前链接对应的是不是某个 portal.mixin 对象自己的 token
  2. 看入口是直接 access_url,还是通过 /mail/view 做受控跳转
  3. 检查下载、打印、相邻记录、附件这些派生 URL 是否继续带并校验对象 token
  4. 区分 access token 与 partner hash,不要把对象权限和来访者身份混成一层
  5. 回头看自定义有没有把单对象分享扩成列表、搜索或跨对象访问。

一句话记忆

Odoo Portal 的分享链接不是“免登录”,而是“把一条具体记录按最小必要能力做 token 化委托访问”;真正要守住的是对象级边界,而不是只盯着登录态。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。