Files
cedricandClaude Opus 5.5 42f6137391 Safer defaults, read-only role, security headers and syslog TCP limits
- ALLOW_PURGE is now false by default; the UI shows a banner when there is
  no authentication.
- Read-only role: AUTH_VIEWER_USER/AUTH_VIEWER_PASS in local mode, or
  OIDC_ADMIN_GROUP in OIDC mode; changes get 403 and the admin settings
  are greyed out.
- Content-Security-Policy (inline scripts allowed by hash) and other
  security headers; cross-site changes are refused.
- Syslog TCP: at most SYSLOG_TCP_MAX_CONNS connections, closed after
  SYSLOG_TCP_IDLE of silence; HTTP idle timeout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 16:30:40 +02:00

169 lines
5.6 KiB
Go

package main
import (
"errors"
"io/fs"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
var echo = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("app " + r.URL.Path)) })
func TestSecureHeadersAndCrossSite(t *testing.T) {
h := secure(echo, "default-src 'self'", false)
cases := []struct {
method string
hdr map[string]string
want int
}{
{"GET", map[string]string{"Sec-Fetch-Site": "cross-site"}, 200}, // reading is fine
{"POST", nil, 200}, // curl, scripts
{"POST", map[string]string{"Sec-Fetch-Site": "same-origin"}, 200},
{"POST", map[string]string{"Sec-Fetch-Site": "cross-site"}, 403},
{"DELETE", map[string]string{"Sec-Fetch-Site": "same-site"}, 403},
{"POST", map[string]string{"Origin": "http://logs.lan:8080"}, 200},
{"POST", map[string]string{"Origin": "https://evil.example"}, 403},
{"PUT", map[string]string{"Origin": "null"}, 403},
}
for _, c := range cases {
r := httptest.NewRequest(c.method, "http://logs.lan:8080/api/purge", nil)
for k, v := range c.hdr {
r.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
if rec.Code != c.want {
t.Errorf("%s %v: %d, want %d", c.method, c.hdr, rec.Code, c.want)
}
if rec.Header().Get("Content-Security-Policy") == "" || rec.Header().Get("X-Frame-Options") != "DENY" {
t.Errorf("%s %v: security headers missing", c.method, c.hdr)
}
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/auth/me", nil))
if !strings.Contains(rec.Body.String(), `"mode":"none"`) || !strings.Contains(rec.Body.String(), `"role":"admin"`) {
t.Errorf("/auth/me without auth: %s", rec.Body)
}
rec = httptest.NewRecorder()
secure(echo, "", true).ServeHTTP(rec, httptest.NewRequest("GET", "/auth/me", nil))
if rec.Body.String() != "app /auth/me" {
t.Errorf("/auth/me with auth answered by the guard: %s", rec.Body)
}
}
func TestContentSecurityPolicyHashesInlineScripts(t *testing.T) {
static, _ := fs.Sub(webFS, "web")
csp := contentSecurityPolicy(static)
// index.html and login.html each have inline scripts.
if n := strings.Count(csp, "'sha256-"); n < 3 {
t.Errorf("%d script hashes in %q", n, csp)
}
for _, want := range []string{"frame-ancestors 'none'", "connect-src 'self'", "https://fonts.bunny.net"} {
if !strings.Contains(csp, want) {
t.Errorf("CSP lacks %q", want)
}
}
}
func TestLocalViewerIsReadOnly(t *testing.T) {
loginFailDelay = 0
h, err := newAuth(authConfig{mode: "local", user: "admin", pass: "pw", viewerUser: "guest", viewerPass: "ro", dataDir: t.TempDir()}, readOnly(echo))
if err != nil {
t.Fatal(err)
}
jar, _ := cookiejar.New(nil)
c := &http.Client{Jar: jar, Transport: hosts{"app.test": h}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
app := "http://app.test"
login(t, c, app, "guest", "ro", "/")
if code, body := get(t, c, app+"/auth/me"); code != 200 || !strings.Contains(body, `"role":"viewer"`) {
t.Fatalf("me: %d %s", code, body)
}
if code, _ := get(t, c, app+"/api/logs"); code != 200 {
t.Errorf("viewer reading logs: %d", code)
}
res, err := c.Post(app+"/api/purge", "application/json", strings.NewReader(`{"confirm":"PURGE"}`))
if err != nil {
t.Fatal(err)
}
if res.StatusCode != http.StatusForbidden {
t.Errorf("viewer purge: %d, want 403", res.StatusCode)
}
// The admin account still changes things, also through Basic auth.
req, _ := http.NewRequest("PUT", app+"/api/syslog", strings.NewReader("{}"))
req.SetBasicAuth("admin", "pw")
if res, _ := (&http.Client{Transport: hosts{"app.test": h}}).Do(req); res.StatusCode != 200 {
t.Errorf("admin change: %d", res.StatusCode)
}
req, _ = http.NewRequest("PUT", app+"/api/syslog", strings.NewReader("{}"))
req.SetBasicAuth("guest", "ro")
if res, _ := (&http.Client{Transport: hosts{"app.test": h}}).Do(req); res.StatusCode != http.StatusForbidden {
t.Errorf("viewer change through Basic auth: %d", res.StatusCode)
}
}
func TestOIDCAdminGroup(t *testing.T) {
for _, tc := range []struct {
groups any
role string
}{
{[]any{"staff", "/logstream-admins"}, "admin"},
{[]any{"staff"}, "viewer"},
{nil, "viewer"},
} {
idp := newFakeIdP(t)
idp.claims = func(c map[string]any) {
if tc.groups != nil {
c["groups"] = tc.groups
}
}
h, err := newAuth(authConfig{
mode: "oidc", issuer: "http://idp.test/realm", clientID: "logstream", clientSecret: "s3cret",
redirectURL: "http://app.test/auth/callback", dataDir: t.TempDir(), adminGroup: "logstream-admins",
}, readOnly(echo))
if err != nil {
t.Fatal(err)
}
netw := hosts{"app.test": h, "idp.test": idp.mux}
h.(*OIDC).client.Transport = netw
jar, _ := cookiejar.New(nil)
c := &http.Client{Jar: jar, Transport: netw}
get(t, c, "http://app.test/")
if _, body := get(t, c, "http://app.test/auth/me"); !strings.Contains(body, `"role":"`+tc.role+`"`) {
t.Errorf("groups %v: %s, want role %s", tc.groups, body, tc.role)
}
}
}
func TestHasGroup(t *testing.T) {
if !hasGroup("ops logstream-admins", "/logstream-admins") || !hasGroup([]any{"a", "b"}, "b") || hasGroup(42, "b") {
t.Error("hasGroup")
}
}
func TestTCPIdleTimeout(t *testing.T) {
a, b := net.Pipe()
defer b.Close()
c := idleConn{a, 30 * time.Millisecond}
go func() { _, _ = b.Write([]byte("x")) }()
buf := make([]byte, 1)
if _, err := c.Read(buf); err != nil {
t.Fatal(err)
}
start := time.Now()
if _, err := c.Read(buf); !errors.Is(err, os.ErrDeadlineExceeded) {
t.Fatalf("silent connection: %v, want a deadline error", err)
}
if time.Since(start) > time.Second {
t.Error("deadline not applied")
}
}