Skip to content

Security zh

SkimMail docs edited this page Sep 18, 2026 · 8 revisions

English · Tiếng Việt · 中文

Security

本页只讲五个具体机制——首次运行的 claim code、屏幕锁定、trusted proxy、 OAuth 登录,以及静态加密——并且刻意坦诚地说明每一个机制没有覆盖到什么。完整的安 全态势(安全响应头、限流、审计日志、SSRF 防护)见仓库里的 SECURITY.md;要报告漏洞,请按那份文件里的说明来,不要开公开 issue。

先把威胁模型说清楚。 这些机制防御的是通过网络接触到你实例的攻击者, 或者拿到了你文件副本的攻击者——被盗的硬盘、一份备份、泄露出去的 DATA_DIR。它们不能防御一个已经在运行中的机器上拿到 root 权限的攻 击者:那样的攻击者能读取进程内存,包括当前正在使用的解锁密钥。

Claim code

在还没有人登录之前,如果没有别的机制拦着,SkimMail 的登录页面会把第一个 凭据的创建权交给不管是谁最先到达的人——第一个密码或账户,由第一个提出 请求的人创建。在任何有一定规模的网络上,这就变成了你和所有能碰到这个端 口的人之间的一场竞速,赛程从"容器已经启动"一直到"你坐下来打开了浏览器"。

1.9.0 开始,一个全新的实例会在首次启动时把一个一次性的 claim codeSKIM-XXXX-XXXX-XXXX)打印到日志里,并且在拿到它之前拒 绝创建第一个凭据。只有 code 的 SHA-256 哈希会被保存下来——不保存明文—— 所以一旦打印出来,唯一的记录就是当时捕获到那行日志的东西。这也意味着 SkimMail 永远无法再把它显示给你一次:没有"重新显示"这个功能,这是 故意设计成这样的,claim-code --show 存在的唯一作用就是解释这一点,并 把你指向 --rotate

读取它(各安装方式的具体命令见 Installation):

sudo journalctl -u skimmail | grep 'claim code'          # apt —— 必须加 sudo
docker compose logs skimmail 2>&1 | grep 'claim code'    # Docker Compose —— 容器并不叫 skimmail
docker logs skimmail 2>&1 | grep 'claim code'            # docker run --name skimmail

在 1.17.0 之前,这个页面(以及 wizard 自己的首次运行界面)只显示 docker logs 这一种形式。 它在 Compose 下会失败:发布的 docker-compose.yml 把项目 名定为 skimmail,却没有 container_name,于是真正的容器名是 skimmail-skimmail-1,而不是 skimmaildocker compose logs <service> 是按 compose 文件里的服务名去查找的,与真实容器名无关,这正是 Compose 部署下该用它 的原因。已在 1.17.0(SKIMMAIL-190)修复。完整说明和每种安装方式的准确命令见 Installation

丢了 code,或者日志已经轮转把它冲掉了?重新生成一个——旧的会在你这么做的 瞬间失效:

sudo -u skimmail skimmail --data-dir /var/lib/skimmail claim-code --rotate

如果对一个重新回到首次运行状态的实例(一个没有任何凭据的实例,或者恢复 到一个全新的目标上)执行恢复操作,会自动生成并打印一个新的 claim code ——一个已经用掉的 code 永远不会像早期版本那样被当作"已经被认领过",早期 的那种处理方式会导致任何人都能在这段窗口期内完成设置。

在一个真正封闭的网络里——没有任何不受信任的人能抢在你之前碰到这个端口 ——你可以用 SKIMMAIL_SKIP_CLAIM=1 完全跳过这道关卡。请刻意地做这个决 定:它移除的是唯一一道挡在"容器已经启动"和"这个实例归第一个提出请求的 人所有"之间的控制措施。

屏幕锁定

空闲屏幕锁定是一道真实的访问边界,不是盖在应用上面的一层画面。在 1.9.0 之前,锁定完全是在浏览器里画出来的:底层的会话在这段时间里始 终完全有效,所以 API 照样正常应答,重新加载被锁定的标签页会让锁定界面消 失,而数据其实一直都在屏幕上。

