Compare commits

...

8 Commits

Author SHA1 Message Date
Lunny Xiao
99053ce4fa Change log for 1.24.7 (#35742) 2025-10-24 17:46:26 -07:00
wxiaoguang
e818de179e Refactor legacy code (#35708) (#35713)
Backport #35708
2025-10-21 09:10:10 +08:00
Giteabot
0a87bf9016 Fixing issue #35530: Password Leak in Log Messages (#35584) (#35665)
Backport #35584 by @shashank-netapp

# Summary
The Gitea codebase was logging `Elasticsearch` and `Meilisearch`
connection strings directly to log files without sanitizing them. Since
connection strings often contain credentials in the format
`protocol://username:password@host:port`, this resulted in passwords
being exposed in plain text in log output.

Fix:
- wrapped all instances of setting.Indexer.RepoConnStr and
setting.Indexer.IssueConnStr with the `util.SanitizeCredentialURLs()`
function before logging them.

Fixes: #35530

Co-authored-by: shashank-netapp <108022276+shashank-netapp@users.noreply.github.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2025-10-15 09:39:33 +00:00
Giteabot
86d99e2f38 Mock external service in hcaptcha TestCaptcha (#35604) (#35663)
Backport #35604 by @silverwind

The test calls out to a web service which may be down or unreachable as
seen in the linked issue. It's better for tests to not have such
external dependencies to make them absolutely stable.

Fixes: https://github.com/go-gitea/gitea/issues/35571

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2025-10-15 08:49:24 +00:00
Giteabot
7bfb7567b2 Correctly override user unitmodes (#35501) (#35666)
Backport #35501 by lynxplay
2025-10-15 08:22:59 +00:00
Giteabot
7619808137 Fix inputing review comment will remove reviewer (#35591) (#35664)
Backport #35591 by @lunny

Fix #34617

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2025-10-15 07:56:52 +00:00
Giteabot
b854930a96 Fix a bug missed return (#35655) (#35671)
Backport #35655 by @lunny

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2025-10-15 07:30:46 +00:00
Lunny Xiao
935f5e0ad5 Fix build (#35669)
backport #35656
2025-10-15 08:20:00 +02:00
25 changed files with 508 additions and 252 deletions

View File

@@ -4,6 +4,18 @@ This changelog goes through the changes that have been made in each release
without substantial changes to our git log; to see the highlights of what has without substantial changes to our git log; to see the highlights of what has
been added to each release, please refer to the [blog](https://blog.gitea.com). been added to each release, please refer to the [blog](https://blog.gitea.com).
## [1.24.7](https://github.com/go-gitea/gitea/releases/tag/1.24.7) - 2025-10-24
* SECURITY
* Refactor legacy code (#35708) (#35713)
* Fixing issue #35530: Password Leak in Log Messages (#35584) (#35665)
* Fix a bug missed return (#35655) (#35671)
* BUGFIXES
* Fix inputing review comment will remove reviewer (#35591) (#35664)
* TESTING
* Mock external service in hcaptcha TestCaptcha (#35604) (#35663)
* Fix build (#35669)
## [1.24.6](https://github.com/go-gitea/gitea/releases/tag/1.24.6) - 2025-09-10 ## [1.24.6](https://github.com/go-gitea/gitea/releases/tag/1.24.6) - 2025-09-10
* SECURITY * SECURITY

View File

@@ -13,7 +13,6 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
"unicode" "unicode"
asymkey_model "code.gitea.io/gitea/models/asymkey" asymkey_model "code.gitea.io/gitea/models/asymkey"
@@ -31,7 +30,6 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/lfs" "code.gitea.io/gitea/services/lfs"
"github.com/golang-jwt/jwt/v5"
"github.com/kballard/go-shellquote" "github.com/kballard/go-shellquote"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
@@ -131,27 +129,6 @@ func getAccessMode(verb, lfsVerb string) perm.AccessMode {
return perm.AccessModeNone return perm.AccessModeNone
} }
func getLFSAuthToken(ctx context.Context, lfsVerb string, results *private.ServCommandResults) (string, error) {
now := time.Now()
claims := lfs.Claims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)),
NotBefore: jwt.NewNumericDate(now),
},
RepoID: results.RepoID,
Op: lfsVerb,
UserID: results.UserID,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign and get the complete encoded token as a string using the secret
tokenString, err := token.SignedString(setting.LFS.JWTSecretBytes)
if err != nil {
return "", fail(ctx, "Failed to sign JWT Token", "Failed to sign JWT token: %v", err)
}
return "Bearer " + tokenString, nil
}
func runServ(c *cli.Context) error { func runServ(c *cli.Context) error {
ctx, cancel := installSignals() ctx, cancel := installSignals()
defer cancel() defer cancel()
@@ -284,7 +261,7 @@ func runServ(c *cli.Context) error {
// LFS SSH protocol // LFS SSH protocol
if verb == git.CmdVerbLfsTransfer { if verb == git.CmdVerbLfsTransfer {
token, err := getLFSAuthToken(ctx, lfsVerb, results) token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, RepoID: results.RepoID})
if err != nil { if err != nil {
return err return err
} }
@@ -295,7 +272,7 @@ func runServ(c *cli.Context) error {
if verb == git.CmdVerbLfsAuthenticate { if verb == git.CmdVerbLfsAuthenticate {
url := fmt.Sprintf("%s%s/%s.git/info/lfs", setting.AppURL, url.PathEscape(results.OwnerName), url.PathEscape(results.RepoName)) url := fmt.Sprintf("%s%s/%s.git/info/lfs", setting.AppURL, url.PathEscape(results.OwnerName), url.PathEscape(results.RepoName))
token, err := getLFSAuthToken(ctx, lfsVerb, results) token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, RepoID: results.RepoID})
if err != nil { if err != nil {
return err return err
} }

View File

@@ -67,13 +67,6 @@ func (key *PublicKey) OmitEmail() string {
return strings.Join(strings.Split(key.Content, " ")[:2], " ") return strings.Join(strings.Split(key.Content, " ")[:2], " ")
} }
// AuthorizedString returns formatted public key string for authorized_keys file.
//
// TODO: Consider dropping this function
func (key *PublicKey) AuthorizedString() string {
return AuthorizedStringForKey(key)
}
func addKey(ctx context.Context, key *PublicKey) (err error) { func addKey(ctx context.Context, key *PublicKey) (err error) {
if len(key.Fingerprint) == 0 { if len(key.Fingerprint) == 0 {
key.Fingerprint, err = CalcFingerprint(key.Content) key.Fingerprint, err = CalcFingerprint(key.Content)

View File

@@ -17,29 +17,13 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"golang.org/x/crypto/ssh"
) )
// _____ __ .__ .__ .___ // AuthorizedStringCommentPrefix is a magic tag
// / _ \ __ ___/ |_| |__ ___________|__|_______ ____ __| _/ // some functions like RegeneratePublicKeys needs this tag to skip the keys generated by Gitea, while keep other keys
// / /_\ \| | \ __\ | \ / _ \_ __ \ \___ // __ \ / __ | const AuthorizedStringCommentPrefix = `# gitea public key`
// / | \ | /| | | Y ( <_> ) | \/ |/ /\ ___// /_/ |
// \____|__ /____/ |__| |___| /\____/|__| |__/_____ \\___ >____ |
// \/ \/ \/ \/ \/
// ____ __.
// | |/ _|____ ___.__. ______
// | <_/ __ < | |/ ___/
// | | \ ___/\___ |\___ \
// |____|__ \___ > ____/____ >
// \/ \/\/ \/
//
// This file contains functions for creating authorized_keys files
//
// There is a dependence on the database within RegeneratePublicKeys however most of these functions probably belong in a module
const (
tplCommentPrefix = `# gitea public key`
tplPublicKey = tplCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict %s` + "\n"
)
var sshOpLocker sync.Mutex var sshOpLocker sync.Mutex
@@ -50,17 +34,45 @@ func WithSSHOpLocker(f func() error) error {
} }
// AuthorizedStringForKey creates the authorized keys string appropriate for the provided key // AuthorizedStringForKey creates the authorized keys string appropriate for the provided key
func AuthorizedStringForKey(key *PublicKey) string { func AuthorizedStringForKey(key *PublicKey) (string, error) {
sb := &strings.Builder{} sb := &strings.Builder{}
_ = setting.SSH.AuthorizedKeysCommandTemplateTemplate.Execute(sb, map[string]any{ _, err := writeAuthorizedStringForKey(key, sb)
return sb.String(), err
}
// WriteAuthorizedStringForValidKey writes the authorized key for the provided key. If the key is invalid, it does nothing.
func WriteAuthorizedStringForValidKey(key *PublicKey, w io.Writer) error {
validKey, err := writeAuthorizedStringForKey(key, w)
if !validKey {
log.Debug("WriteAuthorizedStringForValidKey: key %s is not valid: %v", key, err)
return nil
}
return err
}
func writeAuthorizedStringForKey(key *PublicKey, w io.Writer) (keyValid bool, err error) {
const tpl = AuthorizedStringCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict %s %s` + "\n"
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content))
if err != nil {
return false, err
}
// now the key is valid, the code below could only return template/IO related errors
sbCmd := &strings.Builder{}
err = setting.SSH.AuthorizedKeysCommandTemplateTemplate.Execute(sbCmd, map[string]any{
"AppPath": util.ShellEscape(setting.AppPath), "AppPath": util.ShellEscape(setting.AppPath),
"AppWorkPath": util.ShellEscape(setting.AppWorkPath), "AppWorkPath": util.ShellEscape(setting.AppWorkPath),
"CustomConf": util.ShellEscape(setting.CustomConf), "CustomConf": util.ShellEscape(setting.CustomConf),
"CustomPath": util.ShellEscape(setting.CustomPath), "CustomPath": util.ShellEscape(setting.CustomPath),
"Key": key, "Key": key,
}) })
if err != nil {
return fmt.Sprintf(tplPublicKey, util.ShellEscape(sb.String()), key.Content) return true, err
}
sshCommandEscaped := util.ShellEscape(sbCmd.String())
sshKeyMarshalled := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey)))
sshKeyComment := fmt.Sprintf("user-%d", key.OwnerID)
_, err = fmt.Fprintf(w, tpl, sshCommandEscaped, sshKeyMarshalled, sshKeyComment)
return true, err
} }
// appendAuthorizedKeysToFile appends new SSH keys' content to authorized_keys file. // appendAuthorizedKeysToFile appends new SSH keys' content to authorized_keys file.
@@ -112,7 +124,7 @@ func appendAuthorizedKeysToFile(keys ...*PublicKey) error {
if key.Type == KeyTypePrincipal { if key.Type == KeyTypePrincipal {
continue continue
} }
if _, err = f.WriteString(key.AuthorizedString()); err != nil { if err = WriteAuthorizedStringForValidKey(key, f); err != nil {
return err return err
} }
} }
@@ -120,10 +132,9 @@ func appendAuthorizedKeysToFile(keys ...*PublicKey) error {
} }
// RegeneratePublicKeys regenerates the authorized_keys file // RegeneratePublicKeys regenerates the authorized_keys file
func RegeneratePublicKeys(ctx context.Context, t io.StringWriter) error { func RegeneratePublicKeys(ctx context.Context, t io.Writer) error {
if err := db.GetEngine(ctx).Where("type != ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean any) (err error) { if err := db.GetEngine(ctx).Where("type != ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean any) (err error) {
_, err = t.WriteString((bean.(*PublicKey)).AuthorizedString()) return WriteAuthorizedStringForValidKey(bean.(*PublicKey), t)
return err
}); err != nil { }); err != nil {
return err return err
} }
@@ -144,11 +155,11 @@ func RegeneratePublicKeys(ctx context.Context, t io.StringWriter) error {
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(f)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
if strings.HasPrefix(line, tplCommentPrefix) { if strings.HasPrefix(line, AuthorizedStringCommentPrefix) {
scanner.Scan() scanner.Scan()
continue continue
} }
_, err = t.WriteString(line + "\n") _, err = io.WriteString(t, line+"\n")
if err != nil { if err != nil {
return err return err
} }

View File

@@ -173,7 +173,7 @@ func GetReviewsByIssueID(ctx context.Context, issueID int64) (latestReviews, mig
reviewersMap := make(map[int64][]*Review) // key is reviewer id reviewersMap := make(map[int64][]*Review) // key is reviewer id
originalReviewersMap := make(map[int64][]*Review) // key is original author id originalReviewersMap := make(map[int64][]*Review) // key is original author id
reviewTeamsMap := make(map[int64][]*Review) // key is reviewer team id reviewTeamsMap := make(map[int64][]*Review) // key is reviewer team id
countedReivewTypes := []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest} countedReivewTypes := []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest, ReviewTypeComment}
for _, review := range reviews { for _, review := range reviews {
if review.ReviewerTeamID == 0 && slices.Contains(countedReivewTypes, review.Type) && !review.Dismissed { if review.ReviewerTeamID == 0 && slices.Contains(countedReivewTypes, review.Type) && !review.Dismissed {
if review.OriginalAuthorID != 0 { if review.OriginalAuthorID != 0 {

View File

@@ -123,6 +123,7 @@ func TestGetReviewersByIssueID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
@@ -130,6 +131,12 @@ func TestGetReviewersByIssueID(t *testing.T) {
expectedReviews := []*issues_model.Review{} expectedReviews := []*issues_model.Review{}
expectedReviews = append(expectedReviews, expectedReviews = append(expectedReviews,
&issues_model.Review{
ID: 5,
Reviewer: user1,
Type: issues_model.ReviewTypeComment,
UpdatedUnix: 946684810,
},
&issues_model.Review{ &issues_model.Review{
ID: 7, ID: 7,
Reviewer: org3, Reviewer: org3,
@@ -168,8 +175,9 @@ func TestGetReviewersByIssueID(t *testing.T) {
for _, review := range allReviews { for _, review := range allReviews {
assert.NoError(t, review.LoadReviewer(db.DefaultContext)) assert.NoError(t, review.LoadReviewer(db.DefaultContext))
} }
if assert.Len(t, allReviews, 5) { if assert.Len(t, allReviews, 6) {
for i, review := range allReviews { for i, review := range allReviews {
assert.Equal(t, expectedReviews[i].ID, review.ID)
assert.Equal(t, expectedReviews[i].Reviewer, review.Reviewer) assert.Equal(t, expectedReviews[i].Reviewer, review.Reviewer)
assert.Equal(t, expectedReviews[i].Type, review.Type) assert.Equal(t, expectedReviews[i].Type, review.Type)
assert.Equal(t, expectedReviews[i].UpdatedUnix, review.UpdatedUnix) assert.Equal(t, expectedReviews[i].UpdatedUnix, review.UpdatedUnix)

View File

@@ -348,10 +348,8 @@ func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, use
for _, u := range repo.Units { for _, u := range repo.Units {
for _, team := range teams { for _, team := range teams {
unitAccessMode := minAccessMode teamMode, _ := team.UnitAccessModeEx(ctx, u.Type)
if teamMode, exist := team.UnitAccessModeEx(ctx, u.Type); exist { unitAccessMode := max(perm.unitsMode[u.Type], minAccessMode, teamMode)
unitAccessMode = max(perm.unitsMode[u.Type], unitAccessMode, teamMode)
}
perm.unitsMode[u.Type] = unitAccessMode perm.unitsMode[u.Type] = unitAccessMode
} }
} }

View File

@@ -197,4 +197,37 @@ func TestGetUserRepoPermission(t *testing.T) {
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeCode]) assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeCode])
assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeIssues]) assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeIssues])
}) })
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // org private repo, same org as repo 32
require.NoError(t, repo3.LoadOwner(ctx))
require.True(t, repo3.Owner.IsOrganization())
require.NoError(t, db.TruncateBeans(ctx, &organization.TeamUnit{}, &Access{})) // The user has access set of that repo, remove it, it is useless for our test
require.NoError(t, db.Insert(ctx, &organization.TeamRepo{OrgID: org.ID, TeamID: team.ID, RepoID: repo3.ID}))
t.Run("DoerWithNoopTeamOnPrivateRepo", func(t *testing.T) {
perm, err := GetUserRepoPermission(ctx, repo3, user)
require.NoError(t, err)
assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode)
assert.Equal(t, perm_model.AccessModeNone, perm.unitsMode[unit.TypeCode])
assert.Equal(t, perm_model.AccessModeNone, perm.unitsMode[unit.TypeIssues])
})
require.NoError(t, db.Insert(ctx, &organization.TeamUnit{OrgID: org.ID, TeamID: team.ID, Type: unit.TypeCode, AccessMode: perm_model.AccessModeNone}))
require.NoError(t, db.Insert(ctx, &organization.TeamUnit{OrgID: org.ID, TeamID: team.ID, Type: unit.TypeIssues, AccessMode: perm_model.AccessModeRead}))
t.Run("DoerWithReadIssueTeamOnPrivateRepo", func(t *testing.T) {
perm, err := GetUserRepoPermission(ctx, repo3, user)
require.NoError(t, err)
assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode)
assert.Equal(t, perm_model.AccessModeNone, perm.unitsMode[unit.TypeCode])
assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeIssues])
})
require.NoError(t, db.Insert(ctx, repo_model.Collaboration{RepoID: repo3.ID, UserID: user.ID, Mode: perm_model.AccessModeWrite}))
require.NoError(t, db.Insert(ctx, Access{RepoID: repo3.ID, UserID: user.ID, Mode: perm_model.AccessModeWrite}))
t.Run("DoerWithReadIssueTeamAndWriteCollaboratorOnPrivateRepo", func(t *testing.T) {
perm, err := GetUserRepoPermission(ctx, repo3, user)
require.NoError(t, err)
assert.Equal(t, perm_model.AccessModeWrite, perm.AccessMode)
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeCode])
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeIssues])
})
} }

