Files
sub2api-add/plugins/plugin-admin/marketplace.go
T
Qiufeng 028b505c36
Business Plugins CI / check (plugin-admin) (push) Successful in 1m35s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m29s
feat: add controlled plugin marketplace lifecycle
2026-08-28 00:51:34 +08:00

536 lines
19 KiB
Go

package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
const (
maxMarketplaceIndexBytes = 2 << 20
maxMarketplaceEntries = 256
)
var (
marketplaceIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`)
marketplaceVersionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
marketplaceSHA256Pattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
)
// marketplaceEntry is deliberately metadata-only. The browser never receives
// the archive URL; downloads are performed by this server after catalog and
// transport policy validation.
type marketplaceEntry struct {
PluginID string `json:"plugin_id"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
ArchiveURL string `json:"archive_url"`
ArchiveSHA256 string `json:"archive_sha256"`
ArchiveSize int64 `json:"archive_size,omitempty"`
PublisherKeyID string `json:"publisher_key_id"`
CoreAPIBaseline string `json:"core_api_baseline"`
TestedCoreVersions []string `json:"tested_core_versions,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
ReleaseNotes string `json:"release_notes,omitempty"`
PublishedAt string `json:"published_at,omitempty"`
}
type marketplaceIndex struct {
SchemaVersion int `json:"schema_version"`
Source string `json:"source,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Entries []marketplaceEntry `json:"entries"`
}
type marketplaceService struct {
localPath string
remoteURL *url.URL
allowedHosts map[string]struct{}
allowLoopback bool
client *http.Client
}
type marketplaceOrigin struct {
localPath string
remoteURL *url.URL
}
func (m marketplaceService) httpClient() *http.Client {
if m.client != nil {
return m.client
}
return &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{Proxy: nil, DialContext: marketplaceDialContext(m.allowLoopback)},
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
func newMarketplaceService(source, allowedHosts string, allowLoopback bool) (marketplaceService, error) {
if strings.TrimSpace(source) == "" {
source = "./marketplace/index.json"
}
service := marketplaceService{
allowedHosts: map[string]struct{}{},
allowLoopback: allowLoopback,
client: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{Proxy: nil, DialContext: marketplaceDialContext(allowLoopback)},
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
parsed, err := url.Parse(strings.TrimSpace(source))
if err == nil && parsed.Scheme != "" {
if parsed.User != nil || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX must be an HTTPS URL without credentials, query or fragment")
}
if parsed.Scheme != "https" && !(allowLoopback && isLoopbackHost(parsed.Hostname())) {
return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX must use HTTPS unless it targets loopback in development")
}
service.remoteURL = parsed
for _, host := range strings.FieldsFunc(allowedHosts, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' }) {
host = canonicalAllowedMarketplaceHost(host)
if host != "" {
service.allowedHosts[host] = struct{}{}
}
}
if len(service.allowedHosts) == 0 {
return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_ALLOWED_HOSTS is required for remote marketplace indexes")
}
if !service.hostAllowed(parsed) {
return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX host is not allowlisted")
}
return service, nil
}
if strings.Contains(source, "\x00") {
return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX contains an invalid path")
}
absolute, err := filepath.Abs(source)
if err != nil {
return marketplaceService{}, fmt.Errorf("resolve marketplace index: %w", err)
}
service.localPath = filepath.Clean(absolute)
return service, nil
}
func (m marketplaceService) hostAllowed(u *url.URL) bool {
if u == nil {
return false
}
_, ok := m.allowedHosts[canonicalMarketplaceHost(u)]
return ok
}
func canonicalMarketplaceHost(u *url.URL) string {
if u == nil {
return ""
}
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(u.Hostname()), "."))
if host == "" {
return ""
}
port := u.Port()
if (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") {
port = ""
}
if port == "" {
return host
}
return net.JoinHostPort(host, port)
}
func canonicalAllowedMarketplaceHost(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse("//" + raw)
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return strings.ToLower(strings.TrimSuffix(raw, "."))
}
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
port := parsed.Port()
if port == "80" || port == "443" {
port = ""
}
if port == "" {
return host
}
return net.JoinHostPort(host, port)
}
func (m marketplaceService) loadIndex(ctx context.Context) (marketplaceIndex, marketplaceOrigin, error) {
var raw []byte
origin := marketplaceOrigin{localPath: m.localPath, remoteURL: m.remoteURL}
if m.remoteURL != nil {
if !m.hostAllowed(m.remoteURL) {
return marketplaceIndex{}, origin, errors.New("marketplace index host is not allowlisted")
}
if err := m.validateRemoteHost(ctx, m.remoteURL); err != nil {
return marketplaceIndex{}, origin, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.remoteURL.String(), nil)
if err != nil {
return marketplaceIndex{}, origin, err
}
res, err := m.httpClient().Do(req)
if err != nil {
return marketplaceIndex{}, origin, err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return marketplaceIndex{}, origin, fmt.Errorf("marketplace index returned HTTP %d", res.StatusCode)
}
if res.ContentLength > maxMarketplaceIndexBytes {
return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit")
}
raw, err = io.ReadAll(io.LimitReader(res.Body, maxMarketplaceIndexBytes+1))
if err != nil {
return marketplaceIndex{}, origin, err
}
if len(raw) > maxMarketplaceIndexBytes {
return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit")
}
} else {
if m.localPath == "" {
return marketplaceIndex{}, origin, errors.New("marketplace index is not configured")
}
file, err := os.Open(m.localPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return marketplaceIndex{SchemaVersion: 1, Entries: []marketplaceEntry{}}, origin, nil
}
return marketplaceIndex{}, origin, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return marketplaceIndex{}, origin, err
}
if info.Size() > maxMarketplaceIndexBytes {
return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit")
}
raw, err = io.ReadAll(io.LimitReader(file, maxMarketplaceIndexBytes+1))
if err != nil {
return marketplaceIndex{}, origin, err
}
if len(raw) > maxMarketplaceIndexBytes {
return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit")
}
}
var index marketplaceIndex
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&index); err != nil {
return marketplaceIndex{}, origin, fmt.Errorf("decode marketplace index: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return marketplaceIndex{}, origin, errors.New("marketplace index must contain one JSON value")
}
return marketplaceIndex{}, origin, fmt.Errorf("decode marketplace index trailing data: %w", err)
}
if err := validateMarketplaceIndex(index); err != nil {
return marketplaceIndex{}, origin, err
}
if m.remoteURL != nil && strings.TrimSpace(index.ExpiresAt) == "" {
return marketplaceIndex{}, origin, errors.New("remote marketplace index must include expires_at")
}
return index, origin, nil
}
func (m marketplaceService) validateRemoteHost(ctx context.Context, u *url.URL) error {
if u == nil || u.Hostname() == "" {
return errors.New("marketplace URL host is required")
}
lookupCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
ips, err := net.DefaultResolver.LookupIP(lookupCtx, "ip", u.Hostname())
if err != nil {
return errors.New("marketplace host DNS lookup failed")
}
if len(ips) == 0 {
return errors.New("marketplace host has no address")
}
for _, ip := range ips {
if isForbiddenMarketplaceIP(ip) && !(m.allowLoopback && ip.IsLoopback()) {
return errors.New("marketplace host resolves to a private address")
}
}
return nil
}
func isForbiddenMarketplaceIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() || ip.IsMulticast()
}
func validateMarketplaceIndex(index marketplaceIndex) error {
if index.SchemaVersion != 1 {
return errors.New("marketplace schema_version must be 1")
}
if len(index.Entries) > maxMarketplaceEntries {
return errors.New("marketplace contains too many entries")
}
if value := strings.TrimSpace(index.IssuedAt); value != "" {
if issued, err := time.Parse(time.RFC3339, value); err != nil || issued.After(time.Now().UTC().Add(5*time.Minute)) {
return errors.New("marketplace issued_at is invalid")
}
}
if value := strings.TrimSpace(index.ExpiresAt); value != "" {
expires, err := time.Parse(time.RFC3339, value)
if err != nil {
return errors.New("marketplace expires_at is invalid")
}
if !expires.After(time.Now().UTC()) {
return errors.New("marketplace index has expired")
}
}
seen := map[string]struct{}{}
for _, entry := range index.Entries {
if !marketplaceIDPattern.MatchString(entry.PluginID) || len(entry.PluginID) > 160 {
return fmt.Errorf("invalid marketplace plugin_id: %s", entry.PluginID)
}
if strings.TrimSpace(entry.Name) == "" || len(entry.Name) > 160 {
return fmt.Errorf("invalid marketplace name for %s", entry.PluginID)
}
version := strings.TrimPrefix(strings.TrimSpace(entry.Version), "v")
if !marketplaceVersionPattern.MatchString(version) {
return fmt.Errorf("invalid marketplace version for %s", entry.PluginID)
}
if strings.TrimSpace(entry.ArchiveURL) == "" || len(entry.ArchiveURL) > 2048 || strings.ContainsAny(entry.ArchiveURL, "\x00\r\n") {
return fmt.Errorf("archive_url is required for %s", entry.PluginID)
}
if len(entry.Description) > 4096 || len(entry.ReleaseNotes) > 16384 {
return fmt.Errorf("marketplace description or release notes are too long for %s", entry.PluginID)
}
if !marketplaceSHA256Pattern.MatchString(strings.ToLower(strings.TrimSpace(entry.ArchiveSHA256))) {
return fmt.Errorf("invalid archive_sha256 for %s", entry.PluginID)
}
if entry.ArchiveSize < 0 || entry.ArchiveSize > maxPackageBytes {
return fmt.Errorf("invalid archive_size for %s", entry.PluginID)
}
if strings.TrimSpace(entry.PublisherKeyID) == "" || len(entry.PublisherKeyID) > 160 {
return fmt.Errorf("publisher_key_id is required for %s", entry.PluginID)
}
baseline := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(entry.CoreAPIBaseline), "sub2api-"), "v")
if !marketplaceVersionPattern.MatchString(baseline) {
return fmt.Errorf("invalid core_api_baseline for %s", entry.PluginID)
}
if len(entry.TestedCoreVersions) > 64 || len(entry.Capabilities) > 64 {
return fmt.Errorf("marketplace metadata contains too many values for %s", entry.PluginID)
}
if len(entry.TestedCoreVersions) == 0 {
return fmt.Errorf("tested_core_versions are required for %s", entry.PluginID)
}
for _, tested := range entry.TestedCoreVersions {
if !marketplaceVersionPattern.MatchString(strings.TrimPrefix(strings.TrimSpace(tested), "v")) {
return fmt.Errorf("invalid tested_core_versions for %s", entry.PluginID)
}
}
for _, capability := range entry.Capabilities {
if !marketplaceIDPattern.MatchString(capability) {
return fmt.Errorf("invalid capability for %s", entry.PluginID)
}
}
if len(entry.Capabilities) == 0 {
return fmt.Errorf("capabilities are required for %s", entry.PluginID)
}
key := entry.PluginID + "@" + version
if _, exists := seen[key]; exists {
return fmt.Errorf("duplicate marketplace entry: %s", key)
}
seen[key] = struct{}{}
}
return nil
}
func marketplaceDialContext(allowLoopback bool) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return nil, errors.New("marketplace host DNS lookup failed")
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
var lastErr error
for _, ip := range ips {
if isForbiddenMarketplaceIP(ip) && !(allowLoopback && ip.IsLoopback()) {
lastErr = errors.New("marketplace host resolves to a private address")
continue
}
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
if lastErr != nil {
return nil, lastErr
}
return nil, errors.New("marketplace host has no address")
}
}
func sameCapabilities(left, right []string) bool {
left = append([]string(nil), left...)
right = append([]string(nil), right...)
sort.Strings(left)
sort.Strings(right)
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
func sameCoreVersions(left, right []string) bool {
normalize := func(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
out = append(out, strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(value), "sub2api-"), "v"))
}
sort.Strings(out)
return out
}
return sameCapabilities(normalize(left), normalize(right))
}
func marketplaceEntryVersion(entry marketplaceEntry) string {
return strings.TrimPrefix(strings.TrimSpace(entry.Version), "v")
}
func (m marketplaceService) archiveBytes(ctx context.Context, entry marketplaceEntry, origin marketplaceOrigin) ([]byte, error) {
if origin.remoteURL != nil {
relative, err := url.Parse(strings.TrimSpace(entry.ArchiveURL))
if err != nil || relative.IsAbs() || relative.Host != "" || relative.User != nil || relative.RawQuery != "" || relative.Fragment != "" || relative.Path == "" || strings.HasPrefix(relative.Path, "/") || strings.Contains(relative.Path, "..") {
return nil, errors.New("marketplace archive URL must be a relative path without traversal")
}
archiveURL := origin.remoteURL.ResolveReference(relative)
if archiveURL.Scheme != "https" && !(m.allowLoopback && archiveURL.Scheme == "http" && isLoopbackHost(archiveURL.Hostname())) {
return nil, errors.New("marketplace archive URL must use HTTPS unless it targets loopback in development")
}
if !m.hostAllowed(archiveURL) {
return nil, errors.New("marketplace archive host is not allowlisted")
}
if err := m.validateRemoteHost(ctx, archiveURL); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL.String(), nil)
if err != nil {
return nil, err
}
res, err := m.httpClient().Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("marketplace archive returned HTTP %d", res.StatusCode)
}
if res.ContentLength > maxPackageBytes {
return nil, errors.New("marketplace archive exceeds package size limit")
}
data, err := io.ReadAll(io.LimitReader(res.Body, maxPackageBytes+1))
if err != nil {
return nil, err
}
if len(data) > maxPackageBytes {
return nil, errors.New("marketplace archive exceeds package size limit")
}
return verifyMarketplaceArchive(entry, data)
}
archivePath, err := localMarketplaceArchivePath(origin.localPath, entry.ArchiveURL)
if err != nil {
return nil, err
}
file, err := os.Open(archivePath)
if err != nil {
return nil, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, err
}
if info.Size() > maxPackageBytes {
return nil, errors.New("marketplace archive exceeds package size limit")
}
data, err := io.ReadAll(io.LimitReader(file, maxPackageBytes+1))
if err != nil {
return nil, err
}
if len(data) > maxPackageBytes {
return nil, errors.New("marketplace archive exceeds package size limit")
}
return verifyMarketplaceArchive(entry, data)
}
func localMarketplaceArchivePath(indexPath, raw string) (string, error) {
if indexPath == "" {
return "", errors.New("local marketplace index is not configured")
}
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errors.New("local marketplace archive URL must be a relative path")
}
base := filepath.Dir(indexPath)
candidate := filepath.Clean(filepath.Join(base, filepath.FromSlash(parsed.Path)))
rel, err := filepath.Rel(base, candidate)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", errors.New("local marketplace archive path escapes the index directory")
}
baseResolved, err := filepath.EvalSymlinks(base)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(candidate)
if err != nil {
return "", err
}
rel, err = filepath.Rel(baseResolved, resolved)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", errors.New("local marketplace archive symlink escapes the index directory")
}
return resolved, nil
}
func verifyMarketplaceArchive(entry marketplaceEntry, archive []byte) ([]byte, error) {
if entry.ArchiveSize > 0 && int64(len(archive)) != entry.ArchiveSize {
return nil, errors.New("marketplace archive size mismatch")
}
sum := sha256.Sum256(archive)
hash := hex.EncodeToString(sum[:])
if !strings.EqualFold(hash, strings.TrimSpace(entry.ArchiveSHA256)) {
return nil, errors.New("marketplace archive hash mismatch")
}
return archive, nil
}