package console import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "path/filepath" "strconv" "strings" "testing" "time" "github.com/carlosframework/platform/internal/svcrole" ) // postJSON is how the CLI talks to the console: a JSON body, an optional // bearer token, and no cookies. func postJSON(t *testing.T, u string, body any, bearer string) (int, map[string]any) { t.Helper() raw, err := json.Marshal(body) if err != nil { t.Fatal(err) } req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(raw)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } return doJSON(t, req) } func getJSON(t *testing.T, u, bearer string) (int, map[string]any) { t.Helper() req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { t.Fatal(err) } if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } return doJSON(t, req) } func doJSON(t *testing.T, req *http.Request) (int, map[string]any) { t.Helper() res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() raw, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } out := map[string]any{} if len(bytes.TrimSpace(raw)) > 0 { if err := json.Unmarshal(raw, &out); err != nil { t.Fatalf("%s %s: body is not JSON: %v\n%s", req.Method, req.URL, err, raw) } } return res.StatusCode, out } func str(t *testing.T, m map[string]any, k string) string { t.Helper() v, _ := m[k].(string) return v } // TestCLILoginEndToEnd is the whole ceremony against the real handlers: a // terminal starts a login, a signed-in human approves it in a browser, the // terminal collects a token, uses it, sees it listed, and revokes it. func TestCLILoginEndToEnd(t *testing.T) { fx := newConsoleFixture(t) browser := newBrowser(t) signUp(t, fx, browser, "code-cli") status, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "paul@laptop"}, "") if status != http.StatusOK { t.Fatalf("start = %d %v", status, start) } userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code") if userCode == "" || deviceCode == "" { t.Fatalf("start returned %v, want both codes", start) } if got, want := str(t, start, "verification_uri"), fx.ts.URL+"/auth/cli"; got != want { t.Fatalf("verification_uri = %q, want %q", got, want) } if got := str(t, start, "verification_uri_complete"); !strings.Contains(got, url.QueryEscape(userCode)) { t.Fatalf("verification_uri_complete = %q, want the code in it", got) } // Nothing yet. if _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, ""); str(t, poll, "status") != CLIAuthPending { t.Fatalf("poll before approval = %v, want pending", poll) } // Typing the code is what produces a described terminal — following a // link never does (see TestCLIApprovalPageNeverApprovesFromALink). csrf := sessionToken(t, fx, browser) code, _, body := postForm(t, browser, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"continue"}, "csrf": {csrf}}) if code != http.StatusOK { t.Fatalf("continue = %d", code) } for _, want := range []string{"paul@laptop", userCode, "Approve"} { if !strings.Contains(body, want) { t.Fatalf("approval page missing %q in:\n%s", want, body) } } // CSRF: the session token double-submitted, exactly like every other // mutation on this console. if code, _, _ := postForm(t, browser, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {"wrong"}}); code != http.StatusForbidden { t.Fatalf("approve with a bad CSRF token = %d, want 403", code) } // And a signed-out browser cannot approve at all. if code, loc, _ := postForm(t, newBrowser(t), fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {csrf}}); code != http.StatusFound || loc != "/" { t.Fatalf("signed-out approve = %d -> %q, want 302 -> /", code, loc) } // Approving mints a 30-day credential, so it is behind passkey step-up // (stepup.go): a direct POST is answered with a trip through Keymail, and // only the confirmed round trip approves. if code, loc, _ := postForm(t, browser, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {csrf}}); code != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("approve without a fresh passkey = %d -> %q, want a redirect into Keymail", code, loc) } if _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, ""); str(t, poll, "status") == CLIAuthApproved { t.Fatal("the terminal was approved before the passkey confirmation") } code, _, body = postGated(t, fx, browser, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {csrf}}) if code != http.StatusOK || !strings.Contains(body, "Approved") { t.Fatalf("approve = %d, body:\n%s", code, body) } _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, "") if str(t, poll, "status") != CLIAuthApproved { t.Fatalf("poll after approval = %v", poll) } token := str(t, poll, "token") if token == "" || str(t, poll, "address") != "paul@keymail.dev" { t.Fatalf("poll after approval = %v, want a token bound to paul", poll) } // The token identifies its holder — and only with the right bearer. status, who := getJSON(t, fx.ts.URL+"/auth/cli/whoami", token) if status != http.StatusOK || str(t, who, "address") != "paul@keymail.dev" { t.Fatalf("whoami = %d %v", status, who) } if status, _ := getJSON(t, fx.ts.URL+"/auth/cli/whoami", token+"x"); status != http.StatusUnauthorized { t.Fatalf("whoami with a wrong token = %d, want 401", status) } if status, _ := getJSON(t, fx.ts.URL+"/auth/cli/whoami", ""); status != http.StatusUnauthorized { t.Fatalf("whoami with no token = %d, want 401", status) } // The owner can see it, and kill it, from the console. code, _, body = get(t, browser, fx.ts.URL+"/settings") if code != http.StatusOK || !strings.Contains(body, "paul@laptop") { t.Fatalf("tokens page = %d, body:\n%s", code, body) } if strings.Contains(body, token) { t.Fatal("the tokens page prints the bearer token itself") } id := "" toks, err := fx.st.CLITokens("paul@keymail.dev") if err != nil { t.Fatal(err) } if len(toks) != 1 { t.Fatalf("CLITokens = %d, want 1", len(toks)) } id = toks[0].ID if code, _, _ := postForm(t, browser, fx.ts.URL+"/auth/cli/tokens/revoke", url.Values{"id": {id}, "csrf": {"wrong"}}); code != http.StatusForbidden { t.Fatalf("revoke with a bad CSRF token = %d, want 403", code) } // Revoking is step-up gated too (the same session that has just confirmed // a passkey is still inside the window here, so no second trip is made). if code, _, _ := postGated(t, fx, browser, fx.ts.URL+"/auth/cli/tokens/revoke", url.Values{"id": {id}, "csrf": {csrf}}); code != http.StatusSeeOther { t.Fatalf("revoke = %d, want 303", code) } if status, _ := getJSON(t, fx.ts.URL+"/auth/cli/whoami", token); status != http.StatusUnauthorized { t.Fatalf("whoami after revocation = %d, want 401", status) } } // The device-code phishing attack, refused. An attacker starts a login, sends // the victim the ready-made verification_uri_complete with a pretext, and the // victim clicks it while signed in. If that page rendered an Approve button // for the attacker's code, one click would bind a 90-day credential — held by // the attacker — to the victim's identity. This is the campaign that has been // run against Microsoft tenants at scale, so the page must not do it: no // lookup, no description, no Approve button, and an EMPTY input the human has // to type into off their own screen. func TestCLIApprovalPageNeverApprovesFromALink(t *testing.T) { fx := newConsoleFixture(t) victim := newBrowser(t) signUp(t, fx, victim, "code-phish") // The attacker's terminal, started from somewhere else entirely. _, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "attacker@vps"}, "") userCode := str(t, start, "user_code") status, _, body := get(t, victim, fx.ts.URL+"/auth/cli?code="+url.QueryEscape(userCode)) if status != http.StatusOK { t.Fatalf("approval page = %d", status) } // The code is echoed to be CHECKED against the terminal's screen… if !strings.Contains(body, userCode) { t.Fatalf("the page hides the code the link carried, so it cannot be checked:\n%s", body) } // …but never described (that would also make this an enumeration oracle)… if strings.Contains(body, "attacker@vps") { t.Fatalf("the page resolved a code from a URL:\n%s", body) } // …and never one click from approval. if strings.Contains(body, `value="approve"`) { t.Fatalf("the page rendered an Approve button for a code from a URL:\n%s", body) } if strings.Contains(body, `name="code" value="`+userCode) { t.Fatalf("the page prefilled the typed-code field from the URL:\n%s", body) } // The request really is still pending — the page did not consume it, it // simply refused to act on it. _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": str(t, start, "device_code")}, "") if str(t, poll, "status") != CLIAuthPending { t.Fatalf("poll = %v, want the request untouched and pending", poll) } } // Issuing a credential tells the human, in the inbox their identity is // anchored to. Every other defence here is preventive; this is the one that // makes a phished approval detectable afterwards. func TestCLIIssuanceNotifiesTheUser(t *testing.T) { fx := newConsoleFixture(t) browser := newBrowser(t) signUp(t, fx, browser, "code-notify") fx.srv.cliNotified = make(chan struct{}, 1) _, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "paul@laptop"}, "") userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code") postGated(t, fx, browser, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {sessionToken(t, fx, browser)}}) if _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, ""); str(t, poll, "token") == "" { t.Fatalf("no token issued: %v", poll) } select { case <-fx.srv.cliNotified: case <-time.After(5 * time.Second): t.Fatal("no notification attempt after a token was issued") } var found string for _, body := range fx.fk.sentBodies() { if strings.Contains(body, "command line") || strings.Contains(body, "terminal") { found = body } } if found == "" { t.Fatalf("no issuance notice in the sealed sends: %v", fx.fk.sentBodies()) } for _, want := range []string{"paul@laptop", "/settings", "revoke"} { if !strings.Contains(found, want) { t.Fatalf("issuance notice missing %q:\n%s", want, found) } } } // Someone who signs in by email has no Keymail grant. The issuance notice // still reaches them, by the console mailer; someone with a grant still gets // the sealed Keymail send and no email. func TestCLIIssuedNoticeByEmail(t *testing.T) { fx, m := newEmailFixture(t, true) fx.srv.cliNotified = make(chan struct{}, 2) wait := func() { t.Helper() select { case <-fx.srv.cliNotified: case <-time.After(5 * time.Second): t.Fatal("notifyCLIIssued never finished") } } fx.srv.notifyCLIIssued("new@example.com", "raw-token") wait() sent := m.sentCopy() if len(sent) != 1 { t.Fatalf("console mailer got %d messages, want 1: %q", len(sent), sent) } to, body, _ := strings.Cut(sent[0], "|") if to != "new@example.com" { t.Fatalf("notice went to %q", to) } for _, want := range []string{"A terminal was just approved to act as new@example.com", "/settings", "revoke"} { if !strings.Contains(body, want) { t.Fatalf("notice missing %q:\n%s", want, body) } } if err := fx.st.SetGrantToken("granted@example.com", "grant-tok", time.Now()); err != nil { t.Fatal(err) } fx.srv.notifyCLIIssued("granted@example.com", "raw-token-2") wait() if got := m.sentCopy(); len(got) != 1 { t.Fatalf("a grant holder was also emailed: %q", got) } } // X-Forwarded-For is only believed from a peer we put there. Otherwise one // caller presents a fresh "IP" per request and walks past every per-IP limit. func TestClientIPIgnoresUntrustedForwardedFor(t *testing.T) { st, err := Open(filepath.Join(t.TempDir(), "console.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) // No bucket, so no mesh key and no proof has ever been seen: loopback // keeps the trust it had before proofs existed (edgeproof.go). s := &Server{cfg: Config{EdgeIPs: []string{"198.51.100.9"}}, store: st} r := &http.Request{Header: http.Header{}, RemoteAddr: "203.0.113.5:41234"} r.Header.Set("X-Forwarded-For", "1.2.3.4") if got := s.clientIP(r); got != "203.0.113.5" { t.Fatalf("clientIP from an untrusted peer = %q, want the peer address", got) } // The configured edge, and the loopback edge on this box, are believed — // and it is the LAST element that is theirs to state. for _, peer := range []string{"198.51.100.9:9", "127.0.0.1:9"} { r.RemoteAddr = peer r.Header.Set("X-Forwarded-For", "9.9.9.9, 1.2.3.4") if got := s.clientIP(r); got != "1.2.3.4" { t.Fatalf("clientIP behind %s = %q, want 1.2.3.4", peer, got) } } } // The label is the one field a human is asked to recognise, and the machine // asking chooses it. Bidi overrides and zero-width joiners exist to make a // string render as something it is not. func TestCleanLabelIsPrintableASCII(t *testing.T) { for in, want := range map[string]string{ "paul@laptop": "paul@laptop", "paul\u202e@evil": "paul@evil", "paul\u200b@laptop": "paul@laptop", "paul\x07@laptop\n": "paul@laptop", " paul@laptop ": "paul@laptop", "héllo@laptop": "hllo@laptop", strings.Repeat("a", 200): strings.Repeat("a", 64), } { if got := cleanLabel(in); got != want { t.Errorf("cleanLabel(%q) = %q, want %q", in, got, want) } } } // A denied login is final and tells the terminal so. func TestCLILoginDeny(t *testing.T) { fx := newConsoleFixture(t) browser := newBrowser(t) signUp(t, fx, browser, "code-deny") _, start := postJSON(t, fx.ts.URL+"/auth/cli/start", map[string]string{"label": "someone@elsewhere"}, "") userCode, deviceCode := str(t, start, "user_code"), str(t, start, "device_code") code, _, body := postForm(t, browser, fx.ts.URL+"/auth/cli/approve", url.Values{"code": {userCode}, "action": {"deny"}, "csrf": {sessionToken(t, fx, browser)}}) if code != http.StatusOK || !strings.Contains(body, "Denied") { t.Fatalf("deny = %d, body:\n%s", code, body) } _, poll := postJSON(t, fx.ts.URL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}, "") if str(t, poll, "status") != CLIAuthDenied { t.Fatalf("poll after deny = %v, want denied", poll) } } // The JSON endpoints refuse a form-encoded body. That is the CSRF story for // the unauthenticated half of the flow: a cross-site