Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ snapshots are preserved in the [historical archive](docs/content/archive/_index.

## Development and checks

Use Go **1.25.1** and Node.js **22.16.0 or newer**. The Go workspace contains the
Use Go **1.26.10 or newer** and Node.js **22.16.0 or newer**. The Go workspace contains the
API (`src`) and CLI (`cli`). From the repository root:

```bash
Expand Down Expand Up @@ -77,6 +77,13 @@ Humans are responsible for editing the human facing docs. This is because the hu

Documentation should include explanations of design decisions for later platform maintainers.

## Optional Misty login

Optional Misty email login is documented in
[the rollout guide](docs/agents/misty-login.md). It requires HTTPS, an explicit
expected Misty environment, and reviewed identity mappings for existing accounts.
It is disabled by default and has not been enabled on the live private portal.

## Frontend Testing

how to use fake backend for testing frontend.
Expand All @@ -93,4 +100,4 @@ npm --prefix web-interface run fake-backend



 
 
49 changes: 43 additions & 6 deletions cli/cmd/api_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
Expand All @@ -32,13 +34,11 @@ func (ctx *AppContext) api(method, path string, body, out interface{}) error {
req.Header.Set("X-Mist-Team", team)
}
if ctx.Config != nil && ctx.Config.SessionCookie != "" {
req.Header.Set("Cookie", ctx.Config.SessionCookie)
}
client := ctx.HTTPClient
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
if err := ctx.attachSession(req); err != nil {
return err
}
}
response, err := client.Do(req)
response, err := ctx.credentialClient().Do(req)
if err != nil {
return err
}
Expand All @@ -59,6 +59,43 @@ func (ctx *AppContext) api(method, path string, body, out interface{}) error {
return nil
}

// Credentials belong to one exact scheme, host and port. An API path is not
// part of the origin; changing environments requires a separate --config/login.
func credentialOrigin(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
return "", fmt.Errorf("invalid compute URL")
}
ip := net.ParseIP(u.Hostname())
if u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || (ip != nil && ip.IsLoopback()))) {
return "", fmt.Errorf("credentials require HTTPS (HTTP only on loopback)")
}
return u.Scheme + "://" + strings.ToLower(u.Host), nil
}

func (ctx *AppContext) attachSession(req *http.Request) error {
origin, err := credentialOrigin(req.URL.String())
if err != nil {
return err
}
saved, err := credentialOrigin(ctx.Config.APIBaseURL)
if err != nil || origin != saved {
return fmt.Errorf("session belongs to a different API origin; use a separate --config and sign in there")
}
req.Header.Set("Cookie", ctx.Config.SessionCookie)
return nil
}

func (ctx *AppContext) credentialClient() *http.Client {
client := http.Client{Timeout: 30 * time.Second}
if ctx.HTTPClient != nil {
client = *ctx.HTTPClient
}
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
client.Jar = nil // Only explicitly bound cookies may accompany these requests.
return &client
}

func (ctx *AppContext) baseURL() string {
if ctx.APIBaseURL != "" {
return strings.TrimRight(ctx.APIBaseURL, "/")
Expand Down
26 changes: 23 additions & 3 deletions cli/cmd/auth_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,23 @@ import (
type LoginCmd struct {
Email string `help:"Mist member email" required:""`
PasswordStdin bool `help:"Read password from standard input (for automation)"`
Misty bool `help:"Sign in with a code sent by Misty"`
CodeStdin bool `help:"Read the Misty code from standard input"`
}

func (l *LoginCmd) Run(ctx *AppContext) error {
if l.Misty {
if l.PasswordStdin {
return fmt.Errorf("--password-stdin cannot be used with --misty")
}
return l.loginMisty(ctx)
}
if l.CodeStdin {
return fmt.Errorf("--code-stdin requires --misty")
}
if _, err := credentialOrigin(ctx.baseURL()); err != nil {
return err
}
var password string
if l.PasswordStdin {
data, err := io.ReadAll(io.LimitReader(os.Stdin, 130))
Expand Down Expand Up @@ -49,14 +63,18 @@ func (l *LoginCmd) Run(ctx *AppContext) error {
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", u.Scheme+"://"+u.Host)
response, err := ctx.HTTPClient.Do(req)
response, err := ctx.credentialClient().Do(req)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != 200 {
return fmt.Errorf("login failed (HTTP %d)", response.StatusCode)
}
return ctx.saveLogin(base, response)
}

func (ctx *AppContext) saveLogin(base string, response *http.Response) error {
cookies := []string{}
for _, cookie := range response.Cookies() {
if strings.Contains(cookie.Name, "session_token") && cookie.Value != "" {
Expand Down Expand Up @@ -129,8 +147,10 @@ func (l *LogoutCmd) Run(ctx *AppContext) error {
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", origin)
req.Header.Set("Cookie", ctx.Config.SessionCookie)
response, err := ctx.HTTPClient.Do(req)
if err := ctx.attachSession(req); err != nil {
return err
}
response, err := ctx.credentialClient().Do(req)
if err != nil {
return err
}
Expand Down
91 changes: 91 additions & 0 deletions cli/cmd/auth_misty.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package cmd

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"time"

"golang.org/x/term"
)

func (l *LoginCmd) loginMisty(ctx *AppContext) error {
origin, err := credentialOrigin(ctx.baseURL())
if err != nil {
return err
}
// Never follow a login redirect with a code or binding cookie.
client := ctx.credentialClient()
client.Timeout = 15 * time.Second
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
post := func(path string, body any, cookies []*http.Cookie) (*http.Response, error) {
data, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPost, origin+path, bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("invalid login request")
}
req.Header.Set("Origin", origin)
req.Header.Set("Content-Type", "application/json")
for _, cookie := range cookies {
req.AddCookie(cookie)
}
response, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("compute login unavailable")
}
return response, nil
}
start, err := post("/auth/misty/start", map[string]string{"email": l.Email}, nil)
if err != nil {
return err
}
defer start.Body.Close()
if start.StatusCode != 202 {
return fmt.Errorf("requesting code failed (HTTP %d)", start.StatusCode)
}
var challenge struct {
ID string `json:"challenge_id"`
}
if json.NewDecoder(io.LimitReader(start.Body, 8192)).Decode(&challenge) != nil || challenge.ID == "" {
return fmt.Errorf("invalid login response")
}
var cookies []*http.Cookie
for _, cookie := range start.Cookies() {
if cookie.Name == "misty_login" {
cookies = append(cookies, cookie)
}
}
if len(cookies) != 1 {
return fmt.Errorf("login returned no challenge cookie")
}
fmt.Fprintln(os.Stderr, "If your email belongs to an active Misty account, a code will arrive shortly.")
var code []byte
if l.CodeStdin {
code, err = io.ReadAll(io.LimitReader(os.Stdin, 16))
} else {
fmt.Fprint(os.Stderr, "Verification code: ")
code, err = term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
}
if err != nil {
return fmt.Errorf("cannot read code (use --code-stdin for automation)")
}
value := strings.TrimSpace(string(code))
if !regexp.MustCompile(`^[0-9]{6}$`).MatchString(value) {
return fmt.Errorf("enter a six-digit code")
}
confirm, err := post("/auth/misty/confirm", map[string]string{"challenge_id": challenge.ID, "code": value}, cookies)
if err != nil {
return err
}
defer confirm.Body.Close()
if confirm.StatusCode != 200 {
return fmt.Errorf("code verification failed (HTTP %d); check the code or start again", confirm.StatusCode)
}
return ctx.saveLogin(ctx.baseURL(), confirm)
}
66 changes: 66 additions & 0 deletions cli/cmd/auth_misty_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package cmd

import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)

func TestMistyCLIUsesBoundCodeAndSavesProtectedSession(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Origin") == "" || r.Header.Get("X-API-Key") != "" {
t.Error("incorrect client authentication")
}
switch r.URL.Path {
case "/auth/misty/start":
http.SetCookie(w, &http.Cookie{Name: "misty_login", Value: "binding", Path: "/auth"})
w.WriteHeader(202)
json.NewEncoder(w).Encode(map[string]string{"challenge_id": "challenge"})
case "/auth/misty/confirm":
cookie, err := r.Cookie("misty_login")
if err != nil || cookie.Value != "binding" {
t.Error("binding missing")
}
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
if body["code"] != "123456" || body["challenge_id"] != "challenge" {
t.Error("incorrect confirmation")
}
http.SetCookie(w, &http.Cookie{Name: "session_token", Value: "synthetic-session", HttpOnly: true})
w.WriteHeader(200)
default:
t.Error("unexpected path", r.URL.Path)
http.NotFound(w, r)
}
}))
defer server.Close()
input, err := os.CreateTemp(t.TempDir(), "code")
if err != nil {
t.Fatal(err)
}
defer input.Close()
input.WriteString("123456\n")
input.Seek(0, 0)
original := os.Stdin
os.Stdin = input
defer func() { os.Stdin = original }()
ctx := &AppContext{HTTPClient: server.Client(), APIBaseURL: server.URL, ConfigPath: filepath.Join(t.TempDir(), "config.json")}
if err := (&LoginCmd{Email: "member@example.com", Misty: true, CodeStdin: true}).Run(ctx); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(ctx.ConfigPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "synthetic-session") || strings.Contains(string(data), "123456") || strings.Contains(string(data), "binding") {
t.Fatal("incorrect persisted credentials")
}
info, err := os.Stat(ctx.ConfigPath)
if err != nil || info.Mode().Perm() != 0600 {
t.Fatal("unprotected config", err)
}
}
78 changes: 78 additions & 0 deletions cli/cmd/session_security_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package cmd

import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
)

func TestSessionRejectsChangedOrigin(t *testing.T) {
for _, logout := range []bool{false, true} {
t.Run(map[bool]string{false: "API", true: "logout"}[logout], func(t *testing.T) {
called := false
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }))
defer other.Close()
ctx := &AppContext{HTTPClient: other.Client(), APIBaseURL: other.URL, ConfigPath: filepath.Join(t.TempDir(), "config.json"), Config: &Config{APIBaseURL: "https://production.example.test/api", SessionCookie: "session_token=synthetic"}}
var err error
if logout {
err = (&LogoutCmd{}).Run(ctx)
} else {
err = ctx.api("GET", "/session", nil, nil)
}
if err == nil || called {
t.Fatal("different origin received an authenticated request")
}
if ctx.Config.SessionCookie == "" {
t.Fatal("rejected logout erased the original session")
}
})
}
}

func TestSessionRejectsRedirects(t *testing.T) {
for _, logout := range []bool{false, true} {
t.Run(map[bool]string{false: "API", true: "logout"}[logout], func(t *testing.T) {
called := false
plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }))
defer plain.Close()
secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, plain.URL, http.StatusTemporaryRedirect)
}))
defer secure.Close()
ctx := &AppContext{HTTPClient: secure.Client(), ConfigPath: filepath.Join(t.TempDir(), "config.json"), Config: &Config{APIBaseURL: secure.URL, SessionCookie: "session_token=synthetic"}}
var err error
if logout {
err = (&LogoutCmd{}).Run(ctx)
} else {
err = ctx.api("GET", "/session", nil, nil)
}
if err == nil || called {
t.Fatal("authenticated redirect was followed")
}
})
}
}

func TestSessionAcceptsSavedOrigin(t *testing.T) {
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
if r.Header.Get("Cookie") != "session_token=synthetic" {
t.Error("missing session")
}
}))
defer server.Close()
ctx := &AppContext{HTTPClient: server.Client(), APIBaseURL: server.URL + "/api", Config: &Config{APIBaseURL: server.URL, SessionCookie: "session_token=synthetic"}}
if err := ctx.api("GET", "/session", nil, nil); err != nil || !called {
t.Fatal("saved origin rejected", err)
}
}

func TestCredentialOriginRejectsUnsafeURLs(t *testing.T) {
for _, raw := range []string{"http://compute.example.com/api", "https://user:password@compute.example.com", "https://compute.example.com?token=x", "https://compute.example.com#token", ""} {
if _, err := credentialOrigin(raw); err == nil {
t.Errorf("unsafe origin accepted: %q", raw)
}
}
}
Loading
Loading