Files
privatae-bastion/client-go/uiauth_test.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

272 lines
9.4 KiB
Go

package main
// Deliberately NOT tagged !windows. Windows is the platform this UI exists for,
// so its front door has to be proven there — which means not borrowing
// withConfigDir (freeagent_managed_test.go, behind //go:build !windows) and
// isolating the config dir locally instead.
import (
"net/http"
"net/http/httptest"
"os"
"runtime"
"strings"
"testing"
"time"
)
func uiTestConfigDir(t *testing.T) string {
t.Helper()
d := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", d) // os.UserConfigDir on linux
t.Setenv("HOME", d) // darwin
t.Setenv("AppData", d) // windows
return d
}
// ── the Host guard: DNS rebinding ───────────────────────────────────────────
func TestUIRefusesARebindingHost(t *testing.T) {
// The attack: a page served from evil.example whose hostname is re-resolved
// to 127.0.0.1. Every same-origin protection the browser has agrees the page
// is talking to itself, because by its own rules it is. The Host header is
// the only place the attacker's name still appears.
cases := []struct {
host string
ok bool
why string
}{
{"127.0.0.1:7877", true, "the real thing"},
{"localhost:7877", true, "what a browser sends when you type localhost"},
{"[::1]:7877", true, "loopback over v6"},
{"localhost.:7877", true, "the fully-qualified spelling of the same name"},
{"evil.example.:7877", false, "a trailing dot does not make a stranger local"},
{"localhost..:7877", false, "only the ROOT dot is stripped"},
{"evil.example:7877", false, "REBINDING — resolves to loopback, is not loopback"},
{"bastion.internal:7877", false, "a friendly-looking name is still not loopback"},
{"127.0.0.1:7878", false, "a pairing's control port, not ours"},
{"127.0.0.1", false, "no port: a browser only omits it for :80"},
{"", false, "absent"},
}
for _, c := range cases {
got := uiLoopbackHost(c.host, "7877")
if got != c.ok {
t.Errorf("uiLoopbackHost(%q) = %v, want %v — %s", c.host, got, c.ok, c.why)
}
}
}
func TestUIGuardRefusesRebindingOnEveryMethod(t *testing.T) {
for _, method := range []string{http.MethodGet, http.MethodPost} {
r := httptest.NewRequest(method, "/", nil)
r.Host = "evil.example:7877"
r.Header.Set("Origin", "http://evil.example:7877") // self-consistent, and still not us
if why := uiRequestAllowed(r, "7877", "ui"); why == "" {
t.Errorf("%s from a rebound host was allowed — a read is not safe either, "+
"the page would get the pairing list and the anchor", method)
}
}
}
// ── the Origin guard: CSRF ──────────────────────────────────────────────────
func TestUIGuardOnMutatingRequests(t *testing.T) {
cases := []struct {
name, method, origin string
allowed bool
}{
{"a read needs no Origin", http.MethodGet, "", true},
{"a write from our own page", http.MethodPost, "http://127.0.0.1:7877", true},
{"a write from localhost spelling", http.MethodPost, "http://localhost:7877", true},
{"a write from another site", http.MethodPost, "https://evil.example", false},
{"a write from a sandboxed frame", http.MethodPost, "null", false},
{"a write with no Origin at all", http.MethodPost, "", false},
{"a write from another local port", http.MethodPost, "http://127.0.0.1:7878", false},
{"https to our own port is still not our page", http.MethodPost, "https://127.0.0.1:7877", false},
{"DELETE is a write too", http.MethodDelete, "https://evil.example", false},
}
for _, c := range cases {
r := httptest.NewRequest(c.method, "/x", nil)
r.Host = "127.0.0.1:7877"
if c.origin != "" {
r.Header.Set("Origin", c.origin)
}
why := uiRequestAllowed(r, "7877", "ui")
if (why == "") != c.allowed {
t.Errorf("%s: allowed=%v, want %v (reason %q)", c.name, why == "", c.allowed, why)
}
}
}
// ── tickets ─────────────────────────────────────────────────────────────────
func TestATicketWorksExactlyOnce(t *testing.T) {
s := newUITokenStore(uiTicketTTL)
tok, err := s.mint()
if err != nil {
t.Fatal(err)
}
if !s.redeem(tok) {
t.Fatal("a fresh ticket did not redeem")
}
if s.redeem(tok) {
t.Fatal("the SAME ticket redeemed twice — it travels in a URL, so a " +
"replayable one is a credential sitting in browser history")
}
if s.redeem("not-a-ticket") || s.redeem("") {
t.Fatal("a made-up ticket redeemed")
}
}
func TestATicketExpires(t *testing.T) {
base := time.Date(2026, 9, 13, 12, 0, 0, 0, time.UTC)
setUINow(func() time.Time { return base })
defer setUINow(nil)
s := newUITokenStore(uiTicketTTL)
tok, err := s.mint()
if err != nil {
t.Fatal(err)
}
setUINow(func() time.Time { return base.Add(uiTicketTTL - time.Second) })
if !s.redeem(tok) {
t.Fatal("a ticket died before its TTL")
}
// Back to base before minting, or this ticket's TTL runs from the advanced
// clock and the check below lands well inside it.
setUINow(func() time.Time { return base })
tok2, _ := s.mint()
setUINow(func() time.Time { return base.Add(uiTicketTTL + 2*time.Second) })
if s.redeem(tok2) {
t.Fatal("an expired ticket still redeemed")
}
}
// ── sessions ────────────────────────────────────────────────────────────────
func TestASessionIsReusableUntilItExpires(t *testing.T) {
base := time.Date(2026, 9, 13, 12, 0, 0, 0, time.UTC)
setUINow(func() time.Time { return base })
defer setUINow(nil)
s := newUITokenStore(uiSessionTTL)
tok, _ := s.mint()
for i := 0; i < 3; i++ {
if !s.valid(tok) {
t.Fatalf("session rejected on use %d — unlike a ticket it is presented "+
"on every request", i+1)
}
}
setUINow(func() time.Time { return base.Add(uiSessionTTL + time.Minute) })
if s.valid(tok) {
t.Fatal("an expired session was accepted")
}
}
func TestSignOutDropsTheSession(t *testing.T) {
s := newUITokenStore(uiSessionTTL)
tok, _ := s.mint()
s.drop(tok)
if s.valid(tok) {
t.Fatal("a dropped session still validated — sign out must mean it")
}
}
// ── the secret file ─────────────────────────────────────────────────────────
func TestTheUISecretIsStableAndPrivate(t *testing.T) {
uiTestConfigDir(t)
first, err := loadOrCreateUISecret()
if err != nil {
t.Fatal(err)
}
if len(first) < 32 {
t.Fatalf("secret is %d chars — too short to be a credential", len(first))
}
again, err := loadOrCreateUISecret()
if err != nil {
t.Fatal(err)
}
if again != first {
t.Fatal("the secret changed on the second read — every open browser session " +
"would be signed out, and `ui` would mint against a secret the daemon no " +
"longer holds")
}
p, err := uiSecretPath()
if err != nil {
t.Fatal(err)
}
if runtime.GOOS == "windows" {
return // POSIX modes do not describe Windows ACLs; %AppData% is per-user
}
fi, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o600 {
t.Fatalf("ui.secret is %o, want 600", fi.Mode().Perm())
}
// A file that already exists with loose permissions must be REPAIRED, not
// accepted. os.WriteFile applies its mode only when it creates the file, so
// a secret written once under a bad umask would stay world-readable for ever
// and nothing would ever mention it.
if err := os.Chmod(p, 0o644); err != nil {
t.Fatal(err)
}
if _, err := loadOrCreateUISecret(); err != nil {
t.Fatal(err)
}
fi, err = os.Stat(p)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o600 {
t.Fatalf("a world-readable ui.secret was left at %o — every user on this "+
"machine could open the UI", fi.Mode().Perm())
}
}
func TestUISecretComparison(t *testing.T) {
if !uiSecretMatches("abc", "abc") {
t.Fatal("equal secrets did not match")
}
if uiSecretMatches("abc", "abd") || uiSecretMatches("", "abc") ||
uiSecretMatches("abc", "") || uiSecretMatches("", "") {
t.Fatal("a mismatch matched — note the empty/empty case: an unset secret " +
"must never be satisfied by sending nothing")
}
}
// ── the cookie ──────────────────────────────────────────────────────────────
func TestTheSessionCookieCarriesItsDefences(t *testing.T) {
w := httptest.NewRecorder()
uiSetSessionCookie(w, "tok-1")
raw := w.Header().Get("Set-Cookie")
for _, want := range []string{"HttpOnly", "SameSite=Strict", "Path=/"} {
if !strings.Contains(raw, want) {
t.Errorf("Set-Cookie is missing %s: %s", want, raw)
}
}
// Secure would mean the cookie is never STORED over plain http, so the
// login would appear to work and every later request would be anonymous.
if strings.Contains(raw, "Secure") {
t.Errorf("Secure is set on a cookie served over http on loopback — the "+
"browser will silently drop it: %s", raw)
}
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Cookie", "privatae_ui_session=tok-1")
if got := uiSessionFrom(r); got != "tok-1" {
t.Fatalf("uiSessionFrom = %q", got)
}
if got := uiSessionFrom(httptest.NewRequest(http.MethodGet, "/", nil)); got != "" {
t.Fatalf("a request with no cookie produced %q", got)
}
}