Ctrl K
ComunidadGuías…

internal/pluginstore/auth.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package pluginstore

import (
	"fmt"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"

	"github.com/MoeForge/moego/base64"
	"github.com/router-for-me/CLIProxyAPI/v8/internal/githubauth"
)

const (
	RequestKindRegistry = "registry"
	RequestKindMetadata = "metadata"
	RequestKindArtifact = "artifact"

	AuthTypeNone        = "none"
	AuthTypeBearer      = "bearer"
	AuthTypeBasic       = "basic"
	AuthTypeHeader      = "header"
	AuthTypeGitHubToken = "github-token"
)

type AuthConfig struct {
	Match          string   `yaml:"match,omitempty" json:"match,omitempty"`
	ApplyTo        []string `yaml:"apply-to,omitempty" json:"apply_to,omitempty"`
	Type           string   `yaml:"type,omitempty" json:"type,omitempty"`
	TokenEnv       string   `yaml:"token-env,omitempty" json:"token_env,omitempty"`
	UsernameEnv    string   `yaml:"username-env,omitempty" json:"username_env,omitempty"`
	PasswordEnv    string   `yaml:"password-env,omitempty" json:"password_env,omitempty"`
	HeaderName     string   `yaml:"header-name,omitempty" json:"header_name,omitempty"`
	HeaderValueEnv string   `yaml:"header-value-env,omitempty" json:"header_value_env,omitempty"`
	AllowInsecure  bool     `yaml:"allow-insecure,omitempty" json:"allow_insecure,omitempty"`
}

// Secret holds short-lived credential material that can be overwritten after use.
type Secret []byte

// Clear overwrites the secret and releases its backing slice reference.
func (s *Secret) Clear() {
	if s == nil {
		return
	}
	for index := range *s {
		(*s)[index] = 0
	}
	*s = nil
}

type ResolvedAuthConfig struct {
	Match       string   `yaml:"match,omitempty" json:"match,omitempty"`
	ApplyTo     []string `yaml:"apply-to,omitempty" json:"apply_to,omitempty"`
	Type        string   `yaml:"type,omitempty" json:"type,omitempty"`
	Token       Secret   `yaml:"token,omitempty" json:"token,omitempty"`
	Username    Secret   `yaml:"username,omitempty" json:"username,omitempty"`
	Password    Secret   `yaml:"password,omitempty" json:"password,omitempty"`
	HeaderName  string   `yaml:"header-name,omitempty" json:"header_name,omitempty"`
	HeaderValue Secret   `yaml:"header-value,omitempty" json:"header_value,omitempty"`
}

func (c *ResolvedAuthConfig) Clear() {
	if c == nil {
		return
	}
	c.Token.Clear()
	c.Username.Clear()
	c.Password.Clear()
	c.HeaderValue.Clear()
	c.ApplyTo = nil
}

func ClearResolvedAuthConfigs(auth []ResolvedAuthConfig) {
	for index := range auth {
		auth[index].Clear()
	}
}

func ResolvedAuthForRequest(auth []ResolvedAuthConfig, requestURL string, kind string) (ResolvedAuthConfig, bool) {
	item, ok := matchingResolvedAuthConfig(auth, requestURL, kind)
	if !ok {
		return ResolvedAuthConfig{}, false
	}
	return cloneResolvedAuthConfig(item), true
}

func ValidateResolvedAuthConfig(item ResolvedAuthConfig) error {
	parsed, errParse := url.Parse(strings.TrimSpace(item.Match))
	if errParse != nil || parsed.Scheme == "" || parsed.Host == "" {
		return fmt.Errorf("plugin store resolved auth match is invalid")
	}
	if !strings.EqualFold(parsed.Scheme, "https") {
		return fmt.Errorf("plugin store resolved auth match must use https")
	}
	if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
		return fmt.Errorf("plugin store resolved auth match must not contain credentials, query, or fragment")
	}
	for _, kind := range item.ApplyTo {
		switch strings.ToLower(strings.TrimSpace(kind)) {
		case RequestKindRegistry, RequestKindMetadata, RequestKindArtifact:
		default:
			return fmt.Errorf("plugin store resolved auth has unsupported apply_to %q", kind)
		}
	}
	switch strings.ToLower(strings.TrimSpace(item.Type)) {
	case "", AuthTypeNone:
		return nil
	case AuthTypeBearer, AuthTypeGitHubToken:
		if len(item.Token) == 0 {
			return fmt.Errorf("plugin store resolved auth token is empty")
		}
	case AuthTypeBasic:
		if len(item.Username) == 0 || len(item.Password) == 0 {
			return fmt.Errorf("plugin store resolved basic auth is incomplete")
		}
	case AuthTypeHeader:
		if strings.TrimSpace(item.HeaderName) == "" || strings.ContainsAny(item.HeaderName, "\r\n:") {
			return fmt.Errorf("plugin store resolved auth header name is invalid")
		}
		if len(item.HeaderValue) == 0 || secretContainsCRLF(item.HeaderValue) {
			return fmt.Errorf("plugin store resolved auth header value is invalid")
		}
	default:
		return fmt.Errorf("unsupported plugin store resolved auth type %q", item.Type)
	}
	return nil
}