View File

@@ -137,16 +137,9 @@ func DeleteUploads(ctx context.Context, uploads ...*Upload) (err error) {
for _, upload := range uploads { for _, upload := range uploads {
localPath := upload.LocalPath() localPath := upload.LocalPath()
isFile, err := util.IsFile(localPath)
if err != nil {
log.Error("Unable to check if %s is a file. Error: %v", localPath, err)
}
if !isFile {
continue
}
if err := util.Remove(localPath); err != nil { if err := util.Remove(localPath); err != nil {
return fmt.Errorf("remove upload: %w", err) // just continue, don't fail the whole operation if a file is missing (removed by others)
log.Error("unable to remove upload file %s: %v", localPath, err)
} }
} }

View File

@@ -51,30 +51,16 @@ func GetHook(repoPath, name string) (*Hook, error) {
name: name, name: name,
path: filepath.Join(repoPath, "hooks", name+".d", name), path: filepath.Join(repoPath, "hooks", name+".d", name),
} }
isFile, err := util.IsFile(h.path) if data, err := os.ReadFile(h.path); err == nil {
if err != nil {
return nil, err
}
if isFile {
data, err := os.ReadFile(h.path)
if err != nil {
return nil, err
}
h.IsActive = true h.IsActive = true
h.Content = string(data) h.Content = string(data)
return h, nil return h, nil
} else if !os.IsNotExist(err) {
return nil, err
} }
samplePath := filepath.Join(repoPath, "hooks", name+".sample") samplePath := filepath.Join(repoPath, "hooks", name+".sample")
isFile, err = util.IsFile(samplePath) if data, err := os.ReadFile(samplePath); err == nil {
if err != nil {
return nil, err
}
if isFile {
data, err := os.ReadFile(samplePath)
if err != nil {
return nil, err
}
h.Sample = string(data) h.Sample = string(data)
} }
return h, nil return h, nil