从 1.9.0 开始,锁定一个会话是服务器在做的事。一个被锁定的会话:

  • 保留身份(仍然是,仍然处于登录状态),但失去了访问能力:除了锁定 界面本身需要用到的少数几个路由(/api/auth/unlock/api/auth/logout/api/auth/me/api/server-info),每一个 API 路由都会返回 423 Locked
  • 在页面刷新后依然保持锁定——没有任何客户端状态可以丢失,因为真正 执行锁定的是服务器;
  • 不会暂停你的邮件。被锁定期间后台同步照常运行,新邮件照常送达—— 锁定是针对坐在键盘前的人的边界,不是服务的暂停按钮。

解锁会通过和登录时相同的防暴力破解机制,重新验证你当前的密码(如果启用 了双因素认证,还要验证 2FA 验证码)——它不会生成新的会话,所以你最初登录 的有效期不受影响。在 AUTH_MODE=none 下根本不提供锁定功能:既然没有凭 据可以锁,也就没有什么可以解锁。

有两个设置控制这一切,都在 Settings ▸ Security ▸ Timeouts 里,也都属于 Plane 2(见 Configuration):

设置项 环境变量默认值 含义
Auto-lock after AUTO_LOCK_MINUTES0 = 永不锁定) 客户端自动锁定前的空闲分钟数
Session expires after SESSION_TTL_HOURS720 = 30 天) 一次登录在需要重新登录之前能维持多久

自 1.16.2 起,锁定会保持在每一个打开的标签页上,而不只是触发它的那一 个。 锁定状态一直存在于会话这一行数据上,所以它本来就是共享的——但在 1.16.2 之前,某个标签页是否画出锁定遮罩,是每个标签页各自独立的 React state,于是同一个会话在另一个标签页里仍然显示着完整渲染出来的邮箱,完全 没有遮罩。这就抵消了空闲锁定原本要防止的大部分情况。标签页之间现在会在每 次锁定和解锁时,通过 localStorage 交换一个提示,并且刻意把两个方向处理 成不对称——这个不对称本身才是安全属性,而不是实现细节:

  • 一个 locked 提示会让其它每一个标签页立刻画出遮罩——fail-closed,一 个伪造出来的提示能造成的最坏结果,也只是让人看到输入密码的界面,然后还 是得输入真正的密码;
  • 一个 unlocked 提示从不会自己解除遮罩。它只会让那个标签页重新去问服 务器(/api/auth/me),只有当服务器自己的回答说会话已解锁时,遮罩 才会解除。

没有这个不对称,一段同源脚本就能仅仅靠写一个 localStorage 的 key 来解除 锁定界面——这正是 1.9.0 修复过的那个缺陷,那时锁定还只是画在一个从不 unmount 的 workspace 上面的一层遮罩。

同样自 1.16.2 起:一次拒绝不再被记录成一个空结果。 在这之前,如果页面 在锁定期间被重新加载,它会去请求你的邮箱列表,因为会话被锁定而收到 423 拒绝,然后把这次拒绝记成"你还没有任何邮箱"——在锁定遮罩下面画出首次运行 的"Your first mailbox"界面,而这个实例其实一直都有邮件。输入密码解锁后, 看到的是这个界面而不是收件箱;只有整页刷新才能恢复正常。同一类错误也出现 在:一次失败的邮件加载渲染成"Inbox Zero",一个失败的文件夹列表直接消失而 不是报错,上面那份会话列表可以在你正坐在某个会话里面的时候告诉你"没有活 动会话"。这些都不是只针对锁定的修复——任何被锁定的会话产生的 423 现在都 会统一导向锁定遮罩而不是错误界面,"正在加载"和"空"不再是同一个状态,只有 真正的 401 才会被当作已登出。这个改动替代掉的具体症状见 故障排查

Trusted proxy

