fix: store pull mirror creds encrypted with keying (#11909)

Fixes #9629.

New pull mirrors have credentials stored encrypted in the database, the same as push mirrors, rather than in the repository's `config` file.  `git fetch` on the pull mirror is updated to use the credential store.  Pull mirrors will have their credentials migrated to the encrypted storage in the database as they're synced or otherwise accessed via the web UI.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

- I added test coverage for Go changes...
  - [ ] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11909
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
This commit is contained in:
Mathieu Fenniak 2026-04-04 13:53:22 +02:00 committed by Mathieu Fenniak
parent e4bd84b574
commit 6a99b6b0c1
12 changed files with 774 additions and 182 deletions

View file

@ -0,0 +1,30 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package forgejo_migrations
import (
"xorm.io/xorm"
)
func init() {
registerMigration(&Migration{
Description: "replace remote_address with encrypted_remote_address in table mirror",
Upgrade: addMirrorRemoteAddressAuth,
})
}
func addMirrorRemoteAddressAuth(x *xorm.Engine) error {
type Mirror struct {
EncryptedRemoteAddress []byte `xorm:"BLOB NULL"`
}
if _, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(Mirror)); err != nil {
return err
}
// No data migration is necessary or desired. `remote_address` contains sanitized URLs which don't have
// credentials, so they can't be migrated to `encrypted_remote_address`. Instead, as this data is accessed,
// `DecryptOrRecoverRemoteAddress` will recover the fully credentialed contents of the remote address from the git
// repo's `origin` remote address.
_, err := x.Exec("ALTER TABLE `mirror` DROP COLUMN `remote_address`")
return err
}

View file

@ -6,10 +6,14 @@ package repo
import (
"context"
"errors"
"net/url"
"time"
"forgejo.org/models/db"
"forgejo.org/modules/keying"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/util"
)
@ -31,7 +35,9 @@ type Mirror struct {
LFS bool `xorm:"lfs_enabled NOT NULL DEFAULT false"`
LFSEndpoint string `xorm:"lfs_endpoint TEXT"`
RemoteAddress string `xorm:"VARCHAR(2048)"`
// Encrypted remote address w/ credentials; can be NULL if a mirror has not performed a sync since this field was
// introduced, in which case the remote address exists only in the repo's configured git remote on disk.
EncryptedRemoteAddress []byte `xorm:"BLOB NULL"`
}
func init() {
@ -73,6 +79,71 @@ func (m *Mirror) ScheduleNextUpdate() {
}
}
// InsertMirror inserts a mirror to database. RemoteAddress must be provided so that it can be encrypted and stored
// during the insert process.
func (m *Mirror) InsertWithAddress(ctx context.Context, addr string) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if _, err := db.GetEngine(ctx).Insert(m); err != nil {
return err
}
return m.UpdateRemoteAddress(ctx, addr)
})
}
// Stores a credential-free version of the address in `RemoteAddress`, encrypts the original into `RemoteAddressAuth`,
// and stores both in the database. The ID of the mirror must be known, so this must be done after the mirror is
// inserted.
func (m *Mirror) UpdateRemoteAddress(ctx context.Context, addr string) error {
if m.ID == 0 {
return errors.New("must persist mirror to database before using UpdateRemoteAddress")
}
m.EncryptedRemoteAddress = keying.PullMirror.Encrypt(
[]byte(addr),
keying.ColumnAndID("remote_address_auth", m.ID),
)
_, err := db.GetEngine(ctx).ID(m.ID).Cols("encrypted_remote_address").Update(m)
return err
}
// Retrieves the encrypted remote address and decrypts it. Note that this field is expected to be absent for mirrors
// created before the introduction of EncryptedRemoteAddress, in which case credentials are not known to Forgejo
// directly (but may be on-disk in the repository's config file) and None will be returned.
func (m *Mirror) DecryptRemoteAddress() (optional.Option[string], error) {
if m.EncryptedRemoteAddress == nil {
return optional.None[string](), nil
}
contents, err := keying.PullMirror.Decrypt(m.EncryptedRemoteAddress, keying.ColumnAndID("remote_address_auth", m.ID))
if err != nil {
return optional.None[string](), err
}
return optional.Some(string(contents)), nil
}
// Retrieves the remote address but sanitizes it of sensitive credentials. May be absent for mirrors created before the
// introduction of EncryptedRemoteAddress.
func (m *Mirror) SanitizedRemoteAddress() (optional.Option[string], error) {
maybeAddr, err := m.DecryptRemoteAddress()
if err != nil {
return optional.None[string](), err
} else if has, addr := maybeAddr.Get(); has {
parsedURL, err := url.Parse(addr)
if err != nil {
return optional.None[string](), err
}
// Remove the password if present. Retain the username for consistency with `AddAuthCredentialHelperForRemote`
// which retains the username for the `git clone` command line, which ends up as the remote URL in the mirror's
// git config.
if parsedURL.User != nil {
parsedURL.User = url.User(parsedURL.User.Username())
}
return optional.Some(parsedURL.String()), nil
}
return optional.None[string](), nil
}
// GetMirrorByRepoID returns mirror information of a repository.
func GetMirrorByRepoID(ctx context.Context, repoID int64) (*Mirror, error) {
m := &Mirror{RepoID: repoID}
@ -115,9 +186,3 @@ func MirrorsIterate(ctx context.Context, limit int, f func(idx int, bean any) er
}
return sess.Iterate(new(Mirror), f)
}
// InsertMirror inserts a mirror to database
func InsertMirror(ctx context.Context, mirror *Mirror) error {
_, err := db.GetEngine(ctx).Insert(mirror)
return err
}