feat(extensions): verify decentralized package integrity

This commit is contained in:
zarzet
2026-07-26 01:14:35 +07:00
parent a5f2760023
commit 1705fb2a81
3 changed files with 412 additions and 93 deletions
+103 -83
View File
@@ -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()
@@ -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)
}
}
+128 -10
View File
@@ -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 := &registry.Extensions[index]
rawChecksum := ext.getRawSHA256()
if rawChecksum != "" && normalizeSHA256(rawChecksum) == "" {
return nil, fmt.Errorf(
"extension %q has an invalid SHA-256 checksum",
ext.ID,
)
}
}
return &registry, 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 == "" {