272 lines
9.4 KiB
Go
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)
|
|
}
|
|
}
|