mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 00:17:27 +00:00
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:
1 parent
a524b457ba
commit
db41e58319
5 files changed
+410
-496
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Generated
+321
-493
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user