- 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>
169 lines
5.6 KiB
Go
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")
|
|
}
|
|
}
|