Require a token for the web UI and reconcile AUDIT.md
The UI is a full control plane: it can create tasks, release or complete them, and inject approval keystrokes into live panes. authz.HTTP did cover it (an absent surface header defaults to Web), but the gate is opt-in and the deployed env sets no tokens while binding all interfaces, so in practice it was reachable unauthenticated from the LAN. Setting the token alone did not work either: a browser cannot put a bearer token on a document load, so the UI would 401 on index.html. - ORCHESTRA_WEB_TOKEN is now mandatory; startup fails rather than silently serving an open control plane. - authz.Sessions issues random values stored SHA-256-hashed, with a TTL, so a leaked snapshot yields nothing usable. - POST /v1/ui/session verifies the token in constant time and returns it as an HttpOnly, SameSite=Strict, Secure cookie. This is a presentable form of the same credential, not a new authority. - HTTPWithSessions accepts that cookie in place of the bearer token, and only for the Web surface. The login endpoint and non-/v1/ GETs (the SPA shell) are exempt by necessity; every /v1/ control path stays gated. Note this is a breaking config change: .orchestra-config/orchestra.env sets no tokens, so the service will not start until it does, and setting a Web token newly gates the other /v1/ surfaces that default to Web. AUDIT.md is reconciled against the code rather than against itself. B14, B15 and B16 are closed with their evidence; B17 is closed on the worker path only; the stale claim that B13 was open is corrected. Adds the previously undocumented command channel and web UI, and files what that implementation pass surfaced: federated approvals emit no event (B19), the local capture revision is a timestamp rather than a change counter and can silently defeat approvals (B20), the command queue never prunes (B21), the ntfy token serves two unrelated purposes (S12), and a dead copy of the authorization policy sits in main.go (S13). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01535A3Y8RtkAi8wYuWhtkEd
This commit is contained in:
+102
-3
@@ -3,9 +3,15 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Surface string
|
||||
@@ -66,10 +72,86 @@ func AuthorizeEvent(s Surface, typ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SessionCookie carries a browser's proof of the Web-surface token. A
|
||||
// top-level document load cannot set an Authorization header, so a
|
||||
// bearer-only gate forces operators to run the UI with no token at all
|
||||
// (AUDIT.md B18). The cookie is the browser-presentable equivalent; it is
|
||||
// never a second credential, only a receipt for the same token.
|
||||
const SessionCookie = "orchestra_session"
|
||||
|
||||
// SessionPath is the one Web-surface endpoint exempt from the token gate,
|
||||
// because it *is* the token check: it verifies the Web token itself and
|
||||
// exchanges it for a cookie. Gating it would make login unreachable.
|
||||
const SessionPath = "/v1/ui/session"
|
||||
|
||||
// Sessions issues and validates those receipts. Values are random and stored
|
||||
// hashed, so a leaked snapshot of this map does not yield a usable cookie.
|
||||
type Sessions struct {
|
||||
mu sync.Mutex
|
||||
TTL time.Duration
|
||||
ids map[string]time.Time
|
||||
}
|
||||
|
||||
func (s *Sessions) ttl() time.Duration {
|
||||
if s.TTL > 0 {
|
||||
return s.TTL
|
||||
}
|
||||
return 12 * time.Hour
|
||||
}
|
||||
|
||||
// Issue mints a session value. The caller must have already verified the
|
||||
// Web-surface token; Issue does not check credentials itself.
|
||||
func (s *Sessions) Issue() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
v := hex.EncodeToString(b)
|
||||
sum := sha256.Sum256([]byte(v))
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.ids == nil {
|
||||
s.ids = map[string]time.Time{}
|
||||
}
|
||||
now := time.Now()
|
||||
for k, exp := range s.ids {
|
||||
if now.After(exp) {
|
||||
delete(s.ids, k)
|
||||
}
|
||||
}
|
||||
s.ids[hex.EncodeToString(sum[:])] = now.Add(s.ttl())
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *Sessions) Valid(v string) bool {
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
sum := sha256.Sum256([]byte(v))
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
exp, ok := s.ids[hex.EncodeToString(sum[:])]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(exp) {
|
||||
delete(s.ids, hex.EncodeToString(sum[:]))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// HTTP enforces the same policy at the bus boundary. Authentication is
|
||||
// optional for local development; when a token is supplied, control surfaces
|
||||
// must present it as a Bearer token.
|
||||
func HTTP(tokens map[Surface]string, next http.Handler) http.Handler {
|
||||
return HTTPWithSessions(tokens, nil, next)
|
||||
}
|
||||
|
||||
// HTTPWithSessions additionally accepts a valid session cookie in place of a
|
||||
// Bearer token, but only for the Web surface — every non-browser surface
|
||||
// still has to present the token directly.
|
||||
func HTTPWithSessions(tokens map[Surface]string, sessions *Sessions, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s := ParseSurface(r.Header.Get("X-Orchestra-Surface"))
|
||||
if s == "" {
|
||||
@@ -85,9 +167,26 @@ func HTTP(tokens map[Surface]string, next http.Handler) http.Handler {
|
||||
if s == System {
|
||||
s = Web
|
||||
}
|
||||
if expected := tokens[s]; expected != "" && r.Header.Get("Authorization") != "Bearer "+expected {
|
||||
http.Error(w, "unauthorized surface", http.StatusUnauthorized)
|
||||
return
|
||||
if expected := tokens[s]; expected != "" && subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+expected)) != 1 {
|
||||
ok := false
|
||||
if s == Web && sessions != nil {
|
||||
if c, err := r.Cookie(SessionCookie); err == nil {
|
||||
ok = sessions.Valid(c.Value)
|
||||
}
|
||||
// The login endpoint authenticates itself, and the SPA shell
|
||||
// must load before a browser can present anything. Static
|
||||
// assets are not secrets; every /v1/ control path stays gated.
|
||||
if r.URL.Path == SessionPath {
|
||||
ok = true
|
||||
}
|
||||
if !strings.HasPrefix(r.URL.Path, "/v1/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized surface", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (s == Telegram || s == Ntfy) && r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "notify-only surface", http.StatusForbidden)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSurfaceCapabilities(t *testing.T) {
|
||||
@@ -46,3 +47,66 @@ func TestSystemSurfaceDowngradedByHTTPMiddleware(t *testing.T) {
|
||||
t.Fatalf("unexpected status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// B18: the web UI is a full control plane. A session cookie must be an
|
||||
// alternative *presentation* of the Web token, never a widening of it.
|
||||
func TestWebSessionCookieGatesControlPathsOnly(t *testing.T) {
|
||||
tokens := map[Surface]string{Web: "secret"}
|
||||
sessions := &Sessions{}
|
||||
h := HTTPWithSessions(tokens, sessions, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
do := func(method, path string, c *http.Cookie, surface string) int {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if c != nil {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
if surface != "" {
|
||||
req.Header.Set("X-Orchestra-Surface", surface)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
if got := do(http.MethodPost, "/v1/ui/tasks", nil, ""); got != http.StatusUnauthorized {
|
||||
t.Fatalf("uncredentialed control path = %d, want 401", got)
|
||||
}
|
||||
// The SPA shell and the login endpoint must stay reachable, or no
|
||||
// browser could ever obtain a cookie in the first place.
|
||||
if got := do(http.MethodGet, "/index.html", nil, ""); got != http.StatusOK {
|
||||
t.Fatalf("SPA shell = %d, want 200", got)
|
||||
}
|
||||
if got := do(http.MethodPost, SessionPath, nil, ""); got != http.StatusOK {
|
||||
t.Fatalf("login endpoint = %d, want 200", got)
|
||||
}
|
||||
v, err := sessions.Issue()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := do(http.MethodPost, "/v1/ui/tasks", &http.Cookie{Name: SessionCookie, Value: v}, ""); got != http.StatusOK {
|
||||
t.Fatalf("session-cookie control path = %d, want 200", got)
|
||||
}
|
||||
if got := do(http.MethodPost, "/v1/ui/tasks", &http.Cookie{Name: SessionCookie, Value: "forged"}, ""); got != http.StatusUnauthorized {
|
||||
t.Fatalf("forged cookie = %d, want 401", got)
|
||||
}
|
||||
// A cookie must not authenticate a non-browser surface.
|
||||
tokens[TUI] = "tui-secret"
|
||||
if got := do(http.MethodPost, "/v1/tasks", &http.Cookie{Name: SessionCookie, Value: v}, "tui"); got != http.StatusUnauthorized {
|
||||
t.Fatalf("cookie on TUI surface = %d, want 401", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionExpires(t *testing.T) {
|
||||
s := &Sessions{TTL: time.Millisecond}
|
||||
v, err := s.Issue()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
if s.Valid(v) {
|
||||
t.Fatal("expired session accepted")
|
||||
}
|
||||
if s.Valid("") {
|
||||
t.Fatal("empty session accepted")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user