console: a code you did not type cannot approve a terminal
The approval page honoured a prefilled ?code=, which made the printed verification_uri_complete a one-click credential handover. That is the device-code phishing campaign run at scale against Microsoft tenants: the attacker starts the flow, sends the victim the ready-made link with a pretext, and one click binds a 90-day token — held by the attacker — to the victim's identity. So the page no longer acts on ?code= at all: no lookup (which also stops it being an oracle for enumerating live codes), no description, no Approve button. The code from the URL is shown only as something to check against your own screen, and the field beside it is empty. Approving now takes typing the code, and to type it you have to be looking at a terminal you started. The CLI prints the bare /auth/cli and the code rather than the complete link, because printing that link would teach the habit the attack needs. Prevention is fallible, so issuance is now ANNOUNCED: a sealed Keymail message to the approving address naming the terminal, where it connected from, and how to revoke it — the same inbox sign-in confirmations use. That is what turns "somebody has been me for two months" into "I revoked it within the hour". Off the request goroutine, so a slow Keymail cannot strand a CLI that already has its token. Rate limiting held two DoS levers and one bypass. The poll limiter was keyed only on the caller-chosen device code, so every made-up code was a fresh budget AND a fresh map entry, and the sweep ran per request over a map an attacker sized, holding a global mutex in front of a single-writer database. Now: swept at most once a window, hard-bounded and dropped wholesale past that, and keyed on client IP as well as the code. Wrong typed codes are counted at BOTH places a code is resolved and against the address and the IP, not the session token — which is re-minted by signing in again, and so resets a counter keyed on it. The exposure that matters there is inverted: approving somebody else's pending code binds THEIR terminal to YOUR address. X-Forwarded-For is believed only from the edge (loopback, or a configured EdgeIP); from anyone else it is a string the client typed, and honouring it walks past every per-IP limit above. Labels are printable ASCII — no bidi overrides in the one field a human is asked to recognise their own terminal by — and the page says outright that the machine asking chose that name. Account-create failures log the detail and return a fixed sentence, like every other failure in that file. The CLI floors the poll interval and caps the overall deadline, so a console returning interval 0 is a wait rather than a hot loop; http:// consoles are restricted to loopback, since off-box http posts the token in clear text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9 files changed,
+555
−128
README.md+14 −4accounts.go+3 −0auth.go+64 −16auth_test.go+41 −2internal/console/authcli.go+250 −72internal/console/authcli_test.go+152 −22internal/console/server.go+4 −0internal/console/templates/cli_approve.html+22 −10internal/console/templates/cli_tokens.html+5 −2
diff --git a/README.md b/README.md| index 008acb88d..ae59fcb9e 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -86,10 +86,20 @@ self-hoster's CLI trusts their own box. |
| It prints a code rather than opening a browser or listening on a local port, |
| because the machine that needs the credential is usually an SSH session where |
| -neither works. You open the printed link in whatever browser you are already |
| -signed in on, check the code matches, and approve. The token lands in |
| -`~/.carlos/credentials` (mode 0600, one entry per console origin), expires in |
| -90 days, and is listed with a revoke button at `/auth/cli/tokens`. |
| +neither works. You open `/auth/cli` in whatever browser you are already signed |
| +in on and **type the code** the terminal printed. |
| + |
| +Typing it is the point, not a nicety: a link that arrives one click from an |
| +Approve button is the device-code phishing attack (the one run at scale against |
| +Microsoft tenants) — an attacker starts the flow, sends you their link, and one |
| +click hands them a credential that acts as you. So the console never approves |
| +from a prefilled code, and the CLI never prints the ready-made link. When a |
| +token is issued, a note goes to your Keymail inbox naming the terminal, where |
| +it connected from, and how to revoke it. |
| + |
| +The token lands in `~/.carlos/credentials` (mode 0600, one entry per console |
| +origin), expires in 90 days, and is listed with a revoke button at |
| +`/auth/cli/tokens`. |
| carlos auth whoami # who this terminal is, per the console |
| carlos auth logout # revokes the token, then forgets it |
diff --git a/accounts.go b/accounts.go| index 04a993c78..f0de44cc1 100644 |
| --- a/accounts.go |
| +++ b/accounts.go |
| @@ -151,6 +151,9 @@ func createAccountInBucket(regPath, name, owner string) { |
| // instant it exists — rather than "once something materializes it", which is the |
| // gap that produced accounts nobody could reach. |
| func createAccountViaConsole(cred *credential, regPath, name string) { |
| + if warn := credentialPermWarning(); warn != "" { |
| + fmt.Fprintln(os.Stderr, warn) |
| + } |
| c := newConsoleClient(cred.Console, cred.Token) |
| var out struct { |
| ID int64 `json:"id"` |
diff --git a/auth.go b/auth.go| index 20afff345..6e3d42c37 100644 |
| --- a/auth.go |
| +++ b/auth.go |
| @@ -37,6 +37,7 @@ import ( |
| "flag" |
| "fmt" |
| "io" |
| + "net" |
| "net/http" |
| "net/url" |
| "os" |
| @@ -312,9 +313,25 @@ func normalizeConsole(in string) (string, error) { |
| if u.Host == "" { |
| return "", fmt.Errorf("%q has no host", in) |
| } |
| + // http is for a console on this machine and nowhere else. Over http the |
| + // login flow hands a 90-day bearer token to anyone on the path, and a |
| + // typo'd or attacker-supplied http:// origin is a credential posted in |
| + // clear text to a stranger. |
| + if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) { |
| + return "", fmt.Errorf("%q is http:// but not local — a login token would cross the network in clear text. Use https://", in) |
| + } |
| return u.Scheme + "://" + u.Host, nil |
| } |
| +// isLoopbackHost reports whether a URL host is this machine. |
| +func isLoopbackHost(host string) bool { |
| + if host == "localhost" { |
| + return true |
| + } |
| + ip := net.ParseIP(host) |
| + return ip != nil && ip.IsLoopback() |
| +} |
| + |
| // ---- talking to the console ------------------------------------------------- |
| // consoleClient is the CLI's half of the console's JSON API. |
| @@ -418,18 +435,36 @@ func defaultLabel() string { |
| return name + "@" + host |
| } |
| -// pollWait bounds what a console can ask the CLI to sleep for. A console we |
| -// are about to trust with our identity is not one we need to defend against |
| -// hard, but an unbounded sleep from a JSON field is still not something to |
| -// hand a program. |
| +// Bounds on what a console can talk this process into doing. It is a console |
| +// we are about to trust with our identity, so this is not a threat model so |
| +// much as a refusal to turn two JSON fields into a CPU burner: a zero interval |
| +// is a hot loop, and an enormous expires_in is a hot loop that never ends. |
| +const ( |
| + minPollWait = time.Second |
| + maxPollWait = 30 * time.Second |
| + maxLoginWait = 15 * time.Minute |
| +) |
| + |
| +// pollWait bounds the sleep between polls, floor included: interval 0 must |
| +// never mean "spin". |
| func pollWait(seconds int) time.Duration { |
| - if seconds < 0 { |
| - seconds = 0 |
| + d := time.Duration(seconds) * time.Second |
| + if d < minPollWait { |
| + return minPollWait |
| } |
| - if seconds > 30 { |
| - seconds = 30 |
| + if d > maxPollWait { |
| + return maxPollWait |
| } |
| - return time.Duration(seconds) * time.Second |
| + return d |
| +} |
| + |
| +// loginDeadline bounds how long the CLI waits overall, whatever expires_in says. |
| +func loginDeadline(now time.Time, expiresIn int) time.Time { |
| + d := time.Duration(expiresIn) * time.Second |
| + if d <= 0 || d > maxLoginWait { |
| + d = maxLoginWait |
| + } |
| + return now.Add(d) |
| } |
| func authLogin(out io.Writer, args []string) error { |
| @@ -461,20 +496,27 @@ func authLogin(out io.Writer, args []string) error { |
| if start.DeviceCode == "" || start.UserCode == "" { |
| return fmt.Errorf("%s did not return a login code — is that a CARLOS console?", base) |
| } |
| - link := start.VerifyURIFull |
| + // The BARE verification URI plus the code, never the ready-made |
| + // ?code=… link, even though the console offers one. |
| + // |
| + // A link that lands one click from an Approve button is the whole |
| + // device-code phishing attack: an attacker starts this flow, sends you |
| + // their link, and one click hands them a 90-day credential that acts as |
| + // you. The console refuses to approve from a prefilled code for that |
| + // reason, and printing the complete URL here would teach people that |
| + // following such a link is the normal way to do this — which is exactly |
| + // the habit the attack needs. Type the code. |
| + link := start.VerifyURI |
| if link == "" { |
| - link = base + "/auth/cli?code=" + url.QueryEscape(start.UserCode) |
| + link = base + "/auth/cli" |
| } |
| // Printed, never opened: this frequently runs over SSH, where opening a |
| // browser would open it on the wrong machine. |
| fmt.Fprintf(out, "\nOpen this in a browser signed in to %s:\n\n %s\n\n", base, link) |
| - fmt.Fprintf(out, "and check it shows the code %s\n\n", start.UserCode) |
| + fmt.Fprintf(out, "and type this code:\n\n %s\n\n", start.UserCode) |
| fmt.Fprintf(out, "Waiting for approval (this code expires in %d minutes)…\n", start.ExpiresIn/60) |
| - deadline := time.Now().Add(time.Duration(start.ExpiresIn) * time.Second) |
| - if start.ExpiresIn <= 0 { |
| - deadline = time.Now().Add(10 * time.Minute) |
| - } |
| + deadline := loginDeadline(time.Now(), start.ExpiresIn) |
| wait := pollWait(start.Interval) |
| for time.Now().Before(deadline) { |
| time.Sleep(wait) |
| @@ -517,6 +559,12 @@ func authLogin(out io.Writer, args []string) error { |
| if err := f.save(); err != nil { |
| return fmt.Errorf("storing the credential: %w", err) |
| } |
| + // Checked here as well as in whoami: this is the moment a bearer |
| + // token first exists on this machine, and a pre-existing file with |
| + // loose permissions is exactly when to say so. |
| + if warn := credentialPermWarning(); warn != "" { |
| + fmt.Fprintln(out, warn) |
| + } |
| fmt.Fprintf(out, "\nLogged in to %s as %s.\n", base, poll.Address) |
| fmt.Fprintf(out, "Token stored in %s (mode 0600); it expires %s.\n", |
| credentialsPath(), expires.Format("2006-01-02")) |
diff --git a/auth_test.go b/auth_test.go| index face9e0f1..ae735e5df 100644 |
| --- a/auth_test.go |
| +++ b/auth_test.go |
| @@ -137,6 +137,39 @@ func TestNormalizeConsole(t *testing.T) { |
| t.Errorf("normalizeConsole(%q) = %q, want an error", bad, got) |
| } |
| } |
| + // http is for a console on this machine and nowhere else: over the network |
| + // it posts a 90-day bearer token in clear text. |
| + for _, bad := range []string{"http://platform.carlosframework.com", "http://192.168.1.4:8090"} { |
| + if got, err := normalizeConsole(bad); err == nil { |
| + t.Errorf("normalizeConsole(%q) = %q, want a refusal — http off-box", bad, got) |
| + } |
| + } |
| + for _, ok := range []string{"http://localhost:8090", "http://127.0.0.1:8090", "http://[::1]:8090"} { |
| + if _, err := normalizeConsole(ok); err != nil { |
| + t.Errorf("normalizeConsole(%q) = %v, want a local console to be allowed", ok, err) |
| + } |
| + } |
| +} |
| + |
| +// A console can ask a CLI to wait; it cannot talk it into a hot loop or an |
| +// endless one. |
| +func TestPollBoundsAreEnforced(t *testing.T) { |
| + if got := pollWait(0); got < time.Second { |
| + t.Fatalf("pollWait(0) = %v — interval 0 must not mean spin", got) |
| + } |
| + if got := pollWait(-5); got < time.Second { |
| + t.Fatalf("pollWait(-5) = %v, want the floor", got) |
| + } |
| + if got := pollWait(99999); got > 30*time.Second { |
| + t.Fatalf("pollWait(99999) = %v, want the cap", got) |
| + } |
| + now := time.Now() |
| + if got := loginDeadline(now, 99999999); got.After(now.Add(maxLoginWait)) { |
| + t.Fatalf("loginDeadline with an absurd expires_in = %v, want it capped", got.Sub(now)) |
| + } |
| + if got := loginDeadline(now, 0); !got.After(now) { |
| + t.Fatalf("loginDeadline with no expires_in = %v, want a sane default", got.Sub(now)) |
| + } |
| } |
| // fakeConsole answers the CLI endpoints the way the real console does, so the |
| @@ -231,12 +264,18 @@ func TestAuthLoginStoresACredential(t *testing.T) { |
| if err := authLogin(&out, []string{"-console", fc.ts.URL, "-label", "paul@laptop"}); err != nil { |
| t.Fatalf("authLogin: %v\n%s", err, out.String()) |
| } |
| - // The human is told where to go and what to type — that IS the flow. |
| - for _, want := range []string{"/auth/cli?code=WXYZ-4823", "WXYZ-4823", "paul@keymail.dev"} { |
| + // The human is told where to go and what to TYPE — that IS the flow. |
| + for _, want := range []string{fc.ts.URL + "/auth/cli", "type this code", "WXYZ-4823", "paul@keymail.dev"} { |
| if !strings.Contains(out.String(), want) { |
| t.Fatalf("login output missing %q:\n%s", want, out.String()) |
| } |
| } |
| + // And never the ready-made ?code= link. Printing that would teach people |
| + // that following a link somebody sent them is how this works, which is |
| + // precisely the habit device-code phishing needs. |
| + if strings.Contains(out.String(), "?code=") { |
| + t.Fatalf("login printed a one-click approval link:\n%s", out.String()) |
| + } |
| if got, _ := fc.label.Load().(string); got != "paul@laptop" { |
| t.Fatalf("label sent = %q, want paul@laptop", got) |
| } |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index e3e15cfcf..faa76a60c 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -25,6 +25,7 @@ import ( |
| "context" |
| "crypto/subtle" |
| "encoding/json" |
| + "fmt" |
| "log" |
| "net" |
| "net/http" |
| @@ -44,10 +45,18 @@ const ( |
| cliStartBurst = 10 |
| // cliPollBurst is per device code — a polite CLI polls every 2s, so 60 a |
| // minute is twice what the honest client needs and nowhere near a spin. |
| + // It is NOT a defence on its own: the device code is chosen by the |
| + // caller, so a flood simply presents a fresh key every time. cliPollIPBurst |
| + // is the limit that actually bounds a flood. |
| cliPollBurst = 60 |
| - // cliGuessBurst bounds WRONG typed codes per session. The user code is |
| - // ~39 bits with a ten-minute life; at 10 guesses a minute it is not |
| - // findable in that window by any margin that matters. |
| + // cliPollIPBurst bounds polling per client IP, whatever device codes it |
| + // claims to hold. Roomy enough for a shared NAT with a dozen developers |
| + // mid-login at once. |
| + cliPollIPBurst = 240 |
| + // cliGuessBurst bounds WRONG typed codes, counted against the signed-in |
| + // address and against the client IP. The user code is ~39 bits with a |
| + // ten-minute life; at 10 guesses a minute it is not findable in that |
| + // window by any margin that matters. |
| cliGuessBurst = 10 |
| // cliWriteBurst bounds authenticated mutations per address. |
| cliWriteBurst = 30 |
| @@ -60,10 +69,11 @@ const ( |
| // one process on one box, and reaching for a shared store here would add a |
| // dependency to buy nothing. |
| type rateLimiter struct { |
| - mu sync.Mutex |
| - max int |
| - window time.Duration |
| - hits map[string]*rateWindow |
| + mu sync.Mutex |
| + max int |
| + window time.Duration |
| + lastSweep time.Time |
| + hits map[string]*rateWindow |
| } |
| type rateWindow struct { |
| @@ -71,22 +81,38 @@ type rateWindow struct { |
| n int |
| } |
| +// maxRateKeys hard-bounds a limiter's map. Some limiters are keyed on |
| +// caller-chosen values (a device code), so "one entry per distinct key" is a |
| +// memory budget an attacker writes — this is where that stops. |
| +const maxRateKeys = 8192 |
| + |
| func newRateLimiter(max int, window time.Duration) *rateLimiter { |
| return &rateLimiter{max: max, window: window, hits: map[string]*rateWindow{}} |
| } |
| // allow records one hit and reports whether it is within the window's budget. |
| +// |
| +// The sweep runs at most once per window, NEVER per request: an O(n) walk on |
| +// every call — while holding this mutex, in front of a database limited to one |
| +// writer that every console page also queues behind — turns a flood on one |
| +// endpoint into a stall on the whole console. Over the hard bound the map is |
| +// dropped wholesale rather than walked, because under a flood of made-up keys |
| +// there is nothing to distinguish worth keeping, and forgetting counters is a |
| +// far smaller failure than freezing the site. |
| func (l *rateLimiter) allow(key string, now time.Time) bool { |
| l.mu.Lock() |
| defer l.mu.Unlock() |
| - // Cheap sweep so a long-running console does not accumulate a key per IP |
| - // that ever touched it. Bounded work: only when the map has grown. |
| - if len(l.hits) > 4096 { |
| + if now.Sub(l.lastSweep) >= l.window { |
| for k, w := range l.hits { |
| if now.Sub(w.start) > l.window { |
| delete(l.hits, k) |
| } |
| } |
| + l.lastSweep = now |
| + } |
| + if len(l.hits) > maxRateKeys { |
| + l.hits = make(map[string]*rateWindow, 64) |
| + l.lastSweep = now |
| } |
| w := l.hits[key] |
| if w == nil || now.Sub(w.start) > l.window { |
| @@ -100,15 +126,20 @@ func (l *rateLimiter) allow(key string, now time.Time) bool { |
| // cliLimits is the set of limiters the CLI endpoints share, hung off the |
| // Server so a test gets a fresh set per fixture. |
| type cliLimits struct { |
| - start, poll, guess, write *rateLimiter |
| + start, poll, pollIP, guessAddr, guessIP, write *rateLimiter |
| } |
| func newCLILimits() *cliLimits { |
| return &cliLimits{ |
| - start: newRateLimiter(cliStartBurst, cliRateWindow), |
| - poll: newRateLimiter(cliPollBurst, cliRateWindow), |
| - guess: newRateLimiter(cliGuessBurst, cliRateWindow), |
| - write: newRateLimiter(cliWriteBurst, cliRateWindow), |
| + start: newRateLimiter(cliStartBurst, cliRateWindow), |
| + poll: newRateLimiter(cliPollBurst, cliRateWindow), |
| + pollIP: newRateLimiter(cliPollIPBurst, cliRateWindow), |
| + // Wrong codes are counted against the signed-in ADDRESS and the client |
| + // IP, never the session token: a session is re-minted by signing in |
| + // again, so a token-keyed counter is one the attacker resets at will. |
| + guessAddr: newRateLimiter(cliGuessBurst, cliRateWindow), |
| + guessIP: newRateLimiter(cliGuessBurst, cliRateWindow), |
| + write: newRateLimiter(cliWriteBurst, cliRateWindow), |
| } |
| } |
| @@ -125,24 +156,51 @@ func (s *Server) registerCLIRoutes() { |
| s.mux.HandleFunc("POST /auth/cli/accounts", s.handleCLINewAccount) |
| } |
| -// clientIP is who the rate limiter counts against. The console runs behind |
| -// the edge, which sets X-Forwarded-For with SetXForwarded — appending the |
| -// real client to whatever arrived — so the LAST element is the edge's own |
| -// view of the peer and the only element not under the client's control. |
| -func clientIP(r *http.Request) string { |
| +// clientIP is who the rate limiter counts against. |
| +// |
| +// X-Forwarded-For is only consulted when the PEER is one we put there: the |
| +// edge on this box (loopback — the console listens on 127.0.0.1) or a |
| +// configured edge address. From anyone else the header is just a string the |
| +// client typed, and honouring it would let one caller present a fresh "IP" |
| +// per request and walk straight past every per-IP limit below. |
| +// |
| +// When the peer is trusted, the LAST element is the one to take: the edge |
| +// appends its own view of the peer with SetXForwarded, so everything before |
| +// it may have been supplied by the client. |
| +func (s *Server) clientIP(r *http.Request) string { |
| + host, _, err := net.SplitHostPort(r.RemoteAddr) |
| + if err != nil { |
| + host = r.RemoteAddr |
| + } |
| + if !s.trustsProxy(host) { |
| + return host |
| + } |
| if xff := r.Header.Get("X-Forwarded-For"); xff != "" { |
| parts := strings.Split(xff, ",") |
| if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" { |
| return ip |
| } |
| } |
| - host, _, err := net.SplitHostPort(r.RemoteAddr) |
| - if err != nil { |
| - return r.RemoteAddr |
| - } |
| return host |
| } |
| +// trustsProxy reports whether a peer is allowed to speak for somebody else. |
| +func (s *Server) trustsProxy(host string) bool { |
| + ip := net.ParseIP(host) |
| + if ip == nil { |
| + return false |
| + } |
| + if ip.IsLoopback() { |
| + return true // the edge, on this box, which is the only deployment shape |
| + } |
| + for _, e := range s.cfg.EdgeIPs { |
| + if other := net.ParseIP(strings.TrimSpace(e)); other != nil && other.Equal(ip) { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| // ---- JSON plumbing ---------------------------------------------------------- |
| func writeJSON(w http.ResponseWriter, status int, body any) { |
| @@ -204,14 +262,19 @@ func (s *Server) cliUser(w http.ResponseWriter, r *http.Request) (string, bool) |
| // ---- the flow --------------------------------------------------------------- |
| -// cleanLabel bounds and de-fangs what a terminal calls itself. It is rendered |
| -// on the approval page (auto-escaped there) and stored; control characters |
| -// would let a caller scribble on an operator's terminal when it is echoed |
| -// back by the CLI. |
| +// cleanLabel bounds and de-fangs what a terminal calls itself. |
| +// |
| +// PRINTABLE ASCII ONLY, which is stricter than it looks like it needs to be. |
| +// This is the one field on the approval page a human is asked to recognise — |
| +// "is that my terminal?" — and it is chosen by whoever is asking for the |
| +// credential. Allowing the full printable Unicode range would allow |
| +// right-to-left overrides and zero-width joiners, which exist precisely to |
| +// make a string render as something other than what it is. A label is |
| +// user@host; nobody needs U+202E in one. |
| func cleanLabel(in string) string { |
| var b strings.Builder |
| for _, r := range in { |
| - if r < 0x20 || r == 0x7f { |
| + if r < 0x20 || r > 0x7e { |
| continue |
| } |
| b.WriteRune(r) |
| @@ -233,7 +296,7 @@ func (s *Server) handleCLIStart(w http.ResponseWriter, r *http.Request) { |
| if !readJSON(w, r, &req) { |
| return |
| } |
| - ip := clientIP(r) |
| + ip := s.clientIP(r) |
| if !s.cli.start.allow(ip, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many login attempts from here — wait a minute and try again") |
| return |
| @@ -264,9 +327,13 @@ func (s *Server) handleCLIPoll(w http.ResponseWriter, r *http.Request) { |
| if !readJSON(w, r, &req) { |
| return |
| } |
| - // Keyed on the device code, not the IP: two terminals behind one NAT must |
| - // not throttle each other, and a device code is exactly one flow. |
| - if !s.cli.poll.allow(hashToken(req.DeviceCode), time.Now()) { |
| + // Both keys, and the IP one first. The device code is chosen by the |
| + // caller, so a per-code limit alone is no limit at all — every made-up |
| + // code is a fresh budget, and a fresh map entry. The per-code limit still |
| + // earns its place: it stops one honest-but-broken client spinning, without |
| + // two developers behind one NAT throttling each other. |
| + now := time.Now() |
| + if !s.cli.pollIP.allow(s.clientIP(r), now) || !s.cli.poll.allow(hashToken(req.DeviceCode), now) { |
| jsonError(w, http.StatusTooManyRequests, "polling too fast") |
| return |
| } |
| @@ -282,19 +349,81 @@ func (s *Server) handleCLIPoll(w http.ResponseWriter, r *http.Request) { |
| out["address"] = addr |
| out["expires_at"] = time.Now().Add(CLITokenTTL).UTC().Format(time.RFC3339) |
| out["console"] = s.cfg.Origin |
| + go s.notifyCLIIssued(addr, token) |
| } |
| writeJSON(w, http.StatusOK, out) |
| } |
| +// notifyCLIIssued tells the human, in their own Keymail inbox, that a terminal |
| +// now holds their identity — naming it, where it dialled from, and how to kill |
| +// it. |
| +// |
| +// This is the control that makes a phished approval DETECTABLE. Every other |
| +// defence in this flow is preventive and therefore fallible; a message that |
| +// arrives whether or not the approval was a good idea is what turns "somebody |
| +// has been me for two months" into "I revoked that within the hour". Same |
| +// sealed-envelope channel as sign-in confirmations and invites — the platform |
| +// still emails nobody. |
| +// |
| +// Off the request goroutine on purpose: a slow Keymail must never stall the |
| +// poll the CLI is waiting on. Issuance has already happened and the request row |
| +// is burnt, so a CLI that gave up would otherwise leave a token nobody |
| +// collected. Best-effort throughout — a user who has never completed the OAuth |
| +// ceremony has no grant to send with, and that is not a reason to fail a login. |
| +func (s *Server) notifyCLIIssued(address, rawToken string) { |
| + defer func() { |
| + if s.cliNotified != nil { |
| + s.cliNotified <- struct{}{} |
| + } |
| + }() |
| + grant, err := s.store.GrantToken(address) |
| + if err != nil || grant == "" { |
| + return |
| + } |
| + label, from := "", "" |
| + if toks, err := s.store.CLITokens(address); err == nil { |
| + for _, t := range toks { |
| + if t.ID == hashToken(rawToken) { |
| + label, from = t.Label, t.RemoteAddr |
| + } |
| + } |
| + } |
| + if label == "" { |
| + label = "(unnamed terminal)" |
| + } |
| + if from == "" { |
| + from = "an address the console could not determine" |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) |
| + defer cancel() |
| + if _, err := s.km.SendApp(ctx, grant, address, |
| + "A command line signed in as you on the CARLOS platform", |
| + "A terminal was just approved to act as "+address+" on the CARLOS platform.\n\n"+ |
| + " terminal: "+label+"\n"+ |
| + " from: "+from+"\n"+ |
| + " when: "+time.Now().UTC().Format("2006-01-02 15:04 UTC")+"\n\n"+ |
| + "It can do anything you can do from the command line, for the next "+ |
| + fmt.Sprint(int(CLITokenTTL/(24*time.Hour)))+" days.\n\n"+ |
| + "If that was you running `carlos auth login`, nothing to do.\n\n"+ |
| + "If it was NOT you — if you clicked a link somebody sent you — revoke it now:\n\n"+ |
| + s.cfg.Origin+"/auth/cli/tokens\n"); err != nil { |
| + log.Printf("console: could not notify %s of a CLI token issue: %v", address, err) |
| + } |
| +} |
| + |
| // cliPage is the approval and tokens pages' data. It embeds page so the |
| // shared chrome ("top"/"bottom") keeps working unchanged. |
| type cliPage struct { |
| page |
| - Code string |
| - Console string // this deployment's origin — the -console the CLI needs |
| - Request *CLIAuthRequest |
| - Tokens []CLIToken |
| - Days int |
| + Code string // the code the human typed, echoed back into the form |
| + // LinkCode is the code carried by the ?code= in the URL they followed. |
| + // Shown ONLY as something to check against the terminal's screen — never |
| + // prefilled into the form, never resolved. See handleCLIApprovePage. |
| + LinkCode string |
| + Console string // this deployment's origin — the -console the CLI needs |
| + Request *CLIAuthRequest |
| + Tokens []CLIToken |
| + Days int |
| } |
| func (s *Server) renderCLI(w http.ResponseWriter, status int, name string, p cliPage) { |
| @@ -309,6 +438,17 @@ func (s *Server) renderCLI(w http.ResponseWriter, status int, name string, p cli |
| // goes to the front page to sign in and re-opens the link — the code is in |
| // their terminal, so nothing is lost, and the console never carries a pending |
| // approval through an authentication it has not finished. |
| +// |
| +// IT DELIBERATELY DOES NOT ACT ON ?code=. Not to resolve it, not to look it |
| +// up, and above all not to render an Approve button for it. A prefilled code |
| +// that arrives one click from approval is the device-code phishing attack that |
| +// has been run at scale against Microsoft tenants: the attacker starts the |
| +// flow, sends the victim the ready-made link with a plausible pretext, and one |
| +// click binds a 90-day credential — held by the attacker — to the victim's |
| +// identity. Requiring the code to be TYPED is what breaks it: to type it you |
| +// have to be reading it off a terminal you started, and a code you cannot see |
| +// is one you cannot transcribe. The value from the URL is shown only as |
| +// something to check against your screen, never as something to submit. |
| func (s *Server) handleCLIApprovePage(w http.ResponseWriter, r *http.Request) { |
| addr, raw, ok := s.session(r) |
| if !ok { |
| @@ -317,21 +457,23 @@ func (s *Server) handleCLIApprovePage(w http.ResponseWriter, r *http.Request) { |
| } |
| p := cliPage{page: page{Title: "Approve a terminal", Address: addr, CSRF: raw}, |
| Days: int(CLITokenTTL / (24 * time.Hour))} |
| - p.Code = NormalizeUserCode(r.URL.Query().Get("code")) |
| - if p.Code != "" { |
| - req, err := s.store.CLIAuthByUserCode(p.Code, time.Now()) |
| - if err != nil { |
| - p.Error = "That code has expired or was already used. Run `carlos auth login` again for a fresh one." |
| - } else { |
| - p.Request = req |
| - } |
| - } |
| + // Echoed for comparison only — no store lookup, which also means this page |
| + // is not an oracle for enumerating live codes. |
| + p.LinkCode = NormalizeUserCode(r.URL.Query().Get("code")) |
| s.renderCLI(w, http.StatusOK, "cli_approve", p) |
| } |
| -// handleCLIApprove is the decision. Session-gated and CSRF-checked with the |
| -// double-submitted session token, exactly like every other mutation here: a |
| -// cross-site POST that could approve a terminal would be the whole attack. |
| +// handleCLIApprove is both steps a human takes, distinguished by action: |
| +// |
| +// continue — a typed code is looked up and the terminal asking is DESCRIBED |
| +// (label, where it dialled from, when it expires) |
| +// approve — the decision, on a code the human has now both typed and seen |
| +// described |
| +// deny — "that wasn't me", which is final |
| +// |
| +// All three are session-gated and CSRF-checked with the double-submitted |
| +// session token, exactly like every other mutation here: a cross-site POST |
| +// that could approve a terminal would be the whole attack. |
| func (s *Server) handleCLIApprove(w http.ResponseWriter, r *http.Request) { |
| addr, raw, ok := s.session(r) |
| if !ok { |
| @@ -343,34 +485,66 @@ func (s *Server) handleCLIApprove(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| code := NormalizeUserCode(r.FormValue("code")) |
| - deny := r.FormValue("action") == "deny" |
| + action := r.FormValue("action") |
| + now := time.Now() |
| p := cliPage{page: page{Title: "Approve a terminal", Address: addr, CSRF: raw}, |
| Code: code, Days: int(CLITokenTTL / (24 * time.Hour))} |
| - now := time.Now() |
| - var err error |
| - if deny { |
| - err = s.store.DenyCLIAuth(code, now) |
| - } else { |
| - err = s.store.ApproveCLIAuth(code, addr, now) |
| + |
| + // Wrong codes are counted wherever a code is resolved, and against both |
| + // the signed-in address and the client IP. The exposure this bounds is not |
| + // the obvious one: guessing somebody else's PENDING code and approving it |
| + // binds THEIR terminal to YOUR address, which is the inverted-payoff half |
| + // of this flow and the reason the counter has to exist even though ~39 |
| + // bits in ten minutes is not findable. |
| + badCode := func() bool { |
| + ip := s.clientIP(r) |
| + okAddr := s.cli.guessAddr.allow(addr, now) |
| + okIP := s.cli.guessIP.allow(ip, now) |
| + return okAddr && okIP |
| + } |
| + tooMany := func() { |
| + s.renderMessage(w, r, http.StatusTooManyRequests, "Too many attempts", |
| + "Too many bad login codes. Wait a minute, then try again.") |
| } |
| - if err != nil { |
| - // Only WRONG codes are counted: a human who mistypes twice and then |
| - // gets it right is not an attacker, but a script working through the |
| - // code space only ever lands here. |
| - if !s.cli.guess.allow(hashToken(raw), now) { |
| - s.renderMessage(w, r, http.StatusTooManyRequests, "Too many attempts", |
| - "Too many bad login codes. Wait a minute, then try again.") |
| + |
| + switch action { |
| + case "approve", "deny": |
| + var err error |
| + if action == "deny" { |
| + err = s.store.DenyCLIAuth(code, now) |
| + } else { |
| + err = s.store.ApproveCLIAuth(code, addr, now) |
| + } |
| + if err != nil { |
| + if !badCode() { |
| + tooMany() |
| + return |
| + } |
| + p.Error = "That code has expired or was already used. Run carlos auth login again for a fresh one." |
| + s.renderCLI(w, http.StatusOK, "cli_approve", p) |
| return |
| } |
| - p.Error = "That code has expired or was already used. Run `carlos auth login` again for a fresh one." |
| + p.Title = "Approved" |
| + if action == "deny" { |
| + p.Title = "Denied" |
| + } |
| + s.renderCLI(w, http.StatusOK, "cli_decided", p) |
| + default: |
| + // "continue": describe the terminal, then let the human decide. |
| + req, err := s.store.CLIAuthByUserCode(code, now) |
| + if err != nil { |
| + if !badCode() { |
| + tooMany() |
| + return |
| + } |
| + p.Error = "No login is waiting on that code — check what your terminal printed. " + |
| + "Codes last ten minutes." |
| + s.renderCLI(w, http.StatusOK, "cli_approve", p) |
| + return |
| + } |
| + p.Request = req |
| s.renderCLI(w, http.StatusOK, "cli_approve", p) |
| - return |
| - } |
| - p.Title = "Approved" |
| - if deny { |
| - p.Title = "Denied" |
| } |
| - s.renderCLI(w, http.StatusOK, "cli_decided", p) |
| } |
| // handleCLITokens lists the signed-in user's own CLI credentials. Per USER, |
| @@ -482,8 +656,12 @@ func (s *Server) handleCLINewAccount(w http.ResponseWriter, r *http.Request) { |
| defer cancel() |
| a, err := s.mintAccount(ctx, name, addr) |
| if err != nil { |
| + // Logged, not returned: every other failure in this console answers a |
| + // caller with a fixed sentence and keeps the detail on the box. A |
| + // mint failure is a bucket error, and bucket errors carry key names, |
| + // endpoints and occasionally credentials-adjacent text. |
| log.Printf("console: cli mint account for %s: %v", addr, err) |
| - jsonError(w, http.StatusBadGateway, "could not create the account: "+err.Error()) |
| + jsonError(w, http.StatusBadGateway, "could not create the account — try again") |
| return |
| } |
| writeJSON(w, http.StatusOK, map[string]any{ |
diff --git a/internal/console/authcli_test.go b/internal/console/authcli_test.go| index 082a4b468..c79cea0d8 100644 |
| --- a/internal/console/authcli_test.go |
| +++ b/internal/console/authcli_test.go |
| @@ -8,6 +8,7 @@ import ( |
| "net/url" |
| "strings" |
| "testing" |
| + "time" |
| ) |
| // postJSON is how the CLI talks to the console: a JSON body, an optional |
| @@ -95,11 +96,13 @@ func TestCLILoginEndToEnd(t *testing.T) { |
| t.Fatalf("poll before approval = %v, want pending", poll) |
| } |
| - // The approval page names the terminal, so a human can tell theirs from |
| - // somebody else's. |
| - code, _, body := get(t, browser, fx.ts.URL+"/auth/cli?code="+url.QueryEscape(userCode)) |
| + // 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("approval page = %d", code) |
| + t.Fatalf("continue = %d", code) |
| } |
| for _, want := range []string{"paul@laptop", userCode, "Approve"} { |
| if !strings.Contains(body, want) { |
| @@ -109,7 +112,6 @@ func TestCLILoginEndToEnd(t *testing.T) { |
| // CSRF: the session token double-submitted, exactly like every other |
| // mutation on this console. |
| - csrf := sessionToken(t, fx, browser) |
| 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) |
| @@ -177,6 +179,126 @@ func TestCLILoginEndToEnd(t *testing.T) { |
| } |
| } |
| +// 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") |
| + postForm(t, 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", "/auth/cli/tokens", "revoke"} { |
| + if !strings.Contains(found, want) { |
| + t.Fatalf("issuance notice missing %q:\n%s", want, found) |
| + } |
| + } |
| +} |
| + |
| +// 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) { |
| + s := &Server{cfg: Config{EdgeIPs: []string{"198.51.100.9"}}} |
| + 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) |
| @@ -229,24 +351,32 @@ func TestCLIStartRateLimited(t *testing.T) { |
| } |
| // Guessing the short typed code is the one brute-forceable step, so wrong |
| -// codes are limited too — per browser session, which is what an attacker who |
| -// is signed in as themselves actually has. |
| +// codes are counted — at BOTH places a code is resolved, and against the |
| +// signed-in address and the client IP rather than the session token (which is |
| +// re-minted by signing in again, and so resets a counter keyed on it). |
| +// |
| +// The payoff being bounded here is the inverted one: guessing somebody else's |
| +// pending code and approving it binds THEIR terminal to YOUR address. |
| func TestCLIApproveRateLimitsWrongCodes(t *testing.T) { |
| - fx := newConsoleFixture(t) |
| - browser := newBrowser(t) |
| - signUp(t, fx, browser, "code-brute") |
| - csrf := sessionToken(t, fx, browser) |
| - limited := false |
| - for i := 0; i < cliGuessBurst+3; i++ { |
| - code, _, _ := postForm(t, browser, fx.ts.URL+"/auth/cli/approve", |
| - url.Values{"code": {"AAAA-AAAA"}, "action": {"approve"}, "csrf": {csrf}}) |
| - if code == http.StatusTooManyRequests { |
| - limited = true |
| - break |
| - } |
| - } |
| - if !limited { |
| - t.Fatalf("wrong codes were never rate-limited in %d attempts", cliGuessBurst+3) |
| + for _, action := range []string{"continue", "approve"} { |
| + t.Run(action, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + browser := newBrowser(t) |
| + signUp(t, fx, browser, "code-brute-"+action) |
| + csrf := sessionToken(t, fx, browser) |
| + limited := false |
| + for i := 0; i < cliGuessBurst+3; i++ { |
| + code, _, _ := postForm(t, browser, fx.ts.URL+"/auth/cli/approve", |
| + url.Values{"code": {"AAAA-AAAA"}, "action": {action}, "csrf": {csrf}}) |
| + if code == http.StatusTooManyRequests { |
| + limited = true |
| + break |
| + } |
| + } |
| + if !limited { |
| + t.Fatalf("wrong codes were never rate-limited in %d attempts", cliGuessBurst+3) |
| + } |
| + }) |
| } |
| } |
diff --git a/internal/console/server.go b/internal/console/server.go| index 1fc8a78b4..dd266bdef 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -112,6 +112,10 @@ type Server struct { |
| instances *instanceFetcher |
| secure bool // Secure cookies iff the origin is https |
| cli *cliLimits // rate limits on the CLI-identity endpoints (authcli.go) |
| + // cliNotified, when non-nil, is signalled after each CLI-token issuance |
| + // notification attempt. Tests only — the send is off the request |
| + // goroutine, and a test that asserted on it without this would be racing. |
| + cliNotified chan struct{} |
| } |
| // NewServer wires a server over an opened Store, loading (or minting) the |
diff --git a/internal/console/templates/cli_approve.html b/internal/console/templates/cli_approve.html| index 3268452b2..741918fa3 100644 |
| --- a/internal/console/templates/cli_approve.html |
| +++ b/internal/console/templates/cli_approve.html |
| @@ -5,14 +5,16 @@ |
| <p class="lede">A command line asked to sign in as you.</p> |
| <div class="banner"> |
| <p><strong>Code</strong> <code>{{.Request.UserCode}}</code></p> |
| - <p><strong>Terminal</strong> {{emptyOrDash .Request.Label}}</p> |
| + <p><strong>Terminal</strong> {{emptyOrDash .Request.Label}} |
| + <span class="small">— this name was supplied by the machine asking. Trust |
| + it only if it matches the machine in front of you.</span></p> |
| <p><strong>From</strong> {{emptyOrDash .Request.RemoteAddr}}</p> |
| <p class="small">Expires {{fmtTime .Request.ExpiresAt}}</p> |
| </div> |
| -<p>Approve only if you just ran <code>carlos auth login</code> yourself, and |
| -the code above is the one your terminal printed. Approving gives that terminal |
| -your identity — every account you belong to — for {{.Days}} days. You can |
| -revoke it at any time from <a href="/auth/cli/tokens">command-line access</a>.</p> |
| +<p>Approving gives that terminal your identity — every account you belong to — |
| +for {{.Days}} days. You can revoke it at any time from |
| +<a href="/auth/cli/tokens">command-line access</a>, and a note goes to your |
| +Keymail inbox the moment it is issued.</p> |
| <form class="signin" method="post" action="/auth/cli/approve"> |
| <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| <input type="hidden" name="code" value="{{.Request.UserCode}}"> |
| @@ -21,14 +23,23 @@ revoke it at any time from <a href="/auth/cli/tokens">command-line access</a>.</ |
| </form> |
| {{else}} |
| <p class="lede">Type the code your terminal printed.</p> |
| +{{if .LinkCode}} |
| +<div class="banner"> |
| + <p>The link you followed carries the code <code>{{.LinkCode}}</code>.</p> |
| + <p class="small"><strong>Check it against your own screen before typing it.</strong> |
| + If you did not just run <code>carlos auth login</code> yourself, or that is not |
| + the code your terminal printed, close this page: somebody else is asking to |
| + become you, and typing their code is what would let them.</p> |
| +</div> |
| +{{end}} |
| <form class="signin" method="post" action="/auth/cli/approve"> |
| + <input type="text" name="code" value="" placeholder="XXXX-XXXX" autocomplete="off" spellcheck="false"> |
| <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| - <input type="text" name="code" value="{{.Code}}" placeholder="XXXX-XXXX" autocomplete="off" spellcheck="false"> |
| - <button type="submit" name="action" value="approve">Continue</button> |
| + <button type="submit" name="action" value="continue">Continue</button> |
| </form> |
| <p class="small">Codes look like <code>WXYZ-4823</code> and last ten minutes. |
| -Nothing here can be approved without one, and approving a code you did not |
| -generate hands your identity to whoever did.</p> |
| +Nothing here is ever approved by following a link — the code has to be typed, |
| +which means it has to be on a screen in front of you.</p> |
| {{end}} |
| {{template "bottom" .}}{{end}} |
| @@ -36,7 +47,8 @@ generate hands your identity to whoever did.</p> |
| <h1>{{.Title}}</h1> |
| {{if eq .Title "Approved"}} |
| <p>Your terminal has the credential — it should say so within a couple of |
| -seconds. It expires in {{.Days}} days.</p> |
| +seconds. It expires in {{.Days}} days, and a note is on its way to your Keymail |
| +inbox.</p> |
| <p><a href="/auth/cli/tokens">See every terminal signed in as you</a>, and |
| revoke any you don't recognise.</p> |
| {{else}} |
diff --git a/internal/console/templates/cli_tokens.html b/internal/console/templates/cli_tokens.html| index f44eec201..3dfd680e5 100644 |
| --- a/internal/console/templates/cli_tokens.html |
| +++ b/internal/console/templates/cli_tokens.html |
| @@ -6,10 +6,11 @@ |
| {{if .Tokens}} |
| <div class="tablewrap"> |
| <table> |
| - <tr><th>Terminal</th><th>Signed in</th><th>Last used</th><th>Expires</th><th></th></tr> |
| + <tr><th>Terminal</th><th>From</th><th>Signed in</th><th>Last used</th><th>Expires</th><th></th></tr> |
| {{range .Tokens}} |
| <tr> |
| <td>{{emptyOrDash .Label}}</td> |
| + <td>{{emptyOrDash .RemoteAddr}}</td> |
| <td>{{fmtTime .CreatedAt}}</td> |
| <td>{{if .LastUsed.IsZero}}—{{else}}{{fmtTime .LastUsed}}{{end}}</td> |
| <td>{{fmtTime .ExpiresAt}}</td> |
| @@ -25,7 +26,9 @@ |
| </table> |
| </div> |
| <p class="small">Revoking is immediate: that terminal's next command fails and |
| -it has to log in again. Tokens expire on their own after {{.Days}} days.</p> |
| +it has to log in again. Tokens expire on their own after {{.Days}} days. The |
| +name and the address are what the machine asking told us — if a row here is |
| +not a terminal you recognise, revoke it.</p> |
| {{else}} |
| <p>No terminals are signed in as you.</p> |
| <pre><code>carlos auth login -console {{.Console}}</code></pre> |