diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 27886cc2..1030333e 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -76,7 +76,104 @@ func safeExtensionAssetPath(root, assetPath string) (string, bool) { return fullPath, isPathWithinBase(root, fullPath) } +const ( + maxExtensionArchiveEntries = 2048 + maxExtensionArchiveUncompressedBytes = 256 * 1024 * 1024 + maxExtensionManifestBytes = 1024 * 1024 +) + +func validateExtensionArchive(files []*zip.File) error { + if len(files) > maxExtensionArchiveEntries { + return fmt.Errorf( + "extension archive contains too many entries (maximum %d)", + maxExtensionArchiveEntries, + ) + } + + seenPaths := make(map[string]struct{}, len(files)) + var totalUncompressed uint64 + for _, file := range files { + if file.FileInfo().Mode()&os.ModeSymlink != 0 || strings.Contains(file.Name, `\`) { + return fmt.Errorf("unsafe path in extension archive: %s", file.Name) + } + + relPath := path.Clean(file.Name) + if relPath == "." || relPath == ".." || strings.HasPrefix(relPath, "../") || path.IsAbs(relPath) { + return fmt.Errorf("unsafe path in extension archive: %s", file.Name) + } + pathKey := strings.ToLower(relPath) + if _, exists := seenPaths[pathKey]; exists { + return fmt.Errorf("duplicate path in extension archive: %s", file.Name) + } + seenPaths[pathKey] = struct{}{} + + if file.FileInfo().IsDir() { + continue + } + if file.UncompressedSize64 > maxExtensionArchiveUncompressedBytes-totalUncompressed { + return fmt.Errorf( + "extension archive exceeds the %d MiB extracted size limit", + maxExtensionArchiveUncompressedBytes/(1024*1024), + ) + } + totalUncompressed += file.UncompressedSize64 + } + return nil +} + +func inspectExtensionPackage(files []*zip.File) (*ExtensionManifest, error) { + if err := validateExtensionArchive(files); err != nil { + return nil, err + } + + var manifestFile *zip.File + hasIndexJS := false + for _, file := range files { + switch path.Clean(file.Name) { + case "manifest.json": + manifestFile = file + case "index.js": + hasIndexJS = !file.FileInfo().IsDir() + } + } + + if manifestFile == nil || manifestFile.FileInfo().IsDir() { + return nil, fmt.Errorf("invalid extension package: root manifest.json not found") + } + if !hasIndexJS { + return nil, fmt.Errorf("invalid extension package: root index.js not found") + } + if manifestFile.UncompressedSize64 > maxExtensionManifestBytes { + return nil, fmt.Errorf("invalid extension package: manifest.json is too large") + } + + rc, err := manifestFile.Open() + if err != nil { + return nil, fmt.Errorf("failed to open manifest.json: %w", err) + } + manifestData, readErr := io.ReadAll(io.LimitReader(rc, maxExtensionManifestBytes+1)) + closeErr := rc.Close() + if readErr != nil { + return nil, fmt.Errorf("failed to read manifest.json: %w", readErr) + } + if closeErr != nil { + return nil, fmt.Errorf("failed to close manifest.json: %w", closeErr) + } + if len(manifestData) > maxExtensionManifestBytes { + return nil, fmt.Errorf("invalid extension package: manifest.json is too large") + } + + manifest, err := ParseManifest(manifestData) + if err != nil { + return nil, fmt.Errorf("invalid extension manifest: %w", err) + } + return manifest, nil +} + func extractExtensionArchive(zipReader *zip.ReadCloser, destination string) error { + if err := validateExtensionArchive(zipReader.File); err != nil { + return err + } for _, file := range zipReader.File { if file.FileInfo().IsDir() { continue @@ -260,37 +357,9 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded } defer zipReader.Close() - var manifestData []byte - var hasIndexJS bool - for _, file := range zipReader.File { - name := filepath.Base(file.Name) - if name == "manifest.json" { - rc, err := file.Open() - if err != nil { - return nil, fmt.Errorf("failed to open manifest.json: %w", err) - } - manifestData, err = io.ReadAll(rc) - rc.Close() - if err != nil { - return nil, fmt.Errorf("failed to read manifest.json: %w", err) - } - } - if name == "index.js" { - hasIndexJS = true - } - } - - if manifestData == nil { - return nil, fmt.Errorf("invalid extension package: manifest.json not found") - } - - if !hasIndexJS { - return nil, fmt.Errorf("invalid extension package: index.js not found") - } - - manifest, err := ParseManifest(manifestData) + manifest, err := inspectExtensionPackage(zipReader.File) if err != nil { - return nil, fmt.Errorf("invalid extension manifest: %w", err) + return nil, err } m.mu.RLock() @@ -1006,37 +1075,9 @@ func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExten } defer zipReader.Close() - var manifestData []byte - var hasIndexJS bool - for _, file := range zipReader.File { - name := filepath.Base(file.Name) - if name == "manifest.json" { - rc, err := file.Open() - if err != nil { - return nil, fmt.Errorf("failed to open manifest.json: %w", err) - } - manifestData, err = io.ReadAll(rc) - rc.Close() - if err != nil { - return nil, fmt.Errorf("failed to read manifest.json: %w", err) - } - } - if name == "index.js" { - hasIndexJS = true - } - } - - if manifestData == nil { - return nil, fmt.Errorf("invalid extension package: manifest.json not found") - } - - if !hasIndexJS { - return nil, fmt.Errorf("invalid extension package: index.js not found") - } - - newManifest, err := ParseManifest(manifestData) + newManifest, err := inspectExtensionPackage(zipReader.File) if err != nil { - return nil, fmt.Errorf("invalid extension manifest: %w", err) + return nil, err } m.mu.RLock() @@ -1159,30 +1200,9 @@ func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte } defer zipReader.Close() - var manifestData []byte - for _, file := range zipReader.File { - name := filepath.Base(file.Name) - if name == "manifest.json" { - rc, err := file.Open() - if err != nil { - return nil, fmt.Errorf("failed to open manifest.json") - } - manifestData, err = io.ReadAll(rc) - rc.Close() - if err != nil { - return nil, fmt.Errorf("failed to read manifest.json") - } - break - } - } - - if manifestData == nil { - return nil, fmt.Errorf("manifest.json not found") - } - - newManifest, err := ParseManifest(manifestData) + newManifest, err := inspectExtensionPackage(zipReader.File) if err != nil { - return nil, fmt.Errorf("invalid manifest: %w", err) + return nil, err } m.mu.RLock() diff --git a/go_backend/extension_package_security_test.go b/go_backend/extension_package_security_test.go new file mode 100644 index 00000000..de741415 --- /dev/null +++ b/go_backend/extension_package_security_test.go @@ -0,0 +1,181 @@ +package gobackend + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteVerifiedExtensionPackageAcceptsMatchingSHA256(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "verified.spotiflac-ext") + payload := []byte("extension package") + checksum := fmt.Sprintf("%x", sha256.Sum256(payload)) + + if err := writeVerifiedExtensionPackage(bytes.NewReader(payload), dest, checksum); err != nil { + t.Fatalf("writeVerifiedExtensionPackage: %v", err) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read verified package: %v", err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("verified package = %q, want %q", got, payload) + } +} + +func TestWriteVerifiedExtensionPackageRejectsMismatchBeforeReplace(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "cached.spotiflac-ext") + if err := os.WriteFile(dest, []byte("existing"), 0600); err != nil { + t.Fatal(err) + } + + err := writeVerifiedExtensionPackage( + bytes.NewReader([]byte("tampered")), + dest, + strings.Repeat("0", sha256.Size*2), + ) + if err == nil || !strings.Contains(err.Error(), "SHA-256 mismatch") { + t.Fatalf("expected checksum mismatch, got %v", err) + } + got, readErr := os.ReadFile(dest) + if readErr != nil { + t.Fatal(readErr) + } + if string(got) != "existing" { + t.Fatalf("checksum failure replaced existing package with %q", got) + } +} + +func TestRegistryRejectsMalformedExtensionChecksum(t *testing.T) { + _, err := parseRegistryBody([]byte( + `{"version":1,"extensions":[{"id":"ext","name":"ext","version":"1.0.0","sha256":"not-a-hash"}]}`, + )) + if err == nil || !strings.Contains(err.Error(), "invalid SHA-256") { + t.Fatalf("expected invalid checksum error, got %v", err) + } + + checksum := strings.Repeat("a", sha256.Size*2) + registry, err := parseRegistryBody([]byte( + `{"version":1,"extensions":[{"id":"ext","name":"ext","version":"1.0.0","checksumSha256":"sha256:` + + checksum + + `"}]}`, + )) + if err != nil { + t.Fatalf("parse registry with checksum alias: %v", err) + } + if got := registry.Extensions[0].getSHA256(); got != checksum { + t.Fatalf("normalized checksum = %q, want %q", got, checksum) + } +} + +func TestExtensionPackageRequiresUniqueRootEntrypoints(t *testing.T) { + dir := t.TempDir() + duplicate := filepath.Join(dir, "duplicate.spotiflac-ext") + createTestExtensionPackage( + t, + duplicate, + "duplicate-ext", + "1.0.0", + `registerExtension({});`, + map[string]string{"MANIFEST.JSON": "{}"}, + ) + + reader, err := zip.OpenReader(duplicate) + if err != nil { + t.Fatal(err) + } + _, inspectErr := inspectExtensionPackage(reader.File) + _ = reader.Close() + if inspectErr == nil || !strings.Contains(inspectErr.Error(), "duplicate path") { + t.Fatalf("expected duplicate archive path error, got %v", inspectErr) + } + + nested := filepath.Join(dir, "nested.spotiflac-ext") + writeTestZip(t, nested, map[string]string{ + "nested/manifest.json": validSecurityTestManifest("nested-ext"), + "nested/index.js": `registerExtension({});`, + }) + reader, err = zip.OpenReader(nested) + if err != nil { + t.Fatal(err) + } + _, inspectErr = inspectExtensionPackage(reader.File) + _ = reader.Close() + if inspectErr == nil || !strings.Contains(inspectErr.Error(), "root manifest.json") { + t.Fatalf("expected root entrypoint error, got %v", inspectErr) + } +} + +func TestExtensionPackageRejectsUnsafeAndOversizedEntries(t *testing.T) { + for _, unsafePath := range []string{ + "../outside.js", + `nested\outside.js`, + "/absolute.js", + } { + t.Run(unsafePath, func(t *testing.T) { + archivePath := filepath.Join(t.TempDir(), "unsafe.spotiflac-ext") + writeTestZip(t, archivePath, map[string]string{ + "manifest.json": validSecurityTestManifest("unsafe-ext"), + "index.js": `registerExtension({});`, + unsafePath: "unsafe", + }) + reader, err := zip.OpenReader(archivePath) + if err != nil { + t.Fatal(err) + } + _, inspectErr := inspectExtensionPackage(reader.File) + _ = reader.Close() + if inspectErr == nil || !strings.Contains(inspectErr.Error(), "unsafe path") { + t.Fatalf("expected unsafe archive path error, got %v", inspectErr) + } + }) + } + + oversized := &zip.File{FileHeader: zip.FileHeader{ + Name: "payload.bin", + UncompressedSize64: maxExtensionArchiveUncompressedBytes + 1, + }} + if err := validateExtensionArchive([]*zip.File{oversized}); err == nil || + !strings.Contains(err.Error(), "extracted size limit") { + t.Fatalf("expected extracted size error, got %v", err) + } +} + +func validSecurityTestManifest(name string) string { + return fmt.Sprintf( + `{"name":%q,"displayName":%q,"version":"1.0.0","description":"test","type":["metadata_provider"],"permissions":{}}`, + name, + name, + ) +} + +func writeTestZip(t *testing.T, filePath string, files map[string]string) { + t.Helper() + output, err := os.Create(filePath) + if err != nil { + t.Fatal(err) + } + archive := zip.NewWriter(output) + for name, content := range files { + writer, createErr := archive.Create(name) + if createErr != nil { + t.Fatal(createErr) + } + if _, writeErr := writer.Write([]byte(content)); writeErr != nil { + t.Fatal(writeErr) + } + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := output.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/go_backend/extension_repo.go b/go_backend/extension_repo.go index 7e30a1d1..0f1e7688 100644 --- a/go_backend/extension_repo.go +++ b/go_backend/extension_repo.go @@ -1,6 +1,8 @@ package gobackend import ( + "crypto/sha256" + "crypto/subtle" "encoding/json" "fmt" "io" @@ -34,10 +36,13 @@ type repoExtension struct { Downloads int `json:"downloads"` UpdatedAt string `json:"updated_at"` MinAppVersion string `json:"min_app_version,omitempty"` + SHA256 string `json:"sha256,omitempty"` + ChecksumSHA256 string `json:"checksum_sha256,omitempty"` DisplayNameAlt string `json:"displayName,omitempty"` DownloadURLAlt string `json:"downloadUrl,omitempty"` IconURLAlt string `json:"iconUrl,omitempty"` MinAppVersionAlt string `json:"minAppVersion,omitempty"` + ChecksumAlt string `json:"checksumSha256,omitempty"` } func (e *repoExtension) getDisplayName() string { @@ -71,6 +76,14 @@ func (e *repoExtension) getMinAppVersion() string { return e.MinAppVersionAlt } +func (e *repoExtension) getRawSHA256() string { + return firstNonEmpty(e.SHA256, e.ChecksumSHA256, e.ChecksumAlt) +} + +func (e *repoExtension) getSHA256() string { + return normalizeSHA256(e.getRawSHA256()) +} + type repoRegistry struct { Version int `json:"version"` UpdatedAt string `json:"updated_at"` @@ -90,6 +103,7 @@ type repoExtensionResponse struct { Downloads int `json:"downloads"` UpdatedAt string `json:"updated_at"` MinAppVersion string `json:"min_app_version,omitempty"` + SHA256 string `json:"sha256,omitempty"` IsInstalled bool `json:"is_installed"` InstalledVersion string `json:"installed_version,omitempty"` HasUpdate bool `json:"has_update"` @@ -108,6 +122,7 @@ func (e *repoExtension) toResponse() repoExtensionResponse { Downloads: e.Downloads, UpdatedAt: e.UpdatedAt, MinAppVersion: e.getMinAppVersion(), + SHA256: e.getSHA256(), } if len(e.Tags) > 0 { @@ -316,6 +331,16 @@ func parseRegistryBody(body []byte) (*repoRegistry, error) { } return nil, fmt.Errorf("failed to parse registry: %w", err) } + for index := range registry.Extensions { + ext := ®istry.Extensions[index] + rawChecksum := ext.getRawSHA256() + if rawChecksum != "" && normalizeSHA256(rawChecksum) == "" { + return nil, fmt.Errorf( + "extension %q has an invalid SHA-256 checksum", + ext.ID, + ) + } + } return ®istry, nil } @@ -398,22 +423,115 @@ func (s *extensionRepo) downloadExtension(extensionID string, destPath string) e return fmt.Errorf("download returned HTTP %d", resp.StatusCode) } - out, err := os.Create(destPath) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - defer out.Close() - - _, err = io.Copy(out, resp.Body) - if err != nil { - os.Remove(destPath) - return fmt.Errorf("failed to write file: %w", err) + if err := writeVerifiedExtensionPackage( + resp.Body, + destPath, + ext.getRawSHA256(), + ); err != nil { + return err } LogInfo("ExtensionRepo", "Downloaded %s to %s", ext.getDisplayName(), destPath) return nil } +const maxExtensionPackageBytes int64 = 64 * 1024 * 1024 + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func normalizeSHA256(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + normalized = strings.TrimPrefix(normalized, "sha256:") + if len(normalized) != sha256.Size*2 { + return "" + } + for _, char := range normalized { + if (char < '0' || char > '9') && (char < 'a' || char > 'f') { + return "" + } + } + return normalized +} + +func writeVerifiedExtensionPackage( + reader io.Reader, + destPath string, + expectedSHA256 string, +) error { + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return fmt.Errorf("failed to prepare extension download directory: %w", err) + } + + tempFile, err := os.CreateTemp( + filepath.Dir(destPath), + "."+filepath.Base(destPath)+".download-*", + ) + if err != nil { + return fmt.Errorf("failed to create extension download: %w", err) + } + tempPath := tempFile.Name() + committed := false + defer func() { + _ = tempFile.Close() + if !committed { + _ = os.Remove(tempPath) + } + }() + + hasher := sha256.New() + limited := &io.LimitedReader{R: reader, N: maxExtensionPackageBytes + 1} + written, copyErr := io.Copy(io.MultiWriter(tempFile, hasher), limited) + if copyErr != nil { + return fmt.Errorf("failed to write extension package: %w", copyErr) + } + if written > maxExtensionPackageBytes { + return fmt.Errorf( + "extension package exceeds the %d MiB size limit", + maxExtensionPackageBytes/(1024*1024), + ) + } + if err := tempFile.Sync(); err != nil { + return fmt.Errorf("failed to flush extension package: %w", err) + } + if err := tempFile.Close(); err != nil { + return fmt.Errorf("failed to close extension package: %w", err) + } + + expected := normalizeSHA256(expectedSHA256) + if strings.TrimSpace(expectedSHA256) != "" && expected == "" { + return fmt.Errorf("registry contains an invalid extension SHA-256 checksum") + } + if expected != "" { + actual := fmt.Sprintf("%x", hasher.Sum(nil)) + if subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) != 1 { + return fmt.Errorf( + "extension package integrity check failed: SHA-256 mismatch", + ) + } + } else { + LogWarn( + "ExtensionRepo", + "Registry entry has no SHA-256 checksum; package integrity cannot be verified", + ) + } + + if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to replace cached extension package: %w", err) + } + if err := os.Rename(tempPath, destPath); err != nil { + return fmt.Errorf("failed to publish extension package: %w", err) + } + committed = true + return nil +} + func resolveRegistryURL(input string) (string, error) { input = strings.TrimSpace(input) if input == "" {