backend/api/middleware/context.go (new): - Introduces an unexported ctxKey type and three constants (ctxKeyUserAddress, ctxKeyUserTrack, ctxKeyAuthenticated) that replace the bare string keys 'user_address', 'user_track', and 'authenticated'. Bare strings trigger go vet's SA1029 and collide with keys from any other package that happens to share the name. - Helpers: ContextWithAuth, UserAddress, UserTrack, IsAuthenticated. - Sentinel: ErrMissingAuthorization replaces the misuse of http.ErrMissingFile as an auth-missing signal. (http.ErrMissingFile belongs to multipart form parsing and was semantically wrong.) backend/api/middleware/auth.go: - RequireAuth, OptionalAuth, RequireTrack now all read/write via the helpers; no more string literals for context keys in this file. - extractAuth returns ErrMissingAuthorization instead of http.ErrMissingFile. - Dropped now-unused 'context' import. backend/api/track4/operator_scripts.go, backend/api/track4/endpoints.go, backend/api/rest/features.go: - Read user address / track via middleware.UserAddress() and middleware.UserTrack() instead of a raw context lookup with a bare string key. - Import 'github.com/explorer/backend/api/middleware'. backend/api/track4/operator_scripts_test.go: - Four test fixtures updated to seed the request context through middleware.ContextWithAuth (track 4, authenticated) instead of context.WithValue with a bare 'user_address' string. This is the load-bearing change that proves typed keys are required: a bare string key no longer wakes up the middleware helpers. backend/api/middleware/context_test.go (new): - Round-trip test for ContextWithAuth + UserAddress + UserTrack + IsAuthenticated. - Defaults: UserTrack=1, UserAddress="", IsAuthenticated=false on a bare context. - TestContextKeyIsolation: an outside caller that inserts 'user_address' as a bare string key must NOT be visible to UserAddress; proves the type discipline. - ErrMissingAuthorization sentinel smoke test. Verification: - go build ./... clean. - go vet ./... clean (removes SA1029 on the old bare keys). - go test ./api/middleware/... ./api/track4/... ./api/rest/... PASS. Advances completion criterion 3 (Auth correctness).
147 lines
5.4 KiB
Go
147 lines
5.4 KiB
Go
package track4
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"net/http"
|
|
"net/http/httptest"
|
|
|
|
"github.com/explorer/backend/api/middleware"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type stubRoleManager struct {
|
|
allowed bool
|
|
gotIP string
|
|
logs int
|
|
}
|
|
|
|
func (s *stubRoleManager) IsIPWhitelisted(_ context.Context, _ string, ipAddress string) (bool, error) {
|
|
s.gotIP = ipAddress
|
|
return s.allowed, nil
|
|
}
|
|
|
|
func (s *stubRoleManager) LogOperatorEvent(_ context.Context, _ string, _ *int, _ string, _ string, _ string, _ map[string]interface{}, _ string, _ string) error {
|
|
s.logs++
|
|
return nil
|
|
}
|
|
|
|
func TestHandleRunScriptUsesForwardedClientIPAndRunsAllowlistedScript(t *testing.T) {
|
|
root := t.TempDir()
|
|
scriptPath := filepath.Join(root, "echo.sh")
|
|
require.NoError(t, os.WriteFile(scriptPath, []byte("#!/usr/bin/env bash\necho hello \"$1\"\n"), 0o644))
|
|
|
|
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
|
|
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "echo.sh")
|
|
t.Setenv("OPERATOR_SCRIPT_TIMEOUT_SEC", "30")
|
|
t.Setenv("TRUST_PROXY_CIDRS", "10.0.0.0/8")
|
|
|
|
roleMgr := &stubRoleManager{allowed: true}
|
|
s := &Server{roleMgr: roleMgr, chainID: 138}
|
|
|
|
reqBody := []byte(`{"script":"echo.sh","args":["world"]}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader(reqBody))
|
|
req = req.WithContext(middleware.ContextWithAuth(req.Context(), "0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4, true))
|
|
req.RemoteAddr = "10.0.0.10:8080"
|
|
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.10")
|
|
w := httptest.NewRecorder()
|
|
|
|
s.HandleRunScript(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Equal(t, "203.0.113.9", roleMgr.gotIP)
|
|
require.Equal(t, 2, roleMgr.logs)
|
|
|
|
var out struct {
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out))
|
|
require.Equal(t, "echo.sh", out.Data["script"])
|
|
require.Equal(t, float64(0), out.Data["exit_code"])
|
|
require.Equal(t, "hello world", out.Data["stdout"])
|
|
require.Equal(t, false, out.Data["timed_out"])
|
|
}
|
|
|
|
func TestHandleRunScriptRejectsNonAllowlistedScript(t *testing.T) {
|
|
root := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(root, "allowed.sh"), []byte("#!/usr/bin/env bash\necho ok\n"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(root, "blocked.sh"), []byte("#!/usr/bin/env bash\necho blocked\n"), 0o644))
|
|
|
|
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
|
|
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "allowed.sh")
|
|
|
|
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"blocked.sh"}`)))
|
|
req = req.WithContext(middleware.ContextWithAuth(req.Context(), "0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4, true))
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
w := httptest.NewRecorder()
|
|
|
|
s.HandleRunScript(w, req)
|
|
|
|
require.Equal(t, http.StatusForbidden, w.Code)
|
|
require.Contains(t, w.Body.String(), "script not in OPERATOR_SCRIPT_ALLOWLIST")
|
|
}
|
|
|
|
func TestHandleRunScriptRejectsFilenameCollisionOutsideAllowlistedPath(t *testing.T) {
|
|
root := t.TempDir()
|
|
require.NoError(t, os.MkdirAll(filepath.Join(root, "safe"), 0o755))
|
|
require.NoError(t, os.MkdirAll(filepath.Join(root, "unsafe"), 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(root, "safe", "backup.sh"), []byte("#!/usr/bin/env bash\necho safe\n"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(root, "unsafe", "backup.sh"), []byte("#!/usr/bin/env bash\necho unsafe\n"), 0o644))
|
|
|
|
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
|
|
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "safe/backup.sh")
|
|
|
|
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"unsafe/backup.sh"}`)))
|
|
req = req.WithContext(middleware.ContextWithAuth(req.Context(), "0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4, true))
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
w := httptest.NewRecorder()
|
|
|
|
s.HandleRunScript(w, req)
|
|
|
|
require.Equal(t, http.StatusForbidden, w.Code)
|
|
require.Contains(t, w.Body.String(), "script not in OPERATOR_SCRIPT_ALLOWLIST")
|
|
}
|
|
|
|
func TestHandleRunScriptTruncatesLargeOutput(t *testing.T) {
|
|
root := t.TempDir()
|
|
scriptPath := filepath.Join(root, "large.sh")
|
|
require.NoError(t, os.WriteFile(scriptPath, []byte("#!/usr/bin/env bash\npython3 - <<'PY'\nprint('x' * 70000)\nPY\n"), 0o644))
|
|
|
|
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
|
|
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "large.sh")
|
|
t.Setenv("OPERATOR_SCRIPT_TIMEOUT_SEC", "30")
|
|
|
|
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"large.sh"}`)))
|
|
req = req.WithContext(middleware.ContextWithAuth(req.Context(), "0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4, true))
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
w := httptest.NewRecorder()
|
|
|
|
s.HandleRunScript(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var out struct {
|
|
Data struct {
|
|
ExitCode float64 `json:"exit_code"`
|
|
Stdout string `json:"stdout"`
|
|
StdoutTruncated bool `json:"stdout_truncated"`
|
|
} `json:"data"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out))
|
|
require.Equal(t, float64(0), out.Data.ExitCode)
|
|
require.True(t, out.Data.StdoutTruncated)
|
|
require.Contains(t, out.Data.Stdout, "[truncated after")
|
|
require.LessOrEqual(t, len(out.Data.Stdout), maxOperatorScriptOutputBytes+64)
|
|
}
|