Skip to content

Security

Hades edited this page Aug 23, 2026 · 8 revisions

安全

IPClick 的服务端会代替调用方请求任意 URL。这句话决定了它的威胁模型: 一台配置不当的 IPClick 就是一个开放的内网跳板。

四条防线,各管一件事,不能互相替代

防线 回答的问题
SSRF 防护 能打到哪儿
鉴权 谁能用
TLS 链路上能不能被看见/改掉
机密管理 凭据会不会漏出去

1. SSRF 防护

[SECURITY]
allowed_schemes = ["http", "https"]
block_metadata_endpoints = true    # 默认开
block_private_networks = false     # 默认关
allowlist = []

block_metadata_endpoints(默认开)

拦截云厂商实例元数据地址。这是 SSRF 最典型的目标——一次请求就能拿到实例的 IAM 凭据。 没有正当的代理用途,建议保持开启。

它自己就会拦掉整个链路本地段——IPv4 169.254.0.0/16、IPv6 fe80::/10,这一步与 block_private_networks 是否打开无关,默认开着就生效。169.254.169.254 (AWS / GCP / Azure)正落在这个段里,靠这条规则挡住;另外还硬编码了两个不在链路本地段 的字面地址:100.100.100.200(阿里云,在 100.64.0.0/10 CGNAT 里)、fd00:ec2::254 (AWS IPv6 IMDS,在 fc00::/7 ULA 里)。换句话说,默认配置已经连带挡住了同段内其它用途 的地址——比如 AWS ECS/Fargate 的任务角色凭据端点 169.254.170.2 / 169.254.170.23—— 不需要打开 block_private_networks 才覆盖。

block_private_networks(默认关)

拦截回环 / 私网 / 保留地址。

默认关是为了兼容"本机代理本机服务"的用法(开发、内网工具链)。 部署到不可信网络时必须改成 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 之上也能被利用)等等。

allowed_schemes

只允许 http / httpsfile://gopher://dict:// 这些是 SSRF 的经典放大器。 不在白名单里的协议会得到 URLNotAllowedErrorValidationError 的子类), 在发出去之前就被拦掉。

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,同样纳入了这一层——在读页面 HTML、跑 automation_script 之前 校验落地 URL 与捕获到的响应 URL,任一跳越界就不把响应交回调用方,和其余三条路径打平。 四条路径都覆盖到并不是冗余:Windows 上 default_engine() 默认返回的正是 drissionpage,只要有一条路径漏掉校验,一个 302 Location: http://169.254.169.254/ 就足以把云元数据连同页面一起取回来。

所以在真正不可信的调用方面前,用浏览器渲染时 block_private_networks = true 仍然只是 必要条件而非充分条件:请把服务端放进一个出网受限的网络命名空间 / 安全组,让内网与 元数据地址在网络层就不可达。

打开任一开关后,解析不出的域名会被直接拒绝(报「无法解析主机 …,为避免绕过 SSRF 准入已拒绝请求」),而不是放过去让适配器再试——否则 DNS 抖一下就能绕过准入。 副作用是内网 DNS 不可达时,报错会是 URLNotAllowedError 而不是超时,别去查目标站点。

automation_script 会绕过这一整套

页面里的 JS 自己发的请求,走的是浏览器的网络栈,[SECURITY] 的 URL 策略对它 完全不起作用

[BROWSER]
allow_scripts = false    # 默认关,保持这样

打开它等于把 SSRF 防线让开一整条。只有在确认调用方全部可信时才打开。

2. 鉴权

# .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

3. 传输层加密

[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"

mTLS(双向验证)

[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)。只应在自签名证书的内网环境里用。

4. 机密管理

规则:.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 管理端

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 限死在本源
  • 从不显示任何机密。「生成凭据」也不破这条:生成的值只在那一次响应里出现; 服务端不落盘持久化,只在内存里保留到你把它取走一次为止(最多缓存 8 个, 被更晚生成的顶掉)
  • 安全边界不可从网页修改 —— [SECURITY] 全部、Web 自己的凭据、集群密钥、 allow_scripts。攻破 Web 登录不该等于能关掉 SSRF 防护

唯一放开的一项:能装 / 卸可选组件。 "装依赖要在机器上执行命令"值得停一下, 放开它的理由是:这个界面已经能改配置、能代发任意请求,再加上"装它自己声明过的 那五个可选依赖"没有实质性地扩大攻击面。约束仍然在:

  • 包名只能来自白名单常量,表单传来的值必须精确匹配,绝不拼接;
  • 命令以列表形式交给 subprocessshell=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 隧道访问

集群状态页没有配置节——配置文件里没有任何键能开关或改动它,它只是库 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 私密安全通报

下一步

Clone this wiki locally