Files
43837afca3 fix(security): consolidate & enhance batched security fixes (#1155, #967, #972, #974, #989, #973) (#1185)
* fix(sandbox): avoid shell in FsBridge writes

Replace sh -c with interpolated path by shell-free 'tee -- <path>' argv form,
piping content via stdin. Prevents command injection through filenames
containing shell metacharacters inside the sandbox container.

Co-authored-by: evgyur <evgyur@gmail.com>

* fix(security): fail-closed on pairing DB errors across channels

On IsPaired lookup error, deny instead of granting access. Covers the shared
CheckDMPolicy/CheckGroupPolicy helpers (Slack/Discord/Feishu/WhatsApp/Zalo) and
the four inline Telegram pairing checks.

Co-authored-by: Srini <srinis.k@gmail.com>

* fix(security): harden provider URL validation against SSRF

Enforce scheme check for all provider types; restrict local types (ollama,
claude_cli, acp) to an explicit localhost allowlist instead of skipping checks;
resolve remote hostnames and reject any IP in a private/reserved range via the
shared security.IsBlocked CIDR list (covers loopback, link-local, metadata,
multicast, and unspecified 0.0.0.0/::). Closes the wildcard-DNS bypass and the
local-type escape hatch. Operator opt-in via GOCLAW_ALLOW_PRIVATE_PROVIDER_URLS.

Exports security.IsBlocked as the single source of truth for blocked ranges.

Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>

* feat(pipeline): add fail-closed tool call authorization gate

Gate tool execution against the server-side AllowedTools allowlist built from the
RBAC/tenant-aware filtered tool set. Resolve the tool-call prefix before the
allowlist lookup so prefixed agents are not wrongly blocked, re-check deny on lazy
MCP activation, and expand IsDenied to cover aliased tool names.

Co-authored-by: Huy Doan <tui@pm.me>

* fix(security): expand file-serve deny-list defense-in-depth

Add absolute-path deny prefixes (/home, /Users, /srv, /var/lib, /var/www, /opt)
and an explicit fail-closed log when no file-serving boundary is configured.

Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>

* fix(providers): allow claude cli executable paths

Refs: #1185

---------

Co-authored-by: evgyur <evgyur@gmail.com>
Co-authored-by: Srini <srinis.k@gmail.com>
Co-authored-by: Linh Vo Van <linh.vo@e-cq.net>
Co-authored-by: Huy Doan <tui@pm.me>
2026-06-05 00:48:38 +07:00

430 lines
13 KiB
Go

package http
import (
"encoding/json"
"fmt"
"log/slog"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// FilesHandler serves files over HTTP with Bearer token auth.
// Accepts absolute paths — the auth token protects against unauthorized access.
// When an exact path is not found, falls back to searching the workspace for
// generated files by basename (media filenames include timestamps and are globally unique).
type FilesHandler struct {
workspace string // workspace root for fallback file search
dataDir string // data directory root for tenant path validation
}
var filesAfterOpenHookForTest func(string)
// NewFilesHandler creates a handler that serves files by absolute path.
// workspace is the root directory used for fallback generated file search.
// dataDir is used for tenant path validation (files must be within tenant's dirs).
func NewFilesHandler(workspace, dataDir string) *FilesHandler {
return &FilesHandler{workspace: workspace, dataDir: dataDir}
}
// RegisterRoutes registers the file serving route.
func (h *FilesHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/files/{path...}", h.auth(h.handleServe))
mux.HandleFunc("POST /v1/files/sign", h.handleSign)
}
// handleSign accepts a JSON body with a "path" field (absolute file path),
// returns a signed /v1/files/ URL with ?ft= token. Requires Bearer auth.
func (h *FilesHandler) handleSign(w http.ResponseWriter, r *http.Request) {
provided := extractBearerToken(r)
authedReq, ok := requireAuthBearer("", provided, w, r)
if !ok {
return
}
var body struct {
Path string `json:"path"`
}
if err := json.NewDecoder(authedReq.Body).Decode(&body); err != nil || body.Path == "" {
http.Error(w, `{"error":"path required"}`, http.StatusBadRequest)
return
}
absPath := absoluteFilePath(body.Path)
file, _, _, ok := h.openValidatedFile(authedReq, absPath, false)
if !ok {
http.Error(w, `{"error":"path outside allowed directories"}`, http.StatusForbidden)
return
}
_ = file.Close()
urlPath := fileURLPath(absPath)
ft := SignFileToken(urlPath, FileSigningKey(), FileTokenTTL)
writeJSON(w, http.StatusOK, map[string]string{
"url": urlPath + "?ft=" + ft,
})
}
func (h *FilesHandler) 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 != "" {
path := "/v1/files/" + r.PathValue("path")
if VerifyFileToken(ft, path, 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)
}
}
// deniedFilePrefixes blocks access to sensitive system directories.
// Defense-in-depth: the auth token is the primary barrier, but restricting
// known-sensitive absolute paths limits damage if a token leaks or a root is
// misconfigured. All entries must be absolute paths because hasDeniedFilePrefix
// uses pathWithinDir semantics — relative-rooted entries (e.g. "/.ssh") would
// only block the literal filesystem subtree at that root, not user home dirs.
var deniedFilePrefixes = []string{
"/etc/", "/proc/", "/sys/", "/dev/",
"/root/", "/boot/", "/run/",
"/var/run/", "/var/log/", "/var/lib/", "/var/www/",
"/home/", "/Users/",
"/opt/", "/srv/",
}
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
}
absPath := absoluteFilePath(urlPath)
// Block access to sensitive system directories
if hasDeniedFilePrefix(absPath) {
slog.Warn("security.files_denied_path", "path", absPath)
http.Error(w, i18n.T(locale, i18n.MsgInvalidPath), http.StatusForbidden)
return
}
signed := r.URL.Query().Get("ft") != ""
// Fail-closed observability: if no boundary roots are configured at all,
// lexicallyAllowsFilePath already returns false, but log explicitly so
// operators can detect misconfigured deployments.
if h.workspace == "" && h.dataDir == "" {
slog.Warn("security.files_no_boundary", "path", absPath)
http.NotFound(w, r)
return
}
if !h.lexicallyAllowsFilePath(r, absPath, signed) {
slog.Warn("security.files_path_denied", "path", absPath, "workspace", h.workspace, "data_dir", h.dataDir)
http.NotFound(w, r)
return
}
info, err := os.Stat(absPath)
if err != nil && !os.IsNotExist(err) {
http.NotFound(w, r)
return
}
// Fuzzy match: generated files have timestamp suffixes (e.g. "file_20260326-232559_269000.png")
// but LLM may reference them without timestamp (e.g. "file.png"). Try prefix match in same dir.
if err != nil {
if resolved := fuzzyMatchInDir(absPath); resolved != "" {
absPath = resolved
info, err = os.Stat(absPath)
}
}
if err != nil || info.IsDir() {
// For ft= signed requests, the path is cryptographically bound — no fallback search.
// Searching the global workspace could cross tenant boundaries if a same-basename
// file exists in another tenant's directory.
if signed {
http.NotFound(w, r)
return
}
// Fallback: search workspace for file by basename (handles LLM-hallucinated paths).
// Generated media filenames include timestamps and are globally unique.
// Scoped to tenant workspace (bearer auth always has tenant context).
ws := h.tenantWorkspace(r)
if resolved := h.findInWorkspace(ws, filepath.Base(absPath)); resolved != "" {
absPath = resolved
info, _ = os.Stat(absPath)
} else {
http.NotFound(w, r)
return
}
}
file, realPath, fileInfo, ok := h.openValidatedFile(r, absPath, signed)
if !ok {
http.NotFound(w, r)
return
}
defer file.Close()
absPath = realPath
// Set Content-Type from extension
ext := filepath.Ext(absPath)
ct := mime.TypeByExtension(ext)
if ct != "" {
w.Header().Set("Content-Type", ct)
}
// Trigger browser download with original filename when ?download=true
if r.URL.Query().Get("download") == "true" {
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(absPath)))
}
http.ServeContent(w, r, filepath.Base(absPath), fileInfo.ModTime(), file)
}
func absoluteFilePath(path string) string {
absPath := filepath.Clean(path)
if filepath.IsAbs(absPath) {
return absPath
}
// Windows drive letter path (e.g. "C:\...") is absolute to this handler.
if len(absPath) >= 2 && absPath[1] == ':' {
return absPath
}
return filepath.Clean(string(filepath.Separator) + absPath)
}
func fileURLPath(absPath string) string {
return "/v1/files/" + strings.TrimPrefix(filepath.Clean(absPath), string(filepath.Separator))
}
func hasDeniedFilePrefix(path string) bool {
cleaned := filepath.Clean(path)
for _, prefix := range deniedFilePrefixes {
root := filepath.Clean(prefix)
if pathWithinDir(cleaned, root) {
return true
}
}
return false
}
func configuredFileRoot(root string) string {
if root == "" {
return ""
}
return filepath.Clean(root)
}
func canonicalFileRoots(roots []string) []string {
out := make([]string, 0, len(roots))
for _, root := range roots {
if root = configuredFileRoot(root); root != "" {
out = append(out, evalSymlinkOrClean(root))
}
}
return out
}
func (h *FilesHandler) requestFileRoots(r *http.Request, signed bool, absPath string) []string {
if signed {
return []string{
inferredScopedFileRoot(h.workspace, absPath),
inferredScopedFileRoot(h.dataDir, absPath),
}
}
if edition.Current().RBACEnabled {
return []string{
config.TenantWorkspace(h.workspace, store.TenantIDFromContext(r.Context()), store.TenantSlugFromContext(r.Context())),
config.TenantDataDir(h.dataDir, store.TenantIDFromContext(r.Context()), store.TenantSlugFromContext(r.Context())),
}
}
return []string{h.workspace, h.dataDir}
}
func inferredScopedFileRoot(base, absPath string) string {
base = configuredFileRoot(base)
if base == "" || !pathWithinDir(filepath.Clean(absPath), base) {
return ""
}
tenantsRoot := filepath.Join(base, "tenants")
if !pathWithinDir(filepath.Clean(absPath), tenantsRoot) || filepath.Clean(absPath) == tenantsRoot {
return base
}
rel, err := filepath.Rel(tenantsRoot, filepath.Clean(absPath))
if err != nil {
return ""
}
first, _, _ := strings.Cut(rel, string(filepath.Separator))
if first == "" || first == "." || first == ".." {
return ""
}
return filepath.Join(tenantsRoot, first)
}
func filePathWithinAnyRoot(path string, roots []string) bool {
for _, root := range roots {
if root != "" && pathWithinDir(filepath.Clean(path), filepath.Clean(root)) {
return true
}
}
return false
}
func (h *FilesHandler) lexicallyAllowsFilePath(r *http.Request, absPath string, signed bool) bool {
return filePathWithinAnyRoot(absPath, h.requestFileRoots(r, signed, absPath))
}
func (h *FilesHandler) openValidatedFile(r *http.Request, absPath string, signed bool) (*os.File, string, os.FileInfo, bool) {
file, err := os.Open(absPath)
if err != nil {
return nil, "", nil, false
}
if filesAfterOpenHookForTest != nil {
filesAfterOpenHookForTest(absPath)
}
realPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
slog.Warn("security.files_path_unresolved", "path", absPath, "error", err)
_ = file.Close()
return nil, "", nil, false
}
realPath = filepath.Clean(realPath)
if hasDeniedFilePrefix(realPath) {
slog.Warn("security.files_realpath_denied", "path", absPath, "resolved", realPath)
_ = file.Close()
return nil, "", nil, false
}
roots := canonicalFileRoots(h.requestFileRoots(r, signed, absPath))
if !filePathWithinAnyRoot(realPath, roots) {
slog.Warn("security.files_realpath_escape", "path", absPath, "resolved", realPath, "roots", roots)
_ = file.Close()
return nil, "", nil, false
}
realInfo, err := os.Stat(realPath)
if err != nil {
_ = file.Close()
return nil, "", nil, false
}
fileInfo, err := file.Stat()
if err != nil {
_ = file.Close()
slog.Warn("security.files_open_race", "path", realPath, "error", err)
return nil, "", nil, false
}
if fileInfo.IsDir() || realInfo.IsDir() || !os.SameFile(realInfo, fileInfo) {
_ = file.Close()
slog.Warn("security.files_open_race", "path", realPath)
return nil, "", nil, false
}
return file, realPath, fileInfo, true
}
// tenantWorkspace resolves the workspace scoped to the requesting tenant.
func (h *FilesHandler) tenantWorkspace(r *http.Request) string {
tid := store.TenantIDFromContext(r.Context())
slug := store.TenantSlugFromContext(r.Context())
return config.TenantWorkspace(h.workspace, tid, slug)
}
// findInWorkspace searches the workspace directory tree for a file by basename.
// Returns the absolute path if found, empty string otherwise.
// Searches team directories including generated/ and system/ subdirs.
func (h *FilesHandler) findInWorkspace(workspace, basename string) string {
if workspace == "" || basename == "" {
return ""
}
var found string
_ = filepath.WalkDir(workspace, func(path string, d os.DirEntry, err error) error {
if err != nil {
return filepath.SkipDir
}
if d.IsDir() {
name := d.Name()
// Allow workspace root
if path == workspace {
return nil
}
// Allow direct children of workspace root (agent workspace dirs like "quill", "goclaw")
if filepath.Dir(path) == workspace {
return nil
}
// Allow known directory structures
if name == "teams" || name == "generated" || name == "system" || name == "ws" || name == ".uploads" || name == "tenants" {
return nil
}
// Allow date directories (e.g. 2026-03-20)
if len(name) == 10 && name[4] == '-' {
return nil
}
// Allow team/user ID directories (UUIDs, numeric IDs)
if strings.Contains(name, "-") || isNumeric(name) {
return nil
}
return filepath.SkipDir
}
if d.Name() == basename {
found = path
return filepath.SkipAll
}
return nil
})
return found
}
// fuzzyMatchInDir handles LLM-hallucinated filenames missing timestamp suffixes.
// E.g. requested "file.png" matches "file_20260326-232559_269000.png" in the same directory.
func fuzzyMatchInDir(absPath string) string {
dir := filepath.Dir(absPath)
base := filepath.Base(absPath)
ext := filepath.Ext(base)
stem := strings.TrimSuffix(base, ext) // "smart-home-cover"
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
// Match: starts with stem, has same extension, has timestamp between
// e.g. "smart-home-cover_20260326-232444_269000.png"
if strings.HasPrefix(name, stem) && strings.HasSuffix(name, ext) && name != base {
return filepath.Join(dir, name)
}
}
return ""
}
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}