Twitterで見かけたGiteaの脆弱性「CVE-2026-58443」を調べた

Twitterを見ていたら、Gitea 1.27.0で複数の脆弱性が修正された、という投稿が流れてきました。

その中で気になったのが、CVE-2026-58443です。

説明には、次のようなことが書かれていました。

公開リポジトリだけを操作できるトークンを使って、非公開リポジトリのPull Requestのheadブランチを更新できる

最初に読んだとき、少し意味が分かりませんでした。

公開リポジトリ専用のトークンなら、非公開リポジトリへのアクセスは入口で拒否されるはずです。

それなのに、どうして非公開リポジトリのブランチを更新できたのでしょうか。

公式のSecurity Advisoryや概念実証コードを確認していくと、単純に「権限チェックがなかった」という問題ではないことが分かりました。

Giteaは権限を確認していました。

ただし、

  • トークンの制限を確認したリポジトリ
  • ユーザー本人の権限を確認したリポジトリ
  • 実際に更新されたリポジトリ

が、すべて同じではありませんでした。

この記事では、CVE-2026-58443について、公式情報を読みながらわたしが理解した内容を整理します。

まず公式の説明を読んでみる

GiteaのSecurity Advisoryでは、この問題は次のように説明されています。

public-only,write:repository のトークンを持つ攻撃者が、公開リポジトリをbase、非公開リポジトリをheadとするPull Requestの更新APIを実行すると、非公開側のheadブランチを更新できる場合がある、というものです。

影響を受けるのはGitea 1.26.4以前で、Gitea 1.27.0で修正されています。

深刻度はCritical、CVSSスコアは9.6です。 Gitea Security Advisory

ただ、この説明だけでは、まだ次の疑問が残ります。

public-onlyのチェックは、
どのリポジトリに対して行われていたのか
 
実際に更新されるリポジトリは、
どの時点で決まるのか

そこで、まずPull Requestに登場するbaseとheadの関係から整理します。

public-onlyトークンとは

Giteaでは、APIを操作するためのアクセストークンを発行できます。

今回問題になったのは、次の条件を持つトークンです。

public-only
write:repository

write:repository は、リポジトリへの書き込み操作を許可するスコープです。

一方の public-only は、そのトークンによる操作を公開リポジトリだけに制限する設定です。

つまり、次のような権限を持つトークンです。

リポジトリへの書き込みは可能
ただし、対象は公開リポジトリに限る

公式の検証コードでは、このトークンを使って非公開リポジトリへ直接ファイルを書き込もうとすると、リクエストは失敗しています。

ここを見て、最初の疑問がさらに強くなりました。

直接書き込めないのであれば、Pull Request更新APIでは何が違うのでしょうか。

調べて分かったポイント

今回のポイントは、次の部分にありました。

トークンのpublic-only制限
    公開baseリポジトリについて確認
 
ユーザー本人の書き込み権限
    非公開headリポジトリについて確認
 
実際の更新先
    非公開headリポジトリ

Giteaは権限チェックをまったくしていなかったわけではありません。

それぞれのチェックで、別の対象を見ていたのです。

この関係を理解するには、Pull Requestのbaseとheadを分けて考える必要があります。

Pull Requestにはbaseとheadがある

今回の問題は、Pull Requestが複数のリポジトリにまたがることから発生しました。

Pull Requestには、主に次の2つのリポジトリが登場します。

baseリポジトリ
変更を取り込む側
 
headリポジトリ
変更を送り出す側

たとえば、次のようなPull Requestが存在するとします。

公開リポジトリA

       │ Pull Request

非公開リポジトリB

この場合は、次の関係になります。

base = 公開リポジトリA
head = 非公開リポジトリB

Giteaでは、条件によっては公開リポジトリをbase、非公開リポジトリをheadとするPull Requestを作成できます。

Pull Requestのブランチ更新機能

Pull Requestを作成したあと、baseブランチに新しいコミットが追加されることがあります。

その場合、headブランチはbaseブランチより古い状態になります。

Giteaには、baseブランチの最新内容をheadブランチへ取り込むAPIがあります。

今回問題になったのは、次のエンドポイントです。

POST /api/v1/repos/{owner}/{repo}/pulls/{index}/update

たとえば、公開リポジトリAのPull Requestを更新する場合は、次のようなURLになります。

POST /api/v1/repos/user/public-repo/pulls/123/update

URLで指定されているのは、公開されているbaseリポジトリです。

