fix(security): SSRF validation, path hardening, and dependency CVE fixes (#525)

Provider SSRF prevention:
- Add validateProviderURL() blocking internal/private network targets
- Whitelist http/https schemes only (blocks file://, gopher://, etc.)
- Normalize IPv6-mapped IPv4 (::ffff:127.0.0.1) before private IP check
- Validate both api_base and base_url fields on create and update

Path hardening:
- Symlink resolution in DooD workspace resolver (resolve-then-match)
- Strict regex for media ID validation (replaces ad-hoc string checks)

Dependency CVE fixes:
- Upgrade Vite 6→8 (Rollup path traversal, Picomatch ReDoS)
This commit is contained in:
viettranx committed 2026-03-29 12:54:12 +07:00
1 parent a524b457ba
commit db41e58319
5 files changed
+410 -496

No files matched your search

+6 -2
View File
@@ -5,12 +5,16 @@ import (
"mime"
"net/http"
"path/filepath"
"strings"
"regexp"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/media"
)
// validMediaID matches safe media identifiers: alphanumeric, hyphens, underscores, dots.
// Minimum 1 char. Rejects path separators, traversal sequences, and special characters.
var validMediaID = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
// MediaServeHandler serves persisted media files by ID.
type MediaServeHandler struct {
store *media.Store
@@ -51,7 +55,7 @@ func (h *MediaServeHandler) auth(next http.HandlerFunc) http.HandlerFunc {
func (h *MediaServeHandler) handleServe(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
id := r.PathValue("id")
if id == "" || strings.Contains(id, "..") || strings.Contains(id, "/") {
if !validMediaID.MatchString(id) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "invalid media id")})
return
}
+67
View File
@@ -2,8 +2,12 @@ package http
import (
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"sync"
"github.com/google/uuid"
@@ -206,6 +210,47 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) {
}
}
// validateProviderURL rejects provider base URLs pointing to internal/private networks.
// Defense-in-depth: prevents SSRF when providers are later used for API calls.
func validateProviderURL(rawURL string) error {
if rawURL == "" {
return nil
}
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
// Only allow http/https schemes — block file://, gopher://, dict://, etc.
switch u.Scheme {
case "http", "https":
default:
return fmt.Errorf("provider URL must use http or https scheme, got %q", u.Scheme)
}
host := u.Hostname()
// Block obvious internal targets
blocked := []string{"localhost", "127.0.0.1", "::1", "0.0.0.0", "169.254.169.254", "metadata.google.internal"}
for _, b := range blocked {
if strings.EqualFold(host, b) {
return fmt.Errorf("provider URL cannot point to %s", b)
}
}
// Block private IP ranges (normalize IPv6-mapped IPv4 to catch ::ffff:127.0.0.1)
ip := net.ParseIP(host)
if ip != nil {
if v4 := ip.To4(); v4 != nil {
ip = v4
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return fmt.Errorf("provider URL cannot point to private network: %s", host)
}
}
// Block common internal hostnames
if strings.HasSuffix(host, ".internal") || strings.HasSuffix(host, ".local") {
return fmt.Errorf("provider URL cannot point to internal hostname: %s", host)
}
return nil
}
// --- Provider CRUD ---
func (h *ProvidersHandler) handleListProviders(w http.ResponseWriter, r *http.Request) {
@@ -267,6 +312,11 @@ func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.R
return
}
if err := validateProviderURL(p.APIBase); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := h.store.CreateProvider(r.Context(), &p); err != nil {
slog.Error("providers.create", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -377,6 +427,23 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R
candidate.Settings = rawSettings
}
if apiBase, ok := updates["api_base"]; ok {
if s, _ := apiBase.(string); s != "" {
if err := validateProviderURL(s); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
}
}
if baseURL, ok := updates["base_url"]; ok {
if s, _ := baseURL.(string); s != "" {
if err := validateProviderURL(s); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
}
}
if err := validateChatGPTOAuthProviderCandidate(r.Context(), h.store, id, &candidate); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
+15
View File
@@ -53,6 +53,21 @@ func resolveHostWorkspacePath(ctx context.Context, localPath string) string {
}
targetDir := filepath.Clean(localPath)
// Resolve symlinks so mount matching uses the canonical path.
// If the resolved path doesn't match any known mount, the mount-matching
// loop below will fall through to the "no matching mount" warning.
if resolved, err := filepath.EvalSymlinks(localPath); err == nil {
resolvedClean := filepath.Clean(resolved)
if resolvedClean != targetDir {
slog.Info("sandbox.resolve: symlink resolved",
"path", localPath,
"resolved", resolvedClean,
)
}
targetDir = resolvedClean
}
var bestDest string
var bestSource string
var bestRel string
+1 -1
View File
@@ -47,7 +47,7 @@
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.7.0",
"vite": "^6.0.0",
"vite": "^8.0.3",
"ws": "^8.19.0"
},
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
+321 -493
View File
File diff suppressed because it is too large. Load diff