新增主域名匿名密码恢复入口

This commit is contained in:
smail 2026-09-16 12:57:54 +08:00
parent ef01c8a6c4
commit a8a65170d3
5 changed files with 513 additions and 21 deletions

View File

@ -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
}

View File

@ -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,

View File

@ -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(`<div id="app"></div>`)
replacement := []byte(`<div id="app" data-login-url="` + html.EscapeString(loginURL) + `"></div>`)
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
}

View File

@ -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)
}
}

View File

@ -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{
"",