しかし、APIの実行によって実際に更新されるのは、Pull Requestのheadブランチです。

今回の構成では、更新先は非公開リポジトリBになります。

APIのURLで指定されるリポジトリ
    公開リポジトリA
 
実際にブランチが更新されるリポジトリ
    非公開リポジトリB

つまり、APIの入口で確認するリポジトリと、実際に変更されるリポジトリが異なっていました。

APIの入口では、公開リポジトリが確認される

APIリクエストを受け取ったGiteaは、まずトークンの public-only 制限を確認します。

この時点で確認されるのは、URLに指定されたリポジトリです。

/repos/user/public-repo/pulls/123/update

    このリポジトリを確認

URLに指定されているのは公開リポジトリAなので、public-only の条件を満たします。

トークンはpublic-only

URLで指定された対象は公開リポジトリA

許可

この判定だけを見れば、問題はありません。

しかし、このあと実際の処理対象は非公開のheadリポジトリへ移ります。

headリポジトリでは、ユーザー本人の権限だけが確認されていた

続いてGiteaは、Pull Requestのheadブランチを更新してよいか確認します。

この時点での対象は、非公開リポジトリBです。

修正前のGiteaでは、トークンを発行したユーザー本人が、その非公開リポジトリへ書き込めるかを確認していました。

概念的には、次のような判定です。

if user.canPush(headRepository) {
    updatePullRequest()
}

ユーザー本人が非公開リポジトリBへの書き込み権限を持っていれば、この判定は通ります。

しかし、本来はユーザー本人の権限だけでなく、現在使用しているトークンがheadリポジトリを操作できるかも確認する必要があります。

if user.canPush(headRepository) &&
   token.canAccess(headRepository) {
    updatePullRequest()
}

修正前の処理では、次の確認が不足していました。

現在使用しているトークンは、
非公開リポジトリBを操作してよいか

公式アドバイザリでは、headリポジトリに対するユーザーのRBAC権限は確認されていた一方、実行中のAPIトークンがそのリポジトリを操作できるかは確認されていなかったと説明されています。

攻撃に必要な条件

この脆弱性を利用すれば、任意の非公開リポジトリへ書き込めるわけではありません。

公式情報では、少なくとも次の条件が必要とされています。

  • public-only,write:repository の有効なトークンを持っている
  • トークンを発行したユーザーが、対象の非公開headリポジトリに書き込み権限を持っている
  • 公開baseリポジトリと非公開headリポジトリの間に、対象となるPull Requestが存在する
  • baseの変更をheadへmergeまたはrebaseできる状態になっている

つまり、攻撃者が新しいユーザー権限を取得する問題ではありません。

本来は公開リポジトリだけに制限されているトークンを使って、そのトークンの所有者がすでに持っている非公開リポジトリへの権限を、一部利用できてしまう問題です。

たとえば、公開プロジェクト用の自動処理に限定して発行したトークンが漏えいした場合を考えます。

管理者は、そのトークンについて次のように考えている可能性があります。

このトークンは公開リポジトリにしか使えない

しかし、トークンを発行したユーザーが非公開リポジトリへの書き込み権限も持っていると、Pull Request更新APIを経由して非公開ブランチを更新される可能性があります。

Actionsが有効な場合の影響

非公開headリポジトリでGitea Actionsが有効になっており、対象ブランチへのpushを契機に実行されるワークフローが設定されている場合、ブランチ更新によってワークフローも起動します。

on:
  push:
    branches:
      - feature-branch

Pull Request更新APIによる変更は、Giteaサーバーからのpushとして扱われます。

そのため、次のような処理が発生します。

公開リポジトリの変更を非公開ブランチへ反映

非公開リポジトリでpushイベントが発生

Actionsのワークフローが起動

公式の概念実証でも、非公開リポジトリのブランチが更新されたあと、そのリポジトリに対する ActionRunActionRunJob が作成されることが確認されています。

ただし、公式のCVSS評価では、機密性への直接的な影響は「なし」とされています。

この脆弱性だけで、非公開リポジトリの内容やSecretsを自由に読み取れるとまでは確認されていません。

公開情報から直接確認できる主な影響は、次の2点です。

非公開リポジトリのheadブランチを変更できる
 
その変更を契機にActionsを起動できる

ワークフローの起動によって何が起きるかは、各リポジトリの設定や実行環境によって異なります。

認証ではなく認可の問題

この脆弱性は、CWE-863「Incorrect Authorization」に分類されています。

認証と認可は、役割が異なります。

