From a8a65170d393d5358b1964970aabd78d194bcdf5 Mon Sep 17 00:00:00 2001 From: smail Date: Wed, 16 Sep 2026 12:57:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=BB=E5=9F=9F=E5=90=8D?= =?UTF-8?q?=E5=8C=BF=E5=90=8D=E5=AF=86=E7=A0=81=E6=81=A2=E5=A4=8D=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/listen/http_server/http_server.go | 49 +-- backend/listen/http_server/https_server.go | 9 +- backend/listen/http_server/public_recovery.go | 288 ++++++++++++++++++ .../http_server/public_recovery_test.go | 158 ++++++++++ backend/listen/http_server/redirect_test.go | 30 +- 5 files changed, 513 insertions(+), 21 deletions(-) create mode 100644 backend/listen/http_server/public_recovery.go create mode 100644 backend/listen/http_server/public_recovery_test.go diff --git a/backend/listen/http_server/http_server.go b/backend/listen/http_server/http_server.go index c492a80..48a79ae 100644 --- a/backend/listen/http_server/http_server.go +++ b/backend/listen/http_server/http_server.go @@ -316,7 +316,11 @@ func newHTTPHandler(cfg config.HTTPConfig) (http.Handler, error) { if session.Instance == nil { return nil, fmt.Errorf("session manager is unavailable") } - handler = dbmigration.GuardHTTP(session.Instance.LoadAndSave(mux)) + publicRecovery, err := newPublicRecoveryHandler() + if err != nil { + return nil, err + } + handler = dbmigration.GuardHTTP(publicRecoveryOrSessionHandler(publicRecovery, session.Instance.LoadAndSave(mux))) } else { mux.HandleFunc("/.well-known/", controllers.AcmeChallenge) mux.HandleFunc("/api/ping", controllers.Ping) @@ -331,30 +335,39 @@ func redirectToHTTPS(w http.ResponseWriter, r *http.Request) { http.Error(w, "HTTPS is required", http.StatusUpgradeRequired) return } - host := strings.TrimSpace(config.CurrentWebDomain()) - if host == "" { + host, ok := configuredHTTPSRedirectHost(r) + if !ok { w.Header().Set("Connection", "close") http.Error(w, "HTTPS is required", http.StatusUpgradeRequired) return } - parsed, err := url.Parse("//" + host) - if err != nil || parsed.Hostname() == "" || parsed.User != nil || - parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { - w.Header().Set("Connection", "close") - http.Error(w, "HTTPS is required", http.StatusUpgradeRequired) - return - } - host = strings.TrimSuffix(parsed.Hostname(), ".") - if strings.Contains(host, ":") { - host = "[" + host + "]" - } - httpsPort := config.CurrentHTTPConfig().HTTPSPort - if httpsPort > 0 && httpsPort != 443 { - host = fmt.Sprintf("%s:%d", host, httpsPort) - } target := "https://" + host + r.URL.EscapedPath() if r.URL.RawQuery != "" { target += "?" + r.URL.RawQuery } http.Redirect(w, r, target, http.StatusPermanentRedirect) } + +func configuredHTTPSRedirectHost(r *http.Request) (string, bool) { + if r == nil { + return "", false + } + requestHost, _, ok := normalizeAuthority(r.Host, "80") + if !ok { + return "", false + } + for _, configured := range []string{config.CurrentWebDomain(), config.CurrentMailDomains().Primary} { + host, port, valid := configuredHTTPSAuthority(configured) + if !valid || host != requestHost { + continue + } + if strings.Contains(host, ":") { + host = "[" + host + "]" + } + if port != "443" { + host = fmt.Sprintf("%s:%s", host, port) + } + return host, true + } + return "", false +} diff --git a/backend/listen/http_server/https_server.go b/backend/listen/http_server/https_server.go index b6826a2..7133c47 100644 --- a/backend/listen/http_server/https_server.go +++ b/backend/listen/http_server/https_server.go @@ -54,6 +54,13 @@ func HttpsStart() error { if err := router(mux); err != nil { return err } + publicRecovery, err := newPublicRecoveryHandler() + if err != nil { + return err + } + if session.Instance == nil { + return fmt.Errorf("session manager is unavailable") + } nullLog := olog.New(&nullWrite{}, "", olog.Ldate) httpsPort := 443 @@ -75,7 +82,7 @@ func HttpsStart() error { httpsServer = &http.Server{ TLSConfig: tlsConfig, Addr: fmt.Sprintf(":%d", httpsPort), - Handler: securityHeaders(dbmigration.GuardHTTP(session.Instance.LoadAndSave(mux))), + Handler: securityHeaders(dbmigration.GuardHTTP(publicRecoveryOrSessionHandler(publicRecovery, session.Instance.LoadAndSave(mux)))), ReadTimeout: time.Second * 90, ReadHeaderTimeout: time.Second * 10, WriteTimeout: time.Second * 90, diff --git a/backend/listen/http_server/public_recovery.go b/backend/listen/http_server/public_recovery.go new file mode 100644 index 0000000..cf3fada --- /dev/null +++ b/backend/listen/http_server/public_recovery.go @@ -0,0 +1,288 @@ +package http_server + +import ( + "bytes" + "fmt" + "html" + "io/fs" + "mime" + "net" + "net/http" + "net/url" + "strconv" + "strings" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/controllers" + "git.pcux.cn/xuans/Smail/session" +) + +const publicRecoveryPrefix = "/rest" + +// newPublicRecoveryHandler 创建不经过主站会话和业务上下文的匿名恢复处理器。 +// 该处理器只能由 publicRecoveryOrSessionHandler 在会话中间件之前调用。 +func newPublicRecoveryHandler() (http.Handler, error) { + frontend, err := fs.Sub(local, "dist") + if err != nil { + return nil, fmt.Errorf("load embedded frontend for public recovery: %w", err) + } + return &publicRecoveryHandler{ + frontend: frontend, + static: http.FileServer(http.FS(frontend)), + }, nil +} + +type publicRecoveryHandler struct { + frontend fs.FS + static http.Handler +} + +func (h *publicRecoveryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r == nil || !controllers.PublicMailRecoveryEnabled() { + http.NotFound(w, r) + return + } + if !config.DevelopmentMode && requestScheme(r) != "https" { + http.NotFound(w, r) + return + } + + path := normalizedPublicRecoveryPath(r.URL.Path) + if path == "/public-password-recovery" { + h.redirectFromWebLogin(w, r) + return + } + + if !isPublicRecoveryMailHost(r) { + http.NotFound(w, r) + return + } + + switch path { + case "/rest", "/rest/": + h.servePage(w, r) + case "/rest/api/recovery/begin": + h.serveAPI(w, r, controllers.BeginPublicLinkPasswordRecovery) + case "/rest/api/recovery/requirements": + h.serveAPI(w, r, controllers.PublicLinkPasswordRecoveryRequirements) + case "/rest/api/recovery/complete": + h.serveAPI(w, r, controllers.CompletePublicLinkPasswordRecovery) + default: + if isPublicRecoveryAssetPath(path) { + h.serveStatic(w, r) + return + } + http.NotFound(w, r) + } +} + +func (h *publicRecoveryHandler) redirectFromWebLogin(w http.ResponseWriter, r *http.Request) { + if !isCurrentWebHost(r) { + http.NotFound(w, r) + return + } + target, ok := publicRecoveryBaseURL() + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "no-store") + http.Redirect(w, r, target, http.StatusFound) +} + +func (h *publicRecoveryHandler) servePage(w http.ResponseWriter, r *http.Request) { + loginURL, ok := publicRecoveryLoginURL() + if !ok { + http.NotFound(w, r) + return + } + content, err := fs.ReadFile(h.frontend, "index.html") + if err != nil { + http.Error(w, "public recovery page is unavailable", http.StatusServiceUnavailable) + return + } + marker := []byte(`
`) + replacement := []byte(`
`) + if !bytes.Contains(content, marker) { + http.Error(w, "public recovery page is unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(bytes.Replace(content, marker, replacement, 1)) +} + +func (h *publicRecoveryHandler) serveStatic(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=86400") + h.static.ServeHTTP(w, r) +} + +func (h *publicRecoveryHandler) serveAPI(w http.ResponseWriter, r *http.Request, handler func(http.ResponseWriter, *http.Request)) { + if !publicRecoveryAPIRequestAllowed(r) { + w.Header().Set("Cache-Control", "no-store") + http.Error(w, "invalid public recovery request", http.StatusBadRequest) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) + handler(w, r) +} + +// publicRecoveryOrSessionHandler 将匿名恢复路径从主站 Session 之前分流。 +// 除匿名恢复页、静态资源和恢复 API 外,所有请求维持原有主站会话处理路径。 +func publicRecoveryOrSessionHandler(publicRecovery, mainSite http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := normalizedPublicRecoveryPath(r.URL.Path) + if path == "/public-password-recovery" || isPublicRecoveryReservedPath(path) || + (isPublicRecoveryMailHost(r) && isPublicRecoveryAssetPath(path)) { + publicRecovery.ServeHTTP(w, r) + return + } + mainSite.ServeHTTP(w, r) + }) +} + +func normalizedPublicRecoveryPath(value string) string { + path := strings.TrimSpace(value) + for range 2 { + decoded, err := url.PathUnescape(path) + if err != nil || decoded == path { + break + } + path = decoded + } + if path == "" { + return "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path +} + +func isPublicRecoveryReservedPath(path string) bool { + return path == publicRecoveryPrefix || strings.HasPrefix(path, publicRecoveryPrefix+"/") +} + +func isPublicRecoveryAssetPath(path string) bool { + return strings.HasPrefix(path, "/assets/") || path == "/smail-logo.png" +} + +func publicRecoveryAPIRequestAllowed(r *http.Request) bool { + if r == nil || r.Method != http.MethodPost || hasPublicRecoveryCookie(r) { + return false + } + if strings.TrimSpace(r.Header.Get("X-CSRF-Token")) != "" || + strings.EqualFold(strings.TrimSpace(r.Header.Get("Sec-Fetch-Site")), "cross-site") { + return false + } + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || !strings.EqualFold(mediaType, "application/json") { + return false + } + return publicRecoveryOriginsAllowed(r) +} + +func hasPublicRecoveryCookie(r *http.Request) bool { + if r == nil { + return false + } + for _, cookie := range r.Cookies() { + name := strings.ToLower(strings.TrimSpace(cookie.Name)) + if name == "smail_csrf" || name == "smail_recovery_context" || name == "smail_session" { + return true + } + if session.Instance != nil && strings.EqualFold(cookie.Name, session.Instance.Cookie.Name) { + return true + } + } + return false +} + +func publicRecoveryOriginsAllowed(r *http.Request) bool { + for _, value := range []string{r.Header.Get("Origin"), r.Header.Get("Referer")} { + value = strings.TrimSpace(value) + if value != "" && !isPublicRecoveryOrigin(value) { + return false + } + } + return true +} + +func isPublicRecoveryOrigin(value string) bool { + u, err := url.Parse(strings.TrimSpace(value)) + if err != nil || u.User != nil || !strings.EqualFold(u.Scheme, "https") || u.Host == "" { + return false + } + expectedHost, expectedPort, ok := configuredHTTPSAuthority(config.CurrentMailDomains().Primary) + if !ok { + return false + } + originHost, originPort, ok := normalizeAuthority(u.Host, "443") + return ok && originHost == expectedHost && originPort == expectedPort +} + +func isPublicRecoveryMailHost(r *http.Request) bool { + return requestMatchesHTTPSAuthority(r, config.CurrentMailDomains().Primary) +} + +func isCurrentWebHost(r *http.Request) bool { + return requestMatchesHTTPSAuthority(r, config.CurrentWebDomain()) +} + +func requestMatchesHTTPSAuthority(r *http.Request, configured string) bool { + if r == nil { + return false + } + expectedHost, _, ok := configuredHTTPSAuthority(configured) + if !ok { + return false + } + requestHost, _, ok := normalizeAuthority(r.Host, "443") + return ok && requestHost == expectedHost +} + +func configuredHTTPSAuthority(value string) (string, string, bool) { + defaultPort := strconv.Itoa(currentHTTPSPort()) + return normalizeAuthority(value, defaultPort) +} + +func currentHTTPSPort() int { + port := config.CurrentHTTPConfig().HTTPSPort + if port < 1 || port > 65535 { + return 443 + } + return port +} + +func publicRecoveryBaseURL() (string, bool) { + return configuredHTTPSURL(config.CurrentMailDomains().Primary, "/rest/", "") +} + +func publicRecoveryLoginURL() (string, bool) { + return configuredHTTPSURL(config.CurrentWebDomain(), "/", "/login") +} + +func configuredHTTPSURL(value, path, fragment string) (string, bool) { + parsed, err := url.Parse("//" + strings.TrimSpace(value)) + if err != nil || parsed.User != nil || parsed.Hostname() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", false + } + host := strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".") + if host == "" { + return "", false + } + port := parsed.Port() + if port == "" && currentHTTPSPort() != 443 { + port = strconv.Itoa(currentHTTPSPort()) + } + if port != "" { + portNumber, err := strconv.Atoi(port) + if err != nil || portNumber < 1 || portNumber > 65535 { + return "", false + } + host = net.JoinHostPort(host, strconv.Itoa(portNumber)) + } else if strings.Contains(host, ":") { + host = "[" + host + "]" + } + return (&url.URL{Scheme: "https", Host: host, Path: path, Fragment: fragment}).String(), true +} diff --git a/backend/listen/http_server/public_recovery_test.go b/backend/listen/http_server/public_recovery_test.go new file mode 100644 index 0000000..e2d435e --- /dev/null +++ b/backend/listen/http_server/public_recovery_test.go @@ -0,0 +1,158 @@ +package http_server + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.pcux.cn/xuans/Smail/config" +) + +func configurePublicRecoveryRouteTest(t *testing.T) { + t.Helper() + oldConfig := config.Instance + oldManager := config.CurrentManager() + oldDevelopment := config.DevelopmentMode + config.SetRuntimeManager(nil) + config.DevelopmentMode = false + config.Instance = &config.Config{ + SetupState: config.SetupStateReady, + Domain: "mx.example.com", + WebDomain: "web.example.com", + ExternalRecoveryEnabled: true, + HttpsPort: 443, + } + t.Cleanup(func() { + config.Instance = oldConfig + config.SetRuntimeManager(oldManager) + config.DevelopmentMode = oldDevelopment + }) +} + +func publicRecoveryRequest(method, target string) *http.Request { + request := httptest.NewRequest(method, target, nil) + request.TLS = &tls.ConnectionState{} + return request +} + +func TestPublicRecoveryPageIsServedOnlyFromMailDomain(t *testing.T) { + configurePublicRecoveryRouteTest(t) + handler, err := newPublicRecoveryHandler() + if err != nil { + t.Fatal(err) + } + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://mx.example.com/rest/")) + if recorder.Code != http.StatusOK { + t.Fatalf("mail recovery page returned %d", recorder.Code) + } + if !strings.Contains(recorder.Body.String(), `data-login-url="https://web.example.com/#/login"`) { + t.Fatal("mail recovery page did not contain the configured web login URL") + } + if recorder.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("recovery page cache policy = %q", recorder.Header().Get("Cache-Control")) + } + + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://web.example.com/rest/")) + if recorder.Code != http.StatusNotFound { + t.Fatalf("web domain recovery page returned %d, want 404", recorder.Code) + } +} + +func TestPublicRecoveryLoginEntryRedirectsOnlyFromWebDomain(t *testing.T) { + configurePublicRecoveryRouteTest(t) + handler, err := newPublicRecoveryHandler() + if err != nil { + t.Fatal(err) + } + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://web.example.com/public-password-recovery")) + if recorder.Code != http.StatusFound || recorder.Header().Get("Location") != "https://mx.example.com/rest/" { + t.Fatalf("unexpected login entry redirect: status=%d location=%q", recorder.Code, recorder.Header().Get("Location")) + } + + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://evil.example/public-password-recovery")) + if recorder.Code != http.StatusNotFound { + t.Fatalf("foreign login entry returned %d, want 404", recorder.Code) + } +} + +func TestPublicRecoveryAPIRejectsCookiesAndCrossSiteRequests(t *testing.T) { + configurePublicRecoveryRouteTest(t) + handler, err := newPublicRecoveryHandler() + if err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + prepare func(*http.Request) + }{ + {name: "session cookie", prepare: func(request *http.Request) { request.AddCookie(&http.Cookie{Name: "smail_session", Value: "session"}) }}, + {name: "csrf header", prepare: func(request *http.Request) { request.Header.Set("X-CSRF-Token", "csrf") }}, + {name: "cross site", prepare: func(request *http.Request) { request.Header.Set("Sec-Fetch-Site", "cross-site") }}, + {name: "foreign origin", prepare: func(request *http.Request) { request.Header.Set("Origin", "https://evil.example") }}, + {name: "wrong content type", prepare: func(request *http.Request) { request.Header.Set("Content-Type", "text/plain") }}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + request := publicRecoveryRequest(http.MethodPost, "https://mx.example.com/rest/api/recovery/begin") + request.Header.Set("Content-Type", "application/json") + test.prepare(request) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("invalid public recovery request returned %d", recorder.Code) + } + }) + } +} + +func TestPublicRecoveryAPIUsesNoMainSessionHandler(t *testing.T) { + configurePublicRecoveryRouteTest(t) + publicRecovery, err := newPublicRecoveryHandler() + if err != nil { + t.Fatal(err) + } + mainCalls := 0 + handler := publicRecoveryOrSessionHandler(publicRecovery, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mainCalls++ + w.WriteHeader(http.StatusNoContent) + })) + + request := publicRecoveryRequest(http.MethodPost, "https://mx.example.com/rest/api/recovery/begin") + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if mainCalls != 0 { + t.Fatal("public recovery request entered the main session handler") + } + if recorder.Header().Get("Set-Cookie") != "" { + t.Fatalf("public recovery request set a cookie: %q", recorder.Header().Get("Set-Cookie")) + } +} + +func TestPublicRecoveryStaticAssetsBypassMainSessionHandler(t *testing.T) { + configurePublicRecoveryRouteTest(t) + publicRecovery, err := newPublicRecoveryHandler() + if err != nil { + t.Fatal(err) + } + mainCalls := 0 + handler := publicRecoveryOrSessionHandler(publicRecovery, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mainCalls++ + w.WriteHeader(http.StatusNoContent) + })) + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://mx.example.com/smail-logo.png")) + if recorder.Code != http.StatusOK || mainCalls != 0 { + t.Fatalf("public recovery asset used main handler: status=%d calls=%d", recorder.Code, mainCalls) + } +} diff --git a/backend/listen/http_server/redirect_test.go b/backend/listen/http_server/redirect_test.go index 7622dc1..3773db4 100644 --- a/backend/listen/http_server/redirect_test.go +++ b/backend/listen/http_server/redirect_test.go @@ -15,7 +15,7 @@ func TestRedirectToHTTPSPreservesPathQueryAndPort(t *testing.T) { config.Instance = &config.Config{WebDomain: "mail.example.com", HttpsPort: 8443} t.Cleanup(func() { config.Instance = old }) - request := httptest.NewRequest(http.MethodGet, "http://old.example/a%2Fb?x=1&y=2", nil) + request := httptest.NewRequest(http.MethodGet, "http://mail.example.com/a%2Fb?x=1&y=2", nil) recorder := httptest.NewRecorder() redirectToHTTPS(recorder, request) @@ -78,12 +78,38 @@ func TestRedirectToHTTPSAllowsConfiguredIPHost(t *testing.T) { t.Cleanup(func() { config.Instance = old }) recorder := httptest.NewRecorder() - redirectToHTTPS(recorder, httptest.NewRequest(http.MethodGet, "http://old.example/Reset", nil)) + redirectToHTTPS(recorder, httptest.NewRequest(http.MethodGet, "http://192.0.2.10/Reset", nil)) if location := recorder.Header().Get("Location"); recorder.Code != http.StatusPermanentRedirect || location != "https://192.0.2.10/Reset" { t.Fatalf("unexpected IP redirect: status=%d location=%q", recorder.Code, location) } } +func TestRedirectToHTTPSKeepsConfiguredMailDomain(t *testing.T) { + old := config.Instance + config.Instance = &config.Config{Domain: "mx.example.com", WebDomain: "web.example.com", HttpsPort: 443} + t.Cleanup(func() { config.Instance = old }) + + request := httptest.NewRequest(http.MethodGet, "http://mx.example.com/rest/?next=1", nil) + recorder := httptest.NewRecorder() + redirectToHTTPS(recorder, request) + + if recorder.Code != http.StatusPermanentRedirect || recorder.Header().Get("Location") != "https://mx.example.com/rest/?next=1" { + t.Fatalf("unexpected mail domain redirect: status=%d location=%q", recorder.Code, recorder.Header().Get("Location")) + } +} + +func TestRedirectToHTTPSRejectsUnknownRequestHost(t *testing.T) { + old := config.Instance + config.Instance = &config.Config{Domain: "mx.example.com", WebDomain: "web.example.com", HttpsPort: 443} + t.Cleanup(func() { config.Instance = old }) + + recorder := httptest.NewRecorder() + redirectToHTTPS(recorder, httptest.NewRequest(http.MethodGet, "http://evil.example/rest/", nil)) + if recorder.Code != http.StatusUpgradeRequired || recorder.Header().Get("Location") != "" { + t.Fatalf("unknown host was redirected: status=%d location=%q", recorder.Code, recorder.Header().Get("Location")) + } +} + func TestRedirectToHTTPSRejectsUnsafeConfiguredHost(t *testing.T) { cases := []string{ "",