func NormalizeAuthConfigs(auth []AuthConfig) []AuthConfig {
	if len(auth) == 0 {
		return nil
	}
	out := make([]AuthConfig, 0, len(auth))
	for _, item := range auth {
		item.Match = strings.TrimSpace(item.Match)
		item.Type = strings.ToLower(strings.TrimSpace(item.Type))
		item.TokenEnv = strings.TrimSpace(item.TokenEnv)
		item.UsernameEnv = strings.TrimSpace(item.UsernameEnv)
		item.PasswordEnv = strings.TrimSpace(item.PasswordEnv)
		item.HeaderName = strings.TrimSpace(item.HeaderName)
		item.HeaderValueEnv = strings.TrimSpace(item.HeaderValueEnv)
		if item.Type == "" {
			item.Type = AuthTypeNone
		}
		if item.Match == "" {
			continue
		}
		if len(item.ApplyTo) > 0 {
			applyTo := make([]string, 0, len(item.ApplyTo))
			seen := map[string]struct{}{}
			for _, value := range item.ApplyTo {
				value = strings.ToLower(strings.TrimSpace(value))
				if value == "" {
					continue
				}
				if _, exists := seen[value]; exists {
					continue
				}
				seen[value] = struct{}{}
				applyTo = append(applyTo, value)
			}
			item.ApplyTo = applyTo
		}
		out = append(out, item)
	}
	return out
}

func AuthConfigured(auth []AuthConfig, requestURL string, kind string) bool {
	item, ok := matchingAuthConfig(auth, requestURL, kind)
	if !ok {
		return false
	}
	switch strings.ToLower(strings.TrimSpace(item.Type)) {
	case AuthTypeNone:
		return false
	case AuthTypeBearer, AuthTypeGitHubToken:
		return strings.TrimSpace(os.Getenv(item.TokenEnv)) != ""
	case AuthTypeBasic:
		return strings.TrimSpace(os.Getenv(item.UsernameEnv)) != "" && strings.TrimSpace(os.Getenv(item.PasswordEnv)) != ""
	case AuthTypeHeader:
		return item.HeaderName != "" && strings.TrimSpace(os.Getenv(item.HeaderValueEnv)) != ""
	default:
		return false
	}
}

func PluginAuthConfigured(source Source, plugin Plugin, auth []AuthConfig) bool {
	if AuthConfigured(auth, source.URL, RequestKindRegistry) {
		return true
	}
	switch PluginInstallType(plugin) {
	case InstallTypeDirect:
		for _, artifact := range PluginArtifacts(plugin) {
			if AuthConfigured(auth, artifact.URL, RequestKindArtifact) {
				return true
			}
		}
	case InstallTypeGitHubRelease:
		return pluginGitHubReleaseAuthConfigured(plugin, auth)
	}
	return false
}

func pluginGitHubReleaseAuthConfigured(plugin Plugin, auth []AuthConfig) bool {
	owner, repo, errRepository := GitHubRepositoryParts(plugin.Repository)
	if errRepository != nil {
		return false
	}
	releasesURL := fmt.Sprintf(
		"https://api.github.com/repos/%s/%s/releases/",
		url.PathEscape(owner),
		url.PathEscape(repo),
	)
	return AuthConfigured(auth, releasesURL+"latest", RequestKindMetadata) ||
		AuthConfigured(auth, releasesURL+"tags/", RequestKindMetadata)
}

func applyPluginStoreAuth(headers http.Header, auth []AuthConfig, requestURL string, kind string) error {
	_, errApply := applyPluginStoreAuthForClient(headers, nil, auth, requestURL, kind)
	return errApply
}

