Files
buildkit/source/git/identifier.go
T
Tonis Tiigi e9c7645d8a security: validate Git full remote URLs
Require git.fullurl to resolve to the same host and repository path as the
source identifier. Normalize accepted aliases before using the remote.

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
(cherry picked from commit 7134cb38a068f3f64cb6b0b49cc3b87384423d88)
2026-09-30 15:12:30 +02:00

252 lines
7.8 KiB
Go

package git
import (
"net/url"
"path"
"strings"
"github.com/containerd/containerd/v2/pkg/reference"
"github.com/moby/buildkit/solver/llbsolver/provenance"
provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
"github.com/moby/buildkit/source"
srctypes "github.com/moby/buildkit/source/types"
"github.com/moby/buildkit/util/gitutil"
"github.com/moby/buildkit/util/sshutil"
digest "github.com/opencontainers/go-digest"
"github.com/pkg/errors"
)
type GitIdentifier struct {
Remote string
Ref string
Checksum string
Subdir string
KeepGitDir bool
AuthTokenSecret string
AuthHeaderSecret string
MountSSHSock string
KnownSSHHosts string
SkipSubmodules bool
MTime string // "checkout" (default) or "commit"
Advice bool
FetchByCommit bool
// Bundle, when non-empty, instructs the git source to fetch commits from a
// pre-built git bundle stored as a blob instead of fetching from the remote
// repository. The locator must use the "docker-image+blob://" or
// "oci-layout+blob://" scheme.
Bundle string
// BundleOCISessionID, when set, pins the OCI-layout bundle fetch to a
// specific client session. Only meaningful when Bundle uses the
// oci-layout+blob:// scheme.
BundleOCISessionID string
// BundleOCIStoreID, when set, overrides the OCI-layout store name
// derived from the bundle locator. Only meaningful when Bundle uses the
// oci-layout+blob:// scheme.
BundleOCIStoreID string
// CheckoutBundle, when true, produces a single-file git bundle at the
// checkout mount root (filename "bundle") instead of a worktree.
CheckoutBundle bool
VerifySignature *GitSignatureVerifyOptions
}
type GitSignatureVerifyOptions struct {
PubKey []byte
RejectExpiredKeys bool
RequireSignedTag bool // signed tag must be present
IgnoreSignedTag bool // even if signed tag is present, verify signature on commit object
}
func NewGitIdentifier(remoteURL string) (*GitIdentifier, error) {
if !gitutil.IsGitTransport(remoteURL) {
remoteURL = "https://" + remoteURL
}
u, err := gitutil.ParseURL(remoteURL)
if err != nil {
return nil, err
}
repo := GitIdentifier{Remote: u.Remote}
if u.Opts != nil {
repo.Ref = u.Opts.Ref
repo.Subdir = u.Opts.Subdir
}
return &repo, nil
}
func validateFullRemoteURL(identifier, remoteURL string) (string, error) {
if !gitutil.IsGitTransport(remoteURL) {
remoteURL = "https://" + remoteURL
}
remote, err := gitutil.ParseURL(remoteURL)
if err != nil {
return "", errors.New("failed to parse git.fullurl")
}
if !gitutil.IsGitTransport(identifier) {
identifier = "https://" + identifier
}
id, err := gitutil.ParseURL(identifier)
if err != nil {
return "", errors.Wrap(err, "failed to parse Git source identifier")
}
if !strings.EqualFold(id.Host, remote.Host) || gitRepositoryPath(id.Path) != gitRepositoryPath(remote.Path) {
return "", errors.New("git.fullurl does not match source identifier")
}
return normalizeGitRemote(remote, id.Path), nil
}
func gitRepositoryPath(p string) string {
// Match the canonicalization used when llb.Git creates the identifier.
return strings.TrimSuffix(path.Join("/", p), ".git")
}
func normalizeGitRemote(remote *gitutil.GitURL, repositoryPath string) string {
repositoryPath = path.Join("/", repositoryPath)
if !strings.HasPrefix(strings.ToLower(remote.Remote), remote.Scheme+"://") {
if !strings.HasPrefix(remote.Path, "/") {
repositoryPath = strings.TrimPrefix(repositoryPath, "/")
}
return (&sshutil.SCPStyleURL{
User: remote.User,
Host: remote.Host,
Path: repositoryPath,
}).String()
}
return (&url.URL{
Scheme: remote.Scheme,
User: remote.User,
Host: remote.Host,
Path: repositoryPath,
}).String()
}
func (id *GitIdentifier) String() string {
remote := id.Remote
if u, err := gitutil.ParseURL(remote); err == nil {
remote = u.Host + path.Join("/", u.Path)
}
ref := srctypes.GitScheme + "://" + remote
if id.Ref != "" || id.Subdir != "" {
ref += "#" + id.Ref
if id.Subdir != "" {
ref += ":" + id.Subdir
}
}
return ref
}
func (GitIdentifier) Scheme() string {
return srctypes.GitScheme
}
var _ source.Identifier = (*GitIdentifier)(nil)
func (id *GitIdentifier) Capture(c *provenance.Capture, pin string) error {
remoteURL := id.Remote
if id.Ref != "" {
remoteURL += "#" + id.Ref
}
gs := provenancetypes.GitSource{
URL: remoteURL,
Commit: pin,
}
if id.Bundle != "" {
gs.Bundle = &provenancetypes.GitBundle{URL: id.Bundle}
}
c.AddGit(gs)
if id.AuthTokenSecret != "" {
c.AddSecret(provenancetypes.Secret{
ID: id.AuthTokenSecret,
Optional: true,
})
}
if id.AuthHeaderSecret != "" {
c.AddSecret(provenancetypes.Secret{
ID: id.AuthHeaderSecret,
Optional: true,
})
}
if id.MountSSHSock != "" {
c.AddSSH(provenancetypes.SSH{
ID: id.MountSSHSock,
Optional: true,
})
}
return nil
}
// validateBundleAttrs enforces the bundle-mode constraints that are statically
// decidable at identifier parse time.
func validateBundleAttrs(id *GitIdentifier) error {
if id.Bundle != "" {
if _, _, _, _, err := parseBundleLocator(id.Bundle); err != nil {
return err
}
if id.Checksum == "" {
return errors.New("git.bundle requires git.checksum to be set")
}
// Reject the pathological "Ref=sha1, Checksum=sha2" case: if both
// are set and Ref is itself a commit SHA, the two must agree.
// Without this check bundle mode silently overwrites Ref with
// Checksum in tryRemoteFetch and the mismatch never surfaces.
if id.Ref != "" && gitutil.IsCommitSHA(id.Ref) && !strings.HasPrefix(id.Ref, id.Checksum) {
return errors.Errorf("expected checksum to match %s, got %s", id.Checksum, id.Ref)
}
}
if id.CheckoutBundle {
if id.KeepGitDir {
return errors.New("git.checkoutbundle is incompatible with git.keepgitdir")
}
if id.Subdir != "" {
return errors.New("git.checkoutbundle is incompatible with git.subdir")
}
}
return nil
}
// splitBundleLocator splits a locator of the form "<scheme>://<body>" into
// (scheme, body). It returns ("","") if raw does not contain a "://" separator.
// This avoids net/url for schemes like "oci-layout+blob" whose body contains
// a colon after '@' (the digest) and confuses url.Parse.
func splitBundleLocator(raw string) (string, string) {
const sep = "://"
scheme, body, ok := strings.Cut(raw, sep)
if !ok {
return "", ""
}
return scheme, body
}
// parseBundleLocator extracts the scheme, normalized reference string, the
// reference locator (i.e. the body before '@digest', used as the OCI-layout
// store id), and the digest from a bundle locator of the form
// "<scheme>://<ref>@sha256:<hex>". Only sha256 digests are accepted because
// downloadBundleToFile hashes with sha256 and would fail to match any other
// algorithm.
func parseBundleLocator(raw string) (string, string, string, digest.Digest, error) {
scheme, body := splitBundleLocator(raw)
if scheme == "" {
return "", "", "", "", errors.Errorf("failed to parse git.bundle locator %q: missing scheme", raw)
}
if scheme != srctypes.DockerImageBlobScheme && scheme != srctypes.OCIBlobScheme {
return "", "", "", "", errors.Errorf("git.bundle locator scheme %q is not supported; expected %q or %q", scheme, srctypes.DockerImageBlobScheme, srctypes.OCIBlobScheme)
}
ref, err := reference.Parse(body)
if err != nil {
return "", "", "", "", errors.Wrapf(err, "failed to parse git.bundle locator %q", raw)
}
dgst := ref.Digest()
if err := dgst.Validate(); err != nil {
return "", "", "", "", errors.Wrapf(err, "failed to parse git.bundle locator %q: invalid digest", raw)
}
if dgst.Algorithm() != digest.SHA256 {
return "", "", "", "", errors.Errorf("git.bundle locator %q: digest algorithm %q not supported, expected %q", raw, dgst.Algorithm(), digest.SHA256)
}
return scheme, ref.String(), ref.Locator, dgst, nil
}