| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2026-27771 |
| 영향 제품/버전 | Gitea 1.26.1 이하 (Composer 패키지 레지스트리 기능을 사용하는 모든 버전) |
| CVSS v3.0 | 8.2 (High) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N |
| CWE | CWE-862 (Missing Authorization) |
| 공개일 | 2026-07-03 (NVD) |
| 수정 버전 | 1.26.2 |
| 취약 지점 | routers/api/packages/composer/api.go의 createPackageMetadataResponse() |
Gitea는 npm, Cargo, Composer 등 여러 언어 패키지 생태계를 자체 인스턴스 안에서 호스팅해 주는 패키지 레지스트리 기능을 제공한다. 이 중 PHP 생태계용 Composer 레지스트리는 각 패키지를 Gitea에 있는 소스 코드 저장소(repository)와 "링크"해 둘 수 있는데, 링크를 걸 때는 "같은 소유자의 저장소인지"만 확인하고 그 저장소가 공개(Public)인지 비공개(Private)인지는 따지지 않는다. 문제는 그다음 단계다. Composer 클라이언트가 패키지 메타데이터를 조회하면 서버는 링크된 저장소의 웹 URL을 응답의 source 필드에 그대로 담아 돌려주는데, 이때 "이 요청을 보낸 사람이 실제로 그 저장소를 볼 권한이 있는가"를 검사하지 않았다. 그 결과 어떤 패키지가 공개 상태라면, 그 패키지에 링크된 저장소가 비공개이거나 인스턴스 내부 전용(Limited/Internal)이더라도 누구나 — 로그인조차 하지 않은 사용자라도 — 그 저장소의 존재와 정확한 경로(URL)를 알아낼 수 있었다. 1.26.2는 저장소를 응답에 포함하기 직전에 요청자의 실제 저장소 접근 권한을 확인하도록 고쳤다.
Gitea의 패키지 레지스트리는 사용자나 조직 계정(owner) 아래에 npm/Composer/Cargo/Maven 등 다양한 포맷의 패키지를 업로드해 둘 수 있는 기능이다. 패키지 하나는 Package 레코드로 표현되고, 이 레코드에는 그 패키지를 만든 소스 코드가 어느 Git 저장소에 있는지를 가리키는 RepoID 필드가 있다.
// models/packages/package.go
type Package struct {
ID int64
OwnerID int64 // 패키지 소유자(사용자/조직)
RepoID int64 // 링크된 저장소 (0이면 링크 없음)
Type Type
Name string
...
}
func SetRepositoryLink(ctx context.Context, packageID, repoID int64) error {
_, err := db.GetEngine(ctx).ID(packageID).Cols("repo_id").Update(&Package{RepoID: repoID})
return err
}패키지 소유자는 웹 UI의 패키지 설정 화면에서 저장소 이름을 입력해 링크를 건다. 이때 서버가 확인하는 조건은 단 하나, "그 이름의 저장소가 패키지와 같은 소유자 소속인가"뿐이다.
// routers/web/user/package.go — packageSettingsPostActionLink
repo, err := repo_model.GetRepositoryByName(ctx, pd.Owner.ID, form.RepoName)
...
packages_model.SetRepositoryLink(ctx, pd.Package.ID, repo.ID)저장소 자체의 가시성(visibility)은 전혀 확인하지 않는다. Gitea 저장소는 세 단계 가시성을 갖는다.
| 가시성 | 누가 볼 수 있는가 |
|---|---|
| Public | 로그인 여부와 무관하게 누구나 |
| Limited(내부, "Internal"로 표시) | 이 Gitea 인스턴스에 로그인한 사용자만 |
| Private | 소유자, 협업자, 접근 권한을 부여받은 팀/사용자만 |
즉 한 사용자가 "공개 패키지" A와 "비공개 저장소" B를 모두 가지고 있다면, A와 B가 같은 소유자라는 이유만으로 A에 B를 링크할 수 있다. 이 자체는 소유자 본인의 선택이라 문제가 아니다. 문제는 그다음, 그 링크 정보를 누구에게 얼마나 보여주는가에서 생긴다.
Composer는 PHP의 표준 패키지 매니저다. Composer 클라이언트가 커스텀 레지스트리에서 패키지를 찾을 때는 packages.json(v2 프로토콜에서는 p2/{vendor}/{project}.json) 형식의 메타데이터를 조회한다. 이 메타데이터의 각 버전 항목에는 패키지를 내려받는 두 가지 경로가 함께 실린다.
{
"name": "acme/widgets",
"version": "1.0.0",
"dist": { "type": "zip", "url": "https://gitea.example/api/.../files/...", "shasum": "..." },
"source": { "type": "git", "url": "https://gitea.example/acme/widgets-src", "reference": "1.0.0" }
}dist는 Gitea가 자체 저장소(blob storage)에 보관한 압축 파일을 내려받는 경로이고, source는 (설정돼 있다면) 그 패키지의 원본 소스가 있는 Git 저장소 정보다. composer install --prefer-source 같은 옵션을 쓰면 클라이언트는 dist의 zip이 아니라 source.url을 git clone 대상으로 사용한다. Gitea 구현에서 이 source.url은 Repository.HTMLURL(), 즉 저장소의 웹 페이지 URL(https://gitea.example/<owner>/<repo>) 그대로다.
Gitea는 "이 사용자가 이 저장소에 어떤 권한을 갖는가"를 매 요청마다 Permission 값으로 계산한다. 익명 사용자(로그인하지 않은 요청자)가 비공개 저장소를 보려 하면 이 값은 즉시 AccessModeNone으로 확정된다.
// models/perm/access/repo_permission.go — GetIndividualUserRepoPermission
if user == nil && repo.IsPrivate {
perm.AccessMode = perm_model.AccessModeNone
return perm, nil
}그리고 이 값에 "이 저장소의 어떤 유닛(코드/이슈/위키 등)에라도 읽기 이상의 접근권이 있는가"를 묻는 헬퍼가 있다.
func (p *Permission) HasAnyUnitAccessOrPublicAccess() bool {
return p.HasAnyUnitPublicAccess() || p.HasAnyUnitAccess()
}이 계산 자체는 오래전부터 정확했다. 문제는 Composer 메타데이터 응답을 만드는 코드가 이 계산을 아예 호출하지 않았다는 데 있었다.
Composer 라우트는 패키지 소유자 기준으로만 접근을 제어한다. 아래는 1.26.1 기준 라우팅 등록 코드다.
// routers/api/packages/api.go
r.Group("/composer", func() {
r.Get("/packages.json", composer.ServiceIndex)
r.Get("/p2/{vendorname}/{projectname}.json", composer.PackageMetadata)
...
}, reqPackageAccess(perm.AccessModeRead))reqPackageAccess(AccessModeRead)가 확인하는 것은 "요청자가 패키지 소유자(ctx.Package.Owner)에 대해 읽기 권한이 있는가"이다. 패키지 소유자가 공개 계정이면 이 값은 익명 사용자에게도 항상 참이다 — Composer 레지스트리 자체가 그런 용도이기 때문에 이 자체는 의도된 설계다. 문제는 이 검사가 패키지에 링크된 저장소의 가시성과는 완전히 별개라는 점이다.
PackageMetadata 핸들러는 버전 목록을 불러온 뒤 GetPackageDescriptors로 각 버전의 상세 정보를 채우는데, 이 단계에서 링크된 저장소는 조건 없이 로드된다.
// models/packages/descriptor.go
if p.RepoID > 0 {
repository, err = cache.GetWithEphemeralCache(ctx, c, "repo", p.RepoID, repo_model.GetRepositoryByID)
}GetRepositoryByID는 이름 그대로 ID로 저장소를 가져올 뿐, 호출자가 누구인지·볼 권한이 있는지는 전혀 묻지 않는다. 그리고 이렇게 채워진 pd.Repository는 응답을 만드는 함수에서 그대로 직렬화된다.
// routers/api/packages/composer/api.go (1.26.1, 패치 전)
func createPackageMetadataResponse(registryURL string, pds []*packages_model.PackageDescriptor) *PackageMetadataResponse {
...
if pd.Repository != nil {
pkg.Source = Source{
URL: pd.Repository.HTMLURL(),
Type: "git",
Reference: pd.Version.Version,
}
}
...
}즉 판단 기준이 "링크가 설정돼 있는가"(pd.Repository != nil) 하나뿐이다. 요청자가 익명이든, 그 저장소와 아무 관계가 없는 다른 로그인 사용자든, 저장소가 비공개이든 인스턴스 내부 전용이든 상관없이 링크된 저장소 정보는 항상 source 필드에 실려 나갔다.
[패치 전 요청 흐름]
익명 사용자 ──GET /api/packages/acme/composer/p2/acme/widgets.json──▶ Gitea
reqPackageAccess(Read)
│
├─ 확인 대상: 패키지 소유자(acme)가 공개 계정인가? ──▶ 예
│ (링크된 저장소 acme/widgets-src 의 가시성은 검사 대상이 아님)
▼
GetPackageDescriptors
│
├─ RepoID > 0 이면 GetRepositoryByID로 무조건 로드
│ (권한 검사 없음 — acme/widgets-src 가 Private이어도 로드됨)
▼
createPackageMetadataResponse
│
├─ pd.Repository != nil → 참
▼
응답 JSON
{
"source": {
"url": "https://gitea.example/acme/widgets-src", ◀── Private 저장소의 URL이 그대로 노출
"type": "git",
"reference": "1.0.0"
}
}
CVSS 벡터의 C:H(기밀성 높음)·I:L(무결성 낮음)·A:N은 이 흐름과 일치한다. 공격자가 저장소 내용을 직접 읽는 것은 아니지만(그 저장소 자체는 여전히 별도 권한 검사로 보호된다), 비공개로 유지하려던 저장소의 존재 사실과 정확한 경로가 노출된다. 이는 그 자체로 정보 노출이면서, 동시에 저장소 이름을 알아낸 뒤의 후속 정찰(예: 조직 구조 추정, 다른 취약점과의 연계)로 이어질 수 있는 정보다.
1.26.2는 createPackageMetadataResponse에 요청 컨텍스트(*context.Context, 즉 ctx.Doer 포함)를 넘기도록 시그니처를 바꾸고, 저장소를 응답에 넣기 직전에 "이 요청자가 실제로 그 저장소를 읽을 수 있는가"를 한 번 더 확인한다.
// routers/api/packages/composer/api.go (1.26.2, 패치 후)
func createPackageMetadataResponse(ctx *context.Context, registryURL string, pds []*packages_model.PackageDescriptor) *PackageMetadataResponse {
...
if pd.Repository != nil {
permission, err := access_model.GetDoerRepoPermission(ctx, pd.Repository, ctx.Doer)
if err != nil {
log.Error("GetDoerRepoPermission[%d]: %v", pd.Repository.ID, err)
} else if permission.HasAnyUnitAccessOrPublicAccess() {
pkg.Source = Source{
URL: pd.Repository.HTMLURL(),
Type: "git",
Reference: pd.Version.Version,
}
}
}
...
}GetDoerRepoPermission은 앞서 "사전 지식"에서 본 GetIndividualUserRepoPermission을 호출한다 — 익명 사용자(ctx.Doer == nil)가 비공개 저장소를 대상으로 하면 AccessModeNone이 반환되고, HasAnyUnitAccessOrPublicAccess()는 거짓이 된다. 이 경우 pkg.Source는 채워지지 않고 제로 값(빈 문자열들)으로 남는다. 호출부인 PackageMetadata 핸들러도 이 새 인자를 넘기도록 함께 바뀌었다.
// routers/api/packages/composer/composer.go (1.26.2)
resp := createPackageMetadataResponse(
ctx,
setting.AppURL+"api/packages/"+ctx.Package.Owner.Name+"/composer",
pds,
)[패치 후 요청 흐름]
익명 사용자 ──GET /api/packages/acme/composer/p2/acme/widgets.json──▶ Gitea
reqPackageAccess(Read) → 패키지 소유자 acme는 공개 계정 ──▶ 통과 (변화 없음)
▼
GetPackageDescriptors → RepoID>0이면 여전히 무조건 로드 (변화 없음)
▼
createPackageMetadataResponse(ctx, ...)
│
├─ pd.Repository != nil → 참
├─ GetDoerRepoPermission(ctx, repo, ctx.Doer=nil)
│ └─ repo.IsPrivate && user==nil → AccessModeNone
├─ HasAnyUnitAccessOrPublicAccess() → 거짓
▼
pkg.Source는 채워지지 않음 (빈 문자열)
응답 JSON
{
"source": { "url": "", "type": "", "reference": "" } ◀── 저장소 정보 노출 없음
}
같은 PR은 회귀를 막기 위해 통합 테스트도 추가했다. 요청자가 링크된 저장소에 접근 권한이 있는 경우(AddBasicAuth(user.Name), 해당 저장소 소유자 본인)에는 source가 정상적으로 채워지고, 접근 권한이 없는 다른 사용자(otherUser)로 같은 엔드포인트를 호출하면 Source.URL·Type·Reference가 모두 빈 값인지 확인한다.
// tests/integration/api_packages_composer_test.go
// Callers without repository access still get the package metadata, but not the private source URL.
req = NewRequest(t, "GET", .../p2/%s/%s.json...).AddBasicAuth(otherUser.Name)
...
assert.Empty(t, pkgs[0].Source.URL)
assert.Empty(t, pkgs[0].Source.Type)
assert.Empty(t, pkgs[0].Source.Reference)같은 PR에는 패키지 카드·상세 화면에 Private/Internal 배지를 표시하는 UI 변경도 함께 들어 있는데, 이는 이번 CVE의 근본 원인과는 무관한 부가 기능(사용자가 패키지 가시성을 화면에서 직관적으로 알 수 있게 하는 UX 개선)이다. 실제 보안 수정은 위에서 본 createPackageMetadataResponse와 PackageMetadata의 권한 검사 추가 두 곳이다.
Gitea 운영자는 1.26.2 이상으로 업그레이드해야 한다. 업그레이드 이전에 비공개/내부 저장소를 공개 패키지에 링크해 둔 이력이 있다면, 그 저장소 경로(이름)가 이미 외부에 노출됐을 가능성을 배제할 수 없으므로 필요하다면 저장소 이름 변경이나 접근 로그 점검을 함께 고려해야 한다.
- NVD 공개일(2026-07-03)과 실제 수정 커밋 병합일(main 기준 2026-05-11) 사이에 약 두 달의 간격이 있다. 이 기간이 비공개 사전 조율(embargo) 기간이었는지, 단순히 CVE 채번·공개 절차 지연이었는지는 공개 자료로 확인되지 않는다.
- GitHub Security Advisory(GHSA-8qw8-rq86-9pc2)의 상세 본문은 로그인 세션이 필요한 동적 렌더링 페이지라 정적 조회로는 전체 내용을 확인하지 못했다. 이 보고서의 기술적 분석은 NVD 설명과 실제 코드 diff(PR #37610/#37643)로 직접 검증한 내용만 사용했다.
- CVE-2026-27771 — NVD — 영향 버전, CVSS, CWE-862 분류
- PR #37610 — go-gitea/gitea (main) — 실제 수정 diff,
GetDoerRepoPermission도입 - PR #37643 — go-gitea/gitea (release/v1.26 백포트) — 1.26.2에 반영된 백포트
- Gitea 1.26.2 릴리스 노트 — "fix composer package source permission check" 항목으로 공식 확인
- 1.26.1 취약 코드 — api.go — 패치 전
createPackageMetadataResponse - 1.26.2 수정 코드 — api.go — 패치 후 권한 검사 추가분
models/perm/access/repo_permission.go—GetIndividualUserRepoPermission,HasAnyUnitAccessOrPublicAccess구현models/packages/package.go—RepoID/SetRepositoryLink정의routers/web/user/package.go— 저장소 링크 시 소유자만 검사하는packageSettingsPostActionLink