func applyPluginStoreAuthForClient(headers http.Header, resolved []ResolvedAuthConfig, auth []AuthConfig, requestURL string, kind string) (bool, error) {
	if item, ok := matchingResolvedAuthConfig(resolved, requestURL, kind); ok {
		applied, errApply := applyResolvedPluginStoreAuth(headers, item)
		return applied, errApply
	}
	item, ok := matchingAuthConfig(auth, requestURL, kind)
	if !ok {
		if token := githubauth.TokenForURL(requestURL); token != "" {
			headers.Set("Authorization", "Bearer "+token)
			return true, nil
		}
		return false, nil
	}
	switch strings.ToLower(strings.TrimSpace(item.Type)) {
	case "", AuthTypeNone:
		return false, nil
	case AuthTypeBearer:
		token, errToken := envValueRequired(item.TokenEnv, "token-env")
		if errToken != nil {
			return false, errToken
		}
		headers.Set("Authorization", "Bearer "+token)
	case AuthTypeBasic:
		username, errUsername := envValueRequired(item.UsernameEnv, "username-env")
		if errUsername != nil {
			return false, errUsername
		}
		password, errPassword := envValueRequired(item.PasswordEnv, "password-env")
		if errPassword != nil {
			return false, errPassword
		}
		encoded := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
		headers.Set("Authorization", "Basic "+encoded)
	case AuthTypeHeader:
		if strings.TrimSpace(item.HeaderName) == "" {
			return false, fmt.Errorf("plugin store auth missing header-name")
		}
		value, errValue := envValueRequired(item.HeaderValueEnv, "header-value-env")
		if errValue != nil {
			return false, errValue
		}
		headers.Set(item.HeaderName, value)
	case AuthTypeGitHubToken:
		token, errToken := envValueRequired(item.TokenEnv, "token-env")
		if errToken != nil {
			return false, errToken
		}
		headers.Set("Authorization", "Bearer "+token)
	default:
		return false, fmt.Errorf("unsupported plugin store auth type %q", item.Type)
	}
	return true, nil
}

func applyResolvedPluginStoreAuth(headers http.Header, item ResolvedAuthConfig) (bool, error) {
	switch strings.ToLower(strings.TrimSpace(item.Type)) {
	case "", AuthTypeNone:
		return false, nil
	case AuthTypeBearer, AuthTypeGitHubToken:
		if len(item.Token) == 0 {
			return false, fmt.Errorf("plugin store resolved auth token is empty")
		}
		headers.Set("Authorization", "Bearer "+string(item.Token))
	case AuthTypeBasic:
		if len(item.Username) == 0 || len(item.Password) == 0 {
			return false, fmt.Errorf("plugin store resolved basic auth is incomplete")
		}
		credential := make([]byte, 0, len(item.Username)+1+len(item.Password))
		credential = append(credential, item.Username...)
		credential = append(credential, ':')
		credential = append(credential, item.Password...)
		encoded := base64.StdEncoding.EncodeToString(credential)
		for index := range credential {
			credential[index] = 0
		}
		headers.Set("Authorization", "Basic "+encoded)
	case AuthTypeHeader:
		if strings.TrimSpace(item.HeaderName) == "" {
			return false, fmt.Errorf("plugin store resolved auth missing header-name")
		}
		if len(item.HeaderValue) == 0 {
			return false, fmt.Errorf("plugin store resolved auth header value is empty")
		}
		headers.Set(item.HeaderName, string(item.HeaderValue))
	default:
		return false, fmt.Errorf("unsupported plugin store resolved auth type %q", item.Type)
	}
	return true, nil
}

func validatePluginStoreRequestURL(auth []AuthConfig, requestURL string, kind string) error {
	parsed, errParse := url.Parse(strings.TrimSpace(requestURL))
	if errParse != nil || parsed.Scheme == "" || parsed.Host == "" {
		return fmt.Errorf("invalid plugin store url")
	}
	if parsed.User != nil {
		return fmt.Errorf("plugin store url must not contain credentials")
	}
	if hasSensitiveQueryParameter(parsed) {
		return fmt.Errorf("plugin store url contains sensitive query parameter")
	}
	if strings.EqualFold(parsed.Scheme, "http") && !allowInsecurePluginStoreURL(auth, requestURL, kind) {
		return fmt.Errorf("insecure plugin store url requires matching allow-insecure auth rule")
	}
	return nil
}

func allowInsecurePluginStoreURL(auth []AuthConfig, requestURL string, kind string) bool {
	item, ok := matchingAuthConfig(auth, requestURL, kind)
	return ok && item.AllowInsecure
}

