首页
/ career-ops 安全策略解析:本地运行工具的攻击面划定与源码级纵深防御

career-ops 安全策略解析:本地运行工具的攻击面划定与源码级纵深防御

2026-09-05 17:24:45作者:裴麒琰

career-ops 的 SECURITY.md 定义了这条开源 AI 求职流水线(扫描职位、生成 A-H 分级报告、定制简历、跟踪投递)的完整安全模型:漏洞如何报告、哪些组件算攻击面、"本地运行"为何不意味着"不可达"。本篇以该安全策略文档为骨架,结合仓库中 providers/_ip-guard.mjsweb/src/lib/origin-guard.mjs 等真实防护实现,把策略中每一类攻击面拆解到具体的代码防线,帮助贡献者理解项目如何评估安全问题,也帮助用户(尤其是自托管 Web Dashboard 的读者)理解哪些默认值是安全的、哪些配置项会改变攻击面。

漏洞报告渠道:不走公开 Issue

安全策略的第一条硬性规则是:不要为安全漏洞公开打开 Issue。公开 Issue 会先于修复把利用方式传播出去,这正是策略要求私有渠道的原因。

报告方式是通过邮件联系 hi@santifer.io,并附四要素:

  1. 漏洞描述(Description of the vulnerability);
  2. 复现步骤(Steps to reproduce);
  3. 潜在影响(Potential impact);
  4. 建议修复方案(Suggested fix,如有)。

维护方承诺在 72 小时内响应,并在任何公开披露之前与报告者协作理解与修复问题。这一流程对应下文"协调披露(coordinated disclosure)"章节。

Scope:什么算 career-ops 的安全问题

策略文档按仓库的真实代码布局划定了五个在范围内的攻击面。逐一对照源码,可以看出每一类范围都有对应的防御实现,而非纸面声明。

脚本(*.mjs):命令注入、路径穿越、SSRF

仓库根目录散布上百个 .mjs 脚本(scan.mjstracker.mjsgenerate-pdf.mjs 等),它们运行在求职者的机器上,直接读取简历、投递记录等敏感文件,并抓取大量不可信的第三方网站内容——这决定了策略将 命令注入、路径穿越、SSRF 三类列为脚本攻击面。

SSRF 是这一层防御最完整的部分,证据在 providers/_ip-guard.mjs

  • 模块内维护了一张 IPv4 禁拨网段表 V4_BLOCKED,覆盖 0.0.0.0/8、RFC1918 私网、100.64.0.0/10 CGNAT、127.0.0.0/8 回环、169.254.0.0/16 链路本地、198.18.0.0/15、组播与保留段。源码注释明确指出 169.254.169.254 是"最有牙齿"的一段——它是 AWS/GCP/Azure 的云实例元数据端点,正是 SSRF 攻击通常瞄准的地址;
  • 校验时机刻意选在 DNS 解析(lookup)时 而非请求前检查:如果域名"先返回公网 IP、再返回内网 IP"(DNS rebinding),前置检查会放行而实际拨号命中内网,而在 lookup 时校验则堵住了这个窗口。因为项目不依赖 undici,无法走 Agent 层,所以 providers/_dns-cache.mjs 直接对 node:dnslookup 做了进程级补丁,由 _ip-guard.mjs 在补丁中执行判定;
  • 判定范围刻意用 AsyncLocalStorage 限定:补丁是进程级的,若无差别拒绝私网地址,仓库中 10 多个起本地 HTTP 服务器做测试的文件(例如 tests/trust-validator.test.mjs 所在测试套件大量使用的 loopback)会全部被误杀。因此 providers/_http.mjsproviderFetchContext.run(...) 给"provider 抓取流量"打上上下文标记,被补丁的 lookup 只在该上下文内校验解析结果,其余流量(本地开发服务器、测试)照常工作。AsyncLocalStorage.run 包裹的是整个 fetch 生命周期,因为 DNS lookup 发生在 fetch() 同步返回之后的 connect 阶段;
  • 地址解析本身也有防绕过细节:v4ToInt 只接受规范十进制点分四段,显式拒绝前导零——因为 0177.0.0.1 在部分解析器里按八进制读是 127.0.0.1,"校验器读成公网、拨号器读成回环"正是完整的绕过路径;IPv4-mapped IPv6(::ffff:127.0.0.1)会被解包后按 IPv4 判定,避免两种写法被区别对待。被拦截的解析抛出带 ECAREEROPS_BLOCKED_ADDRESS 错误码的异常,且源码注释强调它被计入"DNS 解析器故障"的负缓存或宕机信号——这是对单个主机名的判决,不是解析器生病。

重定向是 SSRF 的另一条通道:providers/_http.mjs 中的 fetchWithTimeout 支持 redirect: 'error' 选项,源码注释说明每个 provider 的强制 SSRF 守卫都依赖它——3xx 若指向私有 IP,拒绝跟随而不是被静默跳过去。tests/providers/_http.test.mjs 还固定了 undici 在 redirect: 'error' 遭遇 3xx 时的 err.cause.message === 'unexpected redirect' 这一未文档化行为,防止未来 Node/undici 升级悄悄改变错误形状后重试分类失效。

对"不可信外部内容"的信任评估落在 providers/_trust-validator.mjs:它不丢弃任何职位,只给每条抓取结果打 trustScore(0-100)与 flag 集合,惩罚项包括非法 URL(-50)、缺失投递链接(-40)、可疑域名如链接短站 bit.ly/tinyurl.com(-25)、公司与域名不匹配(-15);等级划分为 >=90 high、>=60 medium、其余 low。这套启发式可通过 templates/portals.example.yml 中的 trust_filter 块配置(enabledsuspicious_domainsats_allowlist),默认短链域名清单与 ATS 白名单(greenhouse、lever、workday 等 16 个域)均在源码中给出,配套测试为 tests/trust-validator.test.mjs

Dashboard(dashboard/):Go 二进制

策略将 dashboard/ 下任何 Go 二进制的漏洞列入范围。这是一个基于 bubbletea 的终端 TUI(dashboard/main.go),数据面是读取用户工作区的 applications.md 与报告文件。从源码结构看,它的对外动作有两条值得关注的边:

  • dashboard/main.go#L267-L288runGeneratePDFexec.Command("node", args...) 生成 PDF——固定解释器、固定脚本名 generate-pdf.mjs、参数以列表形式传递(不经过 shell),cmd.Dir 锚定在解析出的 career-ops 根目录;
  • getRepoRoot/resolveEnvPath 的根目录解析逻辑(dashboard/main.go#L316-L340):优先读 CAREER_OPS_ROOT/CAREER_OPS_DATA_DIR 环境变量或 .career-ops-data 标记文件,相对路径一律拼接到仓库根下——这类"从环境变量/标记文件拼路径"的代码正是策略中路径穿越一类问题需要持续盯防的位置。

Web Dashboard(web/):一切运行期间可达的东西

这是策略中范围写得最宽的一条:"它运行期间任何可达的东西,都算在内——包括用户访问的某个页面发起的跨源请求、同网络上其他主机的请求,以及通过其 API 的命令注入。" 这句宽泛措辞不是留白,源码里能找到与之逐句对应的两层防线。

第一层:统一 API 闸门。 web/src/proxy.ts 是一个 matcher: "/api/:path*" 的中间件,注释直白地说明了动机:"/api 路由会 spawn 子进程、读写用户文件,因此任何无鉴权即应答的路由都是远程代码执行原语"。它把每个请求的 Sec-Fetch-SiteOriginHostCAREER_OPS_WEB_ALLOWED_HOSTS 环境变量交给纯函数 checkRequest 做决策,不通过就返回 403,不进入任何路由处理。

第二层:同源自适应 + 回环主机双重校验。 web/src/lib/origin-guard.mjscheckRequest 分两层,必须同时通过:

针对威胁 判定逻辑
Host 层 F2:LAN 可达 默认只应答回环 Host(localhost127.0.0.0/8 全段、IPv6 ::1);要额外放行主机必须显式设置 CAREER_OPS_WEB_ALLOWED_HOSTS(逗号/空格分隔),未设置即"仅回环"
Origin 层 F1:drive-by CSRF 浏览器发送 Sec-Fetch-Site 时以其为准:same-origin 放行、none(直接导航/书签,攻击者无法伪造)放行、same-site/cross-site 拒绝;无 Fetch Metadata 的老客户端退化为比较 OriginHost 是否一致;完全没有 Origin 头(curl、服务端 fetch)不可能构成浏览器 CSRF,放行;不可解析的 Origin(含不透明 null)拒绝

Host 规范化(origin-guard.mjs#L22-L33)处理了端口剥离与裸 IPv6 字面量(一个冒号是 host:port,两个以上是 IPv6 无端口)这类容易写歪的边界。

命令注入面的收口。 API 路由确实会启动子进程——例如 web/src/app/api/assistant/route.tsweb/src/app/api/cv/ingest/route.ts 通过 spawn 无头 AI CLI,web/src/app/api/doctor/route.tsexecFile("node", [doctor, "--json"], { timeout: 10_000 })。从源码结构看,项目把这些调用收口到单一入口 web/src/lib/spawn-cli.mjsspawnHeadlessCli:所有 CLI 调用路由"应当"只经它 spawn(注释明言这是唯一 spawn 路径,"so the fix can't drift"),它关闭 stdin 防止 codex exec 类 CLI 等待管道输入,且参数以数组传递而非 shell 字符串——配合上面的 origin 闸门,"API 命令注入"这一攻击面被限制在"同源自适应 + 回环 Host"的请求内部、且只作用于固定参数形态的子进程。

模板(templates/):生成物中的 XSS

范围条目覆盖"生成 HTML/PDF 中的 XSS"。这一面涉及 templates/ 下的 HTML 简历/CV 模板与 cv-templates.mjs 的渲染逻辑:简历内容源自用户填写的事实与抓取的公司信息,若渲染时对注入内容转义不足,生成产物(以及任何预览它的浏览器)就承载了存储型脚本。仓库为这条面保留了对应的测试资产(如 tests/cv-templates.test.mjs),docs/CV_VISUAL_TESTING.md 则描述了基于 Playwright 的可视化回归流程(npm run test:cv-visual,见 package.json)。

配置:密钥暴露与不安全默认值

范围中的"Configuration"对应仓库里所有 *.example.* 模板:config/cv-facts.example.jsonconfig/local-paths.example.txtconfig/plugins.example.ymlconfig/profile.example.yml。从测试命名(tests/user-layer-gitignored.test.mjstests/user-layer-untracked.test.mjs)可以推断,用户真实配置层被要求保持在 git 追踪之外——这本身就是"密钥暴露"防线的组成部分:example 文件进仓库,真实值留在用户层。依赖侧,package.json 引入 dotenv 承载环境密钥。安全视角下的"不安全默认值"审查对象还包括本文前面提到的那些默认策略:回环-only 的 API 默认、trust_filter 缺席时全员 100 分的默认、以及 CAREER_OPS_WEB_ALLOWED_HOSTS 必须显式设置才生效的默认——它们都遵循"默认收窄、显式放宽"的方向。

Out of Scope:范围之外的四类问题

策略同样明确列出了不在范围内的情况:

  • 第三方依赖的问题——向上游报告(upstream),而非本项目;
  • 需要物理接触用户机器的问题
  • 社会工程攻击
  • 针对托管基础设施的攻击——原话是"career-ops 运行在本地,没有可供攻击的我们自己的服务器"。

最后一条值得展开:项目虽然提供 Dockerfiledocker-compose.yml 用于本地容器化部署,但部署主体仍然是用户自己的环境,不存在项目方运营的后端服务。这与"本地 ≠ 不可达"的告诫并不矛盾:没有项目方的服务器,不代表用户本地的服务器不可达。

"Local" does not mean "unreachable"

这是整份策略中最关键的一段,值得单独成节:

Web dashboard 是一个本地 HTTP 服务器,而本地服务器仍然可以被用户碰巧访问的某个跨源页面、以及同一网络上的任何设备触达。只要问题需要的只是"用户运行了 career-ops 并且正常浏览网页",它就在范围内——请报告,而不是假定"本地工具"这条线把它排除在外。

翻译成两个具体攻击场景(与 origin-guard.mjs 注释中的 F1/F2 一一对应):

  1. Drive-by(F1):受害者访问任意恶意网页,该页面在后台向 http://localhost:PORT/api/... 发 POST。由于 API 无鉴权且能触发子进程,这是经典 CSRF 升级为 RCE 的路径——对应 Origin 层防线;
  2. 同网段直连(F2):若开发服务器绑定 0.0.0.0,同一 LAN 上的任何设备都可以直接命中这些路由——对应 Host 层防线与 CAREER_OPS_WEB_ALLOWED_HOSTS 的显式 opt-in 设计。

给用户的实操结论可以直接从源码读出:保持 Web Dashboard 仅监听/访问回环地址;CAREER_OPS_WEB_ALLOWED_HOSTS 只在确有受信任局域网设备需要访问时才设置,且只加最小主机集合;理解到"本地工具"并不豁免跨源与网络层面的安全问题,发现此类问题应走前述邮件渠道报告。

协调披露(Coordinated Disclosure)

披露流程与报告渠道闭环:项目遵循 coordinated disclosure——修复版本发布前不公开细节;修复发布后,会在 release notes 中署名感谢报告者(除非报告者希望匿名)。结合"72 小时响应"的承诺,一个完整的报告生命周期是:邮件提交(四要素)→ 72 小时内响应 → 协作确认与修复 → 修复发布 → release notes 署名。

小结:一份与代码互相印证的安全策略

career-ops(当前仓库版本 v1.31.0,见 package.json)的 SECURITY.md 把攻击面精确地切在代码目录上:.mjs 脚本(SSRF/注入/路径穿越)、Go dashboard、Web API、生成模板与配置,并对每一项给出了"默认收窄 + 显式放宽"的实现——lookup 时机的私网地址守卫(providers/_ip-guard.mjs)、回环-only 的 API 双闸门(web/src/lib/origin-guard.mjsweb/src/proxy.ts)、单一收口的子进程 spawn 路径(web/src/lib/spawn-cli.mjs)、只标注不丢弃的抓取信任分(providers/_trust-validator.mjs)。对贡献者而言,这份文档是评估自己改动是否触及攻击面的检查清单;对报告者而言,它是明确承诺响应时效与披露方式的合同。

登录后查看全文
热门项目推荐
相关项目推荐