package webui
import (
"bufio"
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"albedo/cli/internal/daemon"
)
// These cover the boundary that keeps the daemon token out of the browser;
// the e2e suites drive the daemon through the CLI and never load this page.
type fakeUpstream struct {
snapshot daemon.ConnectionSnapshot
refreshes atomic.Int32
}
func (f *fakeUpstream) Snapshot() daemon.ConnectionSnapshot { return f.snapshot }
func (f *fakeUpstream) Refresh(context.Context) error {
f.refreshes.Add(1)
return nil
}
func newServer(t *testing.T, daemonHandler http.HandlerFunc) (*Server, *fakeUpstream) {
t.Helper()
t.Setenv("ALBEDO_HOME", t.TempDir())
dist := t.TempDir()
if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte(""), 0o600); err != nil {
t.Fatal(err)
}
upstream := &fakeUpstream{snapshot: daemon.ConnectionSnapshot{Token: "secret"}}
if daemonHandler != nil {
backend := httptest.NewServer(daemonHandler)
t.Cleanup(backend.Close)
upstream.snapshot.Port = backend.Listener.Addr().(*net.TCPAddr).Port
}
server, err := New(dist, upstream)
if err != nil {
t.Fatal(err)
}
return server, upstream
}
func request(method, target string, cookie string) *http.Request {
r := httptest.NewRequest(method, "http://127.0.0.1:4317"+target, nil)
if cookie != "" {
r.AddCookie(&http.Cookie{Name: cookieName, Value: cookie})
}
return r
}
// refuseOrigins checks that the server answers each browser Origin on host
// with 403.
func refuseOrigins(t *testing.T, server *Server, host string, origins map[string]string) {
t.Helper()
for name, origin := range origins {
t.Run(name, func(t *testing.T) {
call := request(http.MethodPost, "/api/sessions", server.key)
call.Host = host
call.Header.Set("Origin", origin)
response := httptest.NewRecorder()
server.ServeHTTP(response, call)
if response.Code != http.StatusForbidden {
t.Errorf("Origin %q on Host %q answered %d, want 403", origin, host, response.Code)
}
})
}
}
func TestLaunchKeyBecomesCookieAndUnlocksTheAPI(t *testing.T) {
var forwarded *http.Request
server, _ := newServer(t, func(w http.ResponseWriter, r *http.Request) {
forwarded = r.Clone(context.Background())
w.WriteHeader(http.StatusOK)
})
signIn := httptest.NewRecorder()
server.ServeHTTP(signIn, request(http.MethodGet, "/?key="+server.key, ""))
if signIn.Code != http.StatusSeeOther || signIn.Header().Get("Location") != "/" {
t.Fatalf("sign-in answered %d to %q", signIn.Code, signIn.Header().Get("Location"))
}
cookies := signIn.Result().Cookies()
if len(cookies) != 1 || cookies[0].Value != server.key || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode {
t.Fatalf("sign-in set %+v", cookies)
}
call := request(http.MethodGet, "/api/sessions?scope=roots", server.key)
call.Header.Set("Origin", "http://127.0.0.1:4317")
response := httptest.NewRecorder()
server.ServeHTTP(response, call)
if response.Code != http.StatusOK || forwarded == nil {
t.Fatalf("api answered %d, forwarded %v", response.Code, forwarded != nil)
}
if forwarded.URL.Path != "/sessions" || forwarded.URL.RawQuery != "scope=roots" {
t.Errorf("forwarded %s?%s", forwarded.URL.Path, forwarded.URL.RawQuery)
}
if forwarded.Header.Get("Authorization") != "Bearer secret" {
t.Errorf("forwarded authorization %q", forwarded.Header.Get("Authorization"))
}
if forwarded.Header.Get("Origin") != "" || forwarded.Header.Get("Cookie") != "" {
t.Errorf("forwarded browser headers: origin %q cookie %q", forwarded.Header.Get("Origin"), forwarded.Header.Get("Cookie"))
}
}
func TestRefusesWithoutReachingTheDaemon(t *testing.T) {
var reached atomic.Bool
server, _ := newServer(t, func(http.ResponseWriter, *http.Request) { reached.Store(true) })
crossOrigin := request(http.MethodPost, "/api/sessions", server.key)
crossOrigin.Header.Set("Origin", "http://127.0.0.1:9999")
rebound := request(http.MethodGet, "/api/sessions", server.key)
rebound.Host = "evil.example:4317"
for name, test := range map[string]struct {
request *http.Request
status int
}{
"no cookie": {request(http.MethodGet, "/api/sessions", ""), http.StatusUnauthorized},
"wrong cookie": {request(http.MethodGet, "/api/sessions", "guess"), http.StatusUnauthorized},
"wrong key": {request(http.MethodGet, "/?key=guess", ""), http.StatusForbidden},
"cross origin": {crossOrigin, http.StatusForbidden},
"foreign host": {rebound, http.StatusMisdirectedRequest},
} {
response := httptest.NewRecorder()
server.ServeHTTP(response, test.request)
if response.Code != test.status {
t.Errorf("%s: status %d, want %d", name, response.Code, test.status)
}
if len(response.Result().Cookies()) != 0 {
t.Errorf("%s: set a cookie", name)
}
}
if reached.Load() {
t.Fatal("a refused request reached the daemon")
}
}
func TestNetworksReadAsCIDROrTrailingZeros(t *testing.T) {
for text, want := range map[string]string{
"10.0.0.0": "10.0.0.0/8",
"100.64.0.0": "100.64.0.0/16",
"192.168.1.0": "192.168.1.0/24",
"10.0.0.174": "10.0.0.174/32",
"100.64.0.0/10": "100.64.0.0/10",
"192.168.1.7/24": "192.168.1.0/24",
} {
network, err := ParseNetwork(text)
if err != nil || network.String() != want {
t.Errorf("%s read as %v (%v), want %s", text, network, err, want)
}
}
for _, text := range []string{"", "lan", "fe80::1", "10.0.0"} {
if _, err := ParseNetwork(text); err == nil {
t.Errorf("%q was accepted", text)
}
}
}
func TestSharedNetworksStillNeedTheKey(t *testing.T) {
server, _ := newServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
home, _ := ParseNetwork("10.0.0.0")
if shared := server.Share([]*net.IPNet{home}, []string{"10.0.0.5", "100.64.0.5"}); len(shared) != 1 || shared[0] != "10.0.0.5" {
t.Fatalf("shared %v", shared)
}
lan := func(target, cookie, from string) *http.Request {
r := request(http.MethodGet, target, cookie)
r.Host = "10.0.0.5:4317"
r.RemoteAddr = from + ":50000"
return r
}
outside := lan("/api/sessions", server.key, "10.0.0.9")
outside.Host = "100.64.0.5:4317"
for name, test := range map[string]struct {
request *http.Request
status int
}{
"signed out": {lan("/api/sessions", "", "10.0.0.9"), http.StatusUnauthorized},
"sign in": {lan("/?key="+server.key, "", "10.0.0.9"), http.StatusSeeOther},
"signed in": {lan("/api/sessions", server.key, "10.0.0.9"), http.StatusOK},
"this machine": {lan("/api/sessions", server.key, "127.0.0.1"), http.StatusOK},
"other network": {lan("/api/sessions", server.key, "192.168.1.4"), http.StatusForbidden},
"address not given": {outside, http.StatusMisdirectedRequest},
} {
response := httptest.NewRecorder()
server.ServeHTTP(response, test.request)
if response.Code != test.status {
t.Errorf("%s: status %d, want %d", name, response.Code, test.status)
}
}
}
func TestUnreachableDaemonIsRediscovered(t *testing.T) {
server, upstream := newServer(t, nil)
closed, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
upstream.snapshot.Port = closed.Addr().(*net.TCPAddr).Port
closed.Close()
response := httptest.NewRecorder()
server.ServeHTTP(response, request(http.MethodGet, "/api/sessions", server.key))
if response.Code != http.StatusBadGateway || upstream.refreshes.Load() != 1 {
t.Fatalf("status %d after %d refreshes", response.Code, upstream.refreshes.Load())
}
}
func TestStaticAssetsAreCachedButTheShellIsNot(t *testing.T) {
server, _ := newServer(t, nil)
if err := os.MkdirAll(filepath.Join(server.dist, "assets"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(server.dist, "assets", "index-abc.js"), []byte("1"), 0o600); err != nil {
t.Fatal(err)
}
for path, cache := range map[string]string{"/": "no-cache", "/assets/index-abc.js": "public, max-age=31536000, immutable"} {
response := httptest.NewRecorder()
server.ServeHTTP(response, request(http.MethodGet, path, ""))
if response.Code != http.StatusOK || response.Header().Get("Cache-Control") != cache {
t.Errorf("%s: %d with cache %q", path, response.Code, response.Header().Get("Cache-Control"))
}
}
}
func TestMissingBuildNamesTheFix(t *testing.T) {
if _, err := New(t.TempDir(), &fakeUpstream{}); err == nil {
t.Fatal("an empty dist was accepted")
}
}
func TestArtifactFrameBoundary(t *testing.T) {
var got *http.Request
server, _ := newServer(t, func(w http.ResponseWriter, r *http.Request) {
got = r.Clone(context.Background())
w.Header().Set("Content-Type", "text/html")
// A copy from the daemon must not stand beside the frame's own.
w.Header().Set("Access-Control-Allow-Origin", "https://elsewhere")
_, _ = w.Write([]byte("
ok
"))
})
req := request(http.MethodGet, "/frame/id.key/1/index.html", "")
req.Header.Set("Cookie", "albedo_web=bad")
req.Header.Set("Origin", "https://elsewhere")
req.Header.Set("Referer", "https://elsewhere/")
response := httptest.NewRecorder()
server.ServeHTTP(response, req)
if response.Code != http.StatusOK || got == nil || got.URL.Path != "/extensions/artifacts/frame/id.key/1/index.html" {
t.Fatalf("frame answered %d, request %+v", response.Code, got)
}
if got.Header.Get("Authorization") != "Bearer secret" || got.Header.Get("Cookie") != "" || got.Header.Get("Origin") != "" || got.Header.Get("Referer") != "" {
t.Fatalf("browser headers crossed boundary: %+v", got.Header)
}
for key, want := range frameHeaders {
if values := response.Header().Values(key); len(values) != 1 || values[0] != want {
t.Errorf("%s = %q", key, values)
}
}
for _, method := range []string{http.MethodPut, http.MethodDelete} {
response = httptest.NewRecorder()
server.ServeHTTP(response, request(method, "/frame/id.key/1/index.html", ""))
if response.Code != http.StatusMethodNotAllowed {
t.Errorf("%s answered %d", method, response.Code)
}
}
response = httptest.NewRecorder()
server.ServeHTTP(response, request(http.MethodPost, "/frame/id.key/1/index.html", ""))
if response.Code != http.StatusMethodNotAllowed {
t.Errorf("post file answered %d", response.Code)
}
}
func TestALatestTabForwardsItsChecksUncached(t *testing.T) {
var asked []string
server, _ := newServer(t, func(w http.ResponseWriter, r *http.Request) {
asked = append(asked, r.URL.RequestURI())
w.Header().Set("Cache-Control", "no-store")
if r.URL.Path == "/extensions/artifacts/frame/id.key/latest" {
w.Header().Set("Location", "latest/")
w.WriteHeader(http.StatusFound)
return
}
_, _ = w.Write([]byte(`{"version":2}`))
})
for _, target := range []string{"/frame/id.key/latest", "/frame/id.key/1/.albedo/version?after=1"} {
response := httptest.NewRecorder()
server.ServeHTTP(response, request(http.MethodGet, target, ""))
if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Content-Security-Policy") != frameHeaders["Content-Security-Policy"] {
t.Errorf("%s answered %d with %v", target, response.Code, response.Header())
}
if target == "/frame/id.key/latest" && (response.Code != http.StatusFound || response.Header().Get("Location") != "latest/") {
t.Errorf("the bare latest address redirected %d to %q", response.Code, response.Header().Get("Location"))
}
}
if want := []string{"/extensions/artifacts/frame/id.key/latest", "/extensions/artifacts/frame/id.key/1/.albedo/version?after=1"}; strings.Join(asked, " ") != strings.Join(want, " ") {
t.Fatalf("forwarded %v", asked)
}
}
func TestATabPostsItsMessagesToTheArtifactsChat(t *testing.T) {
var inputs []map[string]string
var paths []string
server, _ := newServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer secret" {
w.WriteHeader(http.StatusUnauthorized)
return
}
switch {
case r.Method == http.MethodGet && r.URL.Path == "/extensions/artifacts/items/art":
_, _ = w.Write([]byte(`{"key":"right","title":"garden","session_id":"chat","session_deleted":false}`))
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/sessions/chat/inputs/"):
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
inputs = append(inputs, body)
paths = append(paths, r.URL.Path)
w.WriteHeader(http.StatusAccepted)
default:
w.WriteHeader(http.StatusNotFound)
}
})
post := func(capability, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, "http://127.0.0.1:4317/frame/"+capability+"/2/.albedo/message", strings.NewReader(body))
response := httptest.NewRecorder()
server.ServeHTTP(response, r)
return response
}
if response := post("art.right", `{"text":"pick 3","gesture":true}`); response.Code != http.StatusNoContent {
t.Fatalf("message answered %d: %s", response.Code, response.Body)
}
if len(inputs) != 1 || inputs[0]["kind"] != "message" || inputs[0]["text"] != "From the artifact “garden” (v2):\n\npick 3" {
t.Fatalf("posted %v", inputs)
}
if id := strings.TrimPrefix(paths[0], "/sessions/chat/inputs/"); len(id) != 36 || id[14] != '7' {
t.Errorf("input id %q is not a UUIDv7", id)
}
if response := post("art.wrong", `{"text":"pick 3","gesture":true}`); response.Code != http.StatusNotFound {
t.Errorf("a wrong key answered %d", response.Code)
}
if response := post("art.right", `{"text":" "}`); response.Code != http.StatusBadRequest {
t.Errorf("an empty message answered %d", response.Code)
}
for i := range unpromptedPerMinute {
if response := post("art.right", `{"text":"tick"}`); response.Code != http.StatusNoContent {
t.Fatalf("unprompted message %d answered %d", i, response.Code)
}
}
held := post("art.right", `{"text":"tick"}`)
if held.Code != http.StatusTooManyRequests || !strings.Contains(held.Body.String(), "beside the chat") {
t.Errorf("past the loop guard: %d %s", held.Code, held.Body)
}
if response := post("art.right", `{"text":"a click","gesture":true}`); response.Code != http.StatusNoContent {
t.Errorf("a message with a gesture past the guard answered %d", response.Code)
}
if got := len(inputs); got != 2+unpromptedPerMinute {
t.Errorf("posted %d messages, want %d", got, 2+unpromptedPerMinute)
}
for _, header := range []string{"Content-Security-Policy", "Access-Control-Allow-Origin"} {
if held.Header().Get(header) == "" {
t.Errorf("a refused message lacks %s", header)
}
}
}
func TestWebSocketUpgradeKeepsTokenBehindProxy(t *testing.T) {
forwarded := make(chan *http.Request, 1)
server, _ := newServer(t, func(w http.ResponseWriter, r *http.Request) {
forwarded <- r.Clone(context.Background())
connection, buffered, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Error(err)
return
}
defer connection.Close()
if err := connection.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Error(err)
return
}
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n")
if err := buffered.Flush(); err != nil {
t.Error(err)
return
}
// Echo bytes after the upgrade to exercise both halves of the tunnel.
_, _ = io.Copy(connection, buffered)
})
proxy := httptest.NewServer(server)
defer proxy.Close()
connection, err := net.Dial("tcp", proxy.Listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer connection.Close()
if err := connection.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatal(err)
}
call, err := http.NewRequest(http.MethodGet, proxy.URL+"/api/events", nil)
if err != nil {
t.Fatal(err)
}
call.AddCookie(&http.Cookie{Name: cookieName, Value: server.key})
call.Header.Set("Origin", proxy.URL)
call.Header.Set("Authorization", "Bearer browser-supplied")
call.Header.Set("Referer", proxy.URL+"/")
call.Header.Set("Connection", "Upgrade")
call.Header.Set("Upgrade", "websocket")
call.Header.Set("Sec-WebSocket-Version", "13")
call.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
if err := call.Write(connection); err != nil {
t.Fatal(err)
}
reader := bufio.NewReader(connection)
response, err := http.ReadResponse(reader, call)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusSwitchingProtocols || response.Header.Get("Upgrade") != "websocket" {
t.Fatalf("upgrade answered %s with %v", response.Status, response.Header)
}
got := <-forwarded
if got.URL.Path != "/events" || got.Header.Get("Authorization") != "Bearer secret" {
t.Fatalf("upgrade forwarded %s with authorization %q", got.URL, got.Header.Get("Authorization"))
}
for _, header := range []string{"Origin", "Cookie", "Referer"} {
if got.Header.Get(header) != "" {
t.Errorf("forwarded browser %s: %q", header, got.Header.Get(header))
}
}
if strings.Contains(response.Header.Get("Authorization"), "secret") || strings.Contains(response.Header.Get("Set-Cookie"), "secret") {
t.Fatal("daemon token reached browser")
}
frame := []byte{0x81, 0x82, 1, 2, 3, 4, 'o' ^ 1, 'k' ^ 2}
if _, err := connection.Write(frame); err != nil {
t.Fatal(err)
}
echo := make([]byte, len(frame))
if _, err := io.ReadFull(reader, echo); err != nil {
t.Fatal(err)
}
if string(echo) != string(frame) {
t.Fatalf("tunnel echoed %x, want %x", echo, frame)
}
}
func TestTailnetNamesAreAnsweredAndServedOnlyWhenRunning(t *testing.T) {
status := `{"BackendState":"Running","Self":{"DNSName":"Caladon.lan.klbr.net."}}`
tailnet, ok := parseTailnet([]byte(status))
if !ok || len(tailnet.Names) != 2 || tailnet.Names[0] != "caladon.lan.klbr.net" || tailnet.Names[1] != "caladon" {
t.Fatalf("parsed %v, %v", tailnet, ok)
}
for _, down := range []string{`{"BackendState":"Stopped","Self":{"DNSName":"a.b."}}`, `{"BackendState":"Running","Self":{}}`, `nope`} {
if _, ok := parseTailnet([]byte(down)); ok {
t.Errorf("%s was taken as a tailnet", down)
}
}
server, _ := newServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
server.Share([]*net.IPNet{TailscaleNetwork}, []string{"100.64.0.8"})
server.Answer(tailnet.Names...)
for host, status := range map[string]int{"Caladon:4317": http.StatusOK, "caladon.lan.klbr.net:4317": http.StatusOK, "evil.example:4317": http.StatusMisdirectedRequest} {
call := request(http.MethodGet, "/api/sessions", server.key)
call.Host, call.RemoteAddr = host, "100.64.0.3:5000"
response := httptest.NewRecorder()
server.ServeHTTP(response, call)
if response.Code != status {
t.Errorf("%s: status %d, want %d", host, response.Code, status)
}
}
}
// TestAnOriginFromAProxyThatTerminatesTLSIsAccepted pins the bug this guards:
// behind `tailscale serve`, or any proxy that terminates TLS while forwarding
// the name it serves, the page is https while this server speaks http, so an
// origin compared against "http://"+Host answered 403 to every POST from the
// page it had served.
func TestAnOriginFromAProxyThatTerminatesTLSIsAccepted(t *testing.T) {
var reached atomic.Bool
server, _ := newServer(t, func(http.ResponseWriter, *http.Request) { reached.Store(true) })
server.Answer("albedo.example.ts.net")
throughProxy := request(http.MethodPost, "/api/sessions", server.key)
throughProxy.Host = "albedo.example.ts.net"
throughProxy.Header.Set("Origin", "https://albedo.example.ts.net")
response := httptest.NewRecorder()
server.ServeHTTP(response, throughProxy)
if response.Code != http.StatusOK || !reached.Load() {
t.Fatalf("https origin through a proxy answered %d, reached %v", response.Code, reached.Load())
}
refuseOrigins(t, server, "albedo.example.ts.net", map[string]string{
"another host": "https://evil.example",
"opaque origin": "null",
})
}
// TestAPublishedOriginIsAnsweredAndAccepted pins the other half a proxy needs:
// --public-url names the page's origin, but a proxy may forward its own address
// as Host (nginx's default), so the published name must be answered as well as
// the public origin accepted beside the request host's own.
func TestAPublishedOriginIsAnsweredAndAccepted(t *testing.T) {
var reached atomic.Bool
server, _ := newServer(t, func(http.ResponseWriter, *http.Request) { reached.Store(true) })
// The flag says the default port; the browser leaves it out.
public, err := ParseOrigin("https://Albedo.Example.TS.net:443")
if err != nil {
t.Fatal(err)
}
server.Publish(public)
// The last is a proxy that forwards the default port the browser left out.
for _, host := range []string{"albedo.example.ts.net", "albedo.example.ts.net:443", "127.0.0.1:4317"} {
t.Run(host, func(t *testing.T) {
reached.Store(false)
call := request(http.MethodPost, "/api/sessions", server.key)
call.Host = host
call.Header.Set("Origin", "https://albedo.example.ts.net")
response := httptest.NewRecorder()
server.ServeHTTP(response, call)
if response.Code != http.StatusOK || !reached.Load() {
t.Fatalf("Host %s: answered %d, reached %v", host, response.Code, reached.Load())
}
})
}
refuseOrigins(t, server, "127.0.0.1:4317", map[string]string{
"another host": "https://evil.example",
"other scheme": "http://albedo.example.ts.net:443",
"opaque origin": "null",
})
// Unpublished, that origin is nobody's page: only the request host's own
// origin passes, so a proxy that rewrites Host needs --public-url.
server, _ = newServer(t, func(http.ResponseWriter, *http.Request) {})
call := request(http.MethodPost, "/api/sessions", server.key)
call.Header.Set("Origin", "https://albedo.example.ts.net")
response := httptest.NewRecorder()
server.ServeHTTP(response, call)
if response.Code != http.StatusForbidden {
t.Errorf("an unpublished origin answered %d, want 403", response.Code)
}
unpublishedHost := request(http.MethodGet, "/api/sessions", server.key)
unpublishedHost.Host = "albedo.example.ts.net"
response = httptest.NewRecorder()
server.ServeHTTP(response, unpublishedHost)
if response.Code != http.StatusMisdirectedRequest {
t.Errorf("an unpublished name answered %d, want 421", response.Code)
}
}