Files
Duy /zuey/ 532ff91d8e fix(security): harden upstream critical surfaces (#32)
* fix(security): harden upstream critical surfaces

Refs #30

* fix(security): close pre-landing review gaps

Refs #30

* fix(security): close official release blockers
2026-05-20 16:33:49 +07:00

435 lines
15 KiB
Go

package http
import (
"context"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// writeStorageTestFile creates a file with the given content for testing.
func writeStorageTestFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatalf("MkdirAll(%s): %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("WriteFile(%s): %v", path, err)
}
}
func TestStorageListHidesTenantRootForMaster(t *testing.T) {
baseDir := t.TempDir()
writeStorageTestFile(t, filepath.Join(baseDir, "master.txt"), "master")
writeStorageTestFile(t, filepath.Join(baseDir, "tenants", "tenant-a", "secret.txt"), "tenant-secret")
handler := NewStorageHandler(baseDir)
req := httptest.NewRequest("GET", "/v1/storage/files", nil)
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
w := httptest.NewRecorder()
handler.handleList(w, req)
if w.Code != 200 {
t.Fatalf("status = %d, want 200", w.Code)
}
var resp struct {
Files []struct {
Path string `json:"path"`
Name string `json:"name"`
} `json:"files"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
for _, f := range resp.Files {
if f.Path == "tenants" || strings.HasPrefix(f.Path, "tenants/") {
t.Fatalf("master storage unexpectedly exposed tenant path %q", f.Path)
}
}
}
func TestStorageListSubpathTenantReturnsNotFound(t *testing.T) {
baseDir := t.TempDir()
writeStorageTestFile(t, filepath.Join(baseDir, "tenants", "tenant-a", "secret.txt"), "tenant-secret")
handler := NewStorageHandler(baseDir)
req := httptest.NewRequest("GET", "/v1/storage/files?path=tenants", nil)
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
w := httptest.NewRecorder()
handler.handleList(w, req)
if w.Code != 404 {
t.Fatalf("status = %d, want 404", w.Code)
}
}
func TestStorageReadTenantRootReturnsNotFoundForMaster(t *testing.T) {
baseDir := t.TempDir()
writeStorageTestFile(t, filepath.Join(baseDir, "tenants", "tenant-a", "secret.txt"), "tenant-secret")
handler := NewStorageHandler(baseDir)
req := httptest.NewRequest("GET", "/v1/storage/files/tenants/tenant-a/secret.txt", nil)
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
req.SetPathValue("path", "tenants/tenant-a/secret.txt")
w := httptest.NewRecorder()
handler.handleRead(w, req)
if w.Code != 404 {
t.Fatalf("status = %d, want 404", w.Code)
}
}
func TestStorageReadRejectsSymlinkedTenantParentForMaster(t *testing.T) {
baseDir := t.TempDir()
tenantSecret := filepath.Join(baseDir, "tenants", "tenant-a", "secret.txt")
writeStorageTestFile(t, tenantSecret, "tenant-secret")
if err := os.Symlink(filepath.Join(baseDir, "tenants"), filepath.Join(baseDir, "tenant-link")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
handler := NewStorageHandler(baseDir)
req := httptest.NewRequest("GET", "/v1/storage/files/tenant-link/tenant-a/secret.txt", nil)
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
req.SetPathValue("path", "tenant-link/tenant-a/secret.txt")
w := httptest.NewRecorder()
handler.handleRead(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
if strings.Contains(w.Body.String(), "tenant-secret") {
t.Fatal("response leaked tenant secret through symlinked parent")
}
}
func TestStorageDeleteRejectsSymlinkedTenantParentForMaster(t *testing.T) {
baseDir := t.TempDir()
tenantSecret := filepath.Join(baseDir, "tenants", "tenant-a", "secret.txt")
writeStorageTestFile(t, tenantSecret, "tenant-secret")
if err := os.Symlink(filepath.Join(baseDir, "tenants"), filepath.Join(baseDir, "tenant-link")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
handler := NewStorageHandler(baseDir)
req := httptest.NewRequest(http.MethodDelete, "/v1/storage/files/tenant-link/tenant-a/secret.txt", nil)
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
req.SetPathValue("path", "tenant-link/tenant-a/secret.txt")
w := httptest.NewRecorder()
handler.handleDelete(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
if _, err := os.Stat(tenantSecret); err != nil {
t.Fatalf("tenant secret should not be deleted through symlinked parent: %v", err)
}
}
func TestStorageSizeExcludesTenantRootForMaster(t *testing.T) {
baseDir := t.TempDir()
writeStorageTestFile(t, filepath.Join(baseDir, "master.txt"), "12345")
writeStorageTestFile(t, filepath.Join(baseDir, "tenants", "tenant-a", "secret.txt"), "1234567890")
handler := NewStorageHandler(baseDir)
req := httptest.NewRequest("GET", "/v1/storage/size", nil)
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
w := httptest.NewRecorder()
handler.handleSize(w, req)
if w.Code != 200 {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
lines := strings.Split(strings.TrimSpace(body), "\n")
if len(lines) == 0 {
t.Fatal("expected SSE response body")
}
// Find the final "done" event.
last := lines[len(lines)-1]
if !strings.HasPrefix(last, "data: ") {
t.Fatalf("unexpected SSE line %q", last)
}
var payload struct {
Total int64 `json:"total"`
Files int `json:"files"`
Done bool `json:"done"`
}
if err := json.Unmarshal([]byte(strings.TrimPrefix(last, "data: ")), &payload); err != nil {
t.Fatalf("unmarshal SSE payload: %v", err)
}
if !payload.Done {
t.Fatalf("expected final SSE payload, got %#v", payload)
}
if payload.Total != 5 {
t.Fatalf("total = %d, want 5 (tenant files should be excluded)", payload.Total)
}
if payload.Files != 1 {
t.Fatalf("files = %d, want 1 (tenant files should be excluded)", payload.Files)
}
}
// TestIsHiddenPathOnlyAffectsMaster verifies that isHiddenPath only blocks
// the master tenant and leaves non-master tenants unaffected.
func TestIsHiddenPathOnlyAffectsMaster(t *testing.T) {
handler := NewStorageHandler(t.TempDir())
nonMasterID := uuid.MustParse("0193a5b0-7000-7000-8000-000000000099")
masterReq := httptest.NewRequest("GET", "/", nil)
masterReq = masterReq.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
otherReq := httptest.NewRequest("GET", "/", nil)
otherReq = otherReq.WithContext(store.WithTenantID(context.Background(), nonMasterID))
// Master tenant: tenants paths are hidden.
if !handler.isHiddenPath(masterReq, "tenants") {
t.Fatal("expected 'tenants' to be hidden for master")
}
if !handler.isHiddenPath(masterReq, "tenants/foo/bar") {
t.Fatal("expected 'tenants/foo/bar' to be hidden for master")
}
if !handler.isHiddenPath(masterReq, "Tenants") {
t.Fatal("expected case-insensitive match for master")
}
// Non-master tenant: tenants paths are NOT hidden.
if handler.isHiddenPath(otherReq, "tenants") {
t.Fatal("tenants should not be hidden for non-master tenant")
}
if handler.isHiddenPath(otherReq, "tenants/foo") {
t.Fatal("tenants/foo should not be hidden for non-master tenant")
}
// Empty path is never hidden.
if handler.isHiddenPath(masterReq, "") {
t.Fatal("empty path should never be hidden")
}
// Non-tenants paths are never hidden.
if handler.isHiddenPath(masterReq, "skills") {
t.Fatal("non-tenants path should not be hidden")
}
if handler.isHiddenPath(masterReq, "my-tenants") {
t.Fatal("partial match should not be hidden")
}
}
func TestStorageDeleteInvalidatesSizeCache(t *testing.T) {
baseDir := t.TempDir()
writeStorageTestFile(t, filepath.Join(baseDir, "tmp.txt"), "abc")
handler := NewStorageHandler(baseDir)
req := httptest.NewRequest(http.MethodDelete, "/v1/storage/files/tmp.txt", nil)
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
req.SetPathValue("path", "tmp.txt")
sizeBase := handler.tenantBaseDir(req)
handler.sizeCache.Store(sizeBase, &sizeCacheEntry{total: 3, files: 1})
w := httptest.NewRecorder()
handler.handleDelete(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if _, ok := handler.sizeCache.Load(sizeBase); ok {
t.Fatal("expected size cache entry to be invalidated after delete")
}
}
func TestStorageMoveInvalidatesSizeCache(t *testing.T) {
baseDir := t.TempDir()
writeStorageTestFile(t, filepath.Join(baseDir, "from.txt"), "abc")
handler := NewStorageHandler(baseDir)
req := httptest.NewRequest(http.MethodPut, "/v1/storage/move?from=from.txt&to=to.txt", nil)
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
sizeBase := handler.tenantBaseDir(req)
handler.sizeCache.Store(sizeBase, &sizeCacheEntry{total: 3, files: 1})
w := httptest.NewRecorder()
handler.handleMove(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if _, ok := handler.sizeCache.Load(sizeBase); ok {
t.Fatal("expected size cache entry to be invalidated after move")
}
}
func TestStorageMoveRejectsSymlinkedTenantDestinationParent(t *testing.T) {
baseDir := t.TempDir()
writeStorageTestFile(t, filepath.Join(baseDir, "from.txt"), "abc")
writeStorageTestFile(t, filepath.Join(baseDir, "tenants", "tenant-a", ".keep"), "")
if err := os.Symlink(filepath.Join(baseDir, "tenants", "tenant-a"), filepath.Join(baseDir, "tenant-link")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
handler := NewStorageHandler(baseDir)
req := httptest.NewRequest(http.MethodPut, "/v1/storage/move?from=from.txt&to=tenant-link/moved.txt", nil)
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
w := httptest.NewRecorder()
handler.handleMove(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
if _, err := os.Stat(filepath.Join(baseDir, "from.txt")); err != nil {
t.Fatalf("source should remain after rejected move: %v", err)
}
if _, err := os.Stat(filepath.Join(baseDir, "tenants", "tenant-a", "moved.txt")); !os.IsNotExist(err) {
t.Fatalf("destination should not be created through symlinked parent, err=%v", err)
}
}
func TestStorageUploadRejectsSymlinkedTenantDestinationParent(t *testing.T) {
baseDir := t.TempDir()
writeStorageTestFile(t, filepath.Join(baseDir, "tenants", "tenant-a", ".keep"), "")
if err := os.Symlink(filepath.Join(baseDir, "tenants", "tenant-a"), filepath.Join(baseDir, "tenant-link")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
handler := NewStorageHandler(baseDir)
req := newStorageUploadRequest(t, "/v1/storage/files?path=tenant-link", "file", "x.txt", "data")
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
w := httptest.NewRecorder()
handler.handleUpload(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
if _, err := os.Stat(filepath.Join(baseDir, "tenants", "tenant-a", "x.txt")); !os.IsNotExist(err) {
t.Fatalf("upload should not write through symlinked parent, err=%v", err)
}
}
func TestStorageUploadReplacesLeafSymlinkWithoutFollowingTarget(t *testing.T) {
baseDir := t.TempDir()
tenantSecret := filepath.Join(baseDir, "tenants", "tenant-a", "secret.txt")
writeStorageTestFile(t, tenantSecret, "tenant-secret")
leaf := filepath.Join(baseDir, "x.txt")
if err := os.Symlink(tenantSecret, leaf); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
handler := NewStorageHandler(baseDir)
req := newStorageUploadRequest(t, "/v1/storage/files", "file", "x.txt", "replacement")
req = req.WithContext(store.WithTenantID(context.Background(), store.MasterTenantID))
w := httptest.NewRecorder()
handler.handleUpload(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
tenantData, err := os.ReadFile(tenantSecret)
if err != nil {
t.Fatalf("read tenant secret: %v", err)
}
if string(tenantData) != "tenant-secret" {
t.Fatalf("tenant secret overwritten through leaf symlink: %q", tenantData)
}
info, err := os.Lstat(leaf)
if err != nil {
t.Fatalf("lstat uploaded leaf: %v", err)
}
if info.Mode()&os.ModeSymlink != 0 {
t.Fatal("upload should replace the leaf symlink itself")
}
uploaded, err := os.ReadFile(leaf)
if err != nil {
t.Fatalf("read uploaded file: %v", err)
}
if string(uploaded) != "replacement" {
t.Fatalf("uploaded content = %q, want replacement", uploaded)
}
}
func TestStorageMutationsRequireTenantAdmin(t *testing.T) {
setupTestToken(t, "gateway-token")
setupTestNoAuthFallback(t, false)
ts := newMockTenantStore()
tenantID := uuid.New()
ts.addTenant(tenantID, "acme")
ts.setUserRole(tenantID, "viewer-user", store.TenantRoleViewer)
ts.setUserRole(tenantID, "admin-user", store.TenantRoleAdmin)
setupTestTenantStore(t, ts)
baseDir := t.TempDir()
writeStorageTestFile(t, filepath.Join(baseDir, "tenants", "acme", "from.txt"), "abc")
handler := NewStorageHandler(baseDir, ts)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
viewerUpload := newStorageUploadRequest(t, "/v1/storage/files", "file", "x.txt", "data")
viewerUpload.Header.Set("Authorization", "Bearer gateway-token")
viewerUpload.Header.Set("X-GoClaw-User-Id", "viewer-user")
viewerUpload.Header.Set("X-GoClaw-Tenant-Id", "acme")
viewerUploadRR := httptest.NewRecorder()
mux.ServeHTTP(viewerUploadRR, viewerUpload)
if viewerUploadRR.Code != http.StatusForbidden {
t.Fatalf("viewer upload status = %d, want 403", viewerUploadRR.Code)
}
viewerMove := httptest.NewRequest(http.MethodPut, "/v1/storage/move?from=from.txt&to=to.txt", nil)
viewerMove.Header.Set("Authorization", "Bearer gateway-token")
viewerMove.Header.Set("X-GoClaw-User-Id", "viewer-user")
viewerMove.Header.Set("X-GoClaw-Tenant-Id", "acme")
viewerMoveRR := httptest.NewRecorder()
mux.ServeHTTP(viewerMoveRR, viewerMove)
if viewerMoveRR.Code != http.StatusForbidden {
t.Fatalf("viewer move status = %d, want 403", viewerMoveRR.Code)
}
viewerDelete := httptest.NewRequest(http.MethodDelete, "/v1/storage/files/from.txt", nil)
viewerDelete.Header.Set("Authorization", "Bearer gateway-token")
viewerDelete.Header.Set("X-GoClaw-User-Id", "viewer-user")
viewerDelete.Header.Set("X-GoClaw-Tenant-Id", "acme")
viewerDeleteRR := httptest.NewRecorder()
mux.ServeHTTP(viewerDeleteRR, viewerDelete)
if viewerDeleteRR.Code != http.StatusForbidden {
t.Fatalf("viewer delete status = %d, want 403", viewerDeleteRR.Code)
}
adminUpload := newStorageUploadRequest(t, "/v1/storage/files", "file", "admin.txt", "data")
adminUpload.Header.Set("Authorization", "Bearer gateway-token")
adminUpload.Header.Set("X-GoClaw-User-Id", "admin-user")
adminUpload.Header.Set("X-GoClaw-Tenant-Id", "acme")
adminUploadRR := httptest.NewRecorder()
mux.ServeHTTP(adminUploadRR, adminUpload)
if adminUploadRR.Code != http.StatusOK {
t.Fatalf("tenant admin upload status = %d, want 200: %s", adminUploadRR.Code, adminUploadRR.Body.String())
}
}
func newStorageUploadRequest(t *testing.T, target, field, filename, content string) *http.Request {
t.Helper()
var body strings.Builder
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
if _, err := part.Write([]byte(content)); err != nil {
t.Fatalf("write multipart content: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body.String()))
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}