f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
380 lines
10 KiB
Go
380 lines
10 KiB
Go
package server
|
|
|
|
import (
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestContentTypeWrapper verifies that Content-Type is only set if missing
|
|
// when the status code matches the trigger status.
|
|
func TestContentTypeWrapper(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
handler http.HandlerFunc
|
|
triggerStatus int
|
|
wantStatus int
|
|
wantContentType string
|
|
wantBody string
|
|
}{
|
|
{
|
|
name: "SetsContentTypeOnTriggerStatusMissingHeader",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
w.Write([]byte(`{"error":"timeout"}`))
|
|
},
|
|
triggerStatus: http.StatusServiceUnavailable,
|
|
wantStatus: http.StatusServiceUnavailable,
|
|
wantContentType: "application/json",
|
|
wantBody: `{"error":"timeout"}`,
|
|
},
|
|
{
|
|
name: "RespectsExistingContentTypeOnTriggerStatus",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
w.Write([]byte("timeout error"))
|
|
},
|
|
triggerStatus: http.StatusServiceUnavailable,
|
|
wantStatus: http.StatusServiceUnavailable,
|
|
wantContentType: "text/plain",
|
|
wantBody: "timeout error",
|
|
},
|
|
{
|
|
name: "IgnoresNonTriggerStatus",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("ok"))
|
|
},
|
|
triggerStatus: http.StatusServiceUnavailable,
|
|
wantStatus: http.StatusOK,
|
|
wantContentType: "", // Not set by wrapper
|
|
wantBody: "ok",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
wrapper := &contentTypeWrapper{
|
|
ResponseWriter: w,
|
|
contentType: "application/json",
|
|
triggerStatus: tt.triggerStatus,
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
tt.handler(wrapper, req)
|
|
|
|
assertRecorderStatus(t, w, tt.wantStatus)
|
|
|
|
resp := w.Result()
|
|
defer resp.Body.Close()
|
|
|
|
gotCT := resp.Header.Get("Content-Type")
|
|
if tt.wantContentType != "" {
|
|
assert.Equal(t, tt.wantContentType, gotCT)
|
|
} else {
|
|
assert.NotEqual(t, "application/json", gotCT,
|
|
"Content-Type unexpectedly forced by wrapper")
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.wantBody, string(body))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMiddlewareTimeout verifies that API routes are wrapped with timeout
|
|
// middleware (which produces a 503 JSON timeout response when the handler
|
|
// exceeds the configured duration) and that export/SPA routes are NOT wrapped.
|
|
func TestMiddlewareTimeout(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := testServer(
|
|
t, 10*time.Millisecond,
|
|
withHandlerDelay(100*time.Millisecond),
|
|
)
|
|
// Use a real listener to discover the bound port, then
|
|
// rebuild Handler() with the correct port in the Host
|
|
// allowlist.
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
port := ln.Addr().(*net.TCPAddr).Port
|
|
srv.SetPort(port)
|
|
ts := httptest.NewUnstartedServer(srv.Handler())
|
|
ts.Listener = ln
|
|
ts.Start()
|
|
t.Cleanup(ts.Close)
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
wantTimeout bool
|
|
wantStatus int // Only checked if wantTimeout is false
|
|
}{
|
|
{"Wrapped_ListSessions", "/api/v1/sessions", true, 0},
|
|
{"Wrapped_GetStats", "/api/v1/stats", true, 0},
|
|
{"Unwrapped_SearchContent", "/api/v1/search/content?pattern=needle", false, http.StatusOK},
|
|
{"Unwrapped_ExportSession", "/api/v1/sessions/invalid-id/export", false, http.StatusNotFound},
|
|
{"Unwrapped_SPA", "/", false, http.StatusOK},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
resp, err := ts.Client().Get(ts.URL + tt.path)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
if tt.wantTimeout {
|
|
assertTimeoutResponse(t, resp)
|
|
} else {
|
|
assert.False(t, isTimeoutResponse(t, resp),
|
|
"%s: unexpected timeout for unwrapped route", tt.path)
|
|
assert.Equal(t, tt.wantStatus, resp.StatusCode, tt.path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// parseCSP splits a Content-Security-Policy string into a map of
|
|
// directive name -> source list.
|
|
func parseCSP(csp string) map[string]string {
|
|
out := map[string]string{}
|
|
for part := range strings.SplitSeq(csp, ";") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
name, sources, _ := strings.Cut(part, " ")
|
|
out[name] = strings.TrimSpace(sources)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestCSPMiddlewareSetsHeaderOnNonAPIRoutes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
host string
|
|
port int
|
|
basePath string
|
|
wantCSP bool
|
|
// wantDirectives maps a directive name to its exact expected
|
|
// source list. Resource directives stay pinned to the server
|
|
// origin; connect-src is widened to any http/https/ws/wss.
|
|
wantDirectives map[string]string
|
|
}{
|
|
{
|
|
name: "SPA root pins origin and widens connect-src",
|
|
path: "/",
|
|
host: "127.0.0.1",
|
|
port: 8081,
|
|
wantCSP: true,
|
|
wantDirectives: map[string]string{
|
|
"default-src": "'self' http://127.0.0.1:8081",
|
|
"script-src": "'self' http://127.0.0.1:8081",
|
|
"connect-src": "'self' http: https: ws: wss:",
|
|
"img-src": "'self' http://127.0.0.1:8081 data:",
|
|
"style-src": "'self' http://127.0.0.1:8081 'unsafe-inline' https://fonts.googleapis.com",
|
|
"font-src": "'self' http://127.0.0.1:8081 data: https://fonts.gstatic.com",
|
|
"object-src": "'none'",
|
|
"base-uri": "'none'",
|
|
"frame-ancestors": "'none'",
|
|
},
|
|
},
|
|
{
|
|
name: "IPv6 loopback brackets pinned origin",
|
|
path: "/sessions/abc",
|
|
host: "::1",
|
|
port: 9090,
|
|
wantCSP: true,
|
|
wantDirectives: map[string]string{
|
|
"script-src": "'self' http://[::1]:9090",
|
|
"connect-src": "'self' http: https: ws: wss:",
|
|
},
|
|
},
|
|
{
|
|
name: "base path relaxes base-uri to self",
|
|
path: "/app/",
|
|
host: "127.0.0.1",
|
|
port: 8081,
|
|
basePath: "/app",
|
|
wantCSP: true,
|
|
wantDirectives: map[string]string{
|
|
"connect-src": "'self' http: https: ws: wss:",
|
|
"base-uri": "'self'",
|
|
},
|
|
},
|
|
{
|
|
name: "API route gets no CSP",
|
|
path: "/api/v1/sessions",
|
|
host: "127.0.0.1",
|
|
port: 8081,
|
|
wantCSP: false,
|
|
},
|
|
{
|
|
name: "API subpath gets no CSP",
|
|
path: "/api/v1/stats",
|
|
host: "127.0.0.1",
|
|
port: 8081,
|
|
wantCSP: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
handler := cspMiddleware(tt.host, tt.port, tt.basePath, "", nil, inner)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
csp := w.Header().Get("Content-Security-Policy")
|
|
if !tt.wantCSP {
|
|
assert.Empty(t, csp, "expected no CSP header on API route")
|
|
return
|
|
}
|
|
require.NotEmpty(t, csp, "expected CSP header")
|
|
got := parseCSP(csp)
|
|
for name, want := range tt.wantDirectives {
|
|
assert.Equal(t, want, got[name], "directive %s", name)
|
|
}
|
|
assert.Equal(t, "DENY", w.Header().Get("X-Frame-Options"))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildCSPPolicyWidensConnectSrcOnly is a regression test for the
|
|
// CSP that blocked the "Connect to Remote Server" feature. The SPA
|
|
// points fetch/SSE/WebSocket at an arbitrary remote origin stored
|
|
// client-side (frontend/src/lib/api/client.ts), so connect-src must
|
|
// permit any http/https/ws/wss origin — while every directive that
|
|
// gates code or resource loading stays pinned to the server origin.
|
|
func TestBuildCSPPolicyWidensConnectSrcOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
directives := parseCSP(buildCSPPolicy("127.0.0.1", 8081, "", "", nil))
|
|
|
|
assert.Equal(t, "'self' http: https: ws: wss:", directives["connect-src"],
|
|
"connect-src should be widened to any http/https/ws/wss origin")
|
|
|
|
// Scheme-source wildcards must not leak into the directives that
|
|
// gate code/resource loading, or the widening would defeat the
|
|
// remaining CSP protection.
|
|
locked := []string{"default-src", "script-src", "img-src", "style-src", "font-src"}
|
|
schemeWildcards := map[string]bool{
|
|
"http:": true, "https:": true, "ws:": true, "wss:": true,
|
|
}
|
|
for _, name := range locked {
|
|
for field := range strings.FieldsSeq(directives[name]) {
|
|
assert.False(t, schemeWildcards[field],
|
|
"directive %s must stay pinned but allows %q (full: %q)",
|
|
name, field, directives[name])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildCSPPolicyPinsPublicURLOrigin(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
directives := parseCSP(buildCSPPolicy(
|
|
"0.0.0.0", 8080, "",
|
|
"https://agentsview.example.com/app",
|
|
nil,
|
|
))
|
|
|
|
assert.Equal(t,
|
|
"'self' https://agentsview.example.com",
|
|
directives["default-src"],
|
|
)
|
|
assert.Equal(t,
|
|
"'self' https://agentsview.example.com",
|
|
directives["script-src"],
|
|
)
|
|
assert.Equal(t,
|
|
"'self' https://agentsview.example.com data:",
|
|
directives["img-src"],
|
|
)
|
|
assert.NotContains(t, directives["default-src"], "0.0.0.0")
|
|
}
|
|
|
|
func TestBuildCSPPolicyKeepsLocalOriginWithPublicURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
directives := parseCSP(buildCSPPolicy(
|
|
"127.0.0.1", 8080, "",
|
|
"https://agentsview.example.com/app",
|
|
nil,
|
|
))
|
|
|
|
assert.Equal(t,
|
|
"'self' https://agentsview.example.com http://127.0.0.1:8080",
|
|
directives["default-src"],
|
|
)
|
|
assert.Equal(t,
|
|
"'self' https://agentsview.example.com http://127.0.0.1:8080",
|
|
directives["script-src"],
|
|
)
|
|
}
|
|
|
|
func TestBuildCSPPolicyPinsPublicOriginWhenPublicURLUnset(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
directives := parseCSP(buildCSPPolicy(
|
|
"0.0.0.0", 8080, "",
|
|
"",
|
|
[]string{"https://viewer.example.test"},
|
|
))
|
|
|
|
assert.Equal(t,
|
|
"'self' https://viewer.example.test",
|
|
directives["default-src"],
|
|
)
|
|
assert.NotContains(t, directives["default-src"], "0.0.0.0")
|
|
}
|
|
|
|
func TestCORSMiddlewareMergesVaryHeader(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
allowedOrigins := map[string]bool{
|
|
"http://127.0.0.1:8080": true,
|
|
}
|
|
cors := corsMiddleware(
|
|
allowedOrigins, false, 8080, nil,
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Vary", "Accept-Encoding")
|
|
cors.ServeHTTP(w, r)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil)
|
|
req.Header.Set("Origin", "http://127.0.0.1:8080")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assertRecorderStatus(t, w, http.StatusOK)
|
|
vary := w.Header().Get("Vary")
|
|
assert.Contains(t, vary, "Accept-Encoding")
|
|
assert.Contains(t, vary, "Origin")
|
|
}
|