Crawl4AI Docker API 服务器安全加固迁移指南:认证门禁、声明式 Hooks 与产物存储完整改造解析
本篇指南聚焦 Crawl4AI 自托管 Docker API 服务器(deploy/docker/)的一次“secure-by-default”重大发布。读完本文,你将掌握升级所需的两步必改操作(API Token 设置与 JWT 重签)、各功能维度的破坏性变更(声明式 Hooks、产物存储、LLM 白名单、CORS、资源配额等)的逐项迁移方法,并能对照仓库源码理解每项加固的底层实现与验证测试。
需要特别强调升级范围的边界:核心 pip 库(SDK / 进程内使用方式)完全不变,这些变更仅影响自托管的 HTTP 服务器。工作量取决于你此前通过 API 使用了多少高级功能:仅做“正常配置爬取这些 URL”的用户只需完成“人人必做”的两步;其余小节只在你用过对应功能时才需要处理。
一、总体原则:默认安全,按需回退
本次发布改变了多项默认行为,使开箱即用的部署即是安全的。迁移文档 deploy/docker/MIGRATION.md 给出的默认值对照表如下,建议直接作为自查清单:
| 设置 | 旧行为 | 新行为 |
|---|---|---|
| 绑定地址 | 0.0.0.0,无认证开放 |
默认 127.0.0.1;对外暴露必须配置 Token |
| 认证 | 默认关闭 | 默认开启 |
| 安全响应头 / CSP | 关闭 | 开启(API 面使用严格 CSP) |
| CORS | 无控制 | 默认拒绝(deny-by-default) |
| TLS 校验 | 关闭 | 开启 |
| Redis | 无密码、端口对外发布 | 有密码、仅 loopback、端口不发布 |
output_path 参数 |
接受 | 移除(改为产物存储 artifact store) |
LLM 请求内 base_url |
接受并生效 | 移除 |
| Hooks | 可传 Python 代码 | 改为声明式动作 |
| 后台任务队列 | 无界 | 有界队列(可配置,0 = 无界) |
从源码结构看,整套加固由若干模块协同实现:最外层 ASGI 认证门禁(auth_gate.py)、资源治理中间件(governor.py)、声明式 Hooks 注册表(hook_registry.py)、沙箱化产物存储(artifacts.py)、LLM 服务方解析(llm_broker.py)与有界作业队列(work_queue.py),配置基线集中在 config.yml。
二、人人必做的两步
2.1 第一步:设置 API Token
服务器不再在 0.0.0.0 上提供无认证 API。默认绑定 loopback,且没有凭据时不会对外暴露:
export CRAWL4AI_API_TOKEN="$(openssl rand -hex 32)"
两种运行姿态的行为在 server.py 的 _resolve_auth() 中精确实现:
- 设置了 Token:可以对外暴露服务器(建议前置一个做 TLS 终结的反向代理),此后除
GET /health外的每个请求都必须携带Authorization: Bearer <token>; - 未设置 Token:服务器只绑定
127.0.0.1,并在启动时打印一个一次性(ephemeral)Token 供本地使用;此时若尝试绑定非 loopback 地址,启动守卫会直接sys.exit(1)拒绝启动,日志明确提示“无凭据绑定非回环地址将暴露未认证 API”。
从源码结构看,认证决策被移到了最外层 ASGI 中间件 AuthGateMiddleware:它统一覆盖所有路由、静态挂载、MCP 传输与 WebSocket,且是 fail-closed 设计——没有有效凭据的请求在到达任何 handler 之前就被拒绝(HTTP 返回 401 JSON,WebSocket 直接以 4401 关闭)。静态 operator token 使用恒定时间比较(constant_time_eq,基于 hmac.compare_digest)校验,且对应 admin scope;服务器签发的 HS256 JWT 则携带自身 scope 声明(默认 data)。唯一的公开路径是健康检查端点与 /token 签发端点(见 server.py 中 public_paths={HEALTH_PATH, "/token"} 的中间件注册)。
对于无法设置请求头的 WebSocket 客户端(MCP、monitor 等),可以在 URL 上以查询参数传递:?token=...。该逻辑在 auth_gate.py 的 _extract_token 中实现,仅对 websocket 类型的连接生效。
2.2 第二步:重签所有旧 Token
JWT 实现已更换,旧版本签发的 Token 一律不再有效。需要重新通过 POST /token 获取,且该端点现在要求服务器已配置 api_token(config.yml 中 security.api_token 或环境变量 CRAWL4AI_API_TOKEN)——从 server.py 的 get_token 实现看,未配置 api_token 时端点直接返回 403 “Token issuance is disabled”,请求方提供的 api_token 字段会用恒定时间比较校验,不匹配返回 401。
从 auth.py 可以看到新 JWT 的具体形态:
- 算法固定为 HS256,
algorithms以列表形式传入,杜绝子串匹配漏洞并拒绝alg:none; - 默认有效期 60 分钟(
ACCESS_TOKEN_EXPIRE_MINUTES = 60); - Token 携带
scope声明,data(普通)或admin(管理员),这一区分直接决定后文 Monitor 管理操作是否可用; SECRET_KEY有强制校验:弱值(mysecret、secret、password等)或长度不足 32 会启动失败;真实部署未设置时会 fail fast,仅 loopback/开发场景才会自动生成临时 key 并告警。
三、按需迁移:功能维度的破坏性变更
以下各小节互相独立,只在你用过对应功能时才需要处理。
3.1 请求体只接受声明式(declarative)选项
爬取请求体现在只承载标量级、声明式选项。通过网络发送时,以下字段会被直接以 HTTP 400 拒绝;它们只能配置在服务器侧,或使用自托管进程内构建(SDK 保留完整控制权):
js_code、js_code_before_wait、c4a_script、proxy / proxy_config、extra_args、user_data_dir、cdp_url、cookies、headers、init_scripts、base_url、deep_crawl_strategy、simulate_user、magic、process_in_browser,以及嵌套的 LLM 配置对象。
此外:未知字段被静默丢弃;timeout、viewport、滚动次数等数值会被钳制到安全上限。
从源码结构看,这一信任边界由 crawl4ai.async_configs 的 Provenance.UNTRUSTED 加载路径实现——server.py 中 _config_from_json 在调用 CrawlerRunConfig.load(data, provenance=Provenance.UNTRUSTED) 时就已过滤禁止的“power 字段”与不安全的嵌套类型(LLM 配置、proxy、deep-crawl 这类可能读取环境变量/密钥的路径全部被禁止)。test_security_trust_boundary.py 覆盖了具体行为:extra_args 中的 Chromium 命令注入载荷被拒、timeout: 0 会被钳制为上限值、未知字段被丢弃而不抛错、以及一个“通过请求配置读取服务器环境变量实现密钥窃取”的 PoC 场景被拒绝。请求 schema 层面,schemas.py 的 CrawlRequest 也将 urls 列表限制为最多 100 个。
3.2 Hooks:声明式动作替代 Python 代码
旧的 hooks.code(Python 字符串)被一组固定的声明式动作取代。请求示例:
{
"hooks": {
"hooks": [
{"action": "block_resources", "params": {"resource_types": ["image", "font"]}},
{"action": "scroll_to_bottom", "params": {"max_steps": 10, "delay_ms": 500}}
]
}
}
可用的 5 个动作为:block_resources、add_cookies、set_headers、scroll_to_bottom、wait_for_timeout。运行时可调用 GET /hooks/info 查看每个动作的参数 schema(server.py 中该端点直接返回 hook_registry.py 的 describe_registry(),包含 hook point、描述与 JSON Schema)。
从 hook_registry.py 的源码可以看到每个动作的安全约束与挂载点:
| 动作 | 挂载点(hook_point) | 参数约束(源码常量) |
|---|---|---|
block_resources |
on_page_context_created |
resource_types 仅限 image / stylesheet / font / media,至少 1 个 |
add_cookies |
on_page_context_created |
最多 20 个 cookie(_MAX_COOKIES),name ≤ 256 字符、value ≤ 4096 字符 |
set_headers |
before_goto |
最多 20 个头(_MAX_HEADERS);头名必须匹配 ^[A-Za-z0-9-]{1,64}$;值中禁止 \r\n\x00 控制字符 |
scroll_to_bottom |
before_retrieve_html |
max_steps ∈ [1, 50](默认 10);delay_ms ∈ [0, 5000](默认 500) |
wait_for_timeout |
before_retrieve_html |
timeout_ms ∈ [0, 60000](默认必须提供) |
设计上有一个关键不变式:任何用户字符串都不会到达解释器。每个动作映射到一个由服务器编写的异步函数,只调用一个确定的 Playwright API。请求最多声明 10 个 hook(超过抛 HookValidationError,映射为 400);针对同一 hook point 的多个动作会按顺序组合执行。模块文档字符串明确记录了改造原因:旧的 hook_manager.py 对用户提供的 Python 执行 exec(),沙箱可被 __subclasses__ MRO 遍历等方式逃逸,在 hooks 开启时构成未认证 RCE,该模块已删除(test_security_2026_04_b2.py 中 test_hook_manager_module_deleted 与 test_registry_actions_are_fixed_and_codeless 均有断言)。另外 hooks 默认整体关闭,需设置 CRAWL4AI_HOOKS_ENABLED=true 才启用;确需任意 hook 代码的高级用户,应使用自托管进程内构建,其中 crawler_strategy.set_hook(...) 仍然可用且被视为可信。
3.3 截图 / PDF:产物 ID 取代 output_path
output_path 参数被彻底移除。服务器将结果写入自有存储并返回 id + URL:
{"success": true, "screenshot": "<base64>",
"artifact_id": "….", "url": "/artifacts/….", "mime": "image/png", "size": 12345}
文件通过 GET /artifacts/{artifact_id} 拉取(需要认证)。产物具有 TTL 和存储配额。
从 artifacts.py 可看到默认值与边界(均可通过环境变量覆盖):
- 目录:
CRAWL4AI_ARTIFACT_DIR,默认/var/lib/crawl4ai/outputs,权限 0700; - 单文件上限:
CRAWL4AI_MAX_ARTIFACT_BYTES,默认 50 MiB,超限写入失败,server.py 的_store_artifact将其映射为 413; - 目录总配额:
CRAWL4AI_ARTIFACT_QUOTA_BYTES,默认 2 GiB,超限映射为 507; - TTL:
CRAWL4AI_ARTIFACT_TTL_SECONDS,默认 3600 秒,过期后resolve_artifact返回 404(不泄露存在性),后台 janitor(每 300 秒运行一次)回收过期与超配额文件; - 文件名是服务器生成的 32 位十六进制 UUID(不可猜测、无遍历面),写入使用
O_EXCL | O_NOFOLLOW、权限 0600;读取时lstat拒绝符号链接。
模块文档字符串记录了动机:旧的 output_path 校验只是字符串级过滤,未做 realpath / O_NOFOLLOW,符号链接即可实现任意文件写入。test_security_artifact_store.py 系统验证了上述每条不变式,包括“非 hex 或遍历形式的 id 一律 404”“TTL 过期即 404 并被清理”“符号链接不被跟随”等。
3.4 LLM 端点:按名称选择 provider,base_url 已移除
/md、/llm 与 /llm/job 不再接受请求内 base_url。你只能按名称选择 provider;端点与密钥一律由服务器侧环境变量(如 OPENAI_BASE_URL / LLM_BASE_URL)与 config.llm.allowed_providers 推导。请求一个白名单之外的 provider 家族返回 400。
llm_broker.py 的文档字符串点明了攻击面:请求可以自带 base_url 时,服务器会把配置好的 provider API key 发送到攻击者控制的端点,泄露服务器持有的所有 provider key。现在的 resolve_llm() 签名中根本没有 base_url/api_token 参数,返回值中的 base_url、api_token、temperature 全部由服务器侧推导。allowed_providers 为空时不限制家族选择(但密钥泄露路径依然关闭);设置非空白名单可进一步收紧。schemas.py 中 MarkdownRequest 的字段注释也明确写道“base_url removed: a request-supplied LLM endpoint was a credential-exfil vector”,test_security_llm_broker.py 对 /md 端点请求非法 provider 返回 400 的行为有直接测试。
3.5 Monitor 管理操作需要 admin scope
POST /monitor/actions/cleanup、POST /monitor/actions/kill_browser、POST /monitor/actions/restart_browser 与 POST /monitor/stats/reset 现在要求 admin scope 主体:静态的 CRAWL4AI_API_TOKEN 天然具备 admin scope,而通过 /token 签发的 JWT 是 data scope。
从 monitor_routes.py 可以看到四个端点均声明 dependencies=[Depends(require_admin)];require_admin(auth.py)检查 AuthGate 已验证的主体 scope == "admin",不满足则 403。test_security_authz.py 验证了 data scope 无法 reset stats / kill browser、admin scope 通过、未认证的 admin 操作返回 401 的完整矩阵。
3.6 浏览器 / JS 客户端:CORS 白名单
跨域浏览器请求默认被拒绝,除非来源在允许列表中:
security:
cors_allow_origins: ["https://your-frontend.example"]
config.yml 中该字段默认为空列表并注释为 “deny-by-default”。server.py 的 _setup_security 在构建 CORS 中间件时会过滤空值与 *——即永远不允许带凭据的通配来源。
3.7 TLS 校验默认开启
自签名 / 内部 TLS 的爬取目标现在默认失败。仅为可信的内部测试准备的两个逃生开关(均默认 false,在 egress_broker.py 中读取):
CRAWL4AI_ALLOW_INSECURE_TLS=true:允许不校验证书的内部测试目标;CRAWL4AI_ALLOW_INTERNAL_URLS=true:允许爬取内网地址(默认下 localhost、RFC1918 段、云厂商 metadata 地址等被出站代理与 URL 校验拦截)。
config.yml 中 Chromium 启动参数也已同步移除 --disable-web-security、--allow-insecure-localhost、--ignore-certificate-errors,注释里写明了原因:这些参数会关闭每一次爬取的 TLS 校验(MITM / SSRF-to-internal-TLS 风险)。
3.8 Webhook 自定义头校验
自定义 webhook 头必须是格式合法的头名、不含控制字符,且不得设置 hop-by-hop / 敏感头(Host、Content-Length、Transfer-Encoding、Authorization、Cookie 等),否则返回 422。webhook.py 的 sanitize_webhook_headers 负责该净化;test_security_headers_xss.py 覆盖了 CRLF 值被拒、hop-by-hop 头被拒、非法头名被拒、Pydantic 层提前拒绝(422)等场景。此外 webhook 的投递目标在每次重定向跳时都会重新做内网校验,解析到非全局地址即终止(SSRF 防护)。
3.9 Redis 必须使用密码
Redis 在容器内运行、仅 loopback 绑定、启用密码,且其端口不再对外发布。若使用外部 Redis,需设置 REDIS_PASSWORD(server.py 的 _build_redis_url 从 REDIS_HOST/REDIS_PORT/REDIS_PASSWORD/REDIS_DB 环境变量或 config.yml 的 redis 段构建连接串)。test_security_container_posture.py 断言了 Dockerfile 不暴露 Redis 端口、supervisord 强制密码与 loopback 绑定、compose 中 cap_drop: [ALL]、no-new-privileges、只读根文件系统与 pid 限制等容器姿态。
3.10 资源限制(全部可配置,0 = 无界)
config.yml 的 limits 段与迁移文档给出的示例一致:
limits:
max_body_bytes: 10485760 # 请求体上限(超限 413);0 = 无界
wall_clock_s: 0 # 单次爬取截止时间(超时 504);0 = 无截止
queue:
maxsize: 1000 # 后台作业队列上限(满则 503);0 = 无界
workers: 4
per_principal: 0 # 单调用方最大并发作业数(超限 429);0 = 不限
配置中还有两个深爬钳制项:max_pages: 100(默认值来自 governor.py 的 DEFAULT_MAX_PAGES)与 max_depth: 5,属于纵深防御——不可信请求体根本无法设置 deep_crawl_strategy,而服务器侧基础配置若携带了无界的深爬策略,clamp_deep_crawl() 会就地钳制。
各限流码与实现位置的对应关系(从源码确认):
- 413:
BodySizeLimitMiddleware在 ASGI 层检查Content-Length,超限直接拒绝(governor.py); - 504:
wall_clock_s为单次爬取的截止时间; - 503:
WorkQueue队列满时入队失败(work_queue.py 的maxsize); - 429:单主体并发作业数超过
per_principal配额。
若要严格保持旧行为,把你不想要的各项上限设为 0 即可(test_security_resource_caps.py 中 test_zero_means_unbounded 验证了该语义)。
3.11 错误响应统一泛化
5xx 响应现在返回 {"error": "Internal server error", "correlation_id": "…"},需凭 correlation id 到服务器日志中查详情;面向开发者的 4xx 消息保持不变。从 server.py 的集中异常处理器看:所有 500 与未捕获异常统一生成 12 位 hex 的 correlation id 并完整记录在服务器日志;而 502/503/504 这类带自身短消息与 Retry-After 等头部的“故意运营状态码”会原样放行。模块注释说明动机:此前 16 处位置会把原始 str(e) 返回给客户端,泄露路径、依赖版本、解析出的内网 IP 甚至密钥。
四、运维注意事项
--no-sandbox仍为默认。容器以非 root 运行且没有可用沙箱,因此 Chromium 默认带--no-sandbox。要移除它:让容器运行在 non-privileged user namespace(unprivileged_userns_clone=1)或提供 seccomp profile,然后设置CRAWL4AI_CHROMIUM_SANDBOX=true——server.py 的_browser_extra_args()在该标志为真时会从启动参数中过滤掉--no-sandbox。- 加固版 docker-compose 姿态。根目录 docker-compose.yml 使用了
read_only: true+ tmpfs、cap_drop: [ALL]、no-new-privileges、pids_limit,以及shm_size取代宿主机/dev/shm绑定——自定义 compose 文件应镜像这些设置。 /dashboard与/playgroundUI 获得基线安全头(nosniff、X-Frame-Options: DENY)并纳入认证门禁;由于这两个 UI 仍内联脚本/样式,暂不施加严格 CSP(API 面才使用default-src 'none'级别的严格 CSP),更严格的 UI CSP 计划后续跟进。
五、升级验证建议
升级顺序上,官方文档给出的建议是:先通读迁移指南,然后在 staging 环境验证后再上生产。仓库内置了可直接运行的安全测试套件作为验收依据,代表性入口包括:
- test_security_default_posture.py:默认姿态断言(
/health公开、其余端点默认需认证、安全头与严格 CSP 存在、Redis 不对外暴露、hooks/execute_js 默认关闭); - test_security_trust_boundary.py:不可信请求体的 power 字段拒绝与数值钳制;
- test_security_authz.py:admin / data scope 的权限矩阵;
- test_security_llm_broker.py、test_security_artifact_store.py、test_security_resource_caps.py、test_security_container_posture.py 分别覆盖前文对应的功能维度。
此外,SECURITY-VERIFY.md 提供了部署检查清单,--no-sandbox 等主机级前提的核对也应以其为准。整体上,本次加固的可验证性体现在:每一项默认值变更都有对应的自动化断言,升级者可以逐条对照本文的“旧行为 / 新行为”表,把不需要的约束以 0 或环境变量显式回退,而不是默认回退。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00