352 lines
13 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|