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 }