Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,14 @@ func NewServer(es state.EventStore, ps *state.SQLiteStore, port int, filter stat
// C1: generate a random per-session token. Required on every WebSocket
// upgrade and on the static-asset HTTP handlers, preventing localhost
// cross-process CSRF and unauthenticated dashboard access.
//
// crypto/rand failures are extremely rare on a healthy host but signal
// OS entropy starvation — the previous fallback to a nanosecond
// timestamp gave ~30 bits of entropy and was bruteforceable in
// milliseconds. Fail fast instead so the operator notices.
tokenBytes := make([]byte, 16)
if _, err := rand.Read(tokenBytes); err != nil {
// extremely unlikely on a healthy host; fall back to time-based.
log.Printf("[web] crypto/rand error, using insecure fallback: %v", err)
copy(tokenBytes, []byte(fmt.Sprintf("%d", time.Now().UnixNano())))
log.Fatalf("[web] crypto/rand failed (OS entropy starvation?): %v", err)
}
token := hex.EncodeToString(tokenBytes)

Expand Down Expand Up @@ -165,10 +168,12 @@ func (s *Server) Start(ctx context.Context) error {
ReadHeaderTimeout: 5 * time.Second, // S3-7-adjacent: prevent slowloris
}

// Open browser with the auth-gated URL.
// Open browser with the auth-gated URL. The URL contains the token —
// don't log the bare token separately so it doesn't end up duplicated
// in log aggregators with a wider read surface than the operator
// console.
url := fmt.Sprintf("http://%s/?token=%s", addr, s.authToken)
log.Printf("Dashboard server running at %s", url)
log.Printf("[web] auth token: %s (required as ?token=<token>)", s.authToken)
openBrowser(url)

// Start hub broadcast loop
Expand Down
36 changes: 36 additions & 0 deletions internal/web/start_lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package web

import (
"bytes"
"context"
"log"
"net"
"net/http"
"strings"
Expand Down Expand Up @@ -215,3 +217,37 @@ func itoa(i int) string {
}
return string(b[pos:])
}

// TestServer_Start_DoesNotLogBareAuthToken guards the SEC-M1 fix: the
// auth token used to be log.Printf'd as its own line, duplicating it
// into any log aggregator that captured stderr. The URL line still
// contains the token (operator UX), but no bare `[web] auth token:`
// line should appear.
func TestServer_Start_DoesNotLogBareAuthToken(t *testing.T) {
var buf bytes.Buffer
prev := log.Writer()
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(prev) })

s := newTestServer(t)
s.port = freePort(t)

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

errCh := make(chan error, 1)
go func() { errCh <- s.Start(ctx) }()

if !waitForPort(t, s.port, 2*time.Second) {
t.Fatal("server did not bind within 2s")
}
cancel()
if err := <-errCh; err != nil && err != http.ErrServerClosed {
t.Fatalf("Start: %v", err)
}

logged := buf.String()
if strings.Contains(logged, "[web] auth token:") {
t.Fatalf("bare auth token leaked to logs:\n%s", logged)
}
}
Loading