Files
mesh-catalog/modules/brother/cmd/brother/admin_test.go
T

352 lines
13 KiB
Go

package main
import (
"context"
"crypto/tls"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// fakePrinter is a TLS server standing in for the printer's admin page. Its login page is the captured one
// (testdata/admin/login-page.html, 2026-10-20) with the password field's name changed to the one the test
// chooses, as the real device generates it. The pages behind the login are SYNTHETIC (testdata/admin/
// synthetic-*.html): the real ones arrive in round 2 of issue 1453.
type fakePrinter struct {
*httptest.Server
field string // the password field's name the login form gives
password string
mu sync.Mutex
conns int // connections opened to it
requests []string // method and path of every request
posted []url.Values // every form body posted
session string // the cookie value of the one logged-in session
loggedIn map[string]bool // cookie values that are logged in
pages map[string]string // path -> body behind the login
}
func adminFixture(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", "admin", name))
if err != nil {
t.Fatal(err)
}
return string(b)
}
func newFakePrinter(t *testing.T, field, password string) *fakePrinter {
t.Helper()
f := &fakePrinter{field: field, password: password, loggedIn: map[string]bool{}, pages: map[string]string{}}
login := strings.ReplaceAll(adminFixture(t, "login-page.html"), "B1891", field)
// The login page the printer serves at /home/status.html carries that page as its loginurl (captured live
// 2026-10-20, testdata/admin/live/home_status_html.body); the page it serves at /etc/loginerror.html carries
// the page that was asked for.
homeLogin := strings.ReplaceAll(login, `value="/general/status.html"`, `value="/home/status.html"`)
synthetic := adminFixture(t, "synthetic-status.html")
// This firmware has no /general/status.html behind the login (issue 1514): asking for it is a 404.
f.pages["/home/status.html"] = synthetic
f.pages["/general/other.html"] = adminFixture(t, "synthetic-settings.html")
authed := func(r *http.Request) bool {
c, err := r.Cookie("session")
return err == nil && f.loggedIn[c.Value]
}
mux := http.NewServeMux()
mux.HandleFunc("/etc/loginerror.html", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(login))
})
mux.HandleFunc("/home/status.html", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if r.Method != http.MethodPost {
if authed(r) {
w.Write([]byte(synthetic))
return
}
w.Write([]byte(homeLogin))
return
}
r.ParseForm()
f.posted = append(f.posted, r.PostForm)
if r.PostForm.Get(field) != password || r.PostForm.Get("loginurl") == "" {
w.Write([]byte(login))
return
}
f.session = "s" + field
f.loggedIn[f.session] = true
http.SetCookie(w, &http.Cookie{Name: "session", Value: f.session, Path: "/"})
// Like Brother's form, a good login goes on to the page named in loginurl.
http.Redirect(w, r, r.PostForm.Get("loginurl"), http.StatusFound)
})
mux.HandleFunc("/home/logout.html", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if c, err := r.Cookie("session"); err == nil {
delete(f.loggedIn, c.Value)
}
w.Write([]byte(login))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if r.URL.Path == "/" {
http.Redirect(w, r, "/home/status.html", http.StatusMovedPermanently)
return
}
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
// Captured: a page behind the login is answered by a redirect to the login page whether it exists or not.
if !authed(r) {
http.Redirect(w, r, "/etc/loginerror.html?url="+url.QueryEscape(r.URL.Path), http.StatusFound)
return
}
body, ok := f.pages[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Write([]byte(body))
})
f.Server = httptest.NewUnstartedServer(mux)
f.Config.ConnState = func(_ net.Conn, st http.ConnState) {
if st == http.StateNew {
f.mu.Lock()
f.conns++
f.mu.Unlock()
}
}
f.StartTLS()
t.Cleanup(f.Close)
return f
}
func (f *fakePrinter) host() string { return strings.TrimPrefix(f.URL, "https://") }
// print is the fingerprint of the certificate the server serves.
func (f *fakePrinter) print() string { return Fingerprint(f.TLS.Certificates[0].Certificate[0]) }
func (f *fakePrinter) connsNow() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.conns
}
func (f *fakePrinter) loggedInNow() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.loggedIn)
}
// 1453/AC2: a certificate whose fingerprint is not the pinned one is refused, the error names both
// fingerprints, and the server saw no request at all, so no password was sent.
func TestACertificateOtherThanThePinnedOneIsRefusedBeforeAnythingIsSent(t *testing.T) {
f := newFakePrinter(t, "B1891", "hunter2")
other := strings.Repeat("AA:", 31) + "AA"
a, err := NewAdmin(f.host(), other, "hunter2")
if err != nil {
t.Fatal(err)
}
err = a.Login(context.Background())
if err == nil {
t.Fatal("a certificate that is not the pinned one was accepted")
}
for _, want := range []string{f.print(), other} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not name the fingerprint %s", err, want)
}
}
if len(f.requests) != 0 || len(f.posted) != 0 {
t.Errorf("the server was sent %v and the forms %v: the password may have gone", f.requests, f.posted)
}
}
// 1453/AC2: the pinned certificate itself is accepted, in either case of hex digits.
func TestThePinnedCertificateIsAccepted(t *testing.T) {
f := newFakePrinter(t, "B1891", "hunter2")
a, _ := NewAdmin(f.host(), strings.ToLower(f.print()), "hunter2")
if err := a.Login(context.Background()); err != nil {
t.Fatal(err)
}
}
// 1453/AC3: the login posts the password in the field the form names, whatever the name is (the test changes
// it), along with the form's hidden fields, and the session cookie it gets is the one later pages are asked with.
func TestLoginUsesThePasswordFieldNameTheFormGives(t *testing.T) {
for _, name := range []string{"B1891", "Zq77x"} {
f := newFakePrinter(t, name, "s3cret pass")
a, _ := NewAdmin(f.host(), f.print(), "s3cret pass")
ctx := context.Background()
if err := a.Login(ctx); err != nil {
t.Fatalf("field %s: %v", name, err)
}
if len(f.posted) != 1 {
t.Fatalf("field %s: %d forms posted", name, len(f.posted))
}
if got := f.posted[0].Get(name); got != "s3cret pass" {
t.Errorf("field %s: the password field holds %q", name, got)
}
if f.posted[0].Get("loginurl") != DefaultPage {
t.Errorf("field %s: the form's hidden loginurl was %q", name, f.posted[0].Get("loginurl"))
}
if len(f.posted[0]) != 2 {
t.Errorf("field %s: posted %v, want the password and loginurl only", name, f.posted[0])
}
body, _, err := a.Get(ctx, "/general/other.html")
if err != nil || !strings.Contains(string(body), "Synthetic settings") {
t.Errorf("field %s: a page behind the login was not given with the session cookie: %v %.60s", name, err, body)
}
}
}
// 1453/AC4: a wrong password is a failed login, said so: the login page comes back.
func TestAWrongPasswordIsAFailedLogin(t *testing.T) {
f := newFakePrinter(t, "B1891", "right")
a, _ := NewAdmin(f.host(), f.print(), "wrong")
err := a.Login(context.Background())
if err == nil || !strings.Contains(err.Error(), "login failed") {
t.Fatalf("error = %v, want a failed login", err)
}
if f.loggedInNow() != 0 {
t.Errorf("a session was opened")
}
}
// 1453: the logout link on a page ends the session; a page with none says so.
func TestLogoutEndsTheSessionAndAPageWithoutOneSaysSo(t *testing.T) {
f := newFakePrinter(t, "B1891", "pw")
a, _ := NewAdmin(f.host(), f.print(), "pw")
ctx := context.Background()
if err := a.Login(ctx); err != nil {
t.Fatal(err)
}
body, at, err := a.Get(ctx, DefaultPage)
if err != nil {
t.Fatal(err)
}
if got := a.Logout(ctx, body, at); got != "logged out" {
t.Errorf("logout = %q", got)
}
if f.loggedInNow() != 0 {
t.Errorf("the session is still open")
}
if got := a.Logout(ctx, []byte("<html><body>nothing</body></html>"), at); !strings.Contains(got, "no logout") {
t.Errorf("logout without a link = %q", got)
}
}
// 1453: the module never sends the password to a host other than the printer, even if the form says so.
func TestThePasswordIsNotPostedToAnotherHost(t *testing.T) {
// A login form whose action names another host.
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`<form method="post" action="https://elsewhere.example/x"><input type="password" name="p"/></form>`))
}))
srv.TLS = &tls.Config{}
srv.StartTLS()
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "https://")
a, _ := NewAdmin(host, Fingerprint(srv.TLS.Certificates[0].Certificate[0]), "pw")
err := a.Login(context.Background())
if err == nil || !strings.Contains(err.Error(), "not sent") {
t.Fatalf("error = %v", err)
}
}
// 1514 review: a page the printer does not have leaves no session open, and the error says the page is missing,
// not that the login failed.
func TestAMissingPageLeavesNoSessionOpenAndSaysSo(t *testing.T) {
f := newFakePrinter(t, "B1891", "pw")
_, run := printerTools(t, f, "pw")
_, err := run(map[string]any{"page": "/general/nope.html"})
if err == nil || !strings.Contains(err.Error(), "the printer has no page /general/nope.html") {
t.Fatalf("error = %v, want the printer has no page", err)
}
if strings.Contains(err.Error(), "login was not answered") {
t.Errorf("a missing page is said to be a failed login: %v", err)
}
if f.loggedInNow() != 0 {
t.Errorf("%d sessions are still open on the printer", f.loggedInNow())
}
}
// 1514 review: the password is posted to https on the printer's host only. A form whose action is plain http
// on the same host, or a root that redirects to plain http, is refused and nothing is posted.
func TestThePasswordIsNotPostedOverPlainHTTP(t *testing.T) {
var posted int
mux := http.NewServeMux()
page := func(action string) string {
return `<html><body><form method="post" action="` + action + `"><input type="password" name="B1891"/><input type="hidden" name="loginurl" value="/home/status.html"/></form></body></html>`
}
var host string
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
posted++
}
w.Write([]byte(page("http://" + host + "/home/status.html")))
})
srv := httptest.NewTLSServer(mux)
defer srv.Close()
host = strings.TrimPrefix(srv.URL, "https://")
a, _ := NewAdmin(host, Fingerprint(srv.TLS.Certificates[0].Certificate[0]), "pw")
err := a.Login(context.Background())
if err == nil || !strings.Contains(err.Error(), "password is not sent") {
t.Errorf("error = %v, want the plain http action refused", err)
}
if posted != 0 {
t.Errorf("%d posts reached the server", posted)
}
}
// 1515: a 307 or 308 after the login post would make the HTTP client send the post again, the password with
// it, to the place the answer names; a redirect off https or off the printer's host is refused as well.
func TestNoRedirectResendsThePasswordOrLeavesHTTPSOnThePrinter(t *testing.T) {
const loginPage = `<html><body><form action="/login" method="post"><input type="password" name="pw"><input type="hidden" name="loginurl" value="/x"></form></body></html>`
cases := []struct {
name string
status int
location string // "SAME" is a path on the printer itself
}{
{"307 on the printer", 307, "/again"},
{"308 on the printer", 308, "/again"},
{"307 to plain http", 307, "http://192.0.2.1/steal"},
{"302 to another host", 302, "https://192.0.2.1/steal"},
{"302 to plain http", 302, "http://192.0.2.1/steal"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var mu sync.Mutex
var posts []string
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
switch {
case r.Method == http.MethodGet && r.URL.Path == "/":
w.Write([]byte(loginPage))
case r.Method == http.MethodPost:
r.ParseForm()
posts = append(posts, r.URL.Path+" "+r.PostForm.Get("pw"))
http.Redirect(w, r, c.location, c.status)
default:
w.Write([]byte("ok"))
}
}))
srv.StartTLS()
defer srv.Close()
a, _ := NewAdmin(strings.TrimPrefix(srv.URL, "https://"), Fingerprint(srv.TLS.Certificates[0].Certificate[0]), "hunter2")
err := a.Login(context.Background())
if err == nil {
t.Fatalf("the login went on after a %d to %s", c.status, c.location)
}
mu.Lock()
defer mu.Unlock()
if len(posts) != 1 {
t.Errorf("the password was posted %d times: %v", len(posts), posts)
}
})
}
}