Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,7 @@ Remember: You're Sentinel, the guardian of switchAILocal. Security is not option
**Vulnerability:** The CLI executor proxy lacked scheme validation for the user-controlled `remoteHost`, creating an SSRF vulnerability. The OAuth callback forwarder lacked prefix validation for `targetBase`, creating an open redirect vulnerability.
**Learning:** Taint analysis revealed that input passed from management configurations or remote host specifications must be strictly validated before being used in HTTP requests or redirect targets to prevent protocol abuse and unauthorized redirects.
**Prevention:** Always enforce strict protocol validation (e.g., scheme is 'http' or 'https') for outbound requests and validate redirect targets against allowed prefixes (e.g., 'http://localhost' or '/').
## 2026-11-11 - Fix SSRF via taint analysis in CLI Executor
**Vulnerability:** A Server-Side Request Forgery (SSRF) vulnerability existed in `/app/internal/runtime/executor/cli_executor.go`. When constructing the `/run` endpoint URL for a remote host, the code copied a parsed URL by value (`targetURL := *parsedURL`) and manually cleared fields like `RawPath` and `Fragment`. If an attacker injected a URL with an `Opaque` field (e.g., `http://example.com;opaque=//malicious.com`), the `.String()` method would prioritize `Opaque` over the `Path`, potentially bypassing path constraints and redirecting the request to an unintended server.
**Learning:** Copying parsed `url.URL` structures by value is risky because the `url.URL.String()` function relies on multiple internal fields (like `Opaque`) that can override the standard components (like `Path`).
**Prevention:** Always construct a new `url.URL{}` struct literal explicitly and only copy the known, safe fields (e.g., `Scheme`, `Host`, `Path`, `RawQuery`, `User`) from the untrusted input to ensure predictable URL serialization.
6 changes: 3 additions & 3 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1047,18 +1047,18 @@ func doMemoryReset(cfg *config.Config, opts *MemoryOptions) {
// Create automatic backup before reset
timestamp := time.Now().Format("20060102-150405")
backupPath := fmt.Sprintf("memory-backup-before-reset-%s.tar.gz", timestamp)

fmt.Println("Creating automatic backup before reset...")
fmt.Printf("Backup file: %s\n", backupPath)

// Create backup
if err := createMemoryBackup(cfg, backupPath); err != nil {
fmt.Fprintf(os.Stderr, "\n❌ Error: Failed to create backup: %v\n", err)
fmt.Fprintf(os.Stderr, "Reset cancelled for safety. Your data is preserved.\n")
fmt.Fprintf(os.Stderr, "\nπŸ’‘ Tip: Ensure you have write permissions and sufficient disk space.\n")
os.Exit(1)
}

fmt.Printf("βœ“ Backup created successfully: %s\n\n", backupPath)
fmt.Println("Proceeding with reset...")

Expand Down
8 changes: 4 additions & 4 deletions internal/access/config_access/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.
if p == nil {
return nil, sdkaccess.ErrNotHandled
}

authHeader := r.Header.Get("Authorization")
authHeaderGoogle := r.Header.Get("X-Goog-Api-Key")
authHeaderAnthropic := r.Header.Get("X-Api-Key")
Expand Down Expand Up @@ -96,7 +96,7 @@ func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.
return &sdkaccess.Result{
Provider: p.Identifier(),
Principal: candidate.value,
Metadata: map[string]string{"source": candidate.source},
Metadata: map[string]string{"source": candidate.source},
}, nil
}
}
Expand All @@ -108,13 +108,13 @@ func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.
return &sdkaccess.Result{
Provider: p.Identifier(),
Principal: firstCandidate,
Metadata: map[string]string{"source": firstSource},
Metadata: map[string]string{"source": firstSource},
}, nil
}
return &sdkaccess.Result{
Provider: p.Identifier(),
Principal: "anonymous",
Metadata: map[string]string{"source": "none"},
Metadata: map[string]string{"source": "none"},
}, nil
}

Expand Down
4 changes: 2 additions & 2 deletions internal/access/config_access/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package configaccess
import (
"context"
"net/http"
"testing"
"net/url"
"testing"

sdkaccess "github.com/traylinx/switchAILocal/sdk/access"
sdkconfig "github.com/traylinx/switchAILocal/sdk/config"
Expand Down Expand Up @@ -109,7 +109,7 @@ func TestProvider_Authenticate(t *testing.T) {
for k, v := range tt.headers {
req.Header.Set(k, v)
}

// Add queryparams
q := req.URL.Query()
for k, v := range tt.queryParams {
Expand Down
52 changes: 26 additions & 26 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,10 +397,10 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
var autoResolver *autoroute.AutoResolver
if cfg.AutoRouting.Enabled {
log.Info("Initializing Intelligent Auto-Routing System")

// 1. Setup Discovery Service
discoverySvc := autoroute.NewDiscoveryService(cfg.AutoRouting.Discovery)

// 2. Register probers based on configured credentials
// CLI Probers
discoverySvc.RegisterProber(probes.NewCLIProber("geminicli", "gemini"))
Expand All @@ -412,16 +412,16 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
name := fmt.Sprintf("gemini-api-%d", i)
endpoint := "/v1beta/models"
if key.ModelsURL != "" {
endpoint = key.ModelsURL
endpoint = key.ModelsURL
}
discoverySvc.RegisterProber(probes.NewAPIProber(name, key.BaseURL, endpoint, key.APIKey, "Authorization"))
}

for i, key := range cfg.ClaudeKey {
name := fmt.Sprintf("claude-api-%d", i)
endpoint := "/v1/models"
if key.ModelsURL != "" {
endpoint = key.ModelsURL
endpoint = key.ModelsURL
}
discoverySvc.RegisterProber(probes.NewAPIProber(name, key.BaseURL, endpoint, key.APIKey, "x-api-key"))
}
Expand All @@ -437,47 +437,47 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
// 3. Run Boot-Time Discovery
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

var candidates []autoroute.CandidateInput
if cfg.AutoRouting.Discovery.ProbeOnStartup {
log.Info("Running boot-time provider discovery")
results := discoverySvc.DiscoverAll(ctx)

// 4. Convert discovery results to CandidateInputs
for provider, res := range results {
if !res.Available {
log.WithField("provider", provider).Warn("Provider unavailable during discovery")
continue
}

for _, model := range res.Models {
candidates = append(candidates, autoroute.CandidateInput{
Provider: provider,
Model: model.ID,
Available: true,
Latency: res.Latency,
SuccessRate: 1.0,
QuotaHealth: 1.0,
})
candidates = append(candidates, autoroute.CandidateInput{
Provider: provider,
Model: model.ID,
Available: true,
Latency: res.Latency,
SuccessRate: 1.0,
QuotaHealth: 1.0,
})
}

log.WithFields(log.Fields{
"provider": provider,
"models": len(res.Models),
"latency": res.Latency.Milliseconds(),
}).Info("Discovered provider models")
}
} else {
// If probing disabled on boot, just populate basic fallback providers
// If probing disabled on boot, just populate basic fallback providers
for _, p := range []string{"geminicli", "claudecli", "codex", "ollama"} {
candidates = append(candidates, autoroute.CandidateInput{
Provider: p,
Model: "auto",
Available: true,
Latency: 0,
candidates = append(candidates, autoroute.CandidateInput{
Provider: p,
Model: "auto",
Available: true,
Latency: 0,
SuccessRate: 1.0,
QuotaHealth: 1.0,
})
})
}
}

Expand Down Expand Up @@ -595,7 +595,7 @@ func (s *Server) setupRoutes() {
if s.cfg != nil {
observability.RegisterMetricsRoute(s.engine, s.cfg.Observability.Metrics.Enabled, s.cfg.Observability.Metrics.Path)
}

s.engine.GET("/management", s.serveManagementControlPanel)
s.engine.GET("/management.html", s.serveManagementControlPanel)
openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers)
Expand Down Expand Up @@ -1454,7 +1454,7 @@ func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc {
if len(result.Metadata) > 0 {
c.Set("accessMetadata", result.Metadata)
}

hasher := sha256.New()
hasher.Write([]byte(result.Principal))
c.Set("api_key_hash", "sha256:"+hex.EncodeToString(hasher.Sum(nil)))
Expand Down
4 changes: 2 additions & 2 deletions internal/api/server_coordinator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ func TestCreatePipelineIntegratorFromCoordinator_ValidCoordinator(t *testing.T)
integrator := createPipelineIntegratorFromCoordinator(mockCoordinator)

assert.NotNil(t, integrator)

// Verify the integrator can be used
model, messages, err := integrator.ApplySteering(nil, []map[string]string{})
assert.NoError(t, err)
Expand Down Expand Up @@ -244,7 +244,7 @@ func TestCreateEventBusIntegratorFromCoordinator_ValidCoordinator(t *testing.T)
integrator := createEventBusIntegratorFromCoordinator(mockCoordinator)

assert.NotNil(t, integrator)

// Verify the integrator can be used
err := integrator.ConnectHeartbeatEvents()
assert.NoError(t, err)
Expand Down
Loading
Loading