V0.38.5 git board rewrite (#238)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -63,13 +63,28 @@ func (r *ConnectionResolverAdapter) decryptInPlace(ctx context.Context, conn *mo
|
||||
if len(enc) == 0 {
|
||||
continue
|
||||
}
|
||||
plaintext, err := r.vault.Decrypt(enc, nonce, conn.Scope, conn.OwnerID)
|
||||
if err == nil {
|
||||
plaintext := r.safeDecrypt(enc, nonce, conn.Scope, conn.OwnerID)
|
||||
if plaintext != "" {
|
||||
conn.Config[k] = plaintext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// safeDecrypt wraps vault.Decrypt to recover from GCM panics caused by
|
||||
// invalid nonce lengths (e.g. stale vault keys after container restart).
|
||||
func (r *ConnectionResolverAdapter) safeDecrypt(enc, nonce []byte, scope, ownerID string) (plaintext string) {
|
||||
defer func() {
|
||||
if rv := recover(); rv != nil {
|
||||
plaintext = ""
|
||||
}
|
||||
}()
|
||||
result, err := r.vault.Decrypt(enc, nonce, scope, ownerID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// lookupSecretFields reads the package manifest to identify secret fields.
|
||||
func (r *ConnectionResolverAdapter) lookupSecretFields(ctx context.Context, packageID, connType string) map[string]bool {
|
||||
result := map[string]bool{}
|
||||
|
||||
@@ -4,6 +4,7 @@ package handlers
|
||||
// Pattern follows apiconfigs.go (ProviderConfigHandler).
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
@@ -27,7 +28,9 @@ func NewConnectionHandler(s store.Stores, vault *crypto.KeyResolver) *Connection
|
||||
// ListConnections returns the user's personal connections.
|
||||
func (h *ConnectionHandler) ListConnections(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
conns, err := h.stores.Connections.ListForUser(c.Request.Context(), userID)
|
||||
// v0.38.5: Show all accessible connections (personal + team + global)
|
||||
// so users can see which connections are available to them.
|
||||
conns, err := h.stores.Connections.ListAccessible(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"})
|
||||
return
|
||||
@@ -252,13 +255,23 @@ func (h *ConnectionHandler) decryptSecrets(config models.JSONMap, secretFields m
|
||||
}
|
||||
|
||||
// toBytes coerces an interface{} to []byte. Handles both []byte (from Go)
|
||||
// and string (from JSON unmarshal of base64).
|
||||
// and string (from JSON unmarshal — base64 encoded by json.Marshal for []byte).
|
||||
func toBytes(v interface{}) []byte {
|
||||
switch t := v.(type) {
|
||||
case []byte:
|
||||
return t
|
||||
case string:
|
||||
return []byte(t)
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
// JSON round-trip: json.Marshal encodes []byte as base64 strings.
|
||||
// Decode back to the original bytes.
|
||||
decoded, err := base64.StdEncoding.DecodeString(t)
|
||||
if err != nil {
|
||||
// Fallback: might be raw string (shouldn't happen, but be safe)
|
||||
return []byte(t)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -100,7 +100,12 @@ func (h *ExtAPIHandler) Handle(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ── 3. Check api.http permission ───────────
|
||||
// ── 3. Check permissions ──────────────────
|
||||
// The package needs at least one granted permission to be active.
|
||||
// api.http is NOT required here — a consumer package may delegate
|
||||
// HTTP to a library via lib.require(). The sandbox wires the http
|
||||
// module only when api.http is granted; without it the script simply
|
||||
// cannot call http.get() directly, which is correct for consumers.
|
||||
if h.stores.ExtPermissions == nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "permission system unavailable"})
|
||||
return
|
||||
@@ -110,8 +115,8 @@ func (h *ExtAPIHandler) Handle(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "permission check failed"})
|
||||
return
|
||||
}
|
||||
if !hasPermission(granted, models.ExtPermAPIHTTP) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "extension does not have api.http permission"})
|
||||
if len(granted) == 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "extension has no granted permissions"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -469,6 +469,11 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// v0.38.5: Declare manifest permissions (same as AdminInstallExtension).
|
||||
// This creates the permission rows and sets status to pending_review
|
||||
// if the package declares permissions.
|
||||
SyncManifestPermissions(c, h.stores, pkgID, manifest)
|
||||
|
||||
// v0.30.0: Run schema migrations if declared.
|
||||
newSchemaVersion := ParseSchemaVersion(manifest)
|
||||
if newSchemaVersion > 0 && h.sandbox != nil {
|
||||
|
||||
@@ -416,6 +416,11 @@ func main() {
|
||||
if cfg.StoragePath != "" {
|
||||
starlarkRunner.SetPackagesDir(cfg.StoragePath + "/packages")
|
||||
}
|
||||
// v0.38.5: allow extensions to reach private IPs (self-hosted Gitea, etc.)
|
||||
if os.Getenv("EXT_ALLOW_PRIVATE_IPS") == "true" {
|
||||
starlarkRunner.SetAllowPrivateIPs(true)
|
||||
log.Printf(" ⚠️ Extension SSRF protection relaxed: private IPs allowed")
|
||||
}
|
||||
|
||||
// Discover and register active Starlark pre-completion filters
|
||||
filters.DiscoverStarlarkFilters(context.Background(), filterChain, stores, starlarkRunner)
|
||||
|
||||
@@ -63,6 +63,12 @@ type HTTPModuleConfig struct {
|
||||
// Ignored when AllowDomains is non-empty.
|
||||
BlockDomains []string
|
||||
|
||||
// AllowPrivateIPs disables the SSRF check that blocks connections to
|
||||
// private/loopback/link-local addresses. Use for self-hosted deployments
|
||||
// where extensions need to reach internal services.
|
||||
// Default: false (private IPs blocked).
|
||||
AllowPrivateIPs bool
|
||||
|
||||
// Timeout overrides the default 10 s request timeout.
|
||||
// Zero means use default.
|
||||
Timeout time.Duration
|
||||
@@ -345,11 +351,13 @@ func (ac *accessControl) checkDomain(host string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// client builds an SSRF-safe http.Client with IP validation on connect.
|
||||
// client builds an HTTP client with optional SSRF IP validation on connect.
|
||||
func (ac *accessControl) client() *http.Client {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
Control: ssrfControl,
|
||||
}
|
||||
if !ac.cfg.AllowPrivateIPs {
|
||||
dialer.Control = ssrfControl
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
|
||||
@@ -31,6 +31,8 @@ import (
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
starlarkjson "go.starlark.net/lib/json"
|
||||
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/store"
|
||||
)
|
||||
@@ -49,14 +51,15 @@ type RunContext struct {
|
||||
|
||||
// Runner executes Starlark package scripts with permission-gated modules.
|
||||
type Runner struct {
|
||||
sandbox *Sandbox
|
||||
stores store.Stores
|
||||
packagesDir string // v0.38.0: disk path for load() support
|
||||
notifier NotificationSender // nil = notifications module unavailable
|
||||
resolver ProviderResolver // nil = provider module unavailable
|
||||
connResolver ConnectionResolver // nil = connections module unavailable (v0.38.1)
|
||||
db *sql.DB // nil = db module unavailable
|
||||
dbPostgres bool // true = use $N placeholders; false = use ?
|
||||
sandbox *Sandbox
|
||||
stores store.Stores
|
||||
packagesDir string // v0.38.0: disk path for load() support
|
||||
notifier NotificationSender // nil = notifications module unavailable
|
||||
resolver ProviderResolver // nil = provider module unavailable
|
||||
connResolver ConnectionResolver // nil = connections module unavailable (v0.38.1)
|
||||
db *sql.DB // nil = db module unavailable
|
||||
dbPostgres bool // true = use $N placeholders; false = use ?
|
||||
allowPrivateIPs bool // v0.38.5: disable SSRF private IP check
|
||||
}
|
||||
|
||||
// NewRunner creates a runner with the given sandbox and dependencies.
|
||||
@@ -97,6 +100,13 @@ func (r *Runner) SetPackagesDir(dir string) {
|
||||
r.packagesDir = dir
|
||||
}
|
||||
|
||||
// SetAllowPrivateIPs disables the SSRF check that blocks connections to
|
||||
// private/loopback IPs. For self-hosted environments where extensions
|
||||
// reach internal services. Controlled by EXT_ALLOW_PRIVATE_IPS env var.
|
||||
func (r *Runner) SetAllowPrivateIPs(allow bool) {
|
||||
r.allowPrivateIPs = allow
|
||||
}
|
||||
|
||||
// ExecPackage loads a package's script from disk (primary) or manifest
|
||||
// (legacy) and executes it with modules gated by granted permissions.
|
||||
//
|
||||
@@ -216,10 +226,13 @@ func (r *Runner) packageLoader(pkgID string, modules map[string]starlark.Value)
|
||||
}
|
||||
|
||||
// Build predeclared with same modules as entry point
|
||||
predeclared := make(starlark.StringDict, len(modules)+1)
|
||||
predeclared := make(starlark.StringDict, len(modules)+2)
|
||||
for k, v := range modules {
|
||||
predeclared[k] = v
|
||||
}
|
||||
// json is always available (added by ExecWithLoader for entry point;
|
||||
// sub-modules need it too)
|
||||
predeclared["json"] = starlarkjson.Module
|
||||
|
||||
globals, err := starlark.ExecFile(thread, module, string(data), predeclared)
|
||||
if err != nil {
|
||||
@@ -299,6 +312,7 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
|
||||
|
||||
case models.ExtPermAPIHTTP:
|
||||
httpCfg := ParseNetworkAccess(manifest)
|
||||
httpCfg.AllowPrivateIPs = r.allowPrivateIPs
|
||||
modules["http"] = BuildHTTPModule(ctx, httpCfg)
|
||||
|
||||
case models.ExtPermProviderComplete:
|
||||
|
||||
@@ -1392,6 +1392,10 @@ type ConnectionStore interface {
|
||||
// to the user (personal + team + global).
|
||||
ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error)
|
||||
|
||||
// ListAccessible returns all connections accessible to the user across
|
||||
// all types: personal + team memberships + global.
|
||||
ListAccessible(ctx context.Context, userID string) ([]models.ExtConnection, error)
|
||||
|
||||
// DeleteByIDAndScope deletes a connection only if it matches the given
|
||||
// scope and owner. Returns rows affected (0 or 1).
|
||||
DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error)
|
||||
|
||||
@@ -117,6 +117,26 @@ func (s *ConnectionStore) ResolveAll(ctx context.Context, userID, connType strin
|
||||
return scanConnections(rows)
|
||||
}
|
||||
|
||||
// ListAccessible returns all connections accessible to the user across all types.
|
||||
func (s *ConnectionStore) ListAccessible(ctx context.Context, userID string) ([]models.ExtConnection, error) {
|
||||
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT %s FROM ext_connections
|
||||
WHERE is_active = true
|
||||
AND (
|
||||
(scope = 'personal' AND owner_id = $1)
|
||||
OR (scope = 'team' AND owner_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = $1
|
||||
))
|
||||
OR (scope = 'global')
|
||||
)
|
||||
ORDER BY type ASC, scope ASC, name ASC`, connCols), userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanConnections(rows)
|
||||
}
|
||||
|
||||
// DeleteByIDAndScope deletes a connection only if it matches the scope/owner.
|
||||
func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx,
|
||||
|
||||
@@ -122,6 +122,25 @@ func (s *ConnectionStore) ResolveAll(ctx context.Context, userID, connType strin
|
||||
return scanConnections(rows)
|
||||
}
|
||||
|
||||
func (s *ConnectionStore) ListAccessible(ctx context.Context, userID string) ([]models.ExtConnection, error) {
|
||||
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT %s FROM ext_connections
|
||||
WHERE is_active = 1
|
||||
AND (
|
||||
(scope = 'personal' AND owner_id = ?)
|
||||
OR (scope = 'team' AND owner_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = ?
|
||||
))
|
||||
OR (scope = 'global')
|
||||
)
|
||||
ORDER BY type ASC, scope ASC, name ASC`, connCols), userID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanConnections(rows)
|
||||
}
|
||||
|
||||
func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM ext_connections WHERE id = ? AND scope = ? AND owner_id = ?`,
|
||||
|
||||
Reference in New Issue
Block a user