feat: add controlled plugin marketplace lifecycle
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user