Files
goclaw/internal/http/files_path_security_test.go
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

361 lines
11 KiB
Go

package http
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// ---- FilesHandler path security tests ----
//
// These tests verify the 2-layer path isolation in FilesHandler.handleServe:
// 1. Workspace/dataDir boundary enforcement (all editions)
// 2. Path traversal prevention ("../"-style attacks)
// 3. Sensitive system directory blocking (/etc/, /proc/, ...)
//
// We bypass the auth wrapper by calling handleServe directly after setting up
// a token-signed file token or by registering the handler without auth on a
// test mux.
// makeTestFilesHandler creates a FilesHandler with a temp workspace and dataDir.
func makeTestFilesHandler(t *testing.T) (*FilesHandler, string) {
t.Helper()
workspace := t.TempDir()
dataDir := t.TempDir()
h := NewFilesHandler(workspace, dataDir)
return h, workspace
}
// ---- handleServe: path traversal prevention ----
func TestFilesHandleServe_DotDotTraversal_Returns400(t *testing.T) {
h, _ := makeTestFilesHandler(t)
// Simulate PathValue("path") returning a traversal attack
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/files/{path...}", h.handleServe)
req := httptest.NewRequest(http.MethodGet, "/v1/files/../../etc/passwd", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
// ".." in path → 400 Bad Request (path traversal prevention)
if w.Code == http.StatusOK {
t.Error("traversal path should not return 200")
}
}
// ---- handleServe: sensitive directory blocking ----
func TestFilesHandleServe_EtcPasswd_Returns403Or400(t *testing.T) {
// Test that /etc/passwd is blocked even with a valid bearer token.
// We bypass auth wrapper entirely by calling handleServe directly with a crafted request.
h, workspace := makeTestFilesHandler(t)
// Write a dummy file to workspace so the handler can run past auth.
_ = os.WriteFile(filepath.Join(workspace, "safe.txt"), []byte("data"), 0644)
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/files/{path...}", h.handleServe)
// /etc/ prefix must be blocked
req := httptest.NewRequest(http.MethodGet, "/v1/files/etc/passwd", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
// Either 400 (traversal detection) or 403 (sensitive path) — both are correct.
if w.Code == http.StatusOK {
t.Errorf("request for /etc/passwd should be denied, got %d", w.Code)
}
}
func TestFilesHandleServe_ProcDir_Blocked(t *testing.T) {
h, _ := makeTestFilesHandler(t)
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/files/{path...}", h.handleServe)
req := httptest.NewRequest(http.MethodGet, "/v1/files/proc/self/environ", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code == http.StatusOK {
t.Errorf("/proc path should be blocked, got 200")
}
}
// ---- handleServe: expanded deny-prefix list ----
// TestFilesHandleServe_ExpandedDenyPrefixes verifies that paths under /home/,
// /Users/, /var/lib/, /var/www/, /opt/, and /srv/ are all blocked. These are
// defense-in-depth blocks for misconfigured roots — the token/RBAC checks are
// the primary barriers.
func TestFilesHandleServe_ExpandedDenyPrefixes_Blocked(t *testing.T) {
h, _ := makeTestFilesHandler(t)
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/files/{path...}", h.handleServe)
cases := []struct {
name string
url string
}{
{"home user ssh key", "/v1/files/home/user/.ssh/id_rsa"},
{"home user aws creds", "/v1/files/home/user/.aws/credentials"},
{"Users admin dir (macOS)", "/v1/files/Users/admin/secrets.txt"},
{"var lib docker secret", "/v1/files/var/lib/docker/overlay2/secret"},
{"var www config", "/v1/files/var/www/config.php"},
{"opt secrets key", "/v1/files/opt/secrets/key.pem"},
{"srv www app config", "/v1/files/srv/www/app/config.yaml"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code == http.StatusOK {
t.Errorf("path %s should be denied (deny-prefix), got 200", tc.url)
}
})
}
}
// ---- handleServe: fail-closed observability on empty workspace/dataDir ----
// TestFilesHandleServe_NoBoundary_Denies verifies that when both workspace and
// dataDir are empty the handler returns 404 (fail-closed) for any path.
func TestFilesHandleServe_NoBoundary_Denies(t *testing.T) {
h := NewFilesHandler("", "")
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/files/{path...}", h.handleServe)
req := httptest.NewRequest(http.MethodGet, "/v1/files/tmp/test.txt", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code == http.StatusOK {
t.Errorf("empty workspace+dataDir should deny all requests, got 200")
}
}
// ---- handleServe: workspace boundary enforcement ----
func TestFilesHandleServe_FileInsideWorkspace_WithToken_Serves(t *testing.T) {
h, workspace := makeTestFilesHandler(t)
// Write a file inside workspace
content := []byte("hello workspace")
filePath := filepath.Join(workspace, "hello.txt")
if err := os.WriteFile(filePath, content, 0644); err != nil {
t.Fatal(err)
}
// Build a valid signed token for this URL path
urlPath := "/v1/files/" + strings.TrimPrefix(filepath.Clean(filePath), "/")
ft := SignFileToken(urlPath, FileSigningKey(), FileTokenTTL)
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/files/{path...}", h.handleServe)
req := httptest.NewRequest(http.MethodGet, urlPath+"?ft="+ft, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
// Should serve the file (200) or return 404 if the OS path doesn't match test env.
// The key check: must NOT be 400/403 (security rejection).
if w.Code == http.StatusBadRequest || w.Code == http.StatusForbidden {
t.Errorf("workspace file with valid token should not be security-rejected, got %d", w.Code)
}
}
func TestFilesHandleServe_FileOutsideAllDirs_WithToken_Returns404(t *testing.T) {
h, _ := makeTestFilesHandler(t)
// Build a signed token for a path outside workspace and dataDir.
outsideDir := t.TempDir()
filePath := filepath.Join(outsideDir, "secret.txt")
_ = os.WriteFile(filePath, []byte("secret"), 0644)
urlPath := "/v1/files/" + strings.TrimPrefix(filepath.Clean(filePath), "/")
ft := SignFileToken(urlPath, FileSigningKey(), FileTokenTTL)
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/files/{path...}", h.handleServe)
req := httptest.NewRequest(http.MethodGet, urlPath+"?ft="+ft, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
// File token exists but path is outside workspace/dataDir → 404 (security denial via NotFound)
if w.Code == http.StatusOK {
t.Errorf("file outside workspace should not be served with signed token, got 200")
}
}
func TestFilesHandleServe_SignedSymlinkEscape_Returns404(t *testing.T) {
h, workspace := makeTestFilesHandler(t)
outsideDir := t.TempDir()
target := filepath.Join(outsideDir, "secret.txt")
if err := os.WriteFile(target, []byte("secret"), 0644); err != nil {
t.Fatal(err)
}
linkPath := filepath.Join(workspace, "link.txt")
if err := os.Symlink(target, linkPath); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
urlPath := "/v1/files/" + strings.TrimPrefix(filepath.Clean(linkPath), "/")
ft := SignFileToken(urlPath, FileSigningKey(), FileTokenTTL)
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/files/{path...}", h.handleServe)
req := httptest.NewRequest(http.MethodGet, urlPath+"?ft="+ft, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code == http.StatusOK {
t.Fatal("signed symlink escaping workspace should not be served")
}
}
func TestFilesHandleServe_OpenThenSwapToSymlinkEscape_Returns404(t *testing.T) {
h, workspace := makeTestFilesHandler(t)
outsideDir := t.TempDir()
secretPath := filepath.Join(outsideDir, "secret.txt")
if err := os.WriteFile(secretPath, []byte("secret"), 0644); err != nil {
t.Fatal(err)
}
filePath := filepath.Join(workspace, "race.txt")
if err := os.WriteFile(filePath, []byte("allowed"), 0644); err != nil {
t.Fatal(err)
}
filesAfterOpenHookForTest = func(opened string) {
if opened != filePath {
return
}
_ = os.Remove(filePath)
_ = os.Symlink(secretPath, filePath)
}
defer func() { filesAfterOpenHookForTest = nil }()
urlPath := "/v1/files/" + strings.TrimPrefix(filepath.Clean(filePath), "/")
ft := SignFileToken(urlPath, FileSigningKey(), FileTokenTTL)
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/files/{path...}", h.handleServe)
req := httptest.NewRequest(http.MethodGet, urlPath+"?ft="+ft, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code == http.StatusOK {
t.Fatal("file swapped to escaping symlink after open should not be served")
}
if strings.Contains(w.Body.String(), "secret") {
t.Fatal("response leaked swapped outside file content")
}
}
func TestFilesHandleSign_SymlinkEscape_ReturnsForbidden(t *testing.T) {
setupTestToken(t, "")
setupTestNoAuthFallback(t, true)
h, workspace := makeTestFilesHandler(t)
outsideDir := t.TempDir()
target := filepath.Join(outsideDir, "secret.txt")
if err := os.WriteFile(target, []byte("secret"), 0644); err != nil {
t.Fatal(err)
}
linkPath := filepath.Join(workspace, "link.txt")
if err := os.Symlink(target, linkPath); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/files/sign", strings.NewReader(`{"path":`+strconv.Quote(linkPath)+`}`))
w := httptest.NewRecorder()
h.handleSign(w, req)
if w.Code == http.StatusOK {
t.Fatal("sign endpoint should reject symlinks escaping allowed roots")
}
}
// ---- handleServe: empty path ----
func TestFilesHandleServe_EmptyPath_Returns400(t *testing.T) {
h, _ := makeTestFilesHandler(t)
// Serve with empty path value — PathValue("path") returns ""
req := httptest.NewRequest(http.MethodGet, "/v1/files/", nil)
w := httptest.NewRecorder()
// Call handleServe directly — PathValue returns "" for this pattern.
h.handleServe(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("empty path should return 400, got %d", w.Code)
}
}
// ---- auth middleware: invalid file token ----
func TestFilesAuthMiddleware_InvalidFileToken_Returns401(t *testing.T) {
h, workspace := makeTestFilesHandler(t)
called := false
wrapped := h.auth(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
filePath := filepath.Join(workspace, "test.txt")
_ = os.WriteFile(filePath, []byte("x"), 0644)
urlPath := "/v1/files/" + strings.TrimPrefix(filepath.Clean(filePath), "/")
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/files/{path...}", wrapped)
req := httptest.NewRequest(http.MethodGet, urlPath+"?ft=invalid-token", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if called {
t.Error("handler should not be called with invalid file token")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("invalid ft should return 401, got %d", w.Code)
}
}
func TestFilesAuthMiddleware_ValidFileToken_Passes(t *testing.T) {
h, workspace := makeTestFilesHandler(t)
called := false
wrapped := h.auth(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
filePath := filepath.Join(workspace, "test.txt")
_ = os.WriteFile(filePath, []byte("x"), 0644)
urlPath := "/v1/files/" + strings.TrimPrefix(filepath.Clean(filePath), "/")
ft := SignFileToken(urlPath, FileSigningKey(), FileTokenTTL)
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/files/{path...}", wrapped)
req := httptest.NewRequest(http.MethodGet, urlPath+"?ft="+ft, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if !called {
t.Error("handler should be called with valid file token")
}
}