brother: the printer config and admin-password secret, HTTPS pinned to one certificate, and the login by the form's own field (issue 1453)
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Admin is a session on the printer's admin web page, over HTTPS to the one certificate pinned (ADR 0467
|
||||
// clauses 3 and 4). The device names the password field itself, so every login reads the form first.
|
||||
type Admin struct {
|
||||
Host string
|
||||
Password string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// loginPath is a page that needs a login: asking for it is answered by the login page.
|
||||
const loginPath = "/general/status.html"
|
||||
|
||||
// maxPage bounds a page of the admin site.
|
||||
const maxPage = 4 << 20
|
||||
|
||||
// NewAdmin is a client for the printer's host that trusts the certificate with this fingerprint alone, with a
|
||||
// cookie jar for the session.
|
||||
func NewAdmin(host, fingerprint, password string) (*Admin, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Admin{
|
||||
Host: host,
|
||||
Password: password,
|
||||
HTTP: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: pinnedTLS(fingerprint),
|
||||
DisableKeepAlives: true,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Admin) url(path string) string { return "https://" + a.Host + path }
|
||||
|
||||
// fetch is one request; the body is read whole, up to maxPage. The final URL is the one after redirects.
|
||||
func (a *Admin) fetch(ctx context.Context, method, rawURL string, form url.Values) ([]byte, *url.URL, error) {
|
||||
var req *http.Request
|
||||
var err error
|
||||
if form != nil {
|
||||
req, err = http.NewRequestWithContext(ctx, method, rawURL, strings.NewReader(form.Encode()))
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
} else {
|
||||
req, err = http.NewRequestWithContext(ctx, method, rawURL, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
resp, err := a.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
buf, err := io.ReadAll(io.LimitReader(resp.Body, maxPage+1))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("the page %s cannot be read: %w", rawURL, err)
|
||||
}
|
||||
if len(buf) > maxPage {
|
||||
return nil, nil, fmt.Errorf("the page %s is larger than %d bytes", rawURL, maxPage)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, nil, fmt.Errorf("the printer answered %s for %s", resp.Status, resp.Request.URL.Path)
|
||||
}
|
||||
return buf, resp.Request.URL, nil
|
||||
}
|
||||
|
||||
// passwordForm is the form of a page that holds a password field, or nil.
|
||||
func passwordForm(p Page) (*Form, *Field) {
|
||||
for i := range p.Forms {
|
||||
for j := range p.Forms[i].Fields {
|
||||
if p.Forms[i].Fields[j].Type == "password" {
|
||||
return &p.Forms[i], &p.Forms[i].Fields[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Login asks for a page that needs one, reads the login form it is answered with, and posts the form's own
|
||||
// fields with the password in the field the form names. A login is failed when the answer is a login page
|
||||
// again.
|
||||
func (a *Admin) Login(ctx context.Context) error {
|
||||
body, at, err := a.fetch(ctx, http.MethodGet, a.url(loginPath), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the printer's admin page cannot be asked for its login: %w", err)
|
||||
}
|
||||
form, pw := passwordForm(ParsePage(body))
|
||||
if form == nil {
|
||||
return fmt.Errorf("the printer's answer to %s holds no login form with a password field", loginPath)
|
||||
}
|
||||
target, err := at.Parse(form.Action)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the login form's action %q is not a URL: %w", form.Action, err)
|
||||
}
|
||||
if target.Host != a.Host {
|
||||
return fmt.Errorf("the login form posts to %s, not to the printer %s: the password is not sent", target.Host, a.Host)
|
||||
}
|
||||
vals := url.Values{}
|
||||
for _, f := range form.Fields {
|
||||
if f.Type == "hidden" {
|
||||
vals.Set(f.Name, f.Value)
|
||||
}
|
||||
}
|
||||
vals.Set(pw.Name, a.Password)
|
||||
answer, _, err := a.fetch(ctx, http.MethodPost, target.String(), vals)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the login was not answered: %w", err)
|
||||
}
|
||||
if again, _ := passwordForm(ParsePage(answer)); again != nil {
|
||||
return fmt.Errorf("the login failed: the printer showed its login page again, so the secret admin-password is not its password")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get is a page of the admin site, after a login.
|
||||
func (a *Admin) Get(ctx context.Context, path string) ([]byte, *url.URL, error) {
|
||||
return a.fetch(ctx, http.MethodGet, a.url(path), nil)
|
||||
}
|
||||
|
||||
// Logout ends the session through the logout link or form of a page of it, and says what it did. A page with
|
||||
// none leaves the session to expire, and the answer says so.
|
||||
func (a *Admin) Logout(ctx context.Context, page []byte, at *url.URL) string {
|
||||
p := ParsePage(page)
|
||||
isLogout := func(s string) bool {
|
||||
s = strings.ToLower(strings.ReplaceAll(s, " ", ""))
|
||||
return strings.Contains(s, "logout") || strings.Contains(s, "logoff")
|
||||
}
|
||||
for _, f := range p.Forms {
|
||||
if !isLogout(f.Action) {
|
||||
continue
|
||||
}
|
||||
target, err := at.Parse(f.Action)
|
||||
if err != nil || target.Host != a.Host {
|
||||
continue
|
||||
}
|
||||
vals := url.Values{}
|
||||
for _, fl := range f.Fields {
|
||||
if fl.Type == "hidden" {
|
||||
vals.Set(fl.Name, fl.Value)
|
||||
}
|
||||
}
|
||||
method := http.MethodPost
|
||||
var send url.Values = vals
|
||||
if f.Method == "get" {
|
||||
method, send = http.MethodGet, nil
|
||||
target.RawQuery = vals.Encode()
|
||||
}
|
||||
if _, _, err := a.fetch(ctx, method, target.String(), send); err != nil {
|
||||
return "logout failed: " + err.Error()
|
||||
}
|
||||
return "logged out"
|
||||
}
|
||||
for _, l := range p.Links {
|
||||
if !isLogout(l.Href) && !isLogout(l.Text) {
|
||||
continue
|
||||
}
|
||||
target, err := at.Parse(l.Href)
|
||||
if err != nil || target.Host != a.Host {
|
||||
continue
|
||||
}
|
||||
if _, _, err := a.fetch(ctx, http.MethodGet, target.String(), nil); err != nil {
|
||||
return "logout failed: " + err.Error()
|
||||
}
|
||||
return "logged out"
|
||||
}
|
||||
return "no logout link or form on the page: the session is left to expire on the printer"
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"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
|
||||
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)
|
||||
synthetic := adminFixture(t, "synthetic-status.html")
|
||||
f.pages["/general/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(login))
|
||||
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: "/"})
|
||||
w.Write([]byte(synthetic))
|
||||
})
|
||||
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()
|
||||
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
|
||||
body, ok := f.pages[r.URL.Path]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !authed(r) {
|
||||
http.Redirect(w, r, "/etc/loginerror.html?url="+url.QueryEscape(r.URL.Path), http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(body))
|
||||
})
|
||||
f.Server = httptest.NewTLSServer(mux)
|
||||
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) 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") != "/general/status.html" {
|
||||
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, "/general/status.html")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// The printer's admin pages are small, tolerant HTML (XHTML 1.0 with unclosed inputs). Only what the module
|
||||
// reads is taken out of them: the title, the links, and the forms with their fields.
|
||||
|
||||
// Link is an anchor of a page.
|
||||
type Link struct {
|
||||
Href string `json:"href"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Field is a named control of a form. A password field's value is never kept (ADR 0467 clause 4).
|
||||
type Field struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
id string
|
||||
}
|
||||
|
||||
// Form is a form of a page with its named fields.
|
||||
type Form struct {
|
||||
Action string `json:"action"`
|
||||
Method string `json:"method"`
|
||||
Fields []Field `json:"fields"`
|
||||
}
|
||||
|
||||
// Page is what a page of the admin site holds.
|
||||
type Page struct {
|
||||
Title string `json:"title"`
|
||||
Links []Link `json:"links"`
|
||||
Forms []Form `json:"forms"`
|
||||
}
|
||||
|
||||
var attrRE = regexp.MustCompile(`([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?`)
|
||||
|
||||
// attrs reads a tag's attributes, names in lower case and values unescaped.
|
||||
func attrs(s string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, m := range attrRE.FindAllStringSubmatch(s, -1) {
|
||||
v := m[2] + m[3] + m[4]
|
||||
out[strings.ToLower(m[1])] = html.UnescapeString(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func squash(s string) string { return strings.Join(strings.Fields(html.UnescapeString(s)), " ") }
|
||||
|
||||
// latin1 turns a body that is not UTF-8 into UTF-8, reading each byte as the character of the same number:
|
||||
// the printer's pages say iso-8859-1.
|
||||
func latin1(b []byte) string {
|
||||
if utf8.Valid(b) {
|
||||
return string(b)
|
||||
}
|
||||
r := make([]rune, len(b))
|
||||
for i, c := range b {
|
||||
r[i] = rune(c)
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
|
||||
// tagEnd is the index of the '>' that closes the tag starting at s[0], skipping quoted values, or -1.
|
||||
func tagEnd(s string) int {
|
||||
var q byte
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case q != 0:
|
||||
if c == q {
|
||||
q = 0
|
||||
}
|
||||
case c == '"' || c == '\'':
|
||||
q = c
|
||||
case c == '>':
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ParsePage reads a page. Unknown markup is skipped, and what is not closed is closed at the end.
|
||||
func ParsePage(body []byte) Page {
|
||||
s := latin1(body)
|
||||
var p Page
|
||||
var (
|
||||
inTitle bool
|
||||
title strings.Builder
|
||||
link *Link
|
||||
formOpen bool
|
||||
form Form
|
||||
labelFor string
|
||||
inLabel bool
|
||||
label strings.Builder
|
||||
labelIDs = map[string]string{}
|
||||
wrapped []int // fields inside a label that names none, by index in form.Fields
|
||||
selIdx = -1
|
||||
inOption bool
|
||||
optValue string
|
||||
optHasV bool
|
||||
optSel bool
|
||||
optText strings.Builder
|
||||
inTextA bool
|
||||
textA strings.Builder
|
||||
textAIdx = -1
|
||||
)
|
||||
finishOption := func() {
|
||||
if !inOption || selIdx < 0 {
|
||||
inOption = false
|
||||
return
|
||||
}
|
||||
v := optValue
|
||||
if !optHasV {
|
||||
v = squash(optText.String())
|
||||
}
|
||||
f := &form.Fields[selIdx]
|
||||
f.Options = append(f.Options, v)
|
||||
if optSel {
|
||||
f.Value = v
|
||||
}
|
||||
inOption = false
|
||||
}
|
||||
closeForm := func() {
|
||||
finishOption()
|
||||
selIdx = -1
|
||||
if formOpen {
|
||||
p.Forms = append(p.Forms, form)
|
||||
}
|
||||
formOpen = false
|
||||
form = Form{}
|
||||
}
|
||||
addField := func(f Field) {
|
||||
form.Fields = append(form.Fields, f)
|
||||
if inLabel && labelFor == "" {
|
||||
wrapped = append(wrapped, len(form.Fields)-1)
|
||||
}
|
||||
}
|
||||
text := func(t string) {
|
||||
switch {
|
||||
case inTitle:
|
||||
title.WriteString(t)
|
||||
case inOption:
|
||||
optText.WriteString(t)
|
||||
case inTextA:
|
||||
textA.WriteString(t)
|
||||
}
|
||||
if link != nil {
|
||||
link.Text += t
|
||||
}
|
||||
if inLabel {
|
||||
label.WriteString(t)
|
||||
}
|
||||
}
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
lt := strings.IndexByte(s[i:], '<')
|
||||
if lt < 0 {
|
||||
text(s[i:])
|
||||
break
|
||||
}
|
||||
text(s[i : i+lt])
|
||||
i += lt
|
||||
switch {
|
||||
case strings.HasPrefix(s[i:], "<!--"):
|
||||
end := strings.Index(s[i:], "-->")
|
||||
if end < 0 {
|
||||
i = len(s)
|
||||
} else {
|
||||
i += end + 3
|
||||
}
|
||||
continue
|
||||
case strings.HasPrefix(s[i:], "<!") || strings.HasPrefix(s[i:], "<?"):
|
||||
end := strings.IndexByte(s[i:], '>')
|
||||
if end < 0 {
|
||||
i = len(s)
|
||||
} else {
|
||||
i += end + 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
end := tagEnd(s[i:])
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
raw := s[i+1 : i+end]
|
||||
i += end + 1
|
||||
closing := strings.HasPrefix(raw, "/")
|
||||
raw = strings.TrimSuffix(strings.TrimPrefix(raw, "/"), "/")
|
||||
name := raw
|
||||
rest := ""
|
||||
if sp := strings.IndexAny(raw, " \t\r\n"); sp >= 0 {
|
||||
name, rest = raw[:sp], raw[sp:]
|
||||
}
|
||||
name = strings.ToLower(name)
|
||||
if !closing && (name == "script" || name == "style") {
|
||||
if e := strings.Index(strings.ToLower(s[i:]), "</"+name); e >= 0 {
|
||||
i += e
|
||||
} else {
|
||||
i = len(s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
a := map[string]string{}
|
||||
if !closing {
|
||||
a = attrs(rest)
|
||||
}
|
||||
switch name {
|
||||
case "title":
|
||||
inTitle = !closing
|
||||
case "a":
|
||||
if closing {
|
||||
if link != nil {
|
||||
link.Text = squash(link.Text)
|
||||
p.Links = append(p.Links, *link)
|
||||
}
|
||||
link = nil
|
||||
} else if href, ok := a["href"]; ok {
|
||||
link = &Link{Href: href}
|
||||
}
|
||||
case "form":
|
||||
closeForm()
|
||||
if !closing {
|
||||
formOpen = true
|
||||
form = Form{Action: a["action"], Method: strings.ToLower(a["method"])}
|
||||
if form.Method == "" {
|
||||
form.Method = "get"
|
||||
}
|
||||
}
|
||||
case "label":
|
||||
if closing {
|
||||
t := squash(label.String())
|
||||
if labelFor != "" {
|
||||
labelIDs[labelFor] = t
|
||||
}
|
||||
for _, k := range wrapped {
|
||||
form.Fields[k].Label = t
|
||||
}
|
||||
inLabel, labelFor, wrapped = false, "", nil
|
||||
label.Reset()
|
||||
} else {
|
||||
inLabel, labelFor = true, a["for"]
|
||||
label.Reset()
|
||||
}
|
||||
case "input":
|
||||
if closing || a["name"] == "" {
|
||||
break
|
||||
}
|
||||
typ := strings.ToLower(a["type"])
|
||||
if typ == "" {
|
||||
typ = "text"
|
||||
}
|
||||
if typ == "submit" || typ == "button" || typ == "image" || typ == "reset" {
|
||||
break
|
||||
}
|
||||
f := Field{Name: a["name"], Type: typ, Value: a["value"], id: a["id"]}
|
||||
if typ == "password" {
|
||||
f.Value = ""
|
||||
}
|
||||
addField(f)
|
||||
case "select":
|
||||
finishOption()
|
||||
selIdx = -1
|
||||
if !closing && a["name"] != "" {
|
||||
addField(Field{Name: a["name"], Type: "select", id: a["id"]})
|
||||
selIdx = len(form.Fields) - 1
|
||||
}
|
||||
case "option":
|
||||
finishOption()
|
||||
if !closing && selIdx >= 0 {
|
||||
inOption = true
|
||||
optValue, optHasV = a["value"], false
|
||||
if _, ok := a["value"]; ok {
|
||||
optHasV = true
|
||||
}
|
||||
_, optSel = a["selected"]
|
||||
optText.Reset()
|
||||
}
|
||||
case "textarea":
|
||||
if closing {
|
||||
if inTextA && textAIdx >= 0 {
|
||||
form.Fields[textAIdx].Value = html.UnescapeString(textA.String())
|
||||
}
|
||||
inTextA = false
|
||||
} else if a["name"] != "" {
|
||||
addField(Field{Name: a["name"], Type: "textarea", id: a["id"]})
|
||||
inTextA, textAIdx = true, len(form.Fields)-1
|
||||
textA.Reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
closeForm()
|
||||
p.Title = squash(title.String())
|
||||
for fi := range p.Forms {
|
||||
for k := range p.Forms[fi].Fields {
|
||||
f := &p.Forms[fi].Fields[k]
|
||||
if f.Label == "" && f.id != "" {
|
||||
f.Label = labelIDs[f.id]
|
||||
}
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -5,18 +5,20 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Emits []string `json:"emits"`
|
||||
Seats []seat `json:"seats"`
|
||||
Claims []seat `json:"claims"`
|
||||
Tools []string `json:"tools"`
|
||||
Config map[string]map[string]any `json:"config"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Emits []string `json:"emits"`
|
||||
OwnSecrets map[string]map[string]any `json:"own-secrets"`
|
||||
Seats []seat `json:"seats"`
|
||||
Claims []seat `json:"claims"`
|
||||
Tools []string `json:"tools"`
|
||||
Config map[string]map[string]any `json:"config"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
@@ -73,7 +75,7 @@ func TestTheManifestDeclaresWhatTheCodeServes(t *testing.T) {
|
||||
// code reads is declared, taken by the file and in its template, with the default the code lays under it.
|
||||
func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
keys := []string{"scanner", "scan-folder", "resolution", "color-mode", "crop"}
|
||||
keys := []string{"scanner", "scan-folder", "printer", "printer-certificate", "resolution", "color-mode", "crop"}
|
||||
for _, k := range keys {
|
||||
if _, ok := m.Config[k]; !ok {
|
||||
t.Errorf("config does not declare %s", k)
|
||||
@@ -82,6 +84,13 @@ func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) {
|
||||
if len(m.Config) != len(keys) {
|
||||
t.Errorf("config declares %d keys, the code reads %d", len(m.Config), len(keys))
|
||||
}
|
||||
// The printer's pins and addresses are written into the file as they are given, and match what the
|
||||
// code's own validators accept (ADR 0467 clause 3).
|
||||
fp := regexp.MustCompile(m.Config["printer-certificate"]["pattern"].(string))
|
||||
host := regexp.MustCompile(m.Config["printer"]["pattern"].(string))
|
||||
if !fp.MatchString(strings.Repeat("0A:", 31)+"0A") || fp.MatchString("0a:0a") || !host.MatchString("192.0.2.10") || host.MatchString("a b/c") {
|
||||
t.Errorf("the printer patterns do not say a host and a SHA-256 fingerprint")
|
||||
}
|
||||
var taken []any
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == "settings" {
|
||||
@@ -118,7 +127,7 @@ func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) {
|
||||
t.Errorf("%s: manifest default %v, template %v, code %v", k, m.Config[k]["default"], tpl[k], want)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"scanner", "scan-folder"} {
|
||||
for _, k := range []string{"scanner", "scan-folder", "printer", "printer-certificate"} {
|
||||
if m.Config[k]["required"] != true || m.Config[k]["trusted"] != true {
|
||||
t.Errorf("%s is the operator's: required and trusted", k)
|
||||
}
|
||||
@@ -162,3 +171,26 @@ func TestTheScanModuleIsGone(t *testing.T) {
|
||||
t.Errorf("modules/scan still exists (err %v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1453/AC4 (ADR 0467 clause 4): the admin password is the module's own secret, issued by the operator (never
|
||||
// generated), and reaches the code as the file BROTHER_ADMIN_PASSWORD names.
|
||||
func TestTheAdminPasswordIsTheOperatorsOwnSecret(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
s, ok := m.OwnSecrets["admin-password"]
|
||||
if !ok {
|
||||
t.Fatal("own-secrets does not declare admin-password")
|
||||
}
|
||||
if s["issued-by"] != "outside" {
|
||||
t.Errorf("issued-by = %v, want outside: the mesh never generates the device's password", s["issued-by"])
|
||||
}
|
||||
path, _ := s["path"].(string)
|
||||
var env string
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "bundle" {
|
||||
env, _ = a["env"].(map[string]any)["BROTHER_ADMIN_PASSWORD"].(string)
|
||||
}
|
||||
}
|
||||
if path == "" || env != path {
|
||||
t.Errorf("the secret's path %q and BROTHER_ADMIN_PASSWORD %q differ", path, env)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Fingerprint is the SHA-256 of a certificate's DER bytes, as upper-case hex pairs joined by colons: the
|
||||
// form the printer's own certificate page and the config value printer-certificate give (ADR 0467 clause 3).
|
||||
func Fingerprint(der []byte) string {
|
||||
sum := sha256.Sum256(der)
|
||||
parts := make([]string, len(sum))
|
||||
for i, b := range sum {
|
||||
parts[i] = fmt.Sprintf("%02X", b)
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
// pinned is the check on the certificate a printer serves: the leaf's fingerprint must be the one pinned, and
|
||||
// any other certificate is refused, with both fingerprints in the message. It runs in the handshake, so a
|
||||
// refused certificate is refused before one byte of a request (a password) is sent.
|
||||
func pinned(want string) func([][]byte, [][]*x509.Certificate) error {
|
||||
want = strings.ToUpper(strings.TrimSpace(want))
|
||||
return func(raw [][]byte, _ [][]*x509.Certificate) error {
|
||||
if len(raw) == 0 {
|
||||
return fmt.Errorf("the printer served no certificate; the pinned printer-certificate is %s", want)
|
||||
}
|
||||
if got := Fingerprint(raw[0]); got != want {
|
||||
return fmt.Errorf("the printer's certificate is %s, but the pinned printer-certificate is %s: refused", got, want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// pinnedTLS is the TLS configuration for the printer's admin page: the printer's certificate is self-signed,
|
||||
// so no chain is verified; the pinned fingerprint is the whole of the trust (ADR 0467 clause 3).
|
||||
func pinnedTLS(fingerprint string) *tls.Config {
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
// The chain is not verified because the pinned fingerprint is checked in its place, below.
|
||||
InsecureSkipVerify: true,
|
||||
VerifyPeerCertificate: pinned(fingerprint),
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Settings are the module's five config values, as the mesh writes them into settings.json (ADR 0348).
|
||||
// Settings are the module's seven config values, as the mesh writes them into settings.json (ADR 0348).
|
||||
type Settings struct {
|
||||
// Scanner is the eSCL base URL, such as http://192.0.2.10/eSCL.
|
||||
Scanner string
|
||||
@@ -21,6 +21,11 @@ type Settings struct {
|
||||
Resolution int
|
||||
// ColorMode is "color" or "gray".
|
||||
ColorMode string
|
||||
// Printer is the device's host (with a port if it is not 443), such as 192.0.2.10: the host of its admin
|
||||
// page and its IPP service (ADR 0467 clause 3).
|
||||
Printer string
|
||||
// PrinterCertificate is the SHA-256 fingerprint of the one certificate the device's admin page may serve.
|
||||
PrinterCertificate string
|
||||
// Crop says whether the document is cut out of the platen's white.
|
||||
Crop bool
|
||||
}
|
||||
@@ -52,6 +57,12 @@ func LoadSettings(path string) (Settings, error) {
|
||||
if v, ok := in["color-mode"].(string); ok && v != "" {
|
||||
s.ColorMode = v
|
||||
}
|
||||
if v, ok := in["printer"].(string); ok {
|
||||
s.Printer = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := in["printer-certificate"].(string); ok {
|
||||
s.PrinterCertificate = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := in["crop"].(bool); ok {
|
||||
s.Crop = v
|
||||
}
|
||||
@@ -74,3 +85,39 @@ func (s Settings) Missing() error {
|
||||
return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node",
|
||||
strings.Join(unset, " and "))
|
||||
}
|
||||
|
||||
// MissingPrinter is nil when the two values the printer's verbs need are set, and otherwise says which is not:
|
||||
// nothing is contacted before that (1453/AC4).
|
||||
func (s Settings) MissingPrinter() error {
|
||||
var unset []string
|
||||
if s.Printer == "" {
|
||||
unset = append(unset, "printer")
|
||||
}
|
||||
if s.PrinterCertificate == "" {
|
||||
unset = append(unset, "printer-certificate")
|
||||
}
|
||||
if len(unset) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node",
|
||||
strings.Join(unset, " and "))
|
||||
}
|
||||
|
||||
// AdminSecret is the module's own secret admin-password: the device's admin password, given by the operator
|
||||
// at the desk and never generated or rotated by the mesh (ADR 0467 clause 4). path is the file the mesh
|
||||
// writes it to. A secret not yet given is said by name, and the device is not contacted.
|
||||
func AdminSecret(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("BROTHER_ADMIN_PASSWORD names no file: the module's secret admin-password does not reach the code")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("the secret admin-password cannot be read: %w", err)
|
||||
}
|
||||
pw := strings.TrimRight(string(raw), "\r\n")
|
||||
if pw == "" {
|
||||
return "", fmt.Errorf("the secret admin-password is not given: the printer's admin password waits for the operator, " +
|
||||
"who gives it at their desk (ADR 0283)")
|
||||
}
|
||||
return pw, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
HTTP/1.1 200 OK
|
||||
Cache-Control: no-store
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
Content-Length: 2974
|
||||
Content-Type: text/html
|
||||
Content-Language: en-gb
|
||||
Connection: close
|
||||
X-XSS-Protection: 1; mode=block
|
||||
Pragma: no-cache
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?><!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html lang="en-gb" xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-gb"><head><meta http-equiv="Content-Script-Type" content="text/javascript" /><meta http-equiv="content-style-type" content="text/css" /><meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" /><meta http-equiv="X-UA-Compatible" content="IE=edge"><script type="text/javascript" src="/common/js/ews.js"></script><link rel="stylesheet" type="text/css" href="../common/css/mobile_common.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../common/css/mobile_ews.css" />
|
||||
<title>Brother MFC-L8390CDW series</title><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" /></head><body><div id="headerFrame"><div id="menuButton" layout="pc" onclick="toggleMenu();"><img src="/common/images/menu_icon.gif" alt="Menu"></div><div id="modelName" layout="pc">MFC-L8390CDW series</div><div id="corporateLogo" layout="pc"><img src="/common/images/logo.gif" alt="Brother" /></div></div></div><div id="Menu" layout="pc"><div id="navigationContainer"><div id="navMenu"><div id="LayoutButton"><img id="PCButton" layout="pc" src="/common/images/chevron_left.gif" onclick="changeLayout('user_layout','mobile');"></img><img id="MobileButton" layout="pc" src="/common/images/pin.gif" onclick="changeLayout('user_layout','pc');"></img></div><hr><div id="LogInOutBox"><form method="post" action="/home/status.html"><div><input type="password" id="LogBox" name="B1891" /><input type="hidden" name="loginurl" value="/general/status.html"/><input id="login" type="submit" value="Login" /></div></form></div><div class="links"><a href="https://update.brother.co.jp/device/redirect.aspx?redirecttype=6&country=w&lang=en&device=MFC-L8390CDW series&alias=aqfs" target="_blank">Trouble logging in?</a></div><hr><div id="menu_tree_root"><div class="menuLv1"><a href="#" onclick="expandMenu('home_sub');">Home</a><div id="home_sub" class="open"><div class="menuLv2"><a href="/home/status.html">Status</a></div><div class="menuLv2"><a href="/home/reflesh.html" class="subMenu">Auto Refresh Interval</a></div></div></div><hr></div></div></div></div><div id="Page" layout="pc"><div id="rightFrameContainer"><div id="rightFrame"><div id="mainContent"><div id="pageTitle"><h2>Please Login</h2></div><div id="pageContents"><div class="contentsGroup"><p class="noteMessage">To Access this page you are required to Login, please Login.</p></div></div></div></div></div></div></div><div id="footerFrame">Copyright(C) 2000-2023 Brother Industries, Ltd. All Rights Reserved.</div><script type="text/javascript" src="/common/js/mobilemenucontrl.js"></script><script>window.onpageshow = function(event) {if (event.persisted) { window.location.reload(); }};</script></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?><html><head><title>Synthetic settings</title></head><body><a href="/home/logout.html">Logout</a><a href="/general/status.html">Status</a><form method="post" action="/general/other.html"><table><tr><td><label for="lock">Scan lock</label></td><td><input type="checkbox" id="lock" name="lock" value="on"/></td></tr><tr><td><label for="name">Name</label></td><td><input type="text" id="name" name="node" value="BRN-SYNTH"/></td></tr><tr><td><label for="mode">Mode</label></td><td><select id="mode" name="mode"><option value="a">Auto</option><option value="m" selected="selected">Manual</option></select></td></tr><tr><td><label>Secret<input type="password" name="pw" value="shouldnotshow"/></label></td></tr><tr><td><textarea name="note">hello & bye</textarea></td></tr></table><input type="hidden" name="token" value="t1"/><input type="submit" value="Save"/></form></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?><html><head><title>Synthetic status</title></head><body><div id="LogInOutBox"><a href="/home/logout.html">Logout</a></div><div id="pageTitle"><h2>Status</h2></div><div><a href="/general/other.html">Other page</a><a href="/net/net/airprint.html">AirPrint</a></div></body></html>
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"scanner": "",
|
||||
"scan-folder": "",
|
||||
"printer": "",
|
||||
"printer-certificate": "",
|
||||
"resolution": 300,
|
||||
"color-mode": "color",
|
||||
"crop": true
|
||||
|
||||
@@ -19,6 +19,20 @@
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"printer": {
|
||||
"type": "string",
|
||||
"description": "The printer's host, such as 192.0.2.10: where its admin page (HTTPS) and its IPP service answer. The device is the scanner's own; the operator's, with no default.",
|
||||
"pattern": "^[A-Za-z0-9.:-]+$",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"printer-certificate": {
|
||||
"type": "string",
|
||||
"description": "The SHA-256 fingerprint of the certificate the printer's admin page serves, as 32 upper-case hex pairs joined by colons. The module trusts that certificate alone and refuses any other; the operator's, with no default.",
|
||||
"pattern": "^([0-9A-F]{2}:){31}[0-9A-F]{2}$",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"resolution": {
|
||||
"type": "number",
|
||||
"description": "The resolution asked of the scanner, in dpi: 100, 200, 300 or 600. A scanner may give another; the scan is filed at the resolution it gave.",
|
||||
@@ -48,6 +62,12 @@
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"own-secrets": {
|
||||
"admin-password": {
|
||||
"path": "${dir:state}/admin-password.secret",
|
||||
"issued-by": "outside"
|
||||
}
|
||||
},
|
||||
"seats": [
|
||||
{
|
||||
"name": "scanner",
|
||||
@@ -93,6 +113,8 @@
|
||||
"takes-config": [
|
||||
"scanner",
|
||||
"scan-folder",
|
||||
"printer",
|
||||
"printer-certificate",
|
||||
"resolution",
|
||||
"color-mode",
|
||||
"crop"
|
||||
@@ -114,7 +136,8 @@
|
||||
"brother"
|
||||
],
|
||||
"env": {
|
||||
"BROTHER_SETTINGS": "${dir:state}/settings.json"
|
||||
"BROTHER_SETTINGS": "${dir:state}/settings.json",
|
||||
"BROTHER_ADMIN_PASSWORD": "${dir:state}/admin-password.secret"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user