跨项目流水线报 404 Not Found?CI_JOB_TOKEN 允许名单 9 步排查

本文环境

组件 版本 / 说明
极狐GitLab 19.x(私有化部署,作业令牌默认 JWT 格式,19.0 起)
GitLab Runner 17.x,Docker 执行器
客户端工具 curl 8.x、git 2.4x、jq 1.6
涉及项目 group-a/ci-orchestrator(触发方)、group-b/shared-libs(被访问方)
角色要求 两个项目均需维护者及以上

一、问题现象:三种 404,长得一模一样

跨项目调用是流水线里最自然的动作:一个编排项目去触发公共库项目的流水线,或者从制品仓库里拉一个共享包。配置看起来没错,令牌也是现成的 CI_JOB_TOKEN,跑起来却是 404。

我这次遇到的是三个场景,报错长得几乎一样,但排查路径完全不同。

场景一:跨项目克隆代码仓库

bash 复制代码
$ git clone https://gitlab-ci-token:$CI_JOB_TOKEN@gitlab.example.com/group-b/shared-libs.git
Cloning into 'shared-libs'...
remote: The project you were looking for could not be found or you don't have permission to view it.
fatal: repository 'https://gitlab-ci-token:[MASKED]@gitlab.example.com/group-b/shared-libs.git/' not found

场景二:跨项目下载通用包

bash 复制代码
$ wget --header="JOB-TOKEN: $CI_JOB_TOKEN" \
  ${CI_API_V4_URL}/projects/1234/packages/generic/my_package/0.0.1/file.txt
--2026-10-22 06:51:13--  https://gitlab.example.com/api/v4/projects/1234/packages/generic/my_package/0.0.1/file.txt
HTTP request sent, awaiting response... 404 Not Found
2026-10-22 06:51:13 ERROR 404: Not Found.

场景三:跨项目触发下游流水线

bash 复制代码
$ curl --verbose --request POST --form "token=$CI_JOB_TOKEN" --form ref=master \
  "https://gitlab.example.com/api/v4/projects/1234/trigger/pipeline"
< HTTP/2 404
< content-type: application/json
{"message":"404 Not Found"}

这三个场景的共同点是:都用了 CI_JOB_TOKEN,都返回 404,但代码路径完全不同。如果你的第一反应是"项目 ID 填错了",大概率会浪费半天------项目 ID 是对的,只是令牌没有访问那个项目的权限。

二、为什么是 404 而不是 403

这是本次排查最反直觉的一点。权限不足,为什么返回"找不到"而不是"不允许"?

原因是安全设计上的主动选择:作业令牌鉴权失败时,极狐GitLab 有意返回 404 Not Found 或类似响应,目的是不向调用方泄露"这个项目存在但你没权限"的信息。对外部攻击者来说,404 和 403 的差别就是"目标是否存在"这条情报。

所以排查的第一步不是查 URL,而是确认令牌的作用域 。默认情况下,作业令牌的访问范围仅限于在当前项目自身的流水线中运行的作业。想让 A 项目的作业用令牌访问 B 项目,必须显式把 A 加入 B 的作业令牌允许名单,同时还得满足另外两个条件:

  1. 触发作业的用户必须是 B 项目的成员;
  2. 该用户必须具有执行该操作的权限。

注意第 2 条------加允许名单不等于授予权限。把项目 A 加进 B 的名单,只是允许 A 的作业令牌"拿着 A 成员的身份去敲门",能不能进还是看那位成员在 B 里本来有什么角色。这条理解错了,后面每一步都会白做。

三、9 步根治:从确认身份到验证生效

步骤 1:先确认"是谁在敲门"

加允许名单之前必须搞清楚一件事:作业令牌携带的是触发流水线那个用户的身份。流水线不是以项目身份运行的,是以人身份运行的。

bash 复制代码
# 在编排项目的作业里打印触发者
echo "triggered by: $GITLAB_USER_LOGIN"
echo "job token exists: ${#CI_JOB_TOKEN} chars"

拿到这个用户名之后,去目标项目 group-b/shared-libs 的成员列表里查他的角色。如果他是 Guest 或根本不是成员,后面怎么配都是 404。