func validateResolvedAuthExpiry(auth []ResolvedAuthConfig, expiresAt time.Time, now time.Time, requestURL string, kind string) error {
	if expiresAt.IsZero() {
		return nil
	}
	if _, ok := matchingResolvedAuthConfig(auth, requestURL, kind); !ok {
		return nil
	}
	if !now.Before(expiresAt) {
		return fmt.Errorf("plugin store resolved auth expired")
	}
	return nil
}

func matchingAuthConfig(auth []AuthConfig, requestURL string, kind string) (AuthConfig, bool) {
	requestURL = strings.TrimSpace(requestURL)
	kind = strings.ToLower(strings.TrimSpace(kind))
	for _, item := range NormalizeAuthConfigs(auth) {
		if !pluginStoreURLMatchesAuthRule(requestURL, item.Match) {
			continue
		}
		if !authAppliesTo(item, kind) {
			continue
		}
		return item, true
	}
	return AuthConfig{}, false
}

func matchingResolvedAuthConfig(auth []ResolvedAuthConfig, requestURL string, kind string) (ResolvedAuthConfig, bool) {
	requestURL = strings.TrimSpace(requestURL)
	kind = strings.ToLower(strings.TrimSpace(kind))
	for _, item := range auth {
		if !pluginStoreURLMatchesAuthRule(requestURL, strings.TrimSpace(item.Match)) {
			continue
		}
		if !resolvedAuthAppliesTo(item, kind) {
			continue
		}
		return item, true
	}
	return ResolvedAuthConfig{}, false
}

func resolvedAuthAppliesTo(item ResolvedAuthConfig, kind string) bool {
	if len(item.ApplyTo) == 0 {
		return true
	}
	for _, value := range item.ApplyTo {
		if strings.EqualFold(strings.TrimSpace(value), kind) {
			return true
		}
	}
	return false
}

func cloneResolvedAuthConfig(item ResolvedAuthConfig) ResolvedAuthConfig {
	item.ApplyTo = append([]string(nil), item.ApplyTo...)
	item.Token = append(Secret(nil), item.Token...)
	item.Username = append(Secret(nil), item.Username...)
	item.Password = append(Secret(nil), item.Password...)
	item.HeaderValue = append(Secret(nil), item.HeaderValue...)
	return item
}

func resolvedAuthConfigured(item ResolvedAuthConfig) bool {
	switch strings.ToLower(strings.TrimSpace(item.Type)) {
	case AuthTypeBearer, AuthTypeGitHubToken:
		return len(item.Token) > 0
	case AuthTypeBasic:
		return len(item.Username) > 0 && len(item.Password) > 0
	case AuthTypeHeader:
		return strings.TrimSpace(item.HeaderName) != "" && len(item.HeaderValue) > 0
	default:
		return false
	}
}

func secretContainsCRLF(secret Secret) bool {
	for _, value := range secret {
		if value == '\r' || value == '\n' {
			return true
		}
	}
	return false
}

func pluginStoreURLMatchesAuthRule(requestURL string, matchURL string) bool {
	request, errRequest := url.Parse(strings.TrimSpace(requestURL))
	if errRequest != nil || request.Scheme == "" || request.Host == "" {
		return false
	}
	rule, errRule := url.Parse(strings.TrimSpace(matchURL))
	if errRule != nil || rule.Scheme == "" || rule.Host == "" {
		return false
	}
	if !strings.EqualFold(request.Scheme, rule.Scheme) || !strings.EqualFold(request.Host, rule.Host) {
		return false
	}
	return pluginStorePathMatchesAuthRule(request.Path, rule.Path)
}

func pluginStorePathMatchesAuthRule(requestPath string, rulePath string) bool {
	if rulePath == "" || rulePath == "/" {
		return true
	}
	if requestPath == "" {
		requestPath = "/"
	}
	if requestPath == rulePath {
		return true
	}
	if strings.HasSuffix(rulePath, "/") {
		return strings.HasPrefix(requestPath, rulePath)
	}
	return strings.HasPrefix(requestPath, rulePath+"/")
}

func authAppliesTo(item AuthConfig, kind string) bool {
	if len(item.ApplyTo) == 0 {
		return true
	}
	for _, value := range item.ApplyTo {
		if strings.EqualFold(strings.TrimSpace(value), kind) {
			return true
		}
	}
	return false
}

func envValueRequired(envName string, field string) (string, error) {
	envName = strings.TrimSpace(envName)
	if envName == "" {
		return "", fmt.Errorf("plugin store auth missing %s", field)
	}
	value := strings.TrimSpace(os.Getenv(envName))
	if value == "" {
		return "", fmt.Errorf("plugin store auth env %s is empty", envName)
	}
	return value, nil
}