X-Forwarded-For 是一个由客户端起头写、每一层代理再往后追加的头—— 所以客户端自己写的地址永远在最左边,而离你最近的那层代理实际观察到 的地址在最右边。SkimMail 从右往左读这个头,遇到第一个不被识别为你 自己代理的跳数就停下来。这一点很重要,因为客户端可以在这个头里写任何内 容——早期版本的代码信任的是最左边那一项,这意味着攻击者可以在每次请求 里都填一个新的 X-Forwarded-For,每次都落进一个新的限流桶,彻底绕过登 录锁定和按 IP 限流,还能往审计日志里写任意他们想要的地址。

有两个设置控制这一切:

  • TRUST_PROXY(Plane 1,trust_proxy)——默认关闭。关闭时, X-Forwarded-For 会被完全忽略,直接 TCP 连接的对端地址就是客户端地 址,没有例外。
  • TRUSTED_PROXIES——一份用逗号分隔的 CIDR 列表,列出哪些跳数算作 你自己的基础设施而不是客户端。留空的话,loopback 和 private 网段的 地址会被当作代理(比如一个 sidecar 或者同一台主机上的 nginx),这对常 见场景是正确的默认值,也没有哪种场景会因此出错。

配置错了会两个方向都出问题。 打开 TRUST_PROXY 只有在 SkimMail 本 身不能被除了你真正的反向代理之外的任何人直接访问到时才有意义——代 码没有别的办法分辨"这个连接是经过我的代理来的"还是"这个连接是个冒充我 代理的陌生人",只能靠检查它实际是从哪里来的。具体来说:

  • 如果你的反向代理位于一个公网地址(一个 CDN 边缘节点、一个隧道出 口),而你把 TRUSTED_PROXIES 留空,SkimMail 根本不会把它识别为代 理,用于限流、锁定和审计日志的地址就会是错的。
  • 如果除了你的代理之外,SkimMail 自己的端口还能被别的什么人访问到—— 或者你把 TRUSTED_PROXIES 设得太宽——一个能直接访问那个端口的攻击者 就可以自己手写 X-Forwarded-For,让 SkimMail 相信他们想要的任何地 址,这正是"从右往左读"这个修复方案在默认场景下堵住的那个伪造漏洞。

一句话总结这条规则:只有在这个端口除了通过你真正的代理之外无法被访问到 的情况下才打开 TRUST_PROXY,并且只要代理本身不是已经处于 loopback 或 private 网段,就把 TRUSTED_PROXIES 设成那个代理的真实地址。

OAuth 登录

1.18.0 起,完成 Google 或 Microsoft 登录的两种方式——普通的重定向 流程,以及手动粘贴流程——都使用 PKCE。这一点对粘贴流程最要紧,因为登录 结果要经过你的剪贴板才能回到 SkimMail。有两把互相独立的锁保护它:你的 client secret——provider 在把 code 换成 token 之前必须要它,而且它从 不离开你的服务器;以及 PKCE——一个一次性的值,由你的服务器自己留存, provider 会再核对一次。仅凭 secret 就已经让一段被复制的内容对任何看到它 的人都毫无用处;PKCE 让这一点即便某个 provider 自己那一侧的 PKCE 实现不 够好,也依然成立。两把锁都不需要单独做到完美。完整流程见 Accounts

承载一次登录流程的内部 ticket,现在会说明自己是干什么用的。 两个互不 相关的短期 ticket——一个在 Google 或 Microsoft 登录期间持有,另一个在你 的密码和 2FA 验证码之间持有——过去以同样的方式封装,字段名还有重叠,原 则上一个可能被误读成另一个。这种方式从未被真正利用过;现在它在结构上就 不可能发生,而不是靠运气。

静态加密(Encryption at rest)

SkimMail 用两个相互独立的层来做静态加密,它们覆盖的东西并不一样。 知道一个设置属于哪一层,就能准确知道它保护了什么、没保护什么——而 1.11.0 改变了其中一件事:缓存的邮件正文现在会静态加密,而你邮件的其余部分不 会。