踩坑 1:我第一版排查直接去加允许名单,加完还是 404。原因就是触发者是 CI 机器人账号,只在编排项目里有权限,在目标项目里没有成员身份。允许名单不授予权限,它只是开门的资格。

步骤 2:确认目标项目的作业令牌范围是"受限"

默认情况下,作业令牌访问仅限于项目自身流水线的作业。如果你的实例是私有化部署,管理员还可以强制启用并锁定这个限制------锁定后所有项目的作业令牌都严格受允许名单约束,项目层面无法关掉。

确认路径:目标项目 → 设置 → CI/CD → 展开「作业令牌权限」。

步骤 3:把编排项目加进允许名单

在目标项目 group-b/shared-libs 里操作:设置 → CI/CD → 作业令牌权限 → 「CI/CD 作业令牌允许名单」右侧点「添加」→ 选择「项目」→ 输入 group-a/ci-orchestrator → 添加。

批量配置可以用 GraphQL(字段名以你实例的 GraphiQL 校验为准):

bash 复制代码
curl --request POST \
  --header "Authorization: Bearer $ADMIN_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{"query":"mutation { ciJobTokenScopeAddProject(input:{projectPath:\"group-b/shared-libs\", targetProjectPath:\"group-a/ci-orchestrator\", direction:INBOUND}) { errors } }"}' \
  "$GITLAB_HOST/api/graphql"

步骤 4:群组条目还是项目条目,想清楚再选

把群组 加进允许名单,等于把该群组下所有项目以及任意层级的子群组项目都放行,而且极狐GitLab 在每次使用令牌时才评估匹配------之后新建的项目自动生效,不用再改名单。

代价是粒度粗。并且有硬上限:群组最多 200 个,项目最多 200 个,两个限制分别计算。

踩坑 2:我把 12 个群组一次性加进去之后,后面再加项目条目时静默失败,页面没有任何红色提示。排查发现是项目条目数已经顶到 200 上限。条目数是分别算的,别以为群组没满就没事。

步骤 5:公开和内部项目,光配允许名单不够

这一条最容易漏。如果目标项目是公开或内部项目,不在允许名单里的项目依然可以用作业令牌访问它的:产物、容器镜像仓库、软件包仓库、发布/部署/环境、代码仓库。允许名单管不到这里。

要收紧,得去:设置 → 通用 → 展开「可见性、项目功能、权限」,把对应功能的可见性改成「仅项目成员」。

踩坑 3 :我们有个公共组件库是内部项目,配完允许名单以为万事大吉,结果外部流水线照样能拉包。就是因为包仓库的可见性没改。其中「获取产物」的访问权限由 CI/CD 可见性设置控制,不在包仓库那一栏里。

步骤 6:Git 推送权限单独开关,默认关闭

如果作业需要往目标项目推代码,还有一道独立开关:设置 → CI/CD → 作业令牌权限 → 权限 → 「允许向代码仓库发起 Git 推送请求」。

这个开关默认关闭,开启后有三个必须知道的行为:

  • 只有在目标项目自身流水线中运行的作业生成的令牌,才能推到该项目;
  • 用作业令牌推送不会触发任何 CI/CD 流水线;
  • 使用 semantic-release 这类工具时,此设置可能会阻止流水线创建。

官方文档还专门提醒:不要在配置为拉取镜像的项目上启用它 ,尤其当镜像更新会跑流水线的时候------上游仓库所有者可能尝试用 CI_JOB_TOKEN 往镜像项目推提交。

步骤 7:细粒度权限,只放行需要的端点

如果目标只是"能下载某个包的某个文件",没必要放开整个项目。细粒度权限允许你明确放行一组有限的 REST API 端点,把爆炸半径压到最小。

踩坑 4 :作业令牌不能用于对 GraphQL 请求做身份认证。我一开始想用 GraphQL 批量查询目标项目的包列表,鉴权直接失败。GraphQL 必须换 Personal Access Token 或项目访问令牌。

步骤 8:改造流水线,用推荐方式传递令牌

跨项目下载制品,官方推荐用请求头而不是 URL 参数(?job_token= 不推荐,会进日志):

yaml 复制代码
fetch_shared_pkg:
  stage: prepare
  script:
    - |
      curl --fail --silent --show-error \
        --header "JOB-TOKEN: $CI_JOB_TOKEN" \
        --output shared.tar.gz \
        "${CI_API_V4_URL}/projects/${TARGET_PROJECT_ID}/packages/generic/shared_lib/1.2.0/shared.tar.gz"
    - tar -xzf shared.tar.gz

跨项目触发下游流水线:

yaml 复制代码
trigger_downstream:
  stage: deploy
  trigger:
    project: group-b/shared-libs
    branch: main

或者用 API 触发并带上变量:

bash 复制代码
curl --request POST \
  --header "JOB-TOKEN: $CI_JOB_TOKEN" \
  --form "ref=main" \
  --form "variables[UPSTREAM_SHA]=$CI_COMMIT_SHA" \
  "${CI_API_V4_URL}/projects/${TARGET_PROJECT_ID}/trigger/pipeline"

跨项目克隆代码仓库(即使实例禁用了 HTTPS 协议,作业令牌克隆依然可用):

bash 复制代码
git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.example.com/group-b/shared-libs.git

步骤 9:用身份认证日志确认生效

配置完别靠"跑一次没报错"下结论。目标项目的作业令牌权限区域里有「身份认证日志」,会列出哪些项目用作业令牌访问过你的项目:

  • 最多显示 100 条事件,超出需下载 CSV;
  • 新的身份认证事件最多需要 5 分钟才会出现,立刻查会误判为没生效;
  • 可以下载 CSV 做审计。

顺手写个批量自检脚本,把三个场景一次验完:

bash 复制代码
#!/usr/bin/env bash
set -u
TARGET_ID="$1"
check() {
  local name="$1"; local code="$2"
  if [ "$code" = "200" ] || [ "$code" = "201" ]; then
    echo "PASS  $name (HTTP $code)"
  else
    echo "FAIL  $name (HTTP $code)"
  fi
}
code=$(curl -s -o /dev/null -w "%{http_code}" \
  --header "JOB-TOKEN: $CI_JOB_TOKEN" \
  "${CI_API_V4_URL}/projects/${TARGET_ID}/packages/generic/shared_lib/1.2.0/shared.tar.gz")
check "package-download" "$code"
code=$(curl -s -o /dev/null -w "%{http_code}" \
  --header "JOB-TOKEN: $CI_JOB_TOKEN" \
  "${CI_API_V4_URL}/projects/${TARGET_ID}/repository/branches")
check "repo-read" "$code"

四、验证结果对比

场景 修复前 修复后 关键动作
跨项目克隆代码仓库 404 not found 克隆成功 加入允许名单 + 触发者为目标项目成员
跨项目下载通用包 404 Not Found HTTP 200 允许名单 + 包仓库可见性设为仅项目成员
跨项目触发流水线 {"message":"404 Not Found"} 下游流水线创建 允许名单 + 改用 JOB-TOKEN 请求头
外部项目拉内部项目包(未授权) 可拉取 404 包仓库可见性收紧
长任务(>6 分钟无输出) 403 Forbidden 正常 升级到 18.9 及以上

五、另一个 403:跟权限一点关系都没有

排查过程中还撞到一类报错,日志里是 403 而不是 404,很容易被当成权限问题继续折腾允许名单。实际上它跟权限无关:

在极狐GitLab 18.8 及更早版本中使用 JWT 格式作业令牌时,作业可能失败并报 403 Forbidden,常见于三种情况:

  • 使用了 needs 的作业;
  • 子流水线中的作业;
  • 运行时间超过约 6 分钟且没有任何控制台输出的作业。

Runner 侧日志长这样:

bash 复制代码
WARNING: Submitting job to coordinator... job failed
  code=403 job=<job_id> status=PUT https://gitlab.example.com/api/v4/jobs/<job_id>: 403 Forbidden

踩坑 5:我们有个编译作业要跑 20 分钟,中间只在结束时才输出日志,前 6 分钟静默。它在 18.8 上稳定 403,加什么允许名单都没用。解决办法是升级到 18.9;在升级之前,临时规避方式是让脚本持续输出进度(比如每分钟打一行时间戳),避免长时间零输出。

顺带记一条版本事实:从 19.0 开始,CI/CD 作业令牌默认使用 JWT 标准格式。如果某些下游系统依赖旧版格式,可以通过为项目配置顶级群组后关闭「为 CI/CD 作业令牌启用 JWT 格式」来沿用旧格式------这个开关只在极狐GitLab 20.0 发布前可用。

六、什么情况下别这么做

允许名单是个方便的东西,但有几个场景它并不是正确答案:

  1. 不要把「所有群组和项目」当默认选项。这是私有化部署上一个可关闭允许名单的开关,官方明确提示存在安全风险------恶意用户可以创建一条由你项目维护者触发的流水线,用作业令牌反过来访问你的项目。只应在测试时临时打开,用完立刻还原。
  2. 不要把令牌放进 URL 查询串 。?job_token= 是不推荐的传递方式,容易落进代理日志和浏览器历史。统一用 JOB-TOKEN 请求头。
  3. 不要把长期凭证换成作业令牌到处传。作业令牌只在作业运行期间有权限,作业结束、被清除或项目删除时立即失效------这是它的优点,也意味着它不适合做跨流水线的长期集成。那种场景应该用项目访问令牌或 OIDC。
  4. 机器人账号要单独治理。如果流水线由 CI 机器人触发,它必须在目标项目里有成员身份且具备最小必要角色,否则允许名单形同虚设。
  5. 别在镜像拉取项目上开 Git 推送权限,尤其镜像更新会触发流水线时会形成回路。

踩坑 6:我一度想省事,把编排项目做成公开项目,这样"谁都能访问"就不用配名单了。结果是对外暴露了内部组件的下载入口,安全同事第二天就找上门。公开不是简化配置的方式。

七、文末福利

这篇里用到的三样东西我都整理好了:

  • 批量自检脚本 check-job-token.sh(覆盖包下载、仓库读取、流水线触发三个场景,输出 PASS/FAIL);
  • 允许名单配置清单(含 200 上限的计数提醒与公开项目可见性收紧项);
  • 18.8 长任务 403 的临时规避脚本(持续输出进度版)。

需要的同学评论区留一句「作业令牌」,或者去我主页置顶动态里自取,脚本和清单都放在一起了。如果你的实例版本与我这篇不同(尤其是 18.8 之前),评论区说一下版本号,我把对应差异补上。

相关推荐
2301_780789661 小时前
IPv6 洪水猛兽:DDoS 攻击面 3 倍扩张下的精准清洗新战法
运维·安全·web安全·ddos·devops
极小狐21 小时前
极狐GitLab双机热备搭建指南,主机备机部署与切换实操
gitlab·高可用·极狐gitlab
骇客野人1 天前
CI/CD A 服务器(构建机)免密登录 B 服务器(目标部署机)完整操作
服务器·servlet·ci/cd
山雀~2 天前
线上CI流水打包失败排查思路案例
前端·ci/cd
研发小能2 天前
DevOps 国产替代选型指南:从“单点替换“到“一体化平台“
运维·研发效能·devops·嘉为蓝鲸
你看我名字2 天前
LAMP 架构演练:从云主机准备到 Apache、PHP、MySQL 上线验证
运维·ci/cd·架构·kubernetes·jenkins
龙智DevSecOps解决方案2 天前
赋能汽车/金融/游戏研发:龙智正式引入AI测试管理平台aqua cloud
自动化测试·devops·需求管理·测试管理·研发管理·aqua cloud
FII工业富联科技服务2 天前
Omniverse 数字孪生与柔性自动化,如何破解产能与交付、设备与空间的双重困境?
运维·自动化·devops
码间留白3 天前
手把手把 GitHub 推上阿里云 ECS:Actions CI/CD 全链路避坑实录(折腾好几天,到 2 分钟见绿)
运维·阿里云·ci/cd·docker·devops·github actions