-
Notifications
You must be signed in to change notification settings - Fork 0
Security
IPClick 的服务端会代替调用方请求任意 URL。这句话决定了它的威胁模型: 一台配置不当的 IPClick 就是一个开放的内网跳板。
四条防线,各管一件事,不能互相替代:
| 防线 | 回答的问题 |
|---|---|
| SSRF 防护 | 能打到哪儿 |
| 鉴权 | 谁能用 |
| TLS | 链路上能不能被看见/改掉 |
| 机密管理 | 凭据会不会漏出去 |
[SECURITY]
allowed_schemes = ["http", "https"]
block_metadata_endpoints = true # 默认开
block_private_networks = false # 默认关
allowlist = []拦截云厂商实例元数据地址。这是 SSRF 最典型的目标——一次请求就能拿到实例的 IAM 凭据。 没有正当的代理用途,建议保持开启。
名单是三个字面地址:169.254.169.254(AWS / GCP / Azure)、100.100.100.200(阿里云)、
fd00:ec2::254(AWS IPv6)。block_private_networks 覆盖整个 link-local 段,
所以开了它等于连带覆盖这三个。
拦截回环 / 私网 / 保留地址。
默认关是为了兼容"本机代理本机服务"的用法(开发、内网工具链)。
部署到不可信网络时必须改成 true:
[SECURITY]
block_private_networks = true
allowlist = ["10.0.0.5", "internal-api.corp"] # 确实要放行的allowlist 的匹配规则是主机名精确相等(小写化后比字符串),不认 CIDR、也不认
*.corp 这种通配——写 10.0.0.0/8 只会放行字面量为 10.0.0.0/8 的主机,等于什么都
没放行,而且不会报错。要放行一个网段就把里面确实要用的地址逐个列出来。
它也不看解析后的 IP:写 internal-api.corp 放行的是这个名字,不是它当前指向的地址。
另外它比元数据、内网检查先判定——所以往 allowlist 里写 169.254.169.254
就等于把元数据防护对这个地址整个关掉了。
不开的话,任何能调你服务的人都能让它去请求 http://192.168.1.1/、
http://localhost:6379/(Redis 协议在 HTTP 之上也能被利用)等等。
只允许 http / https。file://、gopher://、dict:// 这些是 SSRF 的经典放大器。
不在白名单里的协议会得到 URLNotAllowedError(ValidationError 的子类),
在发出去之前就被拦掉。
SSRF 拒绝在客户端是抛异常,不是返回一条错误响应。 服务端回 PERMISSION_DENIED,
SDK 还原成 URLNotAllowedError 抛出来——d.get() 只会把 TransportError 吞成
status_code == -1,这一类不吞。所以它和"目标站点连不上"能一眼分开:
一个要改 [SECURITY] 策略,一个要查网络。
准入不只检你传进来的那个 URL——重定向目标也过策略。但 HTTP 适配器和浏览器适配器 的强度不一样,这个差别值得知道:
curl_cffi / niquests:逐跳前置校验。 不让底层库自己跟随重定向,而是自己一跳一跳
走,每跳发出之前校验一次。Location 会先按当前 URL 解析成绝对地址再校验(否则
/latest/meta-data 这类相对跳转能绕过基于主机名的策略)。所以目标站点回一个
302 Location: http://169.254.169.254/...,那个地址根本不会被请求。
方法改写按 RFC 7231:301/302/303 把 POST 改成 GET 并丢掉请求体,307/308 保持不变。
浏览器适配器:事后校验。 Playwright 的 context.route 处理器对重定向目标不会再次
触发(重定向由浏览器网络栈内部跟随完),所以拦不住请求真的发出去。能做到的是走一遍
重定向链、发现越界就拒绝把响应体交回调用方。读云元数据这类攻击要的就是那段正文,
掐掉它有实际意义——但带副作用的内网写操作(比如 POST 到内网管理接口)依然会执行。
DrissionPage 走 CDP,2.0.0 之后的修复把它也纳入了这一层(在读页面 HTML、跑
automation_script 之前校验落地 URL 与捕获到的响应 URL)。
⚠️ 含 2.0.0 在内的所有已发布版本,DrissionPage 这条路完全不做逐跳校验。 它是四条路径里唯一漏掉的一条,而 Windows 上default_engine()返回的正是drissionpage、adapter=browser也会走到这里——所以一个302 Location: http://169.254.169.254/能把云元数据连同页面一起取回来。 在装上修复版之前,用 DrissionPage 渲染就不要把[SECURITY]当作内网防线, 请在网络层隔离。
所以在真正不可信的调用方面前,用浏览器渲染时 block_private_networks = true 仍然只是
必要条件而非充分条件:请把服务端放进一个出网受限的网络命名空间 / 安全组,让内网与
元数据地址在网络层就不可达。
打开任一开关后,解析不出的域名会被直接拒绝(报「无法解析主机 …,为避免绕过 SSRF
准入已拒绝请求」),而不是放过去让适配器再试——否则 DNS 抖一下就能绕过准入。
副作用是内网 DNS 不可达时,报错会是 URLNotAllowedError 而不是超时,别去查目标站点。
页面里的 JS 自己发的请求,走的是浏览器的网络栈,[SECURITY] 的 URL 策略对它
完全不起作用。
[BROWSER]
allow_scripts = false # 默认关,保持这样打开它等于把 SSRF 防线让开一整条。只有在确认调用方全部可信时才打开。
# .env
IPCLICK_AUTH_TOKEN=<一串够长的随机>Downloader(host="10.0.0.1", token="<同一串>")留空 = 不鉴权,服务端启动时会打显著告警:
未配置鉴权令牌,任何能连到本端口的调用方都可以使用本服务。
监听在非回环地址而不鉴权,等于把这台机器的出口 IP 和内网可达性送给任何人。
比对本身是常量时间(hmac.compare_digest),而且配了多个令牌时不会在第一个命中处
提前返回——否则"命中的是数组里第几个"会从耗时里漏出来。所以轮换期并存两三个令牌
不会带来时序侧信道,代价只是每次多比几次。
环境变量只接受单个令牌。轮换期间要新旧并存的话,只能写在配置里:
[SECURITY]
auth_token = ["新令牌", "旧令牌"]
allow_secrets_in_config = true # 否则每次启动都会被点名轮换完记得删掉旧的、改回环境变量。
集群内部用共享密钥派生每台的令牌,见集群:
token = HMAC-SHA256(secret, "ipclick-node:" + node_id)
每台令牌不同(拿到 B 的不能调 C),但只需要分发一个密钥。
grpc.health.v1 不需要令牌——K8s 探针和服务网格没法带凭据。它只返回
SERVING / NOT_SERVING,不泄露任何信息。不想暴露就 [MONITOR].health_check = false。
[SECURITY.tls]
enabled = false
cert_file = ""
key_file = ""
ca_file = ""
require_client_cert = false
server_name_override = ""默认关闭(明文)以兼容旧部署。但要清楚:令牌鉴权保护的是"谁能用", 明文链路上令牌本身会被同网段原样嗅探到。 公网或不可信网络务必打开。
[SECURITY.tls]
enabled = true
cert_file = "/etc/ipclick/server.crt"
key_file = "/etc/ipclick/server.key"[SECURITY.tls]
enabled = true
cert_file = "/etc/ipclick/server.crt"
key_file = "/etc/ipclick/server.key"
ca_file = "/etc/ipclick/ca.crt" # 验证客户端证书用
require_client_cert = true
⚠️ require_client_cert = true却不配ca_file会启动时直接报错。 那种组合下任何自签名证书都能通过,等于没验证——所以不会静默降级。
[SECURITY.tls]
enabled = true
ca_file = "" # 留空用系统信任库(对接公网 CA 时就该留空)
cert_file = "" # 只有服务端要 mTLS 时才填
key_file = ""
server_name_override = "" # 证书名字与连接地址不一致时用server_name_override 会跳过主机名匹配(比如证书签给 ipclick.internal 而你连
10.0.0.5)。只应在自签名证书的内网环境里用。
规则:.env 只放机密,ipclick.toml 只放行为。
# .env —— 600 权限,绝不进版本库
IPCLICK_AUTH_TOKEN=...
IPCLICK_WEB_USER=admin
IPCLICK_WEB_PASSWORD=...
IPCLICK_PROXY_AUTH_KEY=...
IPCLICK_PROXY_AUTH_PASSWORD=...
IPCLICK_CLUSTER_SECRET=...ipclick init 生成的 .env 是 600 权限(POSIX;Windows 上权限由 NTFS ACL 决定,
init 不做额外收紧),并且在已有 .gitignore 时把 .env 追加进去。目录里没有
.gitignore 的话它只打一条提示、不会替你新建——这时得自己确认 .env 不会被提交。
机密写进 ipclick.toml 仍然生效——受信环境里图省事是合理的。只是启动时会点名提醒:
ipclick.toml 通常要进版本库,机密会跟着进 git、进备份、进 CI 日志。
设 allow_secrets_in_config = true 可以关掉提醒。
两边都写时环境变量优先。
查每个机密来自哪里:
ipclick config-info 机密来源:
gRPC 鉴权令牌 环境变量 / .env
Web 管理端密码 配置文件 ⚠️ 建议改用环境变量
集群共享密钥 未配置
见 Web 管理端 · 安全设计。要点:
- 默认只监听
127.0.0.1 - 无默认口令,不设就随机生成
- CSRF、
HttpOnly+SameSite=Strict、会话 12 小时过期 - 登录限速:300 秒窗口内 5 次失败即锁定该来源 300 秒(从触发阈值的那次失败起算完整 锁定期,锁定期间正确密码也不放行;登录成功即清零)。这套数值不可配——能配就意味着 能被调成形同虚设
- CSP
default-src 'none';script-src用内联脚本的 sha256 哈希, 不是'unsafe-inline'——注入进来的<script>哈希对不上 就执行不了。connect-src 'self'把 fetch 限死在本源 - 从不显示任何机密。「生成凭据」也不破这条:生成的值只在那一次响应里出现, 服务端不保存、不写进任何文件
-
安全边界不可从网页修改 ——
[SECURITY]全部、Web 自己的凭据、集群密钥、allow_scripts。攻破 Web 登录不该等于能关掉 SSRF 防护
唯一放开的一项:能装 / 卸可选组件。 "装依赖要在机器上执行命令"值得停一下, 放开它的理由是:这个界面已经能改配置、能代发任意请求,再加上"装它自己声明过的 那五个可选依赖"没有实质性地扩大攻击面。约束仍然在:
- 包名只能来自白名单常量,表单传来的值必须精确匹配,绝不拼接;
- 命令以列表形式交给
subprocess(shell=False),做不到"执行任意命令"; - 绑定当前解释器,不依赖 PATH。
换句话说,它能装的东西是闭集,不是"任意 pip 包",更不是任意命令。
它本身是明文 HTTP,远程访问请走 SSH 隧道或 TLS 终止的反向代理。
[SERVER]
host = "10.0.0.5" # 不要 0.0.0.0,除非确实需要
[SECURITY]
block_private_networks = true # ← 最容易漏的一项
block_metadata_endpoints = true
allowlist = [] # 只列确实需要的
[SECURITY.tls]
enabled = true
cert_file = "..."
key_file = "..."
[BROWSER]
allow_scripts = false # 保持关闭
no_sandbox = false # 容器里不得不开时,理解代价
[WEB]
host = "127.0.0.1" # 走 SSH 隧道访问集群状态页没有配置节(0.5.0 起整节删除,因为当时那三个键没有任何一处代码读—— 一个开了没反应的开关比没有这个开关更糟)。它是库 API:
StatusPageServer(...).start(port), 默认只监听127.0.0.1。自己起它的时候别把 host 改成0.0.0.0:它会把内网节点地址 和整份拓扑吐出来。
# .env —— 600 权限,不进版本库
IPCLICK_AUTH_TOKEN=<32+ 字节随机>
IPCLICK_WEB_PASSWORD=<32+ 字节随机>
IPCLICK_CLUSTER_SECRET=<32+ 字节随机>生成随机串:
python -c "import secrets; print(secrets.token_urlsafe(32))"最后跑一遍 ipclick config-info 逐项核对——它显示的是什么真生效了,
不是配置文件里写了什么。
发现漏洞请不要开公开 issue,改用 GitHub 私密安全通报。