覆盖范围 方式
第 1 层——密钥包裹 账户凭据、OAuth token、TOTP secret、代理/隧道的密钥、S3 备份用的 secret key 始终使用 AES-256-GCM,基于一个 32 字节的主密钥。KEY_PROVIDER 决定这把主密钥本身怎么存放。
第 2 层——内容 缓存的邮件正文DATA_DIR/blobs/ 自 1.11.0 起用 AES-256-GCM,密钥由主密钥派生。除非把 BLOB_ENCRYPT 明确设为 false0nooff,否则始终开启。
第 2 层——内容 邮件主题、收发件人、skimmail.db 文件、全文搜索索引 SkimMail 不加密它们。 应该在底层加密所在的卷(volume)。

第 1 层——KEY_PROVIDER

  • file(默认)——主密钥是 DATA_DIR/master.key 里的 32 个原始字 节,权限 0600。保护完全依赖文件系统权限:任何复制了 DATA_DIR 的 人,密钥和密文都摆在一起,能解密所有已保存的凭据。只有在卷本身已经 加密的情况下(第 2 层)才是安全的。
  • passphrase(推荐)——主密钥只生成一次,之后以已包裹的形式 保存在 DATA_DIR/keyring.json 里,用一个由独立的解锁密钥(存放在数 据目录之外)经 Argon2id 派生出的密钥加密密钥(KEK)加密。这样一来, 被复制走的 DATA_DIR 如果没有那个密钥就没用了。它被刻意设计成不 是你的登录密码——后台同步需要在重启后无人值守地继续运行,这需要一 个后台进程能自己读到的密钥。随时可以用 skimmail rekey 更换这个密 钥,它只是重新包裹同一把主密钥,不会重新加密任何数据。

passphrase 和一个已加密的卷结合起来,意味着被盗的硬盘或备份既不会 泄露你的凭据,也不会泄露你的邮件。file 加上一个未加密的卷——也就是零 配置的默认状态——两者都会泄露。

第 2 层——邮件内容

缓存的邮件正文自 1.11.0 起被加密——AES-256-GCM,密钥由主密钥派生。没有 新的秘密需要备份:密钥是派生出来的,从不保存。更早版本写下的正文照样可读,因 此升级不需要迁移;把这个设置关掉,也绝不会让已有缓存作废。完整行为见 邮件正文缓存

BLOB_ENCRYPT 除非你把它关掉,否则始终开启。只有 false0nooff 会关闭它;其它任何值,包括 1TRUE,都让它保持开启。

1.11.0 把这件事搞反了。 那个版本只接受 true 这一个词,于是 BLOB_ENCRYPT=1——或者 yesonTRUE——都会悄悄把加密关掉1.11.1 已修复。如果你在 1.11.0 上设过其中之一,那之后缓存下来的正文没有 被加密,也不会被就地重写;在 Settings ▸ Security ▸ 邮件正文缓存 里调低 容量上限或保留天数把它们清理出去,它们回来时就是封装好的。

你邮件的其余部分在磁盘上仍然是明文,这是有意的设计:主题、地址、日期、 摘要片段和全文搜索索引都在数据库里,在应用内加密它们会破坏纯 Go 构建,也会 破坏搜索本身。官方针对这些给出的缓解措施是加密卷本身,而不是加密内容 ——在 DATA_DIR 下面用 LUKS/dm-crypt 或者一个已加密的 ZFS 数据集,在容器卷 下面用已加密的云盘(EBS、GCP/Azure 磁盘加密),或者如果你的 blob 后端是 S3,就启用 S3 服务端加密(SSE-S3/SSE-KMS)。以上任何一种做法都会在存储层 ——而不是应用内部——同时覆盖数据库文件、缓存的邮件正文,以及那份明文密钥文 件。

一个被偷走的 DATA_DIR 会暴露什么:

配置 凭据 / token 缓存的正文 主题、地址、搜索索引
file + 未加密的卷(默认) 暴露 暴露* 暴露
passphrase + 未加密的卷 受保护 受保护* 仍然暴露
file + 已加密的卷 受保护(靠卷) 受保护 受保护(靠卷)
passphrase + 已加密的卷 双重受保护 双重受保护 受保护(靠卷)

