diff --git a/.notes/note-1770858253427-5d5d6gaia.json b/.notes/note-1770858253427-5d5d6gaia.json new file mode 100644 index 0000000000..d43ad6fc12 --- /dev/null +++ b/.notes/note-1770858253427-5d5d6gaia.json @@ -0,0 +1,8 @@ +{ + "id": "note-1770858253427-5d5d6gaia", + "title": "Issues/Releases", + "content": " Access matrix after changes\n ┌────────────────────────┬────────────┬────────────┬────────────┬─────────────┐\n │ Endpoint │ Public │ Limited │ Private │ Private Org │\n ├────────────────────────┼────────────┼────────────┼────────────┼─────────────┤\n │ POST submit.json │ anonymous │ anonymous │ anonymous │ anonymous │\n ├────────────────────────┼────────────┼────────────┼────────────┼─────────────┤\n │ GET status.json │ anonymous* │ anonymous* │ anonymous* │ anonymous* │\n ├────────────────────────┼────────────┼────────────┼────────────┼─────────────┤\n │ GET external/{id}.json │ anonymous │ anonymous │ anonymous │ anonymous │\n ├────────────────────────┼────────────┼────────────┼────────────┼─────────────┤\n │ GET latest.json │ anonymous │ anonymous │ anonymous │ anonymous │\n └────────────────────────┴────────────┴────────────┴────────────┴─────────────┘\n * Only returns issues where external_source = \"gitcaddy-desktop\"", + "createdAt": 1770858253425, + "updatedAt": 1770858258193, + "tags": [] +} \ No newline at end of file diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 69739ca531..624d4ea864 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -439,6 +439,7 @@ func prepareMigrationTasks() []*migration { newMigration(362, "Create wishlist_comment_reaction table", v1_26.CreateWishlistCommentReactionTable), newMigration(363, "Add keep_packages_private to user", v1_26.AddKeepPackagesPrivateToUser), newMigration(364, "Add view_count to blog_post", v1_26.AddViewCountToBlogPost), + newMigration(365, "Add public_app_integration to repository", v1_26.AddPublicAppIntegrationToRepository), } return preparedMigrations } diff --git a/models/migrations/v1_26/v365.go b/models/migrations/v1_26/v365.go new file mode 100644 index 0000000000..a10e7e647b --- /dev/null +++ b/models/migrations/v1_26/v365.go @@ -0,0 +1,13 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import "xorm.io/xorm" + +func AddPublicAppIntegrationToRepository(x *xorm.Engine) error { + type Repository struct { + PublicAppIntegration bool `xorm:"NOT NULL DEFAULT true"` + } + return x.Sync(new(Repository)) +} diff --git a/models/repo/repo.go b/models/repo/repo.go index 4b21a28283..7abdbd8afb 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -221,6 +221,7 @@ type Repository struct { SubscriptionsEnabled bool `xorm:"NOT NULL DEFAULT false"` BlogEnabled bool `xorm:"NOT NULL DEFAULT false"` WishlistEnabled bool `xorm:"NOT NULL DEFAULT false"` + PublicAppIntegration bool `xorm:"NOT NULL DEFAULT true"` ObjectFormatName string `xorm:"VARCHAR(6) NOT NULL DEFAULT 'sha1'"` TrustModel TrustModelType diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 08bc79c2b5..ce70f7ad14 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -4379,6 +4379,9 @@ "repo.settings.hidden_folders.already_hidden": "This folder is already hidden.", "repo.settings.dotfiles": "Dotfiles", "repo.settings.dotfiles.hide_desc": "Hide files and folders starting with \".\" from the code browser for non-admin users", + "repo.settings.app_integration": "App Integration", + "repo.settings.app_integration.enable": "Allow anonymous issue reporting and update checks", + "repo.settings.app_integration.enable_help": "When enabled, the desktop app can submit issues and check for updates without authentication. Disable this for full access control on private repositories.", "repo.gallery": "Gallery", "api": "API", "admin.config.api_header_url": "API Header Link", diff --git a/routers/web/repo/issue_json.go b/routers/web/repo/issue_json.go index 376c6e7f38..9fcd381505 100644 --- a/routers/web/repo/issue_json.go +++ b/routers/web/repo/issue_json.go @@ -19,7 +19,6 @@ import ( "code.gitcaddy.com/server/v3/services/attachment" "code.gitcaddy.com/server/v3/services/context" issue_service "code.gitcaddy.com/server/v3/services/issue" - vault_service "code.gitcaddy.com/server/v3/services/vault" ) // IssueSubmitJSON is the request body for submitting an issue via JSON API @@ -62,34 +61,35 @@ type IssueListJSON struct { TotalCount int64 `json:"total_count"` } -// checkVaultTokenForRepo validates vault token for private repo access +// checkVaultTokenForRepo validates access for app integration endpoints. +// If the user is signed in or the repo has public app integration enabled, +// access is granted. Otherwise, a valid vault token is required. func checkVaultTokenForRepo(ctx *context.Context) bool { - // If user is logged in, they have access through normal means + // If user is logged in, always allow if ctx.Doer != nil { return true } - - // For private repos, check vault token - if ctx.Repo.Repository.IsPrivate { - token := vaultTokenFromHeader(ctx.Req) - if token == "" { - ctx.JSON(http.StatusUnauthorized, map[string]string{ - "error": "unauthorized", - "message": "This repository is private. Provide a vault token via Authorization header.", - }) - return false - } - - validToken, err := validateVaultTokenForRepo(ctx, token, ctx.Repo.Repository.ID) - if err != nil || validToken == nil { - ctx.JSON(http.StatusUnauthorized, map[string]string{ - "error": "invalid_token", - "message": "Invalid or expired vault token", - }) - return false - } + // If public app integration is enabled, allow anonymous access + if ctx.Repo.Repository.PublicAppIntegration { + return true + } + // Otherwise require vault token + token := vaultTokenFromHeader(ctx.Req) + if token == "" { + ctx.JSON(http.StatusUnauthorized, map[string]string{ + "error": "unauthorized", + "message": "Anonymous app integration is disabled. Provide a vault token via Authorization header.", + }) + return false + } + validToken, err := validateVaultTokenForRepo(ctx, token, ctx.Repo.Repository.ID) + if err != nil || validToken == nil { + ctx.JSON(http.StatusUnauthorized, map[string]string{ + "error": "invalid_token", + "message": "Invalid or expired vault token", + }) + return false } - return true } @@ -313,6 +313,10 @@ func IssueSubmitJSONEndpoint(ctx *context.Context) { // IssueStatusJSONEndpoint returns the status of a specific issue // URL: GET /{owner}/{repo}/issues/{index}/status.json +// +// For security, this endpoint only returns issues that were submitted through the +// app integration (external_source = "gitcaddy-desktop"). This prevents anonymous +// enumeration of developer-created issues on private/limited repos. func IssueStatusJSONEndpoint(ctx *context.Context) { // Set rate limit exemption headers ctx.Resp.Header().Set("X-RateLimit-Exempt", "app-integration") @@ -339,6 +343,16 @@ func IssueStatusJSONEndpoint(ctx *context.Context) { return } + // Only expose app-submitted issues to anonymous users. + // Authenticated users with repo access can see all issues through the normal UI. + if ctx.Doer == nil && issue.ExternalSource != "gitcaddy-desktop" { + ctx.JSON(http.StatusNotFound, map[string]string{ + "error": "not_found", + "message": "Issue not found", + }) + return + } + // Load labels if err = issue.LoadLabels(ctx); err != nil { issue.Labels = nil @@ -407,6 +421,3 @@ func IssuesByExternalUserJSONEndpoint(ctx *context.Context) { // validateVaultTokenForRepo is defined in release.go // vaultTokenFromHeader is defined in release.go - -// Ensure vault_service is used (avoid unused import error) -var _ = vault_service.ErrAccessDenied diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index fb277a8c9a..4fdf0a98b6 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -829,9 +829,9 @@ func validateVaultTokenForRepo(ctx stdCtx.Context, rawToken string, repoID int64 // This is a simple endpoint for apps to check for updates // URL: /{owner}/{repo}/releases/latest.json?channel=stable|prerelease|any // -// For private repos, supports authentication via: -// - Normal user session -// - Vault token in Authorization header (Bearer gvt_xxx) +// This endpoint is accessible without authentication regardless of repo visibility. +// Release info is effectively public to anyone running the installed app, since the +// repo URL is embedded in the app binary. // // This endpoint is exempt from rate limiting for update checks func LatestReleaseJSONEndpoint(ctx *context.Context) { @@ -839,33 +839,8 @@ func LatestReleaseJSONEndpoint(ctx *context.Context) { ctx.Resp.Header().Set("X-RateLimit-Exempt", "update-check") ctx.Resp.Header().Set("X-Update-Check-Endpoint", "true") - // Check access for private repos - if ctx.Repo.Repository.IsPrivate { - // If user is logged in and has access, allow - if ctx.Doer != nil { - // User is authenticated, normal permission checks apply - } else { - // Try vault token authentication for machine-to-machine access - token := vaultTokenFromHeader(ctx.Req) - if token == "" { - ctx.JSON(http.StatusUnauthorized, map[string]string{ - "error": "unauthorized", - "message": "This repository is private. Provide a vault token via Authorization header.", - }) - return - } - - // Validate the vault token - use "read" action with "*" to check basic repo access - // Import lazily to avoid circular dependencies - validToken, err := validateVaultTokenForRepo(ctx, token, ctx.Repo.Repository.ID) - if err != nil || validToken == nil { - ctx.JSON(http.StatusUnauthorized, map[string]string{ - "error": "invalid_token", - "message": "Invalid or expired vault token", - }) - return - } - } + if !checkVaultTokenForRepo(ctx) { + return } // Get channel from query param (default: stable) diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index a598cdfecd..6c1c57693f 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -530,6 +530,11 @@ func handleSettingsPostAdvanced(ctx *context.Context) { repoChanged = true } + if repo.PublicAppIntegration != form.EnablePublicAppIntegration { + repo.PublicAppIntegration = form.EnablePublicAppIntegration + repoChanged = true + } + if form.EnableCode && !unit_model.TypeCode.UnitGlobalDisabled() { units = append(units, newRepoUnit(repo, unit_model.TypeCode, nil)) } else if !unit_model.TypeCode.UnitGlobalDisabled() { diff --git a/routers/web/web.go b/routers/web/web.go index abac6d0ef9..a3d26a0529 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -349,6 +349,9 @@ func registerWebRoutes(m *web.Router) { // optional sign in (if signed in, use the user as doer, if not, no doer) optSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict}) optExploreSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict || setting.Service.Explore.RequireSigninView}) + // app integration endpoints: never require sign-in and skip cross-origin protection. + // Used by the desktop app for issue reporting, update checks, etc. + optSignInForIntegration := verifyAuthWithOptions(&common.VerifyOptions{DisableCrossOriginProtection: true}) validation.AddBindingRules() @@ -1598,7 +1601,6 @@ func registerWebRoutes(m *web.Router) { m.Get(".atom", feedEnabled, repo.ReleasesFeedAtom) m.Get("/tag/*", repo.SingleRelease) m.Get("/latest", repo.LatestRelease) - m.Get("/latest.json", repo.LatestReleaseJSONEndpoint) // Simple JSON endpoint for update checks }, ctxDataSet("EnableFeed", setting.Other.EnableFeed)) m.Get("/releases/attachments/{uuid}", repo.GetAttachment) m.Get("/releases/download/{vTag}/{fileName}", repo.RedirectDownload) @@ -1619,14 +1621,18 @@ func registerWebRoutes(m *web.Router) { }, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader) // end "/{username}/{reponame}": repo releases - m.Group("/{username}/{reponame}", func() { // issue JSON API for app integration + m.Group("/{username}/{reponame}", func() { // app integration endpoints (issue reporting, update checks) + // These endpoints are exempt from unit permission checks and cross-origin protection + // so the desktop app can submit issues and check for updates without authentication, + // even for repos with Limited visibility. Each endpoint handles its own auth via checkVaultTokenForRepo. m.Group("/issues", func() { m.Post("/submit.json", web.Bind(repo.IssueSubmitJSON{}), repo.IssueSubmitJSONEndpoint) m.Get("/external/{external_user_id}.json", repo.IssuesByExternalUserJSONEndpoint) m.Get("/{index}/status.json", repo.IssueStatusJSONEndpoint) }) - }, optSignIn, context.RepoAssignment, reqRepoIssuesOrPullsReader) - // end "/{username}/{reponame}": issue JSON API + m.Get("/releases/latest.json", repo.LatestReleaseJSONEndpoint) + }, optSignInForIntegration, context.RepoAssignmentForIntegration) + // end "/{username}/{reponame}": app integration m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments m.Get("/attachments/{uuid}", repo.GetAttachment) diff --git a/services/context/repo.go b/services/context/repo.go index cf6c712b46..b0d03174c5 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -786,6 +786,64 @@ func RepoAssignment(ctx *Context) { } } +// RepoAssignmentForIntegration is a lightweight middleware that loads a repository +// without checking permissions, org visibility, or unit access. It is used for +// app integration endpoints (issue reporting, update checks) that handle their own +// authentication and must be accessible regardless of repo or org visibility settings. +func RepoAssignmentForIntegration(ctx *Context) { + var err error + userName := ctx.PathParam("username") + repoName := ctx.PathParam("reponame") + repoName = strings.TrimSuffix(repoName, ".git") + + // Resolve owner + if ctx.IsSigned && strings.EqualFold(ctx.Doer.LowerName, userName) { + ctx.Repo.Owner = ctx.Doer + } else { + ctx.Repo.Owner, err = user_model.GetUserByName(ctx, userName) + if err != nil { + if user_model.IsErrUserNotExist(err) { + if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil { + RedirectToUser(ctx.Base, ctx.Doer, userName, redirectUserID) + } else { + ctx.NotFound(nil) + } + } else { + ctx.ServerError("GetUserByName", err) + } + return + } + } + ctx.ContextUser = ctx.Repo.Owner + + // Resolve repo + repo, err := repo_model.GetRepositoryByName(ctx, ctx.Repo.Owner.ID, repoName) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + if redirectRepoID, err := repo_model.LookupRedirect(ctx, ctx.Repo.Owner.ID, repoName); err == nil { + RedirectToRepo(ctx.Base, redirectRepoID) + } else { + ctx.NotFound(nil) + } + } else { + ctx.ServerError("GetRepositoryByName", err) + } + return + } + + repo.Owner = ctx.Repo.Owner + if err = repo.LoadOwner(ctx); err != nil { + ctx.ServerError("LoadOwner", err) + return + } + + ctx.Repo.Repository = repo + ctx.Repo.RepoLink = repo.Link() + ctx.Data["Repository"] = repo + ctx.Data["Owner"] = repo.Owner + ctx.Data["RepoLink"] = ctx.Repo.RepoLink +} + const headRefName = "HEAD" func getRefNameFromPath(repo *Repository, path string, isExist func(string) bool) string { diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index a63a7a9c31..95f2cd68d9 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -126,6 +126,7 @@ type RepoSettingForm struct { EnableCloseIssuesViaCommitInAnyBranch bool HideDotfiles bool EnableBlog bool + EnablePublicAppIntegration bool EnableProjects bool ProjectsMode string diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index 228563cf42..40b53b4311 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -659,6 +659,16 @@ {{end}} +
+{{ctx.Locale.Tr "repo.settings.app_integration.enable_help"}}
+