Serve mesh-mail-web's HTTP endpoint: internal host check, CSP, open (issue 1283)
This commit is contained in:
@@ -9,6 +9,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
@@ -18,12 +19,26 @@ func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
func main() {
|
||||
b := &Bundle{Env: envFromOS()}
|
||||
go serveHTTP(b)
|
||||
if err := run(b, os.Stdin, os.Stdout); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// serveHTTP starts the HTTP endpoint `web` (issue 1283), on the port the mesh gave it; a listen it cannot
|
||||
// start is logged, never fatal to the seat verbs stdio.Serve answers on the MCP channel.
|
||||
func serveHTTP(b *Bundle) {
|
||||
if b.Env.Listen == "" {
|
||||
logf("[mesh-mail-web] started without MESH_MAIL_WEB_LISTEN; its HTTP endpoint is not served")
|
||||
return
|
||||
}
|
||||
s := NewServer(InternalHost(b.Env.Node))
|
||||
if err := http.ListenAndServe(":"+b.Env.Listen, s); err != nil {
|
||||
logf("[mesh-mail-web] its HTTP endpoint ended: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// run is main's startup path, factored so a test can run it over a pipe instead of the real stdin/stdout
|
||||
// (mirroring mesh-mail-notifier's own run).
|
||||
func run(b *Bundle, in io.Reader, out io.Writer) error {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
// The web server (novox/hq ADR 0440 §2, §5; issue 1283): answers only its internal name mail.<node>.internal
|
||||
// (404 for any other Host — a public name, an IP, or none), sets a content security policy allowing only its
|
||||
// own origin on every answer, and writes nothing to disk. Pages are rendered through html/template only (its
|
||||
// auto-escaping), never written to a file. The seat verb open (issue 1262's claim) answers the page's address:
|
||||
// the root, or one mail's, by account, folder and uid (mail_read's own addressing, tools.go of mesh-mail).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// OpenTool is the mail-webclient seat's open verb.
|
||||
const OpenTool = "mail-webclient.open"
|
||||
|
||||
// CSP is ADR 0440 §5's own policy, set on every answer: nothing loaded from elsewhere, and this page never
|
||||
// framed by another site.
|
||||
const CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; " +
|
||||
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||
|
||||
// InternalHost is this machine's own internal name for the module (ADR 0440 §2): the operator's own setting
|
||||
// (set on the machine, outside the manifest) assigns it the label `mail`, so it is always mail.<node>.internal.
|
||||
func InternalHost(node string) string { return "mail." + node + ".internal" }
|
||||
|
||||
// Server answers mesh-mail-web's HTTP endpoint: a Host check and a CSP on every answer (issue 1283), wrapping
|
||||
// a plain mux that later issues (1284's form check, 1264's pages, ...) add routes to.
|
||||
type Server struct {
|
||||
Host string // InternalHost(node)
|
||||
Mux *http.ServeMux
|
||||
}
|
||||
|
||||
// NewServer is a Server for host, with the root page issue 1283 itself answers (a stand-in: the real accounts
|
||||
// and folder pages are issue 1264's).
|
||||
func NewServer(host string) *Server {
|
||||
s := &Server{Host: host, Mux: http.NewServeMux()}
|
||||
s.Mux.HandleFunc("/", s.rootPage)
|
||||
return s
|
||||
}
|
||||
|
||||
// ServeHTTP is issue 1283/AC1, AC2: every answer carries the CSP; a request for any Host but this server's own
|
||||
// internal name is 404, naming nothing of the mail.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", CSP)
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
if host != s.Host {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.Mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// rootTemplate is 1283/AC4's own stand-in page: no account or mail content yet (issue 1264), only enough to
|
||||
// prove the server answers at all, through html/template (auto-escaping), never a file written to disk.
|
||||
var rootTemplate = template.Must(template.New("root").Parse(
|
||||
`<!DOCTYPE html><html><head><title>Mail</title></head><body><p>mesh-mail-web is up.</p></body></html>`))
|
||||
|
||||
func (s *Server) rootPage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = rootTemplate.Execute(w, nil)
|
||||
}
|
||||
|
||||
// openTool is issue 1262's claimed verb open, built here (1283) now that the server's own host exists: with
|
||||
// nothing given, the root; with account, folder and uid together, that mail's own address.
|
||||
func (b *Bundle) openTool(args map[string]any) (any, error) {
|
||||
if err := only(args, "account", "folder", "uid"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, _ := args["account"].(string)
|
||||
folder, _ := args["folder"].(string)
|
||||
uidArg, hasUID := args["uid"]
|
||||
|
||||
base := "https://" + InternalHost(b.Env.Node) + "/"
|
||||
if account == "" && folder == "" && !hasUID {
|
||||
return map[string]any{"url": base}, nil
|
||||
}
|
||||
uid, isNum := uidArg.(float64)
|
||||
if account == "" || folder == "" || !isNum || uid <= 0 {
|
||||
return nil, fmt.Errorf("%s needs account, folder and uid together, or none of them", OpenTool)
|
||||
}
|
||||
q := url.Values{"account": {account}, "folder": {folder}, "uid": {strconv.FormatFloat(uid, 'f', 0, 64)}}
|
||||
return map[string]any{"url": "https://" + InternalHost(b.Env.Node) + "/mail?" + q.Encode()}, nil
|
||||
}
|
||||
|
||||
// only refuses an argument the tool does not take (mirroring mesh-mail's own tools.go).
|
||||
func only(args map[string]any, takes ...string) error {
|
||||
for k := range args {
|
||||
ok := false
|
||||
for _, t := range takes {
|
||||
if k == t {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("the argument %q is not taken", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1283/AC1: a request whose Host is not mail.<node>.internal — a public name, an IP, or none — is answered
|
||||
// 404, naming nothing of the mail.
|
||||
func TestServerAnswers404ForAnyHostButItsOwnInternalName(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"))
|
||||
for _, host := range []string{"example.com", "203.0.113.5", "", "mail.other-node.internal"} {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = host
|
||||
s.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("host %q: status %d, want 404", host, w.Code)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(w.Body.String()), "mail") {
|
||||
t.Fatalf("host %q: the 404 body names mail: %s", host, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Its own internal name is answered (not 404).
|
||||
func TestServerAnswersItsOwnInternalName(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"))
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = InternalHost("g14")
|
||||
s.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A Host with a port still matches, port and all (the mesh's own listen carries one).
|
||||
func TestServerAnswersItsOwnInternalNameWithAPort(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"))
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = InternalHost("g14") + ":8099"
|
||||
s.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 1283/AC2: every answer (200 and 404 alike) carries the content security policy.
|
||||
func TestServerSetsTheContentSecurityPolicyOnEveryAnswer(t *testing.T) {
|
||||
s := NewServer(InternalHost("g14"))
|
||||
cases := []string{"example.com", InternalHost("g14")}
|
||||
for _, host := range cases {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = host
|
||||
s.ServeHTTP(w, r)
|
||||
if got := w.Header().Get("Content-Security-Policy"); got != CSP {
|
||||
t.Fatalf("host %q: CSP %q, want %q", host, got, CSP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1283/AC3: open with nothing given answers the root; with account, folder and uid together, that mail's own
|
||||
// address.
|
||||
func TestOpenAnswersTheRootOrOneMailsAddress(t *testing.T) {
|
||||
b := &Bundle{Env: Env{Node: "g14"}}
|
||||
out, err := b.openTool(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := out.(map[string]any)["url"]; got != "https://mail.g14.internal/" {
|
||||
t.Fatalf("root url: %v", got)
|
||||
}
|
||||
|
||||
out, err = b.openTool(map[string]any{"account": "mesh", "folder": "INBOX", "uid": float64(7)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
url := out.(map[string]any)["url"].(string)
|
||||
if !strings.HasPrefix(url, "https://mail.g14.internal/mail?") ||
|
||||
!strings.Contains(url, "account=mesh") || !strings.Contains(url, "folder=INBOX") || !strings.Contains(url, "uid=7") {
|
||||
t.Fatalf("mail url: %s", url)
|
||||
}
|
||||
|
||||
if _, err := b.openTool(map[string]any{"account": "mesh"}); err == nil {
|
||||
t.Fatalf("account alone, with no folder or uid, was not refused")
|
||||
}
|
||||
}
|
||||
|
||||
// 1283/AC4: with the working directory and home read-only, the server still starts and answers a page (it
|
||||
// writes nothing to disk).
|
||||
func TestServerStartsAndAnswersAPageWithACwdAndHomeReadOnly(t *testing.T) {
|
||||
ro := t.TempDir()
|
||||
if err := os.Chmod(ro, 0o500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(ro, 0o700) })
|
||||
|
||||
oldWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldHome := os.Getenv("HOME")
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(oldWd)
|
||||
_ = os.Setenv("HOME", oldHome)
|
||||
})
|
||||
if err := os.Chdir(ro); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Setenv("HOME", filepath.Join(ro, "home-does-not-exist"))
|
||||
|
||||
s := NewServer(InternalHost("g14"))
|
||||
srv := httptest.NewServer(s)
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Host = InternalHost("g14")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,16 @@ func (b *Bundle) now() time.Time {
|
||||
// Tools are the module's tools.
|
||||
func (b *Bundle) Tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: OpenTool,
|
||||
Description: "The mail web client's own address (novox/hq ADR 0440 §8): with nothing given, the " +
|
||||
"page's root; with account, folder and uid together (mail_read's own addressing), that mail's " +
|
||||
"own page. Read-only; answers a URL, never a page's content.",
|
||||
Input: schema(nil, map[string]any{
|
||||
"account": text("the account, by its name in mail_accounts"),
|
||||
"folder": text("the folder, such as INBOX"),
|
||||
"uid": integer("the message's uid in that folder"),
|
||||
}),
|
||||
Run: b.openTool},
|
||||
{Name: StatusTool,
|
||||
Description: "Whether mesh-mail-web serves, and the mail accounts it is granted through " +
|
||||
"seat:mail-store.accounts (novox/hq ADR 0440 §8). Read-only.",
|
||||
@@ -104,3 +114,6 @@ func schema(required []string, props map[string]any) map[string]any {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func text(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||
func integer(d string) map[string]any { return map[string]any{"type": "integer", "description": d} }
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
"mail-webclient.open",
|
||||
"mail-webclient.status",
|
||||
"mail_web_health"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user