新增主域名匿名密码恢复入口
This commit is contained in:
parent
ef01c8a6c4
commit
a8a65170d3
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
288
backend/listen/http_server/public_recovery.go
Normal file
288
backend/listen/http_server/public_recovery.go
Normal 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
|
||||
}
|
||||
158
backend/listen/http_server/public_recovery_test.go
Normal file
158
backend/listen/http_server/public_recovery_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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{
|
||||
"",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user