mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 00:14:33 +00:00
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)
79 lines
2.2 KiB
Go
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)
|
|
}
|