* 正文加密的可靠程度,完全取决于它所派生的那把主密钥。在 KEY_PROVIDER=file 下,那把密钥就在同一个目录里,所以能拷走 DATA_DIR 的 人也能解开缓存——它只是把门槛抬到"随手 grep 不到",仅此而已。在 passphrase 下,密钥根本不在磁盘上,缓存的正文在没有解锁密钥时是真的读不 出来。

本页没有覆盖的地方

  • 按用户的访问控制自 1.10.0 起存在,到 1.13.0 才覆盖到每一个界面。 每个 账户、分组、邮件、附件与发件人标记都只属于一个用户,这一点由数据层强制 执行,而不是靠界面遮挡;越界的请求返回"未找到"而不是"无权限"——因为拒绝 某一具体行本身就等于确认它存在。角色为 owneroperatorviewer; 三者之间的边界是中间件里唯一的一次授权判断,因此新增路由时无法漏掉"谁可以 调用"这个决定。以上描述的是 1.10.0 起的设计;但直到 1.13.0,它才真正描述了 每一条查询——尤其是发件人标记,在此之前一直是整个实例共用的。在 1.10.0 之前的任何版本上,第二个"用户"都只是第二个管理员,而不是拥有独立邮箱的 用户。完整细节(含角色对照表与 skimmail user 命令)见 用户与角色

  • 邮件里的链接不再能对你自己的实例做手脚了。 让邮件正文的 frame 变成 同源——这是 1.18.0 为了让远程图片能带着你的会话加载而做的改动,见 邮件中的远程图片——同时也意味着陌生人邮件 里一个看起来正常的链接,可能指回 SkimMail 自己的地址,并在你已登录的情 况下被打开,把一次点击变成了一次以你的身份发出的请求。自 1.18.0 (SKIMMAIL-209)起,邮件里指向"发到外部网络"以外任何地方的链接都是无效 的(inert)。指向同一封邮件内某处的锚点仍然有效,mailto: 也一样。

  • 打开一封邮件,仍然可能让发件人知道。 如果你按下 Show images, SkimMail 会从发件人的服务器把图片取回来——走该账户的 egress,不带 cookie 也不带 Referer,但这次取回本身就告诉了发件人:这封信被打开了,以及什么 时候打开的。图片默认被拦下,某个发件人可以被永久拒绝,整个功能也可以在 Settings ▸ Security ▸ 远程图片 里对整个实例关闭(仅限 owner,自 1.11.1 起有界面)。它做不到的,是让一张已经取回的图片变得不可见。见 邮件中的远程图片

  • 如果不止一个人登录,请升级到 1.14.0。 从 1.10.0 到 1.13.0,共有十三个 操作只按角色做了把关,却没有按归属者把关,于是它们读写时越过了自己本该守住 的那条用户边界。1.12.0 修好了其中五个Reset groups 会删掉整个实例上 每个用户的分组,稍后提醒(snooze)和置顶(pin)可以作用在别人的邮件上, 推送设备可以从别人的账户里被删掉,拆分收件箱的计数是在所有人的邮件上算出来 的。1.13.0 又修好了六个,其中两个是普通 viewer 通过产品实际发布的界面 就能触发的:

    它做错了什么
    搜索 返回实例上每一个账户的匹配结果——主题、发件人、预览行,以及已缓存的 AI 摘要
    AI "catch me up" 摘要 读取所有人未读邮件的开头几行,并发送给该实例配置的 AI 服务商
    归档/移动/删除 在检查归属之前,就已经在别人真实的邮件服务器上移动了那封邮件,然后告诉调用方"成功了"
    AI 摘要 通过结果缓存跨账户可读,因为缓存在归属检查之前就先回答了
    OAuth 添加的账户 保存时没有归属者:它不属于任何人,从账户列表里消失;两个人连接同一个邮箱会互相覆盖
    静音发件人 作用于整个实例,而你设置的静音与 VIP 又被写到了没有任何地方会读回的位置

    摘要功能只有在实例已启用 AI 且打开了 digest 时才会运行。但请注意,这等 同于"本版本 AI 标签页是隐藏的":隐藏一个标签页决定的是界面画什么,而不是服务 端回答什么。无论如何,路由都是注册着的。请把 GET /api/digest 当作在线接口 看待。

    1.14.0 补上了最后两个,它们形状不同:不是读别人的数据行,而是以别人的身 份行事。服务端搜索和账户的 Test connection 按钮,都直接从请求里取账户 id,然后用那个账户保存的密码和网络出口打开它的 IMAP 连接——在真实邮箱上搜索, 并把取回的邮件头写进数据库。没有任何东西回到调用者手里;但连接照样被打开,数 据照样被写入,而且一个 viewer 就能做到。两者都曾被常驻守卫测试标记出来,又 被一条不属实的豁免说明放行。

    单用户实例——也就是默认情况——从未受影响,因为根本没有第二个用户可以越 界。详情,以及升级会如何处理你的静音发件人列表,见 用户与角色

    1.18.0 又补上了一个,这次是靠一轮内部审计发现的,而不是靠外部报告。 SyncAccount——Sync now 按钮背后的函数——从未检查过它拿到的 account id 属于谁,尽管调用它的 POST /api/accounts/{id}/sync 是一个 operator 级别的路由,而且有一个真实存在的跨用户调用方:侧栏的同步按 钮,operator 能看到的任何账户都可以点。填入别人的账户 id,会让服务器用 他们的已解密凭据、经由他们的 egress 去 dial 他们的邮箱,把找到的邮 件数量报回来,失败时原样暴露邮件服务器自己的错误文本,并且——对 Gmail 或 Outlook 账户而言——强制发起一次刷新,从而轮换他们保存的登录状态。同一个 调用还能把别人的失败计数刷高,直到 SkimMail 的自动熔断把对方的邮箱关掉, 并在对方的 Accounts 界面上留下调用者随意选择的一条错误信息。这是第三 个具有完全相同形状的函数:TestAccountConnectionWatchAccount 都 已在 1.14.0 拿到了同样的归属检查,唯独漏掉了 SyncAccount——而它恰恰是三 者之中唯一有一个真实存在、按用户划分作用域的调用方的那一个。它出现在从 1.10.0 到 1.17.0 每一个已发布的 tag 里,一共十一个 tag,覆盖了"按用户 归属"这个概念存在以来的全部时间。单用户实例从未受影响,因为没有第二 个账户可以填进去。如果你为不止一个人运行 SkimMail,请阅读本次发布 CHANGELOG.md## 1.18.0 一节自带的 Upgrading 部分,了解该在自己 的实例上检查什么——那部分正是为此而写的,本页不再重复一遍。

  • AI 相关的接口没有任何 licence 或 tier 检查,隐藏按钮并不构成一道安全 边界。 六个路由——摘要、分类、翻译、提取行动项、线程摘要,以及收件箱 digest——在每一个版本里都会注册,任何持有 session 的人都能直接调用它 们,无论界面上是否显示相应按钮。每一个都会把相关的邮件正文发给你配置的 那个 AI provider;一个都没配置的话,每一个都会报错,也不会有任何东西离 开你的服务器。这件事在 1.10.0 到 1.17.0 期间被错误地描述过,说这项能力 需要 Pro licence,在 Community 或 Sponsor 上根本不存在。完整更正见 PRIVACY.md 的 "AI features" 一节,并且不论哪个标签页可见,都应把这些 路由当作在线接口对待。

  • 一台已经被拿到 root 权限且正在运行的主机,不在上述任何机制的防护 范围之内。 Claim code、屏幕锁定,以及两层加密,全都假定攻击者还没 有对正在运行的进程拥有那种级别的访问权限。


SkimMail · skimmail@base101.app · 2026-09-18 · commit f525934

Clone this wiki locally