Files
viettranx db41e58319 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)
2026-03-29 12:54:12 +07:00

79 lines
2.2 KiB
Go

package http
import (
"log/slog"
"mime"
"net/http"
"path/filepath"
"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
}
// NewMediaServeHandler creates a media serve handler.
func NewMediaServeHandler(store *media.Store) *MediaServeHandler {
return &MediaServeHandler{store: store}
}
// RegisterRoutes registers the media serve endpoint.
func (h *MediaServeHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/media/{id}", h.auth(h.handleServe))
}
func (h *MediaServeHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Priority 1: short-lived signed file token (?ft=) — decoupled from gateway token.
if ft := r.URL.Query().Get("ft"); ft != "" {
mediaID := r.PathValue("id")
if VerifyFileToken(ft, "/v1/media/"+mediaID, FileSigningKey()) {
next(w, r)
return
}
http.Error(w, "invalid or expired file token", http.StatusUnauthorized)
return
}
// Priority 2: Bearer header (API clients only).
provided := extractBearerToken(r)
authedReq, ok := requireAuthBearer("", provided, w, r)
if !ok {
return
}
next(w, authedReq)
}
}
func (h *MediaServeHandler) handleServe(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
id := r.PathValue("id")
if !validMediaID.MatchString(id) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "invalid media id")})
return
}
filePath, err := h.store.LoadPath(id)
if err != nil {
slog.Debug("media serve: not found", "id", id, "error", err)
http.NotFound(w, r)
return
}
ext := filepath.Ext(filePath)
ct := mime.TypeByExtension(ext)
if ct != "" {
w.Header().Set("Content-Type", ct)
}
w.Header().Set("Cache-Control", "private, max-age=86400")
http.ServeFile(w, r, filePath)
}