ntfy 自建推送加固实测:默认谁都能发能读,behind-proxy 开错了限流就是摆设
我想让服务器上的备份脚本跑完给手机推一条消息,选了 ntfy:Go 写的单文件,发通知就是一条 curl,手机端有现成 App。官方公共服务 ntfy.sh 也能用,但备份成功失败这种信息我不想放在别人那里,所以打算自己搭一个,以后放到华为云 Flexus 云服务器 L 实例上。
装之前先在测试机上把默认行为摸了一遍。说白了,直接跑起来的 ntfy 是一个谁都能用的公共留言板,下面按我踩到的顺序记。
测试环境
- 测试机:Debian 13(trixie),arm64,2 核 8G,普通用户,没有 Docker,直接用官方二进制。
- ntfy:2.37.2,GitHub Release 里的
ntfy_2.37.2_linux_arm64.tar.gz,压缩包 30MB 左右,解出来的二进制 70MB 出头。 - 反代:Caddy v2.11.9,同样是官方二进制。
- 上线目标是 Flexus L 实例,安全组只放 443。这篇所有命令都在测试机上真跑过;安全组那一步我没在这台测试机上做,文中会单独标出来。
从 GitHub 上 ntfy 仓库的 Releases 页面下载 v2.37.2 的 ntfy_2.37.2_linux_arm64.tar.gz 和 checksums.txt,然后对校验和:
V=2.37.2
grep linux_arm64.tar checksums.txt
sha256sum ntfy_${V}_linux_arm64.tar.gz
两边都是 d440e696755f2032592ba6456995f7a9f04b8c47d288d2e3d2cffbc3d53831f6,对上了。
第一次启动就报错
照 ntfy serve --help 里的例子直接 ntfy serve,一秒就退出了:
INFO Listening on :80[http], ntfy 2.37.2, log level is INFO (tag=startup)
FATAL listen tcp :80: bind: permission denied (exit_code=1)
默认监听 :80,普通用户绑不了 1024 以下的端口。压缩包里自带的 server/ntfy.service 用 AmbientCapabilities=CAP_NET_BIND_SERVICE 解决这个问题,所以用 systemd 跑的人一般碰不到(systemd 这条没实测,依据包里的 unit 文件)。我换成 --listen-http :8090 再起,常驻内存 RSS 40MB 左右。
然后看监听地址。/proc/net/tcp6 里 8090 挂在全零地址上,也就是所有网卡都在听。:80 这种只写端口的写法,意思就是 0.0.0.0 加 [::],机器有公网 IP、安全组又放了这个端口,它就直接对外了。
坑一:主题名就是唯一的门槛
ntfy 没有「注册主题」这回事,主题在第一次发消息时自动出现。默认的 auth-default-access 是 read-write,也就是匿名用户对所有主题可读可写。我用一个不带任何认证的请求发了一条,再用另一个同样不带认证的请求去读:

curl -s -d "db backup finished" 127.0.0.1:8090/backup-alerts
curl -s "127.0.0.1:8090/backup-alerts/json?poll=1&since=all"
第二条把刚才的消息原样吐了回来。返回里 expires 减 time 等于 43200 秒,正好是默认 cache-duration 的 12 小时。换句话说,只要有人猜中主题名,加上 since=all 就能把过去 12 小时这个主题里的所有通知一次拉走,还能往里塞假消息,比如冒充一条「备份成功」。
一开始我想的是把主题名起长一点、随机一点,当密码用。这在公共服务上是常见做法,但自建的时候没必要赌这个:主题名会出现在脚本里、手机 App 的订阅列表里、反代的访问日志里,泄露的路径太多。还是把匿名权限关掉更踏实。
顺带看了一眼匿名能访问的接口:/v1/health 返回 {"healthy":true,"role":"active"},/v1/config 会返回一串配置,包括 enable_login、enable_signup 这些开关和一个 config_hash。没有密钥,但能看出这台实例开了哪些功能。
坑二:behind-proxy 打开了,端口却还对外
ntfy 按访客 IP 限流,默认 visitor-request-limit-burst 是 60,之后每 5 秒补 1 个。配置文件里有一段大写的 WARNING:放在反代后面必须开 behind-proxy,否则所有人共用反代的 IP,会被当成同一个访客一起限流。
这句话很多人照做了,但它有个前提:开了 behind-proxy 之后,ntfy 会信任请求里的 X-Forwarded-For。如果端口没收回来,外面的人能绕过反代直连 ntfy,这个头就是他自己写的。我连发 80 次做了几组对比:

默认配置下(不开 behind-proxy),不带头连发 80 次,59 次通过、21 次 429,加上前面发的那条正好 60。紧接着每次换一个 XFF 再发 20 次,全是 429,说明默认模式根本不看这个头。
打开 behind-proxy 再直连:XFF 固定一个值,60 次通过、20 次 429,限流正常;XFF 每次换一个,79 次通过,只有 1 次 429。那 1 次是因为我前一轮用掉了 198.51.100.7 这个地址的额度,第二轮轮到 .7 时它还是空的。其余 79 个「新访客」各有一桶新额度,限流等于没有。
所以 behind-proxy 和「ntfy 只听本机」必须一起做,只做前一半比两样都不做还糟。
加固:deny-all + 声明式用户 + 只写令牌
我的需求很简单:备份脚本只能往 alerts-backup 写,手机只能读 alerts- 开头的主题,管理员什么都能做,匿名一律拒绝。ntfy 现在支持在配置文件里直接声明用户、权限和令牌(auth-users、auth-access、auth-tokens),不用每次上机器敲 ntfy user add。最终配置如下(哈希和令牌已打码,文件路径换成了正式机上要用的位置,测试机上放在工作目录里):

base-url: "(你的域名,带 https 前缀)"
listen-http: "127.0.0.1:2586"
behind-proxy: true
cache-file: "/var/cache/ntfy/cache.db"
cache-duration: "12h"
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
auth-users:
- "admin:$2a$10$...:admin"
- "phone:$2a$10$...:user"
- "backup-bot:$2a$10$...:user"
auth-access:
- "phone:alerts-*:ro"
- "backup-bot:alerts-backup:wo"
auth-tokens:
- "backup-bot:tk_...:cron"
这里有两个小坑。
密码哈希要用 ntfy user hash 生成,它必须在真终端里交互输入。我想在脚本里批量生成,先试了管道 echo 密码 | ntfy user hash,报 password cannot be empty;再试 NTFY_PASSWORD=... ntfy user hash,报 inappropriate ioctl for device。NTFY_PASSWORD 只对 user add 和 change-pass 有用,对 hash 不管用。最后老老实实在终端里手敲两遍,出来一串 $2a$10$ 开头的 bcrypt。令牌用 ntfy token generate 生成,不用交互,直接输出一个 tk_ 开头的串。
另一个是声明式用户和命令行用户会同时存在。我用 ntfy user change-pass phone 想改配置里的用户,被拒绝:cannot change or delete provisioned user,这个符合预期,改密码就得改配置文件。但 ntfy user add eve 照样能成功,加出来的用户不在配置文件里。也就是说配置文件不是用户的唯一来源,排查权限的时候要用 ntfy access 看全量,别只看 yml。测完我把 eve 删了。
启动后逐项验证,脚本里就是一串 curl,只看状态码:

匿名发、匿名读都是 403;备份脚本的令牌能往 alerts-backup 写,但读不了自己写的主题,换个主题写也是 403;手机账号能读到脚本刚发的那条「backup ok 2.1G」,往里写是 403;密码错是 401。ntfy access 的输出也对得上,匿名用户那一栏是 no access to any (other) topics。
最后把 user.db 和 cache.db 改成 600。默认生成出来是 -rw-r--r--,同机其他用户都能读,cache.db 里存的就是这 12 小时的通知原文。
前面挂 Caddy,再测一次限流
ntfy 只听 127.0.0.1:2586 之后,我用测试机自己的网卡地址去连 2586,curl 直接 Couldn't connect(退出码 7);走 Caddy 的 8080 返回 200。Caddyfile 只有一行反代,测试时关了自动 HTTPS:
:8080 {
reverse_proxy 127.0.0.1:2586
}
我原本担心 Caddy 会把客户端带来的 XFF 原样转发,那伪造照样有效。为了确认,单独起了个回显请求头的小服务放在 Caddy 后面,客户端带上 X-Forwarded-For: 198.51.100.66,后端收到的是 XFF= 加测试机自己的地址,伪造的值被换掉了。我这里没给 Caddy 配 trusted_proxies,它就不认客户端自己带的 XFF。
再对 ntfy 做一次每次换 XFF 的 80 连发:60 次 403(匿名没权限)、20 次 429。限流没被绕过。
有个现象记一下:这一轮刚打完,我马上用备份脚本的令牌发一条,也拿到了 429。因为测试里「攻击」和脚本都从同一台机器发出,Caddy 交给 ntfy 的是同一个来源地址。等了 6 秒再发就是 200。真实环境里脚本和攻击者来源不同,应该不会互相影响,这点我没在公网上测。

上到 Flexus L 实例时,安全组入方向只放 443(以及自己用的 SSH),2586 不放(未实测,依据计划配置)。就算以后哪天手滑把 ntfy 的监听改成了 :2586,安全组还挡一层。
小结
- 默认
auth-default-access是read-write,主题名猜中就能读 12 小时缓存、能发假消息;自建直接改deny-all。 behind-proxy: true必须配listen-http: "127.0.0.1:端口",只开前者,伪造 XFF 就能绕过限流,本文实测 80 次过了 79 次。- 用
auth-users/auth-access/auth-tokens把权限写进配置,脚本只给单主题只写令牌,手机只给只读。 ntfy user hash只能交互输入;命令行加的用户不在配置文件里,查权限用ntfy access。user.db、cache.db改 600。- Caddy 默认会替换掉客户端带的 XFF,放在前面正好补上 behind-proxy 的信任问题。
- 点赞
- 收藏
- 关注作者
评论(0)