認証
操作しているのは誰か
 
認可
その人が何をしてよいか

今回、Giteaはトークンからユーザーを特定できていました。

そのため、認証自体には成功しています。

問題は、ユーザー本人に権限があるかだけでなく、現在使用しているトークンにその操作を許可してよいかも確認する必要があったことです。

アクセストークンは、単にユーザーとしてログインするための手段ではありません。

ユーザー本人が持つ権限の一部だけを、特定の用途に限定して利用するための仕組みでもあります。

ユーザー本人
    非公開リポジトリも操作できる
 
今回のトークン
    公開リポジトリだけ操作できる

この場合、実行可能な操作は、ユーザー本人の権限とトークンの権限の両方を満たす範囲に限る必要があります。

実行可能な操作
=
ユーザー本人の権限

トークンの権限

ユーザー本人の権限だけで許可すると、トークンに設定した制限が適用されない処理が生まれる可能性があります。

設計上のポイント

この脆弱性から分かるのは、APIの入口で権限を確認するだけでは不十分な場合があるということです。

特に注意が必要なのは、処理の途中で操作対象が変わる機能です。

今回の処理では、次のように対象が移動しています。

URLで指定された公開baseリポジトリ

Pull Request

非公開headリポジトリ

headブランチへのpush

Actions

APIの入口で確認した対象と、実際に変更される対象が異なります。

このような処理では、実際に変更を加える直前に、最終的な更新対象について権限を再評価する必要があります。

概念的には、次のような処理になります。

func updatePullRequest(ctx Context, pr PullRequest) error {
    targetRepo := pr.HeadRepository
 
    if !ctx.User.CanWrite(targetRepo) {
        return ErrForbidden
    }
 
    if !ctx.Token.CanWrite(targetRepo) {
        return ErrForbidden
    }
 
    return pushToHeadBranch(pr)
}

確認すべきなのは、ユーザー本人に権限があるかだけではありません。

現在使用している認証手段に、その権限を利用させてよいかも確認する必要があります。

対策

Giteaは、この問題をバージョン1.27.0で修正しています。

Gitea公式は、1.27.0に複数のセキュリティ修正が含まれるとして、早めのアップグレードを推奨しています。CVE-2026-58443の修正PRとしては、#38406 が案内されています。 Gitea 1.27.0 release announcement

影響を受けるバージョンは次のとおりです。

影響あり
Gitea 1.26.4以前
 
修正済み
Gitea 1.27.0

Giteaを運用している場合は、利用中のバージョンを確認し、1.27.0以降への更新を検討する必要があります。

あわせて、公開リポジトリ用として発行しているトークンについても、次の点を確認できます。

  • 書き込み権限が本当に必要か
  • トークンの所有者が非公開リポジトリへの強い権限を持っていないか
  • CIや外部サービスに保存したトークンが漏えいしていないか
  • 不要になったトークンが残っていないか

まとめ

Twitterでこの修正を見かけたときは、

public-onlyのトークンなのに、
どうしてprivateリポジトリを更新できるのか

という点がよく分かりませんでした。

公式アドバイザリと検証コードを確認していくと、原因は単純な権限チェックの欠落ではなく、認可の対象が処理の途中で変わっていたことだと分かりました。

APIの入口では、URLに指定された公開baseリポジトリについて、トークンの public-only 制限を確認していました。

その後、非公開headリポジトリについては、トークンを発行したユーザー本人の書き込み権限を確認していました。

しかし、実際に更新される非公開headリポジトリについて、現在使用しているトークンの制限は再確認されていませんでした。

非公開リポジトリへの直接の書き込み
    拒否される
 
公開Pull Requestの更新APIを経由した書き込み
    非公開headブランチを更新できる

同じトークンを使った操作でも、どのAPIを通るかによって確認されるリソースが異なっていた、ということです。

今回の脆弱性を調べていて、認可処理では「ユーザーに権限があるか」だけでは足りない場合があると分かりました。

少なくとも、次の対象を分けて確認する必要があります。

  • 操作しているユーザー
  • 使用しているトークン
  • APIのURLで指定されたリソース
  • 最終的に変更されるリソース

特に、Pull Requestのように複数のリポジトリをまたぐ処理では、入口での認可だけでなく、実際に変更を加える対象についても権限を確認する必要があります。

最初は短い修正情報を見かけただけでしたが、処理を順番に追ってみると、「権限チェックがあること」と「正しい対象について権限チェックしていること」は別の問題なのだと理解できました。

参考資料