安全
这一篇讲的是你和这套软件之间的分工。读完之后,你会知道实例自己已经保护了什么、它刻意不替你做什么、哪些调用会把一份可用的凭据交到你手上,以及在端口能被你自己的笔记本以外的东西访问到之前,你该检查些什么。
它面向的威胁模型
Section titled “它面向的威胁模型”下面每一个默认值都来自同一个前提:一个运维者、一个实例、个位数的账号、没有租户。 机器、数据库、身份池和数据都是你的。没有人在向你租用容量。
正是这个前提,让这里只有三个角色而不是一张权限矩阵,让密码没有有效期,也让身份池里的行不属于任何人。同样是这个前提,让软件对那些它替你决定不了的事情直说:端口该不该公开、你收集的是谁的数据、平台条款允不允许你接下来要做的事。
什么东西值得被偷,挡在前面的又是什么
Section titled “什么东西值得被偷,挡在前面的又是什么”| 资产 | 为什么有人想要它 | 什么挡在前面 |
|---|---|---|
| 身份池里的 cookie jar | 一个可用的平台会话。带登录态的那份就是某个人的账号;游客的那份也是用无头浏览器真金白银跑出来的 | 以 DTK_SECRET_KEY 为密钥的 AES-256-GCM 静态加密;任何常规接口都不会返回它;退休时立即擦除 |
| 代理 URL | 花钱买的出口,凭据就写在 URL 里 | 静态加密;每一处渲染都要过一遍掩码;明文只在一次拨号期间存在 |
| API 接口本身 | 无偿占用你的身份、你的代理和你的 IP 信誉去抓数据 | 默认每个接口都要凭据,除了少数几个从来就没上过锁的路由;按密钥限流;权限范围 |
| 归档 | 攒了几个月的帖子,背后是真实的人 | archive:read 和 archive:export 与平台读取是分开的权限范围 |
| 实例所处的网络位置 | 服务待在调用方本来够不着的网络里——云元数据、局域网网关、10.x 上的数据库 |
对每一个可被抓取的 URL 做正向主机白名单;security.request_proxy 默认拒绝;回调默认关闭 |
| 控制台账号 | 上面这一切 | argon2id、放在 Redis 里的服务端会话、按账号的锁定 |
它防不住什么,而且它自己会说
Section titled “它防不住什么,而且它自己会说”- 拿到宿主机或容器 shell 的人。
DTK_SECRET_KEY就在进程环境里,而 CLI 直连数据库,不做角色检查,也不写审计行。shell 权限就是全部权限,这是设计如此——它同时也是“谁都登不进去”时被写进文档的救援路径。 - 同时拿到数据库和主密钥的人。 静态加密防的是被偷走的 dump、遗落的备份、离开机房的硬盘。它挡不住两半都拿到的人。
- 一个心怀恶意的管理员。 三级阶梯的角色模型关不住阶梯顶上的那个人。管理员能读每一份 jar、导出整个池子、恢复备份、改任何配置。审计日志会记下来,但不会阻止。
- 持有合法密钥、干着看起来合法的破坏的调用方。 限流是防滥用,不是配额系统——这个项目里任何地方都没有计费。
- 平台发现你。 你的身份要么是自己铸的游客,要么是你导入的会话。这里没有任何东西能让一个账号在它所属平台的条款面前变得安全。
- 多租户。 身份池是全实例共享的,行没有归属。谁能点名一个身份,谁就能以它的名义发请求。所以“点名一个身份”是按池管理而不是按读取来把关的。
DTK_SECRET_KEY 是唯一一个不能放进数据库的秘密,因为它就是用来解密数据库的。
openssl rand -base64 48| 属性 | 值 |
|---|---|
| 最短长度 | 32 个字符,检查两次:容器 entrypoint 一次,dtk.core.crypto.derive_key 一次 |
| 密钥派生 | 对字符串做 SHA-256,得到 32 字节 AES 密钥 |
| 算法 | AES-256-GCM,每段密文前面拼 12 字节随机 nonce |
| 附加认证数据(AAD) | 该行自己的 id,所以为身份 A 加密的密文没法挪到身份 B 上 |
| 镜像里自带的默认值 | 没有。也没有任何兜底 |
entrypoint 会拒绝在缺少它的情况下启动三种角色中的任何一种——api、worker、migrate——以 78(EX_CONFIG)退出,并打印生成命令。migrate 同样被拦住:给一个连自己凭据都解不开的部署建好表结构,比迁移失败更糟,而这样做能让失败发生在任何一张表存在之前。
一个自带默认密钥的镜像,等于一个完全没有加密的镜像——这就是这里没有兜底默认值的全部理由。
如果你把它弄丢了
Section titled “如果你把它弄丢了”用它加密的一切都没了。没有恢复路径,也没有密钥托管。
- 身份解不开,因此也签不了名。掩码后的 cookie 视图会报
readable: false;reveal 接口回答 “this identity’s cookies cannot be decrypted with the current secret key”。 - 代理行拨不动,只能重新录入。
- 用旧密钥写出的备份会拒绝恢复:manifest 里带着派生密钥的 HMAC 指纹,不匹配会被明确报成“不匹配”,而不是悄悄写进一堆谁也读不了的行。
回去的路是:把受影响的身份退休,重新铸造或导入;代理重新录入。归档、配置、用户和 API 密钥不受影响——它们都不是用这把密钥加密的。
如果你要轮换它
Section titled “如果你要轮换它”没有轮换工具,而且轮换和弄丢是同一件事:旧密文仍然锁在旧密钥下,没有任何东西会重新加密它们。请把轮换当成 退休整个池子 → 换密钥 → 重建池子 来安排,如果你还可能想退回去,先用旧密钥做一次备份。
请像对待私钥一样对待它:放在仓库根目录的 .env 里(该文件从不提交),不要出现在截图、issue 和聊天记录里。
什么是加密的,什么只是掩码
Section titled “什么是加密的,什么只是掩码”这个区别很重要,因为“掩码”只能保住一张截图。
| 数据 | 静态存储 | 出现在响应里 |
|---|---|---|
identities.cookies_encrypted |
AES-256-GCM,AAD = 身份 id | 从不,除了下文那三个带审计的调用 |
proxies.url_encrypted |
AES-256-GCM,AAD = 代理 id | 掩码:scheme、host、port,有凭据时是 user:***@ |
identities.fingerprint |
明文 JSONB | User-Agent、语言和时区会返回;它标识的是一个浏览器画像,不是一个会话 |
users.password_hash |
argon2id 摘要 | 任何接口都不返回,也不写日志 |
api_keys.key_hash |
完整密钥的 SHA-256 | 从不。只存前缀用于显示 |
携带凭据的运行配置(notify.channels、security.webhook_secret) |
settings 表里的明文 JSON |
读取时掩码;把掩码值写回去表示“保留原值” |
audit_log.detail |
明文 JSONB,写入时就已脱敏 | 配置类的行在返回时再掩码一次 |
有两个结论值得直说:
notify.channels里的 bot token 并没有静态加密。 它在任何人类或 API 读者能看到的地方都以掩码呈现,写进备份归档时也会被加密,但在表里它就是 JSON。拿到数据库的人就拿到了它。- 退休会立刻擦掉 jar。 身份一旦退休,
cookies_encrypted就被置为空字节,行本身只为统计数据继续留着;retention.retired_identity_days(默认 90)之后会把行也删掉。
这套部署持有的其他每一个秘密
Section titled “这套部署持有的其他每一个秘密”| 秘密 | 存在哪里 | 说明 |
|---|---|---|
DTK_SECRET_KEY |
.env,以及 api、worker 和 migrate 的进程环境 |
在 postgres、redis、browser-rpc 和 downloader 的 environment: 块里被显式置空——它们谁都用不上它 |
POSTGRES_PASSWORD、REDIS_PASSWORD |
.env |
两个存储都待在 internal: true 的网络上,没有任何出主机的路由 |
DTK_DOWNLOADER_TOKEN |
.env,可选 |
下载器 sidecar 的共享密钥。纵深防御性质;留空表示 sidecar 什么都不校验 |
| setup token | 只在 Redis 里,TTL 24 小时 | 只在容器日志里打印一次。不出现在任何 HTTP 响应里,不进数据库,不落盘。常数时间比较;五次错误就作废;一旦有账号存在,整个机制永久关闭 |
| API 密钥 | dtk_<prefix>_<secret>,只存前缀和 SHA-256 |
只在创建它的那个响应里出现一次。服务之后没有能力再给任何人看一遍 |
| 控制台会话 token | Redis,7 天 | httpOnly cookie 里的 32 字节不透明 token。控制台用该 token 的 16 位摘要来标识会话,所以“列出会话”不会交出一份能用的凭据 |
| 控制台密码 | Postgres,argon2id | 8–256 个字符。参数编码在摘要里,所以旧 hash 会在下一次登录成功时就地升级 |
仓库里没有任何地方自带默认密码或密钥。这件事是由 entrypoint 检查的,而不是靠文档约定。
把实例暴露出去
Section titled “把实例暴露出去”compose 只发布一个端口,而且绑在回环地址上:
ports: - "${DTK_BIND_HOST:-127.0.0.1}:${DTK_BIND_PORT:-8000}:8000"容器内部 uvicorn 监听的是 0.0.0.0。限制暴露面的是发布地址,不是监听地址。 发布到别处是一个需要明确表态的动作:
DTK_BIND_HOST=0.0.0.0 docker compose -p dtk -f docker/compose.yml up -d在前面没有 TLS 终结点之前,别这么做。容器不做 TLS 终结,不把 HTTP 跳转到 HTTPS,也不发 HSTS。这些是反向代理的活儿,这里没有任何配置项能替代它。
在纯 HTTP 部署下有两件事会坏掉,而且如果你不知道要看,它们都是无声的:
- 会话 cookie 会失去
Secure标记。 这是刻意去掉的——浏览器会把 HTTP 源上的Securecookie 直接丢掉,控制台就没法用了——而且这次降级会记成auth.cookie_insecure而不是悄悄发生。HttpOnly和SameSite=Lax则始终设置。 - 每个请求看起来都来自同一个地址。 在 Docker 的 published-port 用户态代理后面,对端地址是网桥网关;在 TLS 终结点后面,它就是终结点自己。
告诉实例你的反向代理是谁
Section titled “告诉实例你的反向代理是谁”默认情况下 X-Forwarded-For 被忽略,因为没有可信代理时这个头是调用方自己写的。声明代理才会改变这一点:
# 写在仓库根目录的 .env 里DTK_FORWARDED_ALLOW_IPS=172.18.0.5 # nginx/caddy 容器或主机之后 entrypoint 会带上 --proxy-headers --forwarded-allow-ips 启动 uvicorn,uvicorn 只为这些跳点改写对端地址,别人一律不认。
| 取值 | 来源地址意味着什么 |
|---|---|
| 未设置(默认) | 只有对端地址。它会被记录,但不会有任何登录因它被拒——按地址的失败计数器降级成一条告警 |
| 你的代理地址 | 审计日志、会话列表和日志里都是真实客户端地址;按地址的登录限制重新开始拒绝 |
* |
uvicorn 会相信任何人发来的 X-Forwarded-For,地址因此变得可伪造。实例把这种情况当作未声明,仍然不会因为地址而拒绝登录 |
通配符不算声明。可伪造的地址作为锁定依据比共享地址更糟:它等于把撬动所有人访问权的杠杆递给了任何一个陌生人。
应用自己设置的响应头,以及它不设置的
Section titled “应用自己设置的响应头,以及它不设置的”| 响应头 | 值 | 由谁设置 |
|---|---|---|
X-Content-Type-Options |
nosniff |
应用 |
X-Frame-Options |
DENY |
应用 |
Referrer-Policy |
no-referrer |
应用 |
Strict-Transport-Security |
— | 你的反向代理 |
Content-Security-Policy |
— | 你的反向代理 |
看一个部署实际返回了什么:
curl -s -D - -o /dev/null http://127.0.0.1:8000/healthz边界上的其他限制
Section titled “边界上的其他限制”| 限制 | 值 | 为什么 |
|---|---|---|
| 请求体 | 1 MiB | 声明的 Content-Length 超限时,请求根本进不了应用;分块传输的 body 边收边计数,超了就掐断 |
| 粘贴的 cookie / 代理文本 | 200,000 字符 | 对真实输入足够宽松,又不至于让一次粘贴变成内存问题 |
| CORS | 默认为空:只允许同源,完全不发 CORS 头 | security.cors_allow_origins。'*' 会自动丢掉 credentials——浏览器本来也会拒绝这个组合——并记一条 api.cors.wildcard_origin |
| 登录失败 | 每用户名 5 次 / 15 分钟;每地址 20 次(仅在声明了代理时生效);全站 60 秒内 30 次会给后续尝试加 2 秒延迟 | 防滥用,绝不是授权手段。详见用户与 API 密钥 |
SSRF 的收口点
Section titled “SSRF 的收口点”这个服务的存在意义,就是从一个别人够不着的网络内部,去抓别人给的 URL。这正是请求伪造问题的形状,所以 URL 校验被收在一个模块的一个函数里,到处都用它。
服务可能被要求抓取的每一个 URL,都要过 src/dtk/urls/parse.py 里的 is_allowed_host——短链展开之前过一次,展开之后每一跳再过一次。
| 规则 | 后果 |
|---|---|
只允许 http 和 https |
javascript:、data:、file:、gopher: 在任何主机检查之前就被拒 |
| authority 里不允许出现 userinfo | https://[email protected]/ 被拒:这是钓鱼的形状,而且不同解析器对“哪一半是 host”意见不一致 |
| 不允许非默认端口 | http 只许 80,https 只许 443 |
| host 必须是 ASCII DNS 名 | 一步同时拒掉 Unicode 同形字和 0x7f.0.0.1 这类地址字面量 |
| 回环、私网、链路本地、保留地址和单标签内网名优先被拒 | 这样拒绝理由是诚实的,哪怕白名单本身也会拒掉它们 |
| host 必须是白名单里的平台域名或其子域,按标签边界匹配 | douyin.com.evil.com 不通过;evildouyin.com 不通过;www.douyin.com 通过 |
内置列表就是四个可注册域名,含子域:
| 平台 | 域名 |
|---|---|
| 抖音 | douyin.com、iesdouyin.com、amemv.com |
| TikTok | tiktok.com |
这里刻意不做 DNS 解析。 今天解析到私网地址的名字,可能在检查和连接之间解析到别处,所以在这里查一次 DNS,换来的是一种虚假的安全感,代价是请求路径上多一次网络调用。真正的防线是正向白名单,加上每一个出站平台调用都从该身份自己的代理出去这一事实。那些纯粹为了绕过字符串匹配而存在的写法——十进制、十六进制、IPv4-mapped IPv6、结尾多个点——被“拒绝一切不是全球可路由的东西”这条规则一起收掉,而不是靠列举坏东西。
短链每一跳都要重新检查
Section titled “短链每一跳都要重新检查”v.douyin.com、v.amemv.com、vm.tiktok.com 和 vt.tiktok.com 在被跟随之前是不透明的,所以展开是一个循环,全程不让 HTTP 客户端自己去追这条链:
- 最多 5 跳,带环路检测。
- 每个
Location都被交回来,在发出下一个请求之前重新过一遍白名单。 - 展开请求不带 cookie、不带签名,并且从代理池里随机挑一个健康代理出去——刻意不用租借的身份,因为这一跳发生在接口、也因此平台和身份都还没确定之前。一个没有配置任何代理的实例会直接从宿主机展开;如果代理存在但全都不健康,则拒绝这次展开,而不是回落到宿主机地址。
如果让客户端自己跟随跳转,那么等你回头去看的时候,第一个白名单外的主机早就已经被访问过了。
security.url_allowlist 放宽了什么,没放宽什么
Section titled “security.url_allowlist 放宽了什么,没放宽什么”这是唯一一个能放宽 SSRF 边界的配置项,所以它在写入的那一刻就被规范化定死,而不是留给每个读取方各自解释。
| 它会 | 它不会 |
|---|---|
| 在展开过程中放行一个额外的精确主机名作为跳点 | 放行它的子域。cdn.example.com 不会带上 a.cdn.example.com |
| 让一条绕经了预料之外主机的跳转链活下来 | 让调用方提交该主机上的 URL:API 提交侧的检查不带运维白名单,所以加进来的主机放宽的是展开,从来不是路由 |
| 不携带任何平台,因此该主机读起来是允许但无法识别 | 给它一张路由表。基于它构造不出任何调用;它只能是通往平台途中的一跳 |
| 在写入时按名字拒绝指向本机或私有网络的条目 | 先存下来、之后每次读取再悄悄忽略 |
条目在写入时会被转成小写、去重并排序。粘贴 URL 而不是裸主机名会被拒,并给出理由(“must be a bare hostname, not a URL”),因为“把刚才失败的那个 URL 粘进来”是最常见的错误。
只在一条跳转链死在某个未列出的跳点上时才加一条。它是 SENSITIVE 配置:需要管理员角色、admin 权限范围、confirm: true,并留下一条审计行。
两份刻意分开的白名单
Section titled “两份刻意分开的白名单”媒体字节不是页面,它们所在的 CDN 主机也绝不该是分享链接可以指向的地方。所以 src/dtk/media/domains.py 有自己的一份按平台划分的列表——douyinvod.com、zjcdn.com、douyinpic.com、tiktokcdn.com 及其兄弟域名——而 security.url_allowlist 不会放宽它。一个抖音帖子只按抖音那份媒体列表检查。
下载器 sidecar 关掉了名字检查关不掉的那个重绑定窗口:它在拨号时拒绝任何不是全球可路由的地址,所以一个校验通过、片刻后解析到 127.0.0.1 的 CDN 名字照样失败。它也从来收不到 cookie、主密钥、数据库地址或调用方的 ?proxy=——它拿到的只有镜像地址、一份白名单和一个字节上限。
callback_url 是一次由调用方选定目标地址的出站请求,所以它默认关闭,除非管理员打开。
| 关卡 | 在哪里生效 |
|---|---|
security.enable_task_webhook |
提交时检查一次,投递时再检查一次——任务排队期间这个开关可能被关掉 |
只允许 https |
提交时和投递时 |
| 不能是私网、回环或保留地址的主机 | 提交时和投递时 |
| 重新检查解析出来的地址 | 投递时,用事件循环自己的解析器 |
| 校验证书,不跟随跳转 | 始终如此。证书校验才是 DNS 重绑定的真正答案:一个在回环上应答的地址拿不出调用方那个主机名的有效证书 |
| 请求体 | event、task_id、endpoint、state、sent_at,以及一段简短的 error——绝不包含结果。把几兆的采集数据 POST 给第三方,不是谁在查询串里写了个 URL 就能算作做出的决定 |
X-Dtk-Signature |
设置了 security.webhook_secret 时为 sha256=<对实际发出字节的 HMAC>。没有它,接收方无法区分一条真通知和一个猜中了 URL 的人 |
| 失败 | 记日志并吞掉,10 秒超时,3 次尝试,退避 2 秒和 8 秒。一个有敌意或已经宕掉的接收方,不能把一次成功的抓取变成一个失败的任务 |
API 文档首页说回调主机必须在
security.url_allowlist里。代码在回调这条路径上并不查那份列表;真正生效的是上面这张表。以表为准。
调用方自带出口:?proxy=
Section titled “调用方自带出口:?proxy=”运维配置的代理和调用方自带的代理,信任级别是相反的,代码里也把它们当成两个功能。
proxies 表里的一行是有 admin 权限的人录进去的,它被允许指向回环或局域网网关——对自部署来说,本地的一个 SOCKS 监听就是再普通不过的出口。而请求上的 ?proxy= 是持有 API 密钥的人给的一串字符串,“帮我拨这个地址” 就是请求伪造最朴素的形态。
security.request_proxy |
行为 |
|---|---|
deny(默认) |
参数被以 INVALID_PARAM 拒绝,并在消息里点名这个配置项 |
public |
接受,但目标必须是全球可路由地址——回环、私网段、链路本地和内网名一律拒绝 |
any |
原样接受,包括回环和私网段 |
只有当实例上的每一把 API 密钥都握在“你本来就愿意让他接触这台机器所在网络”的人手里时,any 才是自洽的。它是整张配置表里能做出的最大一次放宽。
实现上有三个细节是刻意的:
- 被禁用的功能是拒绝,而不是忽略。 悄悄丢掉这个参数,会让请求从实例自己的地址发出去,而调用方以为它走了自己的代理——这比报错更糟,因为他只能从对端才发现。
- 无法识别的配置值按
deny处理。 运维配置里的一个笔误,绝不能成为打开他们内网的那个东西。 - 这个值从不回显,也从不写日志。 代理 URL 里带着凭据,而请求被拒的那一刻,恰恰是人最可能刚粘了一个真值的时候。只有 scheme 和当前模式会被记录。
被接受的值最长 512 个字符,scheme 必须是 http、https、socks5 或 socks5h。
公开接口,以及永远无法公开的那些
Section titled “公开接口,以及永远无法公开的那些”每个接口都需要 API 密钥或控制台会话,但有两类性质不同的例外。api.public_endpoints 列出的是运营者主动打开的那些,写法是 "<METHOD> <路径模板>",与 API 文档里的拼写完全一致。另一类则是从一开始就没上过锁的,任何开关都关不掉它们:
| 路由 | 为什么它没有锁 |
|---|---|
GET /healthz、GET /readyz |
存活与就绪探针,给管容器的那个东西用 |
GET /api/setup/status |
它要在“存在一个可供认证的账号”之前就能回答 |
POST /api/setup/init |
它就是来创建那个账号的;把守它的是 setup token |
GET /api/v1/ios/shortcut |
快捷指令在还没地方放 API 密钥的时候就要请求它 |
POST /api/v1/auth/login、POST /api/v1/auth/logout |
凭据不可能是“取得凭据”的前置条件 |
控制台的「接口访问控制」页面把上面那些 /api 行标为 always_public,这和“已打开”是两种不同的状态;/healthz 和 /readyz 则根本不在 API 文档里。
有三个前缀无论配置怎么写都会被拒,且没有任何覆盖手段。这条禁令的含义是这些路径无法被写进 api.public_endpoints,而不是说它们底下的每个路由都要凭据——上面的登录和初始化两行就是反例:
| 前缀 | 为什么 |
|---|---|
/api/v1/admin |
身份、代理、用户、API 密钥、配置、备份。开放其中任何一个都等于把实例交出去 |
/api/v1/auth |
登录、会话、改密码 |
/api/setup |
首次初始化,它会创建第一个管理员 |
写了这些路径的条目会在解析配置时被丢弃,并记为 api.public_endpoints.refused。这条禁令写在代码里而不是交给你的判断力,因为它的失败方式既不可逆又无声:一个正好命中管理路由的笔误条目,在被人发现之前看起来什么事都没有。
开放一个路由是必要条件,不是充分条件。匿名调用方运行时的主体恰好只有两个权限范围——douyin:read 和 tiktok:read——外加 viewer 角色。开放一个需要 media:read 的接口,效果只是把 401 变成 403。
匿名调用方按来源地址而不是按密钥限流,所以一个开放接口不至于变成对身份池的无计量抽干。这一点要和上面关于地址的告诫合起来看:没有 DTK_FORWARDED_ALLOW_IPS 的时候,整个互联网共用一个桶——方向上偏严而不是偏松,但也意味着一个繁忙的陌生人能把其他人挡在外面。
有两个接口完全在这个开关之外:
/mcp始终需要 API 密钥。 控制台会话 cookie 会被明确拒绝,而且密钥必须带有某个平台的读取权限范围。它无法被开放。/swagger、/redoc和/openapi.json根本不需要会话,这是设计如此:实例的大多数消费者并没有控制台账号。因此任何能访问到端口的人都能读到完整的 API 接口面。这是有意的,也是你在公开端口之前需要掂量的一个事实。
控制台的「接口访问控制」页面(/endpoint-access)根据运行中应用自己的 OpenAPI 文档来生成这些开关,所以开关和配置不可能对“这条路径叫什么”产生分歧;对永久受保护的行,它渲染成锁定状态,而不是给一个按下去也不会生效的开关。同一份列表也可以从 GET /api/v1/admin/endpoints/access 拿到。
角色、权限范围,以及会交出凭据的调用
Section titled “角色、权限范围,以及会交出凭据的调用”角色是一个阶梯——demo < viewer < operator < admin——而权限范围约束一把密钥。控制台会话只受角色约束;API 密钥两头都受约束,而且权限范围这一半绝不会因为密钥属于管理员就放松。在自部署的机器上几乎每把密钥都属于 admin 用户,所以如果这里为管理员开了短路,archive:export 恰恰会在它被写出来所针对的那种部署里变得无法生效。
管理接口由六个 guard 覆盖:
| Guard | 权限范围 | 最低角色 | 用于 |
|---|---|---|---|
demo_read |
任意 | demo | 演示实例要展示的页面:任务、系统状态 |
read_admin_demo |
admin 或 identity:manage |
demo | 演示实例要展示、且不含凭据的读接口:请求日志、接口健康度 |
authenticated |
任意 | viewer | 任何已认证的调用方 |
read_admin |
admin 或 identity:manage |
viewer | 读取管理接口:各种列表、配置、审计、接口开放情况 |
manage_pool |
admin 或 identity:manage |
operator | 身份、代理和 API 密钥的维护 |
admin_only |
admin |
admin | 用户、SENSITIVE 配置、备份与恢复 |
把 demo 排在 viewer 之下而不是与它并列,是整个安全论证的落点。所有 guard 默认都是 min_role=viewer,所以一个排在下面的角色天然被它们全部拒绝,一行调用点都不用改;明年新加的路由,在有人主动放开之前对演示实例都是关着的。上面那两个 demo guard 就是「主动放开」这个动作本身。
写一个 SENSITIVE 配置要过两道约束——admin 权限范围 加上 admin 角色 加上 confirm: true——因为写配置的那个接口用 identity:manage 也能到达,光靠角色拦不住一把属于管理员的 identity:manage 密钥去放宽 URL 白名单。
另外,一把密钥永远无法签发出一把权限范围超出它自己的密钥。没有这道检查,一把 identity:manage 密钥就能创建一把 admin 密钥,然后在下一个请求里用上它。
三个会返回真实凭据的调用
Section titled “三个会返回真实凭据的调用”管理 API 里其他所有东西都做掩码。这三个刻意不做掩码,而且三个都有审计。
| 操作 | 接口 | 需要 | 审计动作 |
|---|---|---|---|
| 明文查看一份 cookie jar | GET /api/v1/admin/identities/{identity_id}/cookies/reveal |
manage_pool |
identity.cookies_revealed |
| 把身份导出成文件 | POST /api/v1/admin/identities/export(1–200 个 id) |
manage_pool |
identity.exported |
| 解释一次请求 | 内容接口上的 ?explain=true |
admin 或 identity:manage,并且需要 operator 角色 |
request.explained |
- reveal 是一条独立路由,而不是掩码视图上的一个开关,这样审计行记录的是那个刻意的动作,而不会被“每次打开抽屉都加载一次掩码后的 jar”淹没。它之所以存在,是因为一个运维者拿 jar 去和浏览器里的对比时,掩码之后的东西没法对比;而备选方案是开个 shell 手写解密——同样的泄露,却既没有审计行,也没有权限范围检查挡在前面。
- export 交还的是完整的身份,包含 jar 和指纹。文档里有一个
warning字段说明了这一点(导入侧会忽略它),这样以后捡到这个文件的人知道自己手里是什么。里面每一份 jar 在平台让它过期之前都能用,带登录态的那份就是某个人的账号。 - explain 描述的是请求发出去时的样子——签名后的 URL、请求头和该身份的 jar——所以它是一份塞在“一把
douyin:read密钥本来就有权拿到的答案”里的凭据。它按池管理而不是按读取来把关,而且存下来的解释在被后续读者读取时,会对任何不具备identity:manage的人从任务结果里剥掉。审计行只记录谁问的、问的是哪个接口,绝不记 jar。
用 ?identity= 点名一个身份走的是同一道关卡(admin 或 identity:manage,外加 operator 角色),理由也一样:池子是全实例共享且没有归属的,点名一行等于要求以“当初导入那份 jar 的人”的身份发请求。它在提交时就会被校验——id 不存在、已退休、平台不对——所以打错一个 uuid 得到的是一个点名了字段的 400,而不是一个排了队、跑起来、然后死掉的任务。
把实例放到公网让陌生人试用,会遇到一个光靠角色阶梯解决不了的问题:总得有人能登录,而这个人的密码是写在 README 里的。demo.enabled 就是为这件事准备的开关,它对外公开的凭据正好两份。
在设置 → 演示模式里打开。它会生成一个叫 demo 的账号和一把 API Key,并且把密码和 Key 只显示一次——两者都没有以可读形式存下来,所以丢了是重新签发,不是找回。
| demo 会话 | 公开的 API Key | |
|---|---|---|
| 受什么约束 | 角色,排在 viewer 之下 | douyin:read 和 tiktok:read |
| 能调用平台接口 | 能 | 能 |
| 能看总览、资料库、下载、日志、系统信息 | 能 | 不能——权限范围检查会拒绝 |
| 能看身份池、代理、Key、用户、设置 | 不能 | 不能 |
| 能改动任何东西 | 不能 | 不能 |
这张表是由两条独立的规则共同产生的,分清楚哪条管哪边是有用的。权限范围检查负责拦住公开 Key 去读请求日志。只读规则负责拦住控制台会话——它和所有控制台会话一样是不受权限范围约束的——去发起下载或改密码:除非路由在 dtk/api/demo_readonly.py 那份很短的白名单里,否则所有写操作一律拒绝,因此以后新加的接口默认就是关着的。
一次 demo 请求会留下什么
Section titled “一次 demo 请求会留下什么”几乎什么都不留,因为公网实例的数据库是被陌生人随手粘的东西填满的:
- 不写
request_log。 调度器不受影响——身份健康度和接口熔断都是通过 Redis 折算的,所以 demo 调用方照样会把一个已经烧掉的身份冷却下去,和其他人完全一样。 - 不写归档,也不写计数快照。 demo 用户解析出来的东西只返回给他,不留存。
- 会写一行
tasks,这个跳不掉——202加轮询要能work,task id 就必须指向某个东西。这行会被标记is_demo,并按demo.task_retention_minutes(默认 30 分钟)清理,而不是真实调用方那 90 天。
demo 的请求也永远不会和真实调用方的在途任务合并,反过来也一样。共用一个会让上面的保证在两个方向上同时失效。
关闭是在每一次请求上生效的,而不只是在登录时判一次。会话 Cookie 在过期前一直有效,API Key 在吊销前一直有效,所以只在门口查一次的话,已经进来的人还留在里面。现在的做法是:设置一变成 false,两份 demo 凭据立刻解析不出来,所有已打开的 demo 会话被丢弃,同时什么都不删除——重新打开时,还是同一个密码、同一把 Key。
限流不属于这个功能。演示模式决定这个账号能做什么;做多频繁是每把 Key 的限流、或者实例前面那层(比如 Cloudflare)的事。
audit_log 是一张小小的关系表,刻意和请求日志分开,这样“谁改了什么”的记录永远不会被日常流量埋掉。
从 GET /api/v1/admin/audit 读它,或者在控制台「日志」页面(/logs)的审计标签页里看:
curl -s -H "X-API-Key: $DTK_API_KEY" \ 'http://127.0.0.1:8000/api/v1/admin/audit?limit=50&action=identity.cookies_revealed'过滤条件有 limit(最大 500)、action、用于翻页的 before,以及 user_id。
| 分组 | 动作 |
|---|---|
| 身份 | identity.mint_requested、identity.imported、identity.bundle_imported、identity.exported、identity.cookies_revealed、identity.reset、identity.retired |
| 代理 | proxy.created、proxy.imported、proxy.updated、proxy.deleted |
| 凭据 | setup.completed、api_key.created、api_key.revoked、user.created、user.updated、user.deleted、user.password_changed、user.sessions_revoked |
| 配置 | settings.updated、settings.updated_sensitive、settings.reset |
| 运维操作 | backup.requested、backup.restore_requested、diagnose.requested、notification.test_requested、request.explained、watchlist.added、watchlist.updated、watchlist.paused、watchlist.removed |
每一行都带着时间戳、账号、用到的 API 密钥 id、目标类型和 id、一份已脱敏的 detail、来源地址和 User-Agent。
有四个性质值得知道:
detail描述的是改了什么,绝不是凭据本身。 这张表是给人读的,还会被复制进 bug 报告。- 配置类的行在返回时会再掩码一次。 一次配置变更会记录旧值和新值,而有些配置里就装着凭据;那些在“源头脱敏”这件事存在之前写下的行至今还在表里,因为没有东西会清理它。
- 没有任何东西清理它。
audit_log上没有保留策略——请求日志留 14 天、身份事件留 90 天,而审计日志会伴随实例的整个生命周期一直长。它也被刻意排除在备份之外:它记录的是针对“那个即将被替换掉的实例”所做的操作,因此不会跟着恢复一起走。 - 行比它记录的人活得久。 两个外键都是
ON DELETE SET NULL,所以删掉一个用户并不能抹掉这个用户做过的事。
CLI 是这里的缺口。dtk config set、dtk identity retire、dtk proxy add 之类都直接写数据库:没有角色检查,也没有审计行。这是“在控制台用不了的时候仍然可用的救援路径”的代价,也是你应当把 shell 权限当成最高权限来对待的理由。
永远不会写进日志的东西
Section titled “永远不会写进日志的东西”脱敏发生在结构化日志的 processor 链里,而不是在各个调用点,因为“某个调用点忘了”正是一份活的会话 cookie 泄漏出去的方式。
| 规则 | 效果 |
|---|---|
| 按字段名脱敏 | 名为 cookie、cookies、set-cookie、authorization、x-api-key、api_key、apikey、password、secret、token、setup_token、proxy_url 或 dtk_secret_key 的字段整体替换成 [REDACTED] |
| 按值截断 | msToken、a_bogus、X-Bogus、X-Dynosaur、X-Gnarly、_signature、sessionid、sid_guard、odin_tt、uid_tt 和 ttwid 无论出现在哪个字符串里,都被截到前 6 个字符 |
| 第三方 logger | httpx、httpcore 和 hpack 被钉在 WARNING。httpx 会在 INFO 打印每一条请求行,URL 全都在里面——而回调 URL 里常常带着 token,签名过的 CDN 链接里带着签名 |
| 传输层错误 | 只记异常类型,绝不记它的消息:httpx 的错误会把失败的那个 URL 原样带出来,而代理 URL 里带着密码 |
| 校验错误 | pydantic 的 input、ctx 和 url 三个键在错误离开进程之前就被剥掉——input 是那个出问题的值,而对于一个根本解析不了的 body,它就是原始请求字节 |
request_log 里的每请求一行,值得当成一份“这里没有什么”的清单来读:时间戳、request id、task id、平台、逻辑接口名、身份 id、代理 id、API 密钥 id、结果、HTTP 状态、耗时、是否命中缓存、签名器、错误码和拒绝原因。没有 URL,没有查询串,没有请求头,没有 cookie,没有调用方地址。
诊断报告由渲染器负责脱敏,而不是靠粘贴它的人,因为它存在的全部意义就是被粘进一个 issue。dtk config list 和 dtk config get 出于同样理由会对携带凭据的值做掩码。
备份就是凭据文件
Section titled “备份就是凭据文件”有两条规则决定了备份的格式,也正是它们让一份归档可以安全地拷出机器——同时不适合交给别人。
- 凭据以密文形式导出,归档里从不包含主密钥。 cookie jar 和代理 URL 是从
bytea列里逐字节拷出来的,导出过程中什么都不解密。manifest 里带着派生密钥的一个 HMAC——不是密钥本身,也无法反推出密钥——所以用错密钥恢复会被报成不匹配,而不是悄悄写进一堆永远解不开的行。唯一的例外是settings:它的凭据字段在数据库里是明文 JSON,因此在写入归档时会被加密。 - 除非你明确要求,否则不包含身份。 一个身份是绑定了某个代理和某个出口 IP 的 jar;换机器之后出口就变了,那些身份本来也不该继续用。
| 表 | 是否在归档里 |
|---|---|
users、api_keys、proxies、settings、content_snapshots |
总是 |
identities |
只有带 include_identities 时 |
request_log、identity_events、tasks、audit_log、settings_version |
从不 |
因此归档里仍然有账号、API 密钥哈希和配置,所以恢复一份归档可能把某个账号的访问权还回去。恢复只有管理员能做,需要 confirm: true,在排队之前就会校验密钥指纹,并留下一条 backup.restore_requested 审计行。归档成员是按精确名字读取的,从不解压到磁盘,所以一份被手工改过的归档写不出它自己那几个字节之外的地方。
备份的读取和管理接口的其他部分同级——列出一份归档不是凭据——但创建和恢复是 admin_only。
暴露之前的检查清单
Section titled “暴露之前的检查清单”在端口能被你自己以外的人访问到之前,请把这张单子过一遍。
DTK_SECRET_KEY至少 32 个字符、随机生成,并且在另一块盘上有备份。POSTGRES_PASSWORD和REDIS_PASSWORD是随机的,.env没有被提交。- 发布出去的端口前面有 TLS 终结点,并且它会设置 HSTS。
DTK_FORWARDED_ALLOW_IPS指名了那个终结点——不是*。- 第一个管理员账号已经存在,所以 setup 路径已永久关闭。用
GET /api/setup/status确认。 - 每个脚本都有自己的 API 密钥,权限范围取够用的最小集合,并带限流。没有任何东西共用管理员的密钥。
- 除非你确实开放了什么,否则
api.public_endpoints是空的;而且你清楚一个只带两个读取权限范围的匿名调用方能够到哪些东西。 - 没有具体理由的话
security.request_proxy保持deny;不用回调的话security.enable_task_webhook保持关闭——要用的话,把security.webhook_secret设上。 security.cors_allow_origins要么为空,要么写着你自己前端的源,而不是'*'。- 已经做过一次备份,并且列表(
GET /api/v1/admin/backup,或控制台的「备份」页面)对它显示manifest.key_matches: true。
docker compose -p dtk -f docker/compose.yml exec api dtk config list --scope sensitive负责任地使用
Section titled “负责任地使用”这套软件跑在你自己的机器上、由你控制,面对的是有自己服务条款的平台。这意味着为它收集了什么、以及这些东西后来怎么样,负责的人是你——不是这个项目,也不是它的作者。
- 遵守平台条款和你所在地的法律。 Apache-2.0 授予你的是代码;它对这些代码所访问的服务不作任何授权。
- 尊重内容背后的人。 这个实例归档的每一样东西都是某个人的作品,往往还带着他的脸和声音。自部署的归档,依然是一个装着真实的人的数据库。
- 不要拿它去骚扰任何人,也不要二次分发不属于你的作品。
- 关注列表是你不在场时仍在运行的那一部分。 一条每六小时采集某个作者一次的条目,是一个“持续保留这个人的记录”的长期决定。偶尔回头看看这张表,删掉不再需要的;没有任何东西会按时间替你清理归档。
retention.content_days看起来像是干这件事的开关,但当前没有任何代码读取这个键——它只是声明在那里、并不生效——所以归档作品无论它填什么都会一直留着。要删掉它们,得去「资料库」(/library)里做一次显式删除,而什么时候该删,只有你能决定。 - 一份被明文查看的 jar 或一份导出文件就是凭据,带登录态的那份就是一个账号。它不该出现在 bug 报告、共享的 Postman 工作区或聊天消息里。
这些没有别人能替你把关。