security: improve extension sandbox security

- Add file permission requirement for extensions

- Bump version to 3.0.0-beta.1
This commit is contained in:
zarzet
2026-01-13 17:41:24 +07:00
parent c7e1ffd926
commit 8a7815268b
13 changed files with 473 additions and 36 deletions
+23 -3
View File
@@ -1,6 +1,26 @@
# Changelog
## [3.0.0-alpha.4] - Upcoming
## [3.0.0-beta.1] - 2026-01-13
### Security
- Improved extension sandbox security
### Changed
- **Extension Manifest**: New `file` permission required for file operations
```json
"permissions": {
"network": ["api.example.com"],
"storage": true,
"file": true
}
```
Extensions that need to download files must declare `"file": true` in manifest.
---
## [3.0.0-alpha.4] - 2026-01-12
### Added
@@ -15,7 +35,7 @@
- **Custom URL Handler for Extensions**: Extensions can now register custom URL patterns
- Handle URLs from YouTube Music, SoundCloud, Bandcamp, etc.
- Manifest config: `urlHandler: { enabled: true, patterns: ["music.youtube.com"] }`
- Implement `handleURL(url)` function in extension to parse and return track metadata
- Implement `handleUrl(url)` function in extension to parse and return track metadata
- SpotiFLAC automatically routes matching URLs to the appropriate extension
- Supports share intents and paste from clipboard
@@ -36,7 +56,7 @@
- Updated `docs/EXTENSION_DEVELOPMENT.md`:
- Added Custom URL Handler section with examples
- Added `handleURL` function documentation
- Added `handleUrl` function documentation
- Added URL pattern examples for YouTube, SoundCloud, Bandcamp
- Added `utils.hmacSHA1` documentation with TOTP example
+10
View File
@@ -208,6 +208,11 @@ func DownloadTrack(requestJSON string) (string, error) {
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
// Add output directory to allowed download dirs for extensions
if req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
var result DownloadResult
var err error
@@ -345,6 +350,11 @@ func DownloadWithFallback(requestJSON string) (string, error) {
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
// Add output directory to allowed download dirs for extensions
if req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
// Build service order starting with preferred service
allServices := []string{"tidal", "qobuz", "amazon"}
preferredService := req.Service
+1
View File
@@ -29,6 +29,7 @@ const (
type ExtensionPermissions struct {
Network []string `json:"network"` // List of allowed domains
Storage bool `json:"storage"` // Whether extension can use storage API
File bool `json:"file"` // Whether extension can use file API
}
// ExtensionSetting defines a configurable setting for an extension
+59 -14
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
@@ -140,8 +141,11 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
})()
`, query, limit)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("searchTracks timeout: extension took too long to respond")
}
return nil, fmt.Errorf("searchTracks failed: %w", err)
}
@@ -188,8 +192,11 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
})()
`, trackID)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("getTrack timeout: extension took too long to respond")
}
return nil, fmt.Errorf("getTrack failed: %w", err)
}
@@ -231,8 +238,11 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
})()
`, albumID)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("getAlbum timeout: extension took too long to respond")
}
return nil, fmt.Errorf("getAlbum failed: %w", err)
}
@@ -277,8 +287,11 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
})()
`, artistID)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("getArtist timeout: extension took too long to respond")
}
return nil, fmt.Errorf("getArtist failed: %w", err)
}
@@ -322,8 +335,11 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
})()
`, isrc, trackName, artistName)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("checkAvailability timeout: extension took too long to respond")
}
return nil, fmt.Errorf("checkAvailability failed: %w", err)
}
@@ -364,8 +380,11 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
})()
`, trackID, quality)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("getDownloadUrl timeout: extension took too long to respond")
}
return nil, fmt.Errorf("getDownloadUrl failed: %w", err)
}
@@ -387,6 +406,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
return &urlResult, nil
}
// ExtDownloadTimeout is longer for extension download operations (5 minutes)
const ExtDownloadTimeout = 5 * time.Minute
// Download downloads a track with progress reporting
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
@@ -424,12 +446,19 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
})()
`, trackID, quality, outputPath)
result, err := p.vm.RunString(script)
// Use longer timeout for downloads (5 minutes)
result, err := RunWithTimeoutAndRecover(p.vm, script, ExtDownloadTimeout)
if err != nil {
errMsg := err.Error()
errType := "script_error"
if IsTimeoutError(err) {
errMsg = "download timeout: extension took too long to complete"
errType = "timeout"
}
return &ExtDownloadResult{
Success: false,
ErrorMessage: err.Error(),
ErrorType: "script_error",
ErrorMessage: errMsg,
ErrorType: errType,
}, nil
}
@@ -947,8 +976,11 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
})()
`, query, string(optionsJSON))
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("customSearch timeout: extension took too long to respond")
}
return nil, fmt.Errorf("customSearch failed: %w", err)
}
@@ -1013,8 +1045,11 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
})()
`, url)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("handleUrl timeout: extension took too long to respond")
}
return nil, fmt.Errorf("handleUrl failed: %w", err)
}
@@ -1076,8 +1111,11 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
})()
`, string(sourceJSON), string(candidatesJSON))
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("matchTrack timeout: extension took too long to respond")
}
return nil, fmt.Errorf("matchTrack failed: %w", err)
}
@@ -1111,6 +1149,9 @@ type PostProcessResult struct {
SampleRate int `json:"sample_rate,omitempty"`
}
// PostProcessTimeout is longer for post-processing (2 minutes)
const PostProcessTimeout = 2 * time.Minute
// PostProcess runs post-processing hooks on a downloaded file
func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
if !p.extension.Manifest.HasPostProcessing() {
@@ -1132,11 +1173,15 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
})()
`, filePath, string(metadataJSON), hookID)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout)
if err != nil {
errMsg := err.Error()
if IsTimeoutError(err) {
errMsg = "postProcess timeout: extension took too long to complete"
}
return &PostProcessResult{
Success: false,
Error: err.Error(),
Error: errMsg,
}, nil
}
+78 -7
View File
@@ -10,6 +10,9 @@ import (
"github.com/dop251/goja"
)
// Default timeout for JS execution (30 seconds)
const DefaultJSTimeout = 30 * time.Second
// Global auth state for extensions (stores pending auth codes)
var (
extensionAuthState = make(map[string]*ExtensionAuthState)
@@ -101,20 +104,88 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
// Create a cookie jar for this extension
jar, _ := newSimpleCookieJar()
client := &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
}
return &ExtensionRuntime{
runtime := &ExtensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]interface{}),
httpClient: client,
cookieJar: jar,
dataDir: ext.DataDir,
vm: ext.VM,
}
// Create HTTP client with redirect validation to prevent SSRF via open redirect
client := &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Validate redirect target domain against allowed domains
domain := req.URL.Hostname()
if !ext.Manifest.IsDomainAllowed(domain) {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain}
}
// Also block redirects to private/local networks (SSRF protection)
if isPrivateIP(domain) {
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
}
// Default redirect limit (10)
if len(via) >= 10 {
return http.ErrUseLastResponse
}
return nil
},
}
runtime.httpClient = client
return runtime
}
// RedirectBlockedError is returned when a redirect is blocked due to domain validation
type RedirectBlockedError struct {
Domain string
IsPrivate bool
}
func (e *RedirectBlockedError) Error() string {
if e.IsPrivate {
return "redirect blocked: private/local network access denied"
}
return "redirect blocked: domain '" + e.Domain + "' not in allowed list"
}
// isPrivateIP checks if a hostname resolves to a private/local IP address
func isPrivateIP(host string) bool {
// Block common private network patterns
// This is a simple check - for production, consider DNS resolution
privatePatterns := []string{
"localhost",
"127.",
"10.",
"172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.",
"172.24.", "172.25.", "172.26.", "172.27.",
"172.28.", "172.29.", "172.30.", "172.31.",
"192.168.",
"169.254.", // Link-local
"::1", // IPv6 localhost
"fc00:", // IPv6 private
"fe80:", // IPv6 link-local
}
hostLower := host
for _, pattern := range privatePatterns {
if hostLower == pattern || len(hostLower) > len(pattern) && hostLower[:len(pattern)] == pattern {
return true
}
}
// Also block .local domains
if len(host) > 6 && host[len(host)-6:] == ".local" {
return true
}
return false
}
// simpleCookieJar is a simple in-memory cookie jar
+63 -7
View File
@@ -8,25 +8,81 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"github.com/dop251/goja"
)
// ==================== File API (Sandboxed) ====================
// validatePath checks if the path is within the extension's data directory
// For absolute paths (from download queue), it allows them if they're valid
// List of allowed directories for file operations (set by Go backend for download operations)
var (
allowedDownloadDirs []string
allowedDownloadDirsMu sync.RWMutex
)
// SetAllowedDownloadDirs sets the list of directories where extensions can write files
// This should be called by the Go backend when setting up download paths
func SetAllowedDownloadDirs(dirs []string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
allowedDownloadDirs = dirs
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
}
// AddAllowedDownloadDir adds a directory to the allowed list
func AddAllowedDownloadDir(dir string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
absDir, err := filepath.Abs(dir)
if err == nil {
allowedDownloadDirs = append(allowedDownloadDirs, absDir)
}
}
// isPathInAllowedDirs checks if an absolute path is within any allowed directory
func isPathInAllowedDirs(absPath string) bool {
allowedDownloadDirsMu.RLock()
defer allowedDownloadDirsMu.RUnlock()
for _, allowedDir := range allowedDownloadDirs {
if strings.HasPrefix(absPath, allowedDir) {
return true
}
}
return false
}
// validatePath checks if the path is within the extension's sandbox
// Security: Absolute paths are BLOCKED unless they're in allowed download directories
// Extensions should use relative paths for their own data storage
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
// Check if extension has file permission
if !r.manifest.Permissions.File {
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
}
// Clean and resolve the path
cleanPath := filepath.Clean(path)
// If path is absolute, allow it (for download queue paths)
// This is safe because the Go backend controls what paths are passed
// SECURITY: Block absolute paths by default
// Only allow if path is in explicitly allowed download directories
if filepath.IsAbs(cleanPath) {
return cleanPath, nil
absPath, err := filepath.Abs(cleanPath)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
// Check if path is in allowed download directories
if isPathInAllowedDirs(absPath) {
return absPath, nil
}
// Block all other absolute paths
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
}
// For relative paths, join with data directory
// For relative paths, join with data directory (extension's sandbox)
fullPath := filepath.Join(r.dataDir, cleanPath)
// Resolve to absolute path
@@ -35,7 +91,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
return "", fmt.Errorf("invalid path: %w", err)
}
// Ensure path is within data directory
// Ensure path is within data directory (prevent path traversal)
absDataDir, _ := filepath.Abs(r.dataDir)
if !strings.HasPrefix(absPath, absDataDir) {
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
+6
View File
@@ -29,6 +29,12 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
}
domain := parsed.Hostname()
// Block private/local network access (SSRF protection)
if isPrivateIP(domain) {
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
}
if !r.manifest.IsDomainAllowed(domain) {
return fmt.Errorf("network access denied: domain '%s' not in allowed list", domain)
}
+110
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"path/filepath"
"testing"
"github.com/dop251/goja"
@@ -137,6 +138,9 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
File: true, // Enable file permission for test
},
},
DataDir: tempDir,
}
@@ -166,6 +170,36 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
if nestedPath == "" {
t.Error("Expected non-empty nested path")
}
// Test absolute path should be blocked (security fix)
// Use platform-appropriate absolute path
var absPath string
if filepath.IsAbs("C:\\Windows\\System32") {
absPath = "C:\\Windows\\System32\\test.txt" // Windows
} else {
absPath = "/etc/passwd" // Unix
}
_, err = runtime.validatePath(absPath)
if err == nil {
t.Error("Expected absolute path to be blocked")
}
// Test that extension without file permission is blocked
extNoFile := &LoadedExtension{
ID: "test-ext-no-file",
Manifest: &ExtensionManifest{
Name: "test-ext-no-file",
Permissions: ExtensionPermissions{
File: false, // No file permission
},
},
DataDir: tempDir,
}
runtimeNoFile := NewExtensionRuntime(extNoFile)
_, err = runtimeNoFile.validatePath("test.txt")
if err == nil {
t.Error("Expected file access to be denied without file permission")
}
}
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
@@ -217,3 +251,79 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
t.Error("Expected non-empty JSON string")
}
}
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
// Create extension with limited network permissions
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
Network: []string{"api.example.com"},
},
},
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
// Test that private IPs are blocked (SSRF protection)
privateIPs := []string{
"http://localhost/admin",
"http://127.0.0.1/admin",
"http://192.168.1.1/admin",
"http://10.0.0.1/admin",
"http://172.16.0.1/admin",
"http://169.254.169.254/latest/meta-data/", // AWS metadata
"http://router.local/admin",
}
for _, url := range privateIPs {
err := runtime.validateDomain(url)
if err == nil {
t.Errorf("Expected private IP/host '%s' to be blocked", url)
}
}
// Test that allowed public domain still works
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
}
}
func TestIsPrivateIP(t *testing.T) {
tests := []struct {
host string
expected bool
}{
// Private IPs should be blocked
{"localhost", true},
{"127.0.0.1", true},
{"127.0.0.2", true},
{"10.0.0.1", true},
{"10.255.255.255", true},
{"172.16.0.1", true},
{"172.31.255.255", true},
{"192.168.0.1", true},
{"192.168.255.255", true},
{"169.254.169.254", true}, // AWS metadata
{"router.local", true},
{"mydevice.local", true},
// Public IPs should be allowed
{"8.8.8.8", false},
{"1.1.1.1", false},
{"api.example.com", false},
{"google.com", false},
{"172.15.0.1", false}, // Just outside 172.16-31 range
{"172.32.0.1", false}, // Just outside 172.16-31 range
{"192.167.0.1", false}, // Not 192.168.x.x
}
for _, tt := range tests {
result := isPrivateIP(tt.host)
if result != tt.expected {
t.Errorf("isPrivateIP(%s) = %v, expected %v", tt.host, result, tt.expected)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
// Package gobackend provides timeout execution for extension JS code
package gobackend
import (
"context"
"fmt"
"sync"
"time"
"github.com/dop251/goja"
)
// JSExecutionError represents an error during JS execution
type JSExecutionError struct {
Message string
IsTimeout bool
}
func (e *JSExecutionError) Error() string {
return e.Message
}
// RunWithTimeout executes JavaScript code with a timeout
// Returns the result value and any error (including timeout)
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
if timeout <= 0 {
timeout = DefaultJSTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Channel to receive result
type result struct {
value goja.Value
err error
}
resultCh := make(chan result, 1)
// Track if we've interrupted
var interrupted bool
var interruptMu sync.Mutex
// Run script in goroutine
go func() {
defer func() {
if r := recover(); r != nil {
// Check if this was our interrupt
interruptMu.Lock()
wasInterrupted := interrupted
interruptMu.Unlock()
if wasInterrupted {
resultCh <- result{nil, &JSExecutionError{
Message: "execution timeout exceeded",
IsTimeout: true,
}}
} else {
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
}
}
}()
val, err := vm.RunString(script)
resultCh <- result{val, err}
}()
// Wait for result or timeout
select {
case res := <-resultCh:
return res.value, res.err
case <-ctx.Done():
// Timeout - interrupt the VM
interruptMu.Lock()
interrupted = true
interruptMu.Unlock()
vm.Interrupt("execution timeout")
// Wait a bit for the goroutine to finish
select {
case res := <-resultCh:
// If we got a result after interrupt, it might be the timeout error
if res.err != nil {
return nil, res.err
}
return nil, &JSExecutionError{
Message: "execution timeout exceeded",
IsTimeout: true,
}
case <-time.After(1 * time.Second):
// Force return timeout error
return nil, &JSExecutionError{
Message: "execution timeout exceeded (force)",
IsTimeout: true,
}
}
}
}
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
// This should be used when you want to continue using the VM after a timeout
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
result, err := RunWithTimeout(vm, script, timeout)
// Clear any interrupt state so VM can be reused
vm.ClearInterrupt()
return result, err
}
// IsTimeoutError checks if an error is a timeout error
func IsTimeoutError(err error) bool {
if jsErr, ok := err.(*JSExecutionError); ok {
return jsErr.IsTimeout
}
return false
}
+1 -1
View File
@@ -702,7 +702,7 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
Error string `json:"error"`
}
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf(errorResp.Error), duration: time.Since(reqStart)}
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("%s", errorResp.Error), duration: time.Since(reqStart)}
return
}
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.0.0-alpha.4';
static const String buildNumber = '53';
static const String version = '3.0.0-beta.1';
static const String buildNumber = '54';
static const String fullVersion = '$version+$buildNumber';
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.0.0-alpha.4+53
version: 3.0.0-beta.1+54
environment:
sdk: ^3.10.0
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.0.0-alpha.4+53
version: 3.0.0-beta.1+54
environment:
sdk: ^3.10.0