Why: The embedded tier starts without auth, and nothing checked Origin or Host, so a public bind or a foreign page could call write APIs. Evidence: go test ./internal/config ./internal/server covering listen policy, origin rejection, and response headers. Impact: Non-loopback without auth exits before opening the database. HTML artifacts get a sandbox CSP. Reverse proxies must list their public host in allowedHosts. Refs: feature/sillytavern-review Co-authored-by: Cursor <cursoragent@cursor.com>
82 lines
2.9 KiB
Go
82 lines
2.9 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func guard(auth bool, hosts ...string) RequestGuard {
|
|
return RequestGuard{
|
|
AuthOn: auth, AllowedHosts: hosts,
|
|
Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}),
|
|
}
|
|
}
|
|
|
|
func TestRequestGuardWrites(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
auth bool
|
|
host string
|
|
origin string
|
|
site string
|
|
want int
|
|
}{
|
|
{"same origin", false, "127.0.0.1:8788", "http://127.0.0.1:8788", "", http.StatusNoContent},
|
|
{"cross origin", false, "127.0.0.1:8788", "https://evil.example", "", http.StatusForbidden},
|
|
{"null origin", false, "127.0.0.1:8788", "null", "", http.StatusForbidden},
|
|
{"sec-fetch cross", false, "127.0.0.1:8788", "", "cross-site", http.StatusForbidden},
|
|
{"sec-fetch same", false, "127.0.0.1:8788", "", "same-origin", http.StatusNoContent},
|
|
{"no browser headers", false, "127.0.0.1:8788", "", "", http.StatusNoContent},
|
|
{"allowed host origin", false, "127.0.0.1:8788", "https://app.example", "", http.StatusNoContent},
|
|
{"evil host without auth", false, "evil.example", "", "", http.StatusForbidden},
|
|
{"evil host with auth", true, "evil.example", "", "", http.StatusNoContent},
|
|
{"ip host", false, "192.168.1.9:8788", "", "", http.StatusNoContent},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/memory", strings.NewReader(`{}`))
|
|
req.Host = tc.host
|
|
if tc.origin != "" {
|
|
req.Header.Set("Origin", tc.origin)
|
|
}
|
|
if tc.site != "" {
|
|
req.Header.Set("Sec-Fetch-Site", tc.site)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
hosts := []string{}
|
|
if tc.name == "allowed host origin" {
|
|
hosts = []string{"app.example"}
|
|
}
|
|
guard(tc.auth, hosts...).ServeHTTP(rec, req)
|
|
if rec.Code != tc.want {
|
|
t.Fatalf("status %d want %d body %s", rec.Code, tc.want, rec.Body.String())
|
|
}
|
|
if rec.Code == http.StatusForbidden && !strings.Contains(rec.Body.String(), "allowedHosts") {
|
|
t.Fatalf("refusal should mention allowedHosts: %s", rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPageAndArtifactHeaders(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
setPageHeaders(rec)
|
|
if rec.Header().Get("Content-Security-Policy") == "" || rec.Header().Get("X-Content-Type-Options") != "nosniff" || rec.Header().Get("Referrer-Policy") != "same-origin" {
|
|
t.Fatalf("page headers: %v", rec.Header())
|
|
}
|
|
art := httptest.NewRecorder()
|
|
setArtifactHeaders(art, "text/html; charset=utf-8")
|
|
if !strings.Contains(art.Header().Get("Content-Security-Policy"), "sandbox") || art.Header().Get("X-Content-Type-Options") != "nosniff" {
|
|
t.Fatalf("artifact headers: %v", art.Header())
|
|
}
|
|
plain := httptest.NewRecorder()
|
|
setArtifactHeaders(plain, "text/plain")
|
|
if plain.Header().Get("Content-Security-Policy") != "" {
|
|
t.Fatalf("plain artifact should not be sandboxed: %v", plain.Header())
|
|
}
|
|
}
|