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:
2026-03-25 22:24:59 +00:00
committed by xcaliber
parent 495bcc94f4
commit c03ece4230
24 changed files with 1354 additions and 413 deletions

View File

@@ -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{}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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{

View File

@@ -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:

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 = ?`,