View File

@@ -34,12 +34,12 @@ func TestParseGitURLs(t *testing.T) {
}, },
}, },
{ {
kase: "git@[fe80:14fc:cec5:c174:d88%2510]:go-gitea/gitea.git", kase: "git@[fe80::14fc:cec5:c174:d88%2510]:go-gitea/gitea.git",
expected: &GitURL{ expected: &GitURL{
URL: &url.URL{ URL: &url.URL{
Scheme: "ssh", Scheme: "ssh",
User: url.User("git"), User: url.User("git"),
Host: "[fe80:14fc:cec5:c174:d88%10]", Host: "[fe80::14fc:cec5:c174:d88%10]",
Path: "go-gitea/gitea.git", Path: "go-gitea/gitea.git",
}, },
extraMark: 1, extraMark: 1,
@@ -137,11 +137,11 @@ func TestParseGitURLs(t *testing.T) {
}, },
}, },
{ {
kase: "https://[fe80:14fc:cec5:c174:d88%2510]:20/go-gitea/gitea.git", kase: "https://[fe80::14fc:cec5:c174:d88%2510]:20/go-gitea/gitea.git",
expected: &GitURL{ expected: &GitURL{
URL: &url.URL{ URL: &url.URL{
Scheme: "https", Scheme: "https",
Host: "[fe80:14fc:cec5:c174:d88%10]:20", Host: "[fe80::14fc:cec5:c174:d88%10]:20",
Path: "/go-gitea/gitea.git", Path: "/go-gitea/gitea.git",
}, },
extraMark: 0, extraMark: 0,

View File

@@ -4,7 +4,10 @@
package hcaptcha package hcaptcha
import ( import (
"errors"
"io"
"net/http" "net/http"
"net/url"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -21,6 +24,33 @@ func TestMain(m *testing.M) {
os.Exit(m.Run()) os.Exit(m.Run())
} }
type mockTransport struct{}
func (mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.String() != verifyURL {
return nil, errors.New("unsupported url")
}
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
bodyValues, err := url.ParseQuery(string(body))
if err != nil {
return nil, err
}
var responseText string
if bodyValues.Get("response") == dummyToken {
responseText = `{"success":true,"credit":false,"hostname":"dummy-key-pass","challenge_ts":"2025-10-08T16:02:56.136Z"}`
} else {
responseText = `{"success":false,"error-codes":["invalid-input-response"]}`
}
return &http.Response{Request: req, Body: io.NopCloser(strings.NewReader(responseText))}, nil
}
func TestCaptcha(t *testing.T) { func TestCaptcha(t *testing.T) {
tt := []struct { tt := []struct {
Name string Name string
@@ -54,7 +84,8 @@ func TestCaptcha(t *testing.T) {
for _, tc := range tt { for _, tc := range tt {
t.Run(tc.Name, func(t *testing.T) { t.Run(tc.Name, func(t *testing.T) {
client, err := New(tc.Secret, WithHTTP(&http.Client{ client, err := New(tc.Secret, WithHTTP(&http.Client{
Timeout: time.Second * 5, Timeout: time.Second * 5,
Transport: mockTransport{},
})) }))
if err != nil { if err != nil {
// The only error that can be returned from creating a client // The only error that can be returned from creating a client

View File

@@ -22,6 +22,7 @@ import (
"code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
) )
var ( var (
@@ -166,12 +167,12 @@ func Init() {
log.Fatal("PID: %d Unable to initialize the bleve Repository Indexer at path: %s Error: %v", os.Getpid(), setting.Indexer.RepoPath, err) log.Fatal("PID: %d Unable to initialize the bleve Repository Indexer at path: %s Error: %v", os.Getpid(), setting.Indexer.RepoPath, err)
} }
case "elasticsearch": case "elasticsearch":
log.Info("PID: %d Initializing Repository Indexer at: %s", os.Getpid(), setting.Indexer.RepoConnStr) log.Info("PID: %d Initializing Repository Indexer at: %s", os.Getpid(), util.SanitizeCredentialURLs(setting.Indexer.RepoConnStr))
defer func() { defer func() {
if err := recover(); err != nil { if err := recover(); err != nil {
log.Error("PANIC whilst initializing repository indexer: %v\nStacktrace: %s", err, log.Stack(2)) log.Error("PANIC whilst initializing repository indexer: %v\nStacktrace: %s", err, log.Stack(2))
log.Error("The indexer files are likely corrupted and may need to be deleted") log.Error("The indexer files are likely corrupted and may need to be deleted")
log.Error("You can completely remove the \"%s\" index to make Gitea recreate the indexes", setting.Indexer.RepoConnStr) log.Error("You can completely remove the \"%s\" index to make Gitea recreate the indexes", util.SanitizeCredentialURLs(setting.Indexer.RepoConnStr))
} }
}() }()
@@ -181,7 +182,7 @@ func Init() {
cancel() cancel()
(*globalIndexer.Load()).Close() (*globalIndexer.Load()).Close()
close(waitChannel) close(waitChannel)
log.Fatal("PID: %d Unable to initialize the elasticsearch Repository Indexer connstr: %s Error: %v", os.Getpid(), setting.Indexer.RepoConnStr, err) log.Fatal("PID: %d Unable to initialize the elasticsearch Repository Indexer connstr: %s Error: %v", os.Getpid(), util.SanitizeCredentialURLs(setting.Indexer.RepoConnStr), err)
} }
default: default:

View File

@@ -25,6 +25,7 @@ import (
"code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
) )
// IndexerMetadata is used to send data to the queue, so it contains only the ids. // IndexerMetadata is used to send data to the queue, so it contains only the ids.
@@ -100,7 +101,7 @@ func InitIssueIndexer(syncReindex bool) {
issueIndexer = elasticsearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueIndexerName) issueIndexer = elasticsearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueIndexerName)
existed, err = issueIndexer.Init(ctx) existed, err = issueIndexer.Init(ctx)
if err != nil { if err != nil {
log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", setting.Indexer.IssueConnStr, err) log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", util.SanitizeCredentialURLs(setting.Indexer.IssueConnStr), err)
} }
case "db": case "db":
issueIndexer = db.GetIndexer() issueIndexer = db.GetIndexer()
@@ -108,7 +109,7 @@ func InitIssueIndexer(syncReindex bool) {
issueIndexer = meilisearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueConnAuth, setting.Indexer.IssueIndexerName) issueIndexer = meilisearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueConnAuth, setting.Indexer.IssueIndexerName)
existed, err = issueIndexer.Init(ctx) existed, err = issueIndexer.Init(ctx)
if err != nil { if err != nil {
log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", setting.Indexer.IssueConnStr, err) log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", util.SanitizeCredentialURLs(setting.Indexer.IssueConnStr), err)
} }
default: default:
log.Fatal("Unknown issue indexer type: %s", setting.Indexer.IssueType) log.Fatal("Unknown issue indexer type: %s", setting.Indexer.IssueType)

View File

@@ -202,11 +202,11 @@ func NewConfigProviderFromFile(file string) (ConfigProvider, error) {
loadedFromEmpty := true loadedFromEmpty := true
if file != "" { if file != "" {
isFile, err := util.IsFile(file) isExist, err := util.IsExist(file)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to check if %q is a file. Error: %v", file, err) return nil, fmt.Errorf("unable to check if %q exists: %v", file, err)
} }
if isFile { if isExist {
if err = cfg.Append(file); err != nil { if err = cfg.Append(file); err != nil {
return nil, fmt.Errorf("failed to load config file %q: %v", file, err) return nil, fmt.Errorf("failed to load config file %q: %v", file, err)
} }

View File

@@ -115,15 +115,10 @@ func IsDir(dir string) (bool, error) {
return false, err return false, err
} }
// IsFile returns true if given path is a file, func IsRegularFile(filePath string) (bool, error) {
// or returns false when it's a directory or does not exist. f, err := os.Lstat(filePath)
func IsFile(filePath string) (bool, error) {
f, err := os.Stat(filePath)
if err == nil { if err == nil {
return !f.IsDir(), nil return f.Mode().IsRegular(), nil
}
if os.IsNotExist(err) {
return false, nil
} }
return false, err return false, err
} }

View File

@@ -45,7 +45,7 @@ func UpdatePublicKeyInRepo(ctx *context.PrivateContext) {
ctx.PlainText(http.StatusOK, "success") ctx.PlainText(http.StatusOK, "success")
} }
// AuthorizedPublicKeyByContent searches content as prefix (leak e-mail part) // AuthorizedPublicKeyByContent searches content as prefix (without comment part)
// and returns public key found. // and returns public key found.
func AuthorizedPublicKeyByContent(ctx *context.PrivateContext) { func AuthorizedPublicKeyByContent(ctx *context.PrivateContext) {
content := ctx.FormString("content") content := ctx.FormString("content")
@@ -57,5 +57,14 @@ func AuthorizedPublicKeyByContent(ctx *context.PrivateContext) {
}) })
return return
} }
ctx.PlainText(http.StatusOK, publicKey.AuthorizedString())
authorizedString, err := asymkey_model.AuthorizedStringForKey(publicKey)
if err != nil {
ctx.JSON(http.StatusInternalServerError, private.Response{
Err: err.Error(),
UserMsg: "invalid public key",
})
return
}
ctx.PlainText(http.StatusOK, authorizedString)
} }

View File

@@ -639,6 +639,7 @@ func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, s
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest, ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot proceed your request", ErrorDescription: "cannot proceed your request",
}) })
return
} }
resp, tokenErr := oauth2_provider.NewAccessTokenResponse(ctx, authorizationCode.Grant, serverKey, clientKey) resp, tokenErr := oauth2_provider.NewAccessTokenResponse(ctx, authorizationCode.Grant, serverKey, clientKey)
if tokenErr != nil { if tokenErr != nil {

View File

@@ -25,10 +25,7 @@ import (
// There is a dependence on the database within RewriteAllPrincipalKeys & RegeneratePrincipalKeys // There is a dependence on the database within RewriteAllPrincipalKeys & RegeneratePrincipalKeys
// The sshOpLocker is used from ssh_key_authorized_keys.go // The sshOpLocker is used from ssh_key_authorized_keys.go
const ( const authorizedPrincipalsFile = "authorized_principals"
authorizedPrincipalsFile = "authorized_principals"
tplCommentPrefix = `# gitea public key`
)
// RewriteAllPrincipalKeys removes any authorized principal and rewrite all keys from database again. // RewriteAllPrincipalKeys removes any authorized principal and rewrite all keys from database again.
// Note: db.GetEngine(ctx).Iterate does not get latest data after insert/delete, so we have to call this function // Note: db.GetEngine(ctx).Iterate does not get latest data after insert/delete, so we have to call this function
@@ -90,10 +87,9 @@ func rewriteAllPrincipalKeys(ctx context.Context) error {
return util.Rename(tmpPath, fPath) return util.Rename(tmpPath, fPath)
} }
func regeneratePrincipalKeys(ctx context.Context, t io.StringWriter) error { func regeneratePrincipalKeys(ctx context.Context, t io.Writer) error {
if err := db.GetEngine(ctx).Where("type = ?", asymkey_model.KeyTypePrincipal).Iterate(new(asymkey_model.PublicKey), func(idx int, bean any) (err error) { if err := db.GetEngine(ctx).Where("type = ?", asymkey_model.KeyTypePrincipal).Iterate(new(asymkey_model.PublicKey), func(idx int, bean any) (err error) {
_, err = t.WriteString((bean.(*asymkey_model.PublicKey)).AuthorizedString()) return asymkey_model.WriteAuthorizedStringForValidKey(bean.(*asymkey_model.PublicKey), t)
return err
}); err != nil { }); err != nil {
return err return err
} }
@@ -114,11 +110,11 @@ func regeneratePrincipalKeys(ctx context.Context, t io.StringWriter) error {
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(f)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
if strings.HasPrefix(line, tplCommentPrefix) { if strings.HasPrefix(line, asymkey_model.AuthorizedStringCommentPrefix) {
scanner.Scan() scanner.Scan()
continue continue
} }
_, err = t.WriteString(line + "\n") _, err = io.WriteString(t, line+"\n")
if err != nil { if err != nil {
return err return err
} }

View File

@@ -20,8 +20,6 @@ import (
asymkey_service "code.gitea.io/gitea/services/asymkey" asymkey_service "code.gitea.io/gitea/services/asymkey"
) )
const tplCommentPrefix = `# gitea public key`
func checkAuthorizedKeys(ctx context.Context, logger log.Logger, autofix bool) error { func checkAuthorizedKeys(ctx context.Context, logger log.Logger, autofix bool) error {
if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedKeysFile { if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedKeysFile {
return nil return nil
@@ -47,7 +45,7 @@ func checkAuthorizedKeys(ctx context.Context, logger log.Logger, autofix bool) e
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(f)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
if strings.HasPrefix(line, tplCommentPrefix) { if strings.HasPrefix(line, asymkey_model.AuthorizedStringCommentPrefix) {
continue continue
} }
linesInAuthorizedKeys.Add(line) linesInAuthorizedKeys.Add(line)
@@ -67,7 +65,7 @@ func checkAuthorizedKeys(ctx context.Context, logger log.Logger, autofix bool) e
scanner = bufio.NewScanner(regenerated) scanner = bufio.NewScanner(regenerated)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
if strings.HasPrefix(line, tplCommentPrefix) { if strings.HasPrefix(line, asymkey_model.AuthorizedStringCommentPrefix) {
continue continue
} }
if linesInAuthorizedKeys.Contains(line) { if linesInAuthorizedKeys.Contains(line) {

View File

@@ -17,6 +17,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time"
actions_model "code.gitea.io/gitea/models/actions" actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
@@ -51,6 +52,33 @@ type Claims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
} }
type AuthTokenOptions struct {
Op string
UserID int64
RepoID int64
}
func GetLFSAuthTokenWithBearer(opts AuthTokenOptions) (string, error) {
now := time.Now()
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)),
NotBefore: jwt.NewNumericDate(now),
},
RepoID: opts.RepoID,
Op: opts.Op,
UserID: opts.UserID,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign and get the complete encoded token as a string using the secret
tokenString, err := token.SignedString(setting.LFS.JWTSecretBytes)
if err != nil {
return "", fmt.Errorf("failed to sign LFS JWT token: %w", err)
}
return "Bearer " + tokenString, nil
}
// DownloadLink builds a URL to download the object. // DownloadLink builds a URL to download the object.
func (rc *requestContext) DownloadLink(p lfs_module.Pointer) string { func (rc *requestContext) DownloadLink(p lfs_module.Pointer) string {
return setting.AppURL + path.Join(url.PathEscape(rc.User), url.PathEscape(rc.Repo+".git"), "info/lfs/objects", url.PathEscape(p.Oid)) return setting.AppURL + path.Join(url.PathEscape(rc.User), url.PathEscape(rc.Repo+".git"), "info/lfs/objects", url.PathEscape(p.Oid))
@@ -557,9 +585,6 @@ func authenticate(ctx *context.Context, repository *repo_model.Repository, autho
} }
func handleLFSToken(ctx stdCtx.Context, tokenSHA string, target *repo_model.Repository, mode perm_model.AccessMode) (*user_model.User, error) { func handleLFSToken(ctx stdCtx.Context, tokenSHA string, target *repo_model.Repository, mode perm_model.AccessMode) (*user_model.User, error) {
if !strings.Contains(tokenSHA, ".") {
return nil, nil
}
token, err := jwt.ParseWithClaims(tokenSHA, &Claims{}, func(t *jwt.Token) (any, error) { token, err := jwt.ParseWithClaims(tokenSHA, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
@@ -567,7 +592,7 @@ func handleLFSToken(ctx stdCtx.Context, tokenSHA string, target *repo_model.Repo
return setting.LFS.JWTSecretBytes, nil return setting.LFS.JWTSecretBytes, nil
}) })
if err != nil { if err != nil {
return nil, nil return nil, errors.New("invalid token")
} }
claims, claimsOk := token.Claims.(*Claims) claims, claimsOk := token.Claims.(*Claims)

View File

@@ -0,0 +1,51 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lfs
import (
"strings"
"testing"
perm_model "code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/services/contexttest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
func TestAuthenticate(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
token2, _ := GetLFSAuthTokenWithBearer(AuthTokenOptions{Op: "download", UserID: 2, RepoID: 1})
_, token2, _ = strings.Cut(token2, " ")
ctx, _ := contexttest.MockContext(t, "/")
t.Run("handleLFSToken", func(t *testing.T) {
u, err := handleLFSToken(ctx, "", repo1, perm_model.AccessModeRead)
require.Error(t, err)
assert.Nil(t, u)
u, err = handleLFSToken(ctx, "invalid", repo1, perm_model.AccessModeRead)
require.Error(t, err)
assert.Nil(t, u)
u, err = handleLFSToken(ctx, token2, repo1, perm_model.AccessModeRead)
require.NoError(t, err)
assert.EqualValues(t, 2, u.ID)
})
t.Run("authenticate", func(t *testing.T) {
const prefixBearer = "Bearer "
assert.False(t, authenticate(ctx, repo1, "", true, false))
assert.False(t, authenticate(ctx, repo1, prefixBearer+"invalid", true, false))
assert.True(t, authenticate(ctx, repo1, prefixBearer+token2, true, false))
})
}

View File

@@ -13,6 +13,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
@@ -39,29 +40,41 @@ type expansion struct {
Transformers []transformer Transformers []transformer
} }
var defaultTransformers = []transformer{ var globalVars = sync.OnceValue(func() (ret struct {
{Name: "SNAKE", Transform: xstrings.ToSnakeCase}, defaultTransformers []transformer
{Name: "KEBAB", Transform: xstrings.ToKebabCase}, fileNameSanitizeRegexp *regexp.Regexp
{Name: "CAMEL", Transform: xstrings.ToCamelCase}, },
{Name: "PASCAL", Transform: xstrings.ToPascalCase}, ) {
{Name: "LOWER", Transform: strings.ToLower}, ret.defaultTransformers = []transformer{
{Name: "UPPER", Transform: strings.ToUpper}, {Name: "SNAKE", Transform: xstrings.ToSnakeCase},
{Name: "TITLE", Transform: util.ToTitleCase}, {Name: "KEBAB", Transform: xstrings.ToKebabCase},
} {Name: "CAMEL", Transform: xstrings.ToCamelCase},
{Name: "PASCAL", Transform: xstrings.ToPascalCase},
{Name: "LOWER", Transform: strings.ToLower},
{Name: "UPPER", Transform: strings.ToUpper},
{Name: "TITLE", Transform: util.ToTitleCase},
}
func generateExpansion(ctx context.Context, src string, templateRepo, generateRepo *repo_model.Repository, sanitizeFileName bool) string { // invalid filename contents, based on https://github.com/sindresorhus/filename-reserved-regex
// "COM10" needs to be opened with UNC "\\.\COM10" on Windows, so itself is valid
ret.fileNameSanitizeRegexp = regexp.MustCompile(`(?i)[<>:"/\\|?*\x{0000}-\x{001F}]|^(con|prn|aux|nul|com\d|lpt\d)$`)
return ret
})
func generateExpansion(ctx context.Context, src string, templateRepo, generateRepo *repo_model.Repository) string {
transformers := globalVars().defaultTransformers
year, month, day := time.Now().Date() year, month, day := time.Now().Date()
expansions := []expansion{ expansions := []expansion{
{Name: "YEAR", Value: strconv.Itoa(year), Transformers: nil}, {Name: "YEAR", Value: strconv.Itoa(year), Transformers: nil},
{Name: "MONTH", Value: fmt.Sprintf("%02d", int(month)), Transformers: nil}, {Name: "MONTH", Value: fmt.Sprintf("%02d", int(month)), Transformers: nil},
{Name: "MONTH_ENGLISH", Value: month.String(), Transformers: defaultTransformers}, {Name: "MONTH_ENGLISH", Value: month.String(), Transformers: transformers},
{Name: "DAY", Value: fmt.Sprintf("%02d", day), Transformers: nil}, {Name: "DAY", Value: fmt.Sprintf("%02d", day), Transformers: nil},
{Name: "REPO_NAME", Value: generateRepo.Name, Transformers: defaultTransformers}, {Name: "REPO_NAME", Value: generateRepo.Name, Transformers: transformers},
{Name: "TEMPLATE_NAME", Value: templateRepo.Name, Transformers: defaultTransformers}, {Name: "TEMPLATE_NAME", Value: templateRepo.Name, Transformers: transformers},
{Name: "REPO_DESCRIPTION", Value: generateRepo.Description, Transformers: nil}, {Name: "REPO_DESCRIPTION", Value: generateRepo.Description, Transformers: nil},
{Name: "TEMPLATE_DESCRIPTION", Value: templateRepo.Description, Transformers: nil}, {Name: "TEMPLATE_DESCRIPTION", Value: templateRepo.Description, Transformers: nil},
{Name: "REPO_OWNER", Value: generateRepo.OwnerName, Transformers: defaultTransformers}, {Name: "REPO_OWNER", Value: generateRepo.OwnerName, Transformers: transformers},
{Name: "TEMPLATE_OWNER", Value: templateRepo.OwnerName, Transformers: defaultTransformers}, {Name: "TEMPLATE_OWNER", Value: templateRepo.OwnerName, Transformers: transformers},
{Name: "REPO_LINK", Value: generateRepo.Link(), Transformers: nil}, {Name: "REPO_LINK", Value: generateRepo.Link(), Transformers: nil},
{Name: "TEMPLATE_LINK", Value: templateRepo.Link(), Transformers: nil}, {Name: "TEMPLATE_LINK", Value: templateRepo.Link(), Transformers: nil},
{Name: "REPO_HTTPS_URL", Value: generateRepo.CloneLinkGeneral(ctx).HTTPS, Transformers: nil}, {Name: "REPO_HTTPS_URL", Value: generateRepo.CloneLinkGeneral(ctx).HTTPS, Transformers: nil},
@@ -79,32 +92,23 @@ func generateExpansion(ctx context.Context, src string, templateRepo, generateRe
} }
return os.Expand(src, func(key string) string { return os.Expand(src, func(key string) string {
if expansion, ok := expansionMap[key]; ok { if val, ok := expansionMap[key]; ok {
if sanitizeFileName { return val
return fileNameSanitize(expansion)
}
return expansion
} }
return key return key
}) })
} }
// GiteaTemplate holds information about a .gitea/template file // giteaTemplateFileMatcher holds information about a .gitea/template file
type GiteaTemplate struct { type giteaTemplateFileMatcher struct {
Path string LocalFullPath string
Content []byte globs []glob.Glob
globs []glob.Glob
} }
// Globs parses the .gitea/template globs or returns them if they were already parsed func newGiteaTemplateFileMatcher(fullPath string, content []byte) *giteaTemplateFileMatcher {
func (gt *GiteaTemplate) Globs() []glob.Glob { gt := &giteaTemplateFileMatcher{LocalFullPath: fullPath}
if gt.globs != nil {
return gt.globs
}
gt.globs = make([]glob.Glob, 0) gt.globs = make([]glob.Glob, 0)
scanner := bufio.NewScanner(bytes.NewReader(gt.Content)) scanner := bufio.NewScanner(bytes.NewReader(content))
for scanner.Scan() { for scanner.Scan() {
line := strings.TrimSpace(scanner.Text()) line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
@@ -112,73 +116,91 @@ func (gt *GiteaTemplate) Globs() []glob.Glob {
} }
g, err := glob.Compile(line, '/') g, err := glob.Compile(line, '/')
if err != nil { if err != nil {
log.Info("Invalid glob expression '%s' (skipped): %v", line, err) log.Debug("Invalid glob expression '%s' (skipped): %v", line, err)
continue continue
} }
gt.globs = append(gt.globs, g) gt.globs = append(gt.globs, g)
} }
return gt.globs return gt
} }
func readGiteaTemplateFile(tmpDir string) (*GiteaTemplate, error) { func (gt *giteaTemplateFileMatcher) HasRules() bool {
gtPath := filepath.Join(tmpDir, ".gitea", "template") return len(gt.globs) != 0
if _, err := os.Stat(gtPath); os.IsNotExist(err) { }
func (gt *giteaTemplateFileMatcher) Match(s string) bool {
for _, g := range gt.globs {
if g.Match(s) {
return true
}
}
return false
}
func readGiteaTemplateFile(tmpDir string) (*giteaTemplateFileMatcher, error) {
localPath := filepath.Join(tmpDir, ".gitea", "template")
if _, err := os.Stat(localPath); os.IsNotExist(err) {
return nil, nil return nil, nil
} else if err != nil { } else if err != nil {
return nil, err return nil, err
} }
content, err := os.ReadFile(gtPath) content, err := os.ReadFile(localPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &GiteaTemplate{Path: gtPath, Content: content}, nil return newGiteaTemplateFileMatcher(localPath, content), nil
} }
func processGiteaTemplateFile(ctx context.Context, tmpDir string, templateRepo, generateRepo *repo_model.Repository, giteaTemplateFile *GiteaTemplate) error { func substGiteaTemplateFile(ctx context.Context, tmpDir, tmpDirSubPath string, templateRepo, generateRepo *repo_model.Repository) error {
if err := util.Remove(giteaTemplateFile.Path); err != nil { tmpFullPath := filepath.Join(tmpDir, tmpDirSubPath)
return fmt.Errorf("remove .giteatemplate: %w", err) if ok, err := util.IsRegularFile(tmpFullPath); !ok {
return err
} }
if len(giteaTemplateFile.Globs()) == 0 {
content, err := os.ReadFile(tmpFullPath)
if err != nil {
return err
}
if err := util.Remove(tmpFullPath); err != nil {
return err
}
generatedContent := generateExpansion(ctx, string(content), templateRepo, generateRepo)
substSubPath := filepath.Clean(filePathSanitize(generateExpansion(ctx, tmpDirSubPath, templateRepo, generateRepo)))
newLocalPath := filepath.Join(tmpDir, substSubPath)
regular, err := util.IsRegularFile(newLocalPath)
if canWrite := regular || os.IsNotExist(err); !canWrite {
return nil
}
if err := os.MkdirAll(filepath.Dir(newLocalPath), 0o755); err != nil {
return err
}
return os.WriteFile(newLocalPath, []byte(generatedContent), 0o644)
}
func processGiteaTemplateFile(ctx context.Context, tmpDir string, templateRepo, generateRepo *repo_model.Repository, fileMatcher *giteaTemplateFileMatcher) error {
if err := util.Remove(fileMatcher.LocalFullPath); err != nil {
return fmt.Errorf("unable to remove .gitea/template: %w", err)
}
if !fileMatcher.HasRules() {
return nil // Avoid walking tree if there are no globs return nil // Avoid walking tree if there are no globs
} }
tmpDirSlash := strings.TrimSuffix(filepath.ToSlash(tmpDir), "/") + "/"
return filepath.WalkDir(tmpDirSlash, func(path string, d os.DirEntry, walkErr error) error { return filepath.WalkDir(tmpDir, func(fullPath string, d os.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
return walkErr return walkErr
} }
if d.IsDir() { if d.IsDir() {
return nil return nil
} }
tmpDirSubPath, err := filepath.Rel(tmpDir, fullPath)
base := strings.TrimPrefix(filepath.ToSlash(path), tmpDirSlash) if err != nil {
for _, g := range giteaTemplateFile.Globs() { return err
if g.Match(base) { }
content, err := os.ReadFile(path) if fileMatcher.Match(filepath.ToSlash(tmpDirSubPath)) {
if err != nil { return substGiteaTemplateFile(ctx, tmpDir, tmpDirSubPath, templateRepo, generateRepo)
return err
}
generatedContent := []byte(generateExpansion(ctx, string(content), templateRepo, generateRepo, false))
if err := os.WriteFile(path, generatedContent, 0o644); err != nil {
return err
}
substPath := filepath.FromSlash(filepath.Join(tmpDirSlash, generateExpansion(ctx, base, templateRepo, generateRepo, true)))
// Create parent subdirectories if needed or continue silently if it exists
if err = os.MkdirAll(filepath.Dir(substPath), 0o755); err != nil {
return err
}
// Substitute filename variables
if err = os.Rename(path, substPath); err != nil {
return err
}
break
}
} }
return nil return nil
}) // end: WalkDir }) // end: WalkDir
@@ -218,13 +240,13 @@ func generateRepoCommit(ctx context.Context, repo, templateRepo, generateRepo *r
} }
// Variable expansion // Variable expansion
giteaTemplateFile, err := readGiteaTemplateFile(tmpDir) fileMatcher, err := readGiteaTemplateFile(tmpDir)
if err != nil { if err != nil {
return fmt.Errorf("readGiteaTemplateFile: %w", err) return fmt.Errorf("readGiteaTemplateFile: %w", err)
} }
if giteaTemplateFile != nil { if fileMatcher != nil {
err = processGiteaTemplateFile(ctx, tmpDir, templateRepo, generateRepo, giteaTemplateFile) err = processGiteaTemplateFile(ctx, tmpDir, templateRepo, generateRepo, fileMatcher)
if err != nil { if err != nil {
return err return err
} }
@@ -321,12 +343,17 @@ func (gro GenerateRepoOptions) IsValid() bool {
gro.IssueLabels || gro.ProtectedBranch // or other items as they are added gro.IssueLabels || gro.ProtectedBranch // or other items as they are added
} }
var fileNameSanitizeRegexp = regexp.MustCompile(`(?i)\.\.|[<>:\"/\\|?*\x{0000}-\x{001F}]|^(con|prn|aux|nul|com\d|lpt\d)$`) func filePathSanitize(s string) string {
fields := strings.Split(filepath.ToSlash(s), "/")
// Sanitize user input to valid OS filenames for i, field := range fields {
// field = strings.TrimSpace(strings.TrimSpace(globalVars().fileNameSanitizeRegexp.ReplaceAllString(field, "_")))
// Based on https://github.com/sindresorhus/filename-reserved-regex if strings.HasPrefix(field, "..") {
// Adds ".." to prevent directory traversal field = "__" + field[2:]
func fileNameSanitize(s string) string { }
return strings.TrimSpace(fileNameSanitizeRegexp.ReplaceAllString(s, "_")) if strings.EqualFold(field, ".git") {
field = "_" + field[1:]
}
fields[i] = field
}
return filepath.FromSlash(strings.Join(fields, "/"))
} }

View File

@@ -4,13 +4,18 @@
package repository package repository
import ( import (
"os"
"path/filepath"
"testing" "testing"
repo_model "code.gitea.io/gitea/models/repo"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
var giteaTemplate = []byte(` func TestGiteaTemplate(t *testing.T) {
giteaTemplate := []byte(`
# Header # Header
# All .go files # All .go files
@@ -23,48 +28,153 @@ text/*.txt
**/modules/* **/modules/*
`) `)
func TestGiteaTemplate(t *testing.T) { gt := newGiteaTemplateFileMatcher("", giteaTemplate)
gt := GiteaTemplate{Content: giteaTemplate} assert.Len(t, gt.globs, 3)
assert.Len(t, gt.Globs(), 3)
tt := []struct { tt := []struct {
Path string Path string
Match bool Match bool
}{ }{
{Path: "main.go", Match: true}, {Path: "main.go", Match: true},
{Path: "a/b/c/d/e.go", Match: true}, {Path: "sub/sub/foo.go", Match: true},
{Path: "main.txt", Match: false},
{Path: "a/b.txt", Match: false}, {Path: "a.txt", Match: false},
{Path: "text/a.txt", Match: true}, {Path: "text/a.txt", Match: true},
{Path: "text/b.txt", Match: true}, {Path: "sub/text/a.txt", Match: false},
{Path: "text/c.json", Match: false}, {Path: "text/a.json", Match: false},
{Path: "a/b/c/modules/README.md", Match: true}, {Path: "a/b/c/modules/README.md", Match: true},
{Path: "a/b/c/modules/d/README.md", Match: false}, {Path: "a/b/c/modules/d/README.md", Match: false},
} }
for _, tc := range tt { for _, tc := range tt {
t.Run(tc.Path, func(t *testing.T) { assert.Equal(t, tc.Match, gt.Match(tc.Path), "path: %s", tc.Path)
match := false
for _, g := range gt.Globs() {
if g.Match(tc.Path) {
match = true
break
}
}
assert.Equal(t, tc.Match, match)
})
} }
} }
func TestFileNameSanitize(t *testing.T) { func TestFilePathSanitize(t *testing.T) {
assert.Equal(t, "test_CON", fileNameSanitize("test_CON")) assert.Equal(t, "test_CON", filePathSanitize("test_CON"))
assert.Equal(t, "test CON", fileNameSanitize("test CON ")) assert.Equal(t, "test CON", filePathSanitize("test CON "))
assert.Equal(t, "__traverse__", fileNameSanitize("../traverse/..")) assert.Equal(t, "__/traverse/__", filePathSanitize(".. /traverse/ .."))
assert.Equal(t, "http___localhost_3003_user_test.git", fileNameSanitize("http://localhost:3003/user/test.git")) assert.Equal(t, "./__/a/_git/b_", filePathSanitize("./../a/.git/ b: "))
assert.Equal(t, "_", fileNameSanitize("CON")) assert.Equal(t, "_", filePathSanitize("CoN"))
assert.Equal(t, "_", fileNameSanitize("con")) assert.Equal(t, "_", filePathSanitize("LpT1"))
assert.Equal(t, "_", fileNameSanitize("\u0000")) assert.Equal(t, "_", filePathSanitize("CoM1"))
assert.Equal(t, "目标", fileNameSanitize("目标")) assert.Equal(t, "_", filePathSanitize("\u0000"))
assert.Equal(t, "目标", filePathSanitize("目标"))
// unlike filepath.Clean, it only sanitizes, doesn't change the separator layout
assert.Equal(t, "", filePathSanitize("")) //nolint:testifylint // for easy reading
assert.Equal(t, ".", filePathSanitize("."))
assert.Equal(t, "/", filePathSanitize("/"))
}
func TestProcessGiteaTemplateFile(t *testing.T) {
tmpDir := filepath.Join(t.TempDir(), "gitea-template-test")
assertFileContent := func(path, expected string) {
data, err := os.ReadFile(filepath.Join(tmpDir, path))
if expected == "" {
assert.ErrorIs(t, err, os.ErrNotExist)
return
}
require.NoError(t, err)
assert.Equal(t, expected, string(data), "file content mismatch for %s", path)
}
assertSymLink := func(path, expected string) {
link, err := os.Readlink(filepath.Join(tmpDir, path))
if expected == "" {
assert.ErrorIs(t, err, os.ErrNotExist)
return
}
require.NoError(t, err)
assert.Equal(t, expected, link, "symlink target mismatch for %s", path)
}
require.NoError(t, os.MkdirAll(tmpDir+"/.gitea", 0o755))
require.NoError(t, os.WriteFile(tmpDir+"/.gitea/template", []byte("*\ninclude/**"), 0o644))
require.NoError(t, os.MkdirAll(tmpDir+"/sub", 0o755))
require.NoError(t, os.MkdirAll(tmpDir+"/include/foo/bar", 0o755))
require.NoError(t, os.WriteFile(tmpDir+"/sub/link-target", []byte("link target content from ${TEMPLATE_NAME}"), 0o644))
require.NoError(t, os.WriteFile(tmpDir+"/include/foo/bar/test.txt", []byte("include subdir ${TEMPLATE_NAME}"), 0o644))
// case-1
{
require.NoError(t, os.WriteFile(tmpDir+"/normal", []byte("normal content"), 0o644))
require.NoError(t, os.WriteFile(tmpDir+"/template", []byte("template from ${TEMPLATE_NAME}"), 0o644))
}
// case-2
{
require.NoError(t, os.Symlink(tmpDir+"/sub/link-target", tmpDir+"/link"))
}
// case-3
{
require.NoError(t, os.WriteFile(tmpDir+"/subst-${REPO_NAME}", []byte("dummy subst repo name"), 0o644))
}
// case-4
assertSubstTemplateName := func(normalContent, toLinkContent, fromLinkContent string) {
assertFileContent("subst-${TEMPLATE_NAME}-normal", normalContent)
assertFileContent("subst-${TEMPLATE_NAME}-to-link", toLinkContent)
assertFileContent("subst-${TEMPLATE_NAME}-from-link", fromLinkContent)
}
{
// will succeed
require.NoError(t, os.WriteFile(tmpDir+"/subst-${TEMPLATE_NAME}-normal", []byte("dummy subst template name normal"), 0o644))
// will skil if the path subst result is a link
require.NoError(t, os.WriteFile(tmpDir+"/subst-${TEMPLATE_NAME}-to-link", []byte("dummy subst template name to link"), 0o644))
require.NoError(t, os.Symlink(tmpDir+"/sub/link-target", tmpDir+"/subst-TemplateRepoName-to-link"))
// will be skipped since the source is a symlink
require.NoError(t, os.Symlink(tmpDir+"/sub/link-target", tmpDir+"/subst-${TEMPLATE_NAME}-from-link"))
// pre-check
assertSubstTemplateName("dummy subst template name normal", "dummy subst template name to link", "link target content from ${TEMPLATE_NAME}")
}
// process the template files
{
templateRepo := &repo_model.Repository{Name: "TemplateRepoName"}
generatedRepo := &repo_model.Repository{Name: "/../.gIt/name"}
fileMatcher, _ := readGiteaTemplateFile(tmpDir)
err := processGiteaTemplateFile(t.Context(), tmpDir, templateRepo, generatedRepo, fileMatcher)
require.NoError(t, err)
assertFileContent("include/foo/bar/test.txt", "include subdir TemplateRepoName")
}
// the lin target should never be modified, and since it is in a subdirectory, it is not affected by the template either
assertFileContent("sub/link-target", "link target content from ${TEMPLATE_NAME}")
// case-1
{
assertFileContent("no-such", "")
assertFileContent("normal", "normal content")
assertFileContent("template", "template from TemplateRepoName")
}
// case-2
{
// symlink with templates should be preserved (not read or write)
assertSymLink("link", tmpDir+"/sub/link-target")
}
// case-3
{
assertFileContent("subst-${REPO_NAME}", "")
assertFileContent("subst-/__/_gIt/name", "dummy subst repo name")
}
// case-4
{
// the paths with templates should have been removed, subst to a regular file, succeed, the link is preserved
assertSubstTemplateName("", "", "link target content from ${TEMPLATE_NAME}")
assertFileContent("subst-TemplateRepoName-normal", "dummy subst template name normal")
// subst to a link, skip, and the target is unchanged
assertSymLink("subst-TemplateRepoName-to-link", tmpDir+"/sub/link-target")
// subst from a link, skip, and the target is unchanged
assertSymLink("subst-${TEMPLATE_NAME}-from-link", tmpDir+"/sub/link-target")
}
} }
func TestTransformers(t *testing.T) { func TestTransformers(t *testing.T) {
@@ -82,9 +192,9 @@ func TestTransformers(t *testing.T) {
} }
input := "Abc_Def-XYZ" input := "Abc_Def-XYZ"
assert.Len(t, defaultTransformers, len(cases)) assert.Len(t, globalVars().defaultTransformers, len(cases))
for i, c := range cases { for i, c := range cases {
tf := defaultTransformers[i] tf := globalVars().defaultTransformers[i]
require.Equal(t, c.name, tf.Name) require.Equal(t, c.name, tf.Name)
assert.Equal(t, c.expected, tf.Transform(input), "case %s", c.name) assert.Equal(t, c.expected, tf.Transform(input), "case %s", c.name)
} }

View File

@@ -30,7 +30,7 @@ func Test_CmdKeys(t *testing.T) {
"with_key", "with_key",
[]string{"keys", "-e", "git", "-u", "git", "-t", "ssh-rsa", "-k", "AAAAB3NzaC1yc2EAAAADAQABAAABgQDWVj0fQ5N8wNc0LVNA41wDLYJ89ZIbejrPfg/avyj3u/ZohAKsQclxG4Ju0VirduBFF9EOiuxoiFBRr3xRpqzpsZtnMPkWVWb+akZwBFAx8p+jKdy4QXR/SZqbVobrGwip2UjSrri1CtBxpJikojRIZfCnDaMOyd9Jp6KkujvniFzUWdLmCPxUE9zhTaPu0JsEP7MW0m6yx7ZUhHyfss+NtqmFTaDO+QlMR7L2QkDliN2Jl3Xa3PhuWnKJfWhdAq1Cw4oraKUOmIgXLkuiuxVQ6mD3AiFupkmfqdHq6h+uHHmyQqv3gU+/sD8GbGAhf6ftqhTsXjnv1Aj4R8NoDf9BS6KRkzkeun5UisSzgtfQzjOMEiJtmrep2ZQrMGahrXa+q4VKr0aKJfm+KlLfwm/JztfsBcqQWNcTURiCFqz+fgZw0Ey/de0eyMzldYTdXXNRYCKjs9bvBK+6SSXRM7AhftfQ0ZuoW5+gtinPrnmoOaSCEJbAiEiTO/BzOHgowiM="}, []string{"keys", "-e", "git", "-u", "git", "-t", "ssh-rsa", "-k", "AAAAB3NzaC1yc2EAAAADAQABAAABgQDWVj0fQ5N8wNc0LVNA41wDLYJ89ZIbejrPfg/avyj3u/ZohAKsQclxG4Ju0VirduBFF9EOiuxoiFBRr3xRpqzpsZtnMPkWVWb+akZwBFAx8p+jKdy4QXR/SZqbVobrGwip2UjSrri1CtBxpJikojRIZfCnDaMOyd9Jp6KkujvniFzUWdLmCPxUE9zhTaPu0JsEP7MW0m6yx7ZUhHyfss+NtqmFTaDO+QlMR7L2QkDliN2Jl3Xa3PhuWnKJfWhdAq1Cw4oraKUOmIgXLkuiuxVQ6mD3AiFupkmfqdHq6h+uHHmyQqv3gU+/sD8GbGAhf6ftqhTsXjnv1Aj4R8NoDf9BS6KRkzkeun5UisSzgtfQzjOMEiJtmrep2ZQrMGahrXa+q4VKr0aKJfm+KlLfwm/JztfsBcqQWNcTURiCFqz+fgZw0Ey/de0eyMzldYTdXXNRYCKjs9bvBK+6SSXRM7AhftfQ0ZuoW5+gtinPrnmoOaSCEJbAiEiTO/BzOHgowiM="},
false, false,
"# gitea public key\ncommand=\"" + setting.AppPath + " --config=" + util.ShellEscape(setting.CustomConf) + " serv key-1\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDWVj0fQ5N8wNc0LVNA41wDLYJ89ZIbejrPfg/avyj3u/ZohAKsQclxG4Ju0VirduBFF9EOiuxoiFBRr3xRpqzpsZtnMPkWVWb+akZwBFAx8p+jKdy4QXR/SZqbVobrGwip2UjSrri1CtBxpJikojRIZfCnDaMOyd9Jp6KkujvniFzUWdLmCPxUE9zhTaPu0JsEP7MW0m6yx7ZUhHyfss+NtqmFTaDO+QlMR7L2QkDliN2Jl3Xa3PhuWnKJfWhdAq1Cw4oraKUOmIgXLkuiuxVQ6mD3AiFupkmfqdHq6h+uHHmyQqv3gU+/sD8GbGAhf6ftqhTsXjnv1Aj4R8NoDf9BS6KRkzkeun5UisSzgtfQzjOMEiJtmrep2ZQrMGahrXa+q4VKr0aKJfm+KlLfwm/JztfsBcqQWNcTURiCFqz+fgZw0Ey/de0eyMzldYTdXXNRYCKjs9bvBK+6SSXRM7AhftfQ0ZuoW5+gtinPrnmoOaSCEJbAiEiTO/BzOHgowiM= user2@localhost\n", "# gitea public key\ncommand=\"" + setting.AppPath + " --config=" + util.ShellEscape(setting.CustomConf) + " serv key-1\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDWVj0fQ5N8wNc0LVNA41wDLYJ89ZIbejrPfg/avyj3u/ZohAKsQclxG4Ju0VirduBFF9EOiuxoiFBRr3xRpqzpsZtnMPkWVWb+akZwBFAx8p+jKdy4QXR/SZqbVobrGwip2UjSrri1CtBxpJikojRIZfCnDaMOyd9Jp6KkujvniFzUWdLmCPxUE9zhTaPu0JsEP7MW0m6yx7ZUhHyfss+NtqmFTaDO+QlMR7L2QkDliN2Jl3Xa3PhuWnKJfWhdAq1Cw4oraKUOmIgXLkuiuxVQ6mD3AiFupkmfqdHq6h+uHHmyQqv3gU+/sD8GbGAhf6ftqhTsXjnv1Aj4R8NoDf9BS6KRkzkeun5UisSzgtfQzjOMEiJtmrep2ZQrMGahrXa+q4VKr0aKJfm+KlLfwm/JztfsBcqQWNcTURiCFqz+fgZw0Ey/de0eyMzldYTdXXNRYCKjs9bvBK+6SSXRM7AhftfQ0ZuoW5+gtinPrnmoOaSCEJbAiEiTO/BzOHgowiM= user-2\n",
}, },
{"invalid", []string{"keys", "--not-a-flag=git"}, true, "Incorrect Usage: flag provided but not defined: -not-a-flag\n\n"}, {"invalid", []string{"keys", "--not-a-flag=git"}, true, "Incorrect Usage: flag provided but not defined: -not-a-flag\n\n"},
} }