Files
goclaw/internal/http/files.go
T
Luan Vu a7f5acc1e3 fix(security): harden SQL injection, MCP prompt injection, sandbox fallback, and file serving (#246)
- execMapUpdate: validate column names with strict regex to prevent SQL injection
- HTTP update handlers: add field allowlists (agents, providers, custom_tools, mcp, channel_instances)
- pqStringArray: properly escape array elements to prevent PostgreSQL array literal injection
- scanStringArray: handle quoted elements in PostgreSQL array format
- MCP bridge: wrap tool results as external/untrusted content to prevent prompt injection
- File serving: block access to sensitive system directories (/etc, /proc, /sys, etc.)
- Sandbox: fail closed when Docker unavailable instead of silent fallback to host
- Shell deny: fix base64 --decode bypass, add host exec 1MB output limit
- ILIKE queries: escape % and _ wildcards in knowledge_graph, custom_tools, channel_instances

Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
2026-03-18 07:42:38 +07:00

95 lines
2.5 KiB
Go

package http
import (
"log/slog"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
)
// FilesHandler serves files over HTTP with Bearer token auth.
// Accepts absolute paths — the auth token protects against unauthorized access.
type FilesHandler struct {
token string
}
// NewFilesHandler creates a handler that serves files by absolute path.
func NewFilesHandler(token string) *FilesHandler {
return &FilesHandler{token: token}
}
// RegisterRoutes registers the file serving route.
func (h *FilesHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/files/{path...}", h.auth(h.handleServe))
}
func (h *FilesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Accept token via Bearer header or ?token= query param (for <img src>).
provided := extractBearerToken(r)
if provided == "" {
provided = r.URL.Query().Get("token")
}
if !requireAuthBearer(h.token, "", provided, w, r) {
return
}
next(w, r)
}
}
// deniedFilePrefixes blocks access to sensitive system directories.
// Defense-in-depth: the auth token is the primary barrier, but restricting
// known-sensitive paths limits damage if a token leaks.
var deniedFilePrefixes = []string{
"/etc/", "/proc/", "/sys/", "/dev/",
"/root/", "/boot/", "/run/",
"/var/run/", "/var/log/",
}
func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
urlPath := r.PathValue("path")
if urlPath == "" {
http.Error(w, i18n.T(locale, i18n.MsgRequired, "path"), http.StatusBadRequest)
return
}
// Prevent path traversal
if strings.Contains(urlPath, "..") {
slog.Warn("security.files_traversal", "path", urlPath)
http.Error(w, i18n.T(locale, i18n.MsgInvalidPath), http.StatusBadRequest)
return
}
// URL path is the absolute path with leading "/" stripped (e.g. "app/.goclaw/workspace/file.png")
absPath := filepath.Clean("/" + urlPath)
// Block access to sensitive system directories
for _, prefix := range deniedFilePrefixes {
if strings.HasPrefix(absPath, prefix) {
slog.Warn("security.files_denied_path", "path", absPath)
http.Error(w, i18n.T(locale, i18n.MsgInvalidPath), http.StatusForbidden)
return
}
}
info, err := os.Stat(absPath)
if err != nil || info.IsDir() {
http.NotFound(w, r)
return
}
// Set Content-Type from extension
ext := filepath.Ext(absPath)
ct := mime.TypeByExtension(ext)
if ct != "" {
w.Header().Set("Content-Type", ct)
}
http.ServeFile(w, r, absPath)
}