diff --git a/admin/README.md b/admin/README.md new file mode 100644 index 0000000..595b968 --- /dev/null +++ b/admin/README.md @@ -0,0 +1,22 @@ +# 采购服务 + +启动前必须显式设置下列环境变量;服务不提供默认管理员账号、密码或会话密钥。 + +| 变量 | 要求 | +| --- | --- | +| `CMBUYER_ADMIN_USERNAME` | 非空管理员账号。 | +| `CMBUYER_ADMIN_PASSWORD_BCRYPT` | 非空 bcrypt 密码哈希,不接受明文密码。 | +| `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 | +| `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 | + +示例仅展示变量名,不提供可运行凭据: + +```powershell +$env:CMBUYER_ADMIN_USERNAME = '<管理员账号>' +$env:CMBUYER_ADMIN_PASSWORD_BCRYPT = '' +$env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>' +$env:CMBUYER_COOKIE_SECURE = 'true' +go run ./cmd/server +``` + +采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。 diff --git a/admin/cmd/server/main.go b/admin/cmd/server/main.go index b68b8db..19720a3 100644 --- a/admin/cmd/server/main.go +++ b/admin/cmd/server/main.go @@ -5,6 +5,8 @@ import ( "log" "net/http" + "cmbuyer/admin/internal/auth" + "cmbuyer/admin/internal/config" "cmbuyer/admin/internal/server" ) @@ -17,7 +19,21 @@ func main() { } func run() error { - err := http.ListenAndServe(listenAddress, server.NewRouter()) + configuration, err := config.LoadFromEnv() + if err != nil { + return err + } + + router, err := server.NewRouter(server.Options{ + AdminUsername: configuration.AdminUsername, + AdminPasswordBcrypt: configuration.AdminPasswordBcrypt, + Sessions: auth.NewManager(configuration.SessionSecret, configuration.CookieSecure), + }) + if err != nil { + return err + } + + err = http.ListenAndServe(listenAddress, router) if errors.Is(err, http.ErrServerClosed) { return nil } diff --git a/admin/go.mod b/admin/go.mod index cfe986f..ab1225f 100644 --- a/admin/go.mod +++ b/admin/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-gonic/gin v1.11.0 github.com/mattn/go-sqlite3 v1.14.49 github.com/pressly/goose/v3 v3.24.0 + golang.org/x/crypto v0.40.0 ) require ( @@ -35,7 +36,6 @@ require ( go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect diff --git a/admin/internal/auth/session.go b/admin/internal/auth/session.go new file mode 100644 index 0000000..e2d208b --- /dev/null +++ b/admin/internal/auth/session.go @@ -0,0 +1,198 @@ +// Package auth 提供内存会话与 CSRF 防护。会话不落库,服务重启会安全地使所有登录失效。 +package auth + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +const ( + CookieName = "cmbuyer_session" + SessionLifetime = 8 * time.Hour + csrfTokenByteSize = 32 +) + +type session struct { + csrfToken string + authenticated bool + expiresAt time.Time +} + +// Manager 签发、验证并撤销进程内会话。cookie 仅承载经过 HMAC 签名的随机 session ID。 +type Manager struct { + secret []byte + cookieSecure bool + now func() time.Time + random io.Reader + + mu sync.Mutex + sessions map[string]session +} + +// NewManager 创建会话管理器。secret 在启动时已由 config 验证为足够长度。 +func NewManager(secret []byte, cookieSecure bool) *Manager { + return &Manager{ + secret: append([]byte(nil), secret...), + cookieSecure: cookieSecure, + now: time.Now, + random: rand.Reader, + sessions: make(map[string]session), + } +} + +// Ensure 返回当前有效会话;不存在或过期时签发匿名会话,以保护登录表单本身的 POST。 +func (manager *Manager) Ensure(writer http.ResponseWriter, request *http.Request) (csrfToken string, authenticated bool) { + if id, current, ok := manager.current(request); ok { + return current.csrfToken, current.authenticated + } else if id != "" { + manager.delete(id) + } + + id, current := manager.create(false) + manager.writeCookie(writer, id, current.expiresAt) + return current.csrfToken, false +} + +// VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。 +func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) { + _, current, found := manager.current(request) + if !found || token == "" { + return false, false + } + + if subtle.ConstantTimeCompare([]byte(current.csrfToken), []byte(token)) != 1 { + return false, false + } + + return current.authenticated, true +} + +// RotateAuthenticated 在登录成功后撤销旧会话并签发全新认证会话,避免 session fixation 与 CSRF 复用。 +func (manager *Manager) RotateAuthenticated(writer http.ResponseWriter, request *http.Request) string { + if id, _, ok := manager.current(request); ok { + manager.delete(id) + } + + id, current := manager.create(true) + manager.writeCookie(writer, id, current.expiresAt) + return current.csrfToken +} + +// Logout 撤销当前会话并立即清除浏览器 cookie。 +func (manager *Manager) Logout(writer http.ResponseWriter, request *http.Request) { + if id, _, ok := manager.current(request); ok { + manager.delete(id) + } + http.SetCookie(writer, &http.Cookie{ + Name: CookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: manager.cookieSecure, + SameSite: http.SameSiteLaxMode, + }) +} + +func (manager *Manager) current(request *http.Request) (string, session, bool) { + cookie, err := request.Cookie(CookieName) + if err != nil { + return "", session{}, false + } + + id, expiresAt, ok := manager.verifyCookie(cookie.Value) + if !ok || !manager.now().Before(expiresAt) { + return id, session{}, false + } + + manager.mu.Lock() + defer manager.mu.Unlock() + current, found := manager.sessions[id] + if !found || !manager.now().Before(current.expiresAt) { + return id, session{}, false + } + + return id, current, true +} + +func (manager *Manager) create(authenticated bool) (string, session) { + id := manager.randomToken() + current := session{ + csrfToken: manager.randomToken(), + authenticated: authenticated, + expiresAt: manager.now().Add(SessionLifetime), + } + + manager.mu.Lock() + manager.sessions[id] = current + manager.mu.Unlock() + return id, current +} + +func (manager *Manager) delete(id string) { + manager.mu.Lock() + delete(manager.sessions, id) + manager.mu.Unlock() +} + +func (manager *Manager) randomToken() string { + bytes := make([]byte, csrfTokenByteSize) + if _, err := io.ReadFull(manager.random, bytes); err != nil { + panic("crypto/rand failed while creating a session token") + } + return base64.RawURLEncoding.EncodeToString(bytes) +} + +func (manager *Manager) writeCookie(writer http.ResponseWriter, id string, expiresAt time.Time) { + http.SetCookie(writer, &http.Cookie{ + Name: CookieName, + Value: manager.signCookie(id, expiresAt), + Path: "/", + MaxAge: int(expiresAt.Sub(manager.now()).Seconds()), + Expires: expiresAt, + HttpOnly: true, + Secure: manager.cookieSecure, + SameSite: http.SameSiteLaxMode, + }) +} + +func (manager *Manager) signCookie(id string, expiresAt time.Time) string { + payload := id + "." + strconv.FormatInt(expiresAt.Unix(), 10) + mac := hmac.New(sha256.New, manager.secret) + _, _ = mac.Write([]byte(payload)) + return payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func (manager *Manager) verifyCookie(value string) (string, time.Time, bool) { + parts := strings.Split(value, ".") + if len(parts) != 3 || parts[0] == "" { + return "", time.Time{}, false + } + + expiresUnix, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return "", time.Time{}, false + } + provided, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return "", time.Time{}, false + } + + payload := parts[0] + "." + parts[1] + mac := hmac.New(sha256.New, manager.secret) + _, _ = mac.Write([]byte(payload)) + if !hmac.Equal(provided, mac.Sum(nil)) { + return "", time.Time{}, false + } + + return parts[0], time.Unix(expiresUnix, 0), true +} diff --git a/admin/internal/auth/session_test.go b/admin/internal/auth/session_test.go new file mode 100644 index 0000000..2b542ae --- /dev/null +++ b/admin/internal/auth/session_test.go @@ -0,0 +1,49 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestManagerRejectsTamperedAndExpiredCookies(t *testing.T) { + manager := NewManager([]byte(strings.Repeat("s", 32)), true) + request := httptest.NewRequest(http.MethodGet, "/login", nil) + response := httptest.NewRecorder() + csrf, authenticated := manager.Ensure(response, request) + if csrf == "" || authenticated { + t.Fatalf("Ensure = (%q, %t), want anonymous CSRF session", csrf, authenticated) + } + cookie := response.Result().Cookies()[0] + if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" { + t.Fatalf("session cookie is missing security attributes: %#v", cookie) + } + + tampered := *cookie + tampered.Value = flipCookieValue(t, cookie.Value) + tamperedRequest := httptest.NewRequest(http.MethodPost, "/login", nil) + tamperedRequest.AddCookie(&tampered) + if _, ok := manager.VerifyCSRF(tamperedRequest, csrf); ok { + t.Fatal("tampered signed cookie passed CSRF verification") + } + + manager.now = func() time.Time { return time.Now().Add(9 * time.Hour) } + expiredRequest := httptest.NewRequest(http.MethodPost, "/login", nil) + expiredRequest.AddCookie(cookie) + if _, ok := manager.VerifyCSRF(expiredRequest, csrf); ok { + t.Fatal("expired cookie passed CSRF verification") + } +} + +func flipCookieValue(t *testing.T, value string) string { + t.Helper() + if value == "" { + t.Fatal("cannot tamper with an empty cookie") + } + if value[0] == 'A' { + return "B" + value[1:] + } + return "A" + value[1:] +} diff --git a/admin/internal/config/config.go b/admin/internal/config/config.go new file mode 100644 index 0000000..312c91c --- /dev/null +++ b/admin/internal/config/config.go @@ -0,0 +1,84 @@ +// Package config 读取采购服务的启动配置。凭据只允许来自显式环境变量,避免把秘密写入代码或仓库。 +package config + +import ( + "errors" + "fmt" + "os" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +const ( + adminUsernameEnv = "CMBUYER_ADMIN_USERNAME" + adminPasswordBcryptEnv = "CMBUYER_ADMIN_PASSWORD_BCRYPT" + sessionSecretEnv = "CMBUYER_SESSION_SECRET" + cookieSecureEnv = "CMBUYER_COOKIE_SECURE" + minimumSecretLength = 32 +) + +// Config 是启动采购服务所需的最小安全配置。 +type Config struct { + AdminUsername string + AdminPasswordBcrypt string + SessionSecret []byte + CookieSecure bool +} + +// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。 +func LoadFromEnv() (Config, error) { + return Load(os.LookupEnv) +} + +// Load 使用 lookup 读取配置,以便在不污染进程环境的情况下测试启动边界。 +func Load(lookup func(string) (string, bool)) (Config, error) { + username, err := required(lookup, adminUsernameEnv) + if err != nil { + return Config{}, err + } + + passwordHash, err := required(lookup, adminPasswordBcryptEnv) + if err != nil { + return Config{}, err + } + if _, err := bcrypt.Cost([]byte(passwordHash)); err != nil { + return Config{}, fmt.Errorf("%s is not a valid bcrypt hash", adminPasswordBcryptEnv) + } + + secret, err := required(lookup, sessionSecretEnv) + if err != nil { + return Config{}, err + } + if len([]byte(secret)) < minimumSecretLength { + return Config{}, fmt.Errorf("%s must be at least %d bytes", sessionSecretEnv, minimumSecretLength) + } + + cookieSecure := false + if value, present := lookup(cookieSecureEnv); present { + switch value { + case "true": + cookieSecure = true + case "false": + cookieSecure = false + default: + return Config{}, fmt.Errorf("%s must be exactly true or false", cookieSecureEnv) + } + } + + return Config{ + AdminUsername: username, + AdminPasswordBcrypt: passwordHash, + SessionSecret: []byte(secret), + CookieSecure: cookieSecure, + }, nil +} + +func required(lookup func(string) (string, bool), name string) (string, error) { + value, present := lookup(name) + if !present || strings.TrimSpace(value) == "" { + return "", errors.New(name + " must be set") + } + + return value, nil +} diff --git a/admin/internal/config/config_test.go b/admin/internal/config/config_test.go new file mode 100644 index 0000000..b94b776 --- /dev/null +++ b/admin/internal/config/config_test.go @@ -0,0 +1,82 @@ +package config_test + +import ( + "strings" + "testing" + + "cmbuyer/admin/internal/config" + + "golang.org/x/crypto/bcrypt" +) + +func TestLoad(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost) + if err != nil { + t.Fatalf("generate bcrypt hash: %v", err) + } + + values := map[string]string{ + "CMBUYER_ADMIN_USERNAME": "admin", + "CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash), + "CMBUYER_SESSION_SECRET": strings.Repeat("s", 32), + "CMBUYER_COOKIE_SECURE": "true", + } + + got, err := config.Load(lookup(values)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.AdminUsername != "admin" || !got.CookieSecure { + t.Fatalf("Load returned unexpected public configuration: %#v", got) + } +} + +func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost) + if err != nil { + t.Fatalf("generate bcrypt hash: %v", err) + } + + base := map[string]string{ + "CMBUYER_ADMIN_USERNAME": "admin", + "CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash), + "CMBUYER_SESSION_SECRET": strings.Repeat("s", 32), + } + + tests := []struct { + name string + mutate func(map[string]string) + want string + }{ + {"missing username", func(values map[string]string) { delete(values, "CMBUYER_ADMIN_USERNAME") }, "CMBUYER_ADMIN_USERNAME"}, + {"invalid bcrypt", func(values map[string]string) { values["CMBUYER_ADMIN_PASSWORD_BCRYPT"] = "not-a-bcrypt-hash" }, "CMBUYER_ADMIN_PASSWORD_BCRYPT"}, + {"short secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = "short" }, "CMBUYER_SESSION_SECRET"}, + {"invalid secure flag", func(values map[string]string) { values["CMBUYER_COOKIE_SECURE"] = "1" }, "CMBUYER_COOKIE_SECURE"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + values := copyValues(base) + test.mutate(values) + _, err := config.Load(lookup(values)) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Load error = %v, want mention of %s", err, test.want) + } + }) + } +} + +func lookup(values map[string]string) func(string) (string, bool) { + return func(key string) (string, bool) { + value, ok := values[key] + return value, ok + } +} + +func copyValues(values map[string]string) map[string]string { + copy := make(map[string]string, len(values)) + for key, value := range values { + copy[key] = value + } + return copy +} diff --git a/admin/internal/server/router.go b/admin/internal/server/router.go index 0844e1b..037a1af 100644 --- a/admin/internal/server/router.go +++ b/admin/internal/server/router.go @@ -2,18 +2,164 @@ package server import ( + "crypto/subtle" + "errors" "net/http" + "net/url" + "strings" + + "cmbuyer/admin/internal/auth" + "cmbuyer/admin/internal/transport/webui" "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" ) -// NewRouter 返回当前服务范围内的完整 HTTP 路由。 -func NewRouter() *gin.Engine { - router := gin.New() +const maxFormBytes = 8 << 10 - router.GET("/healthz", func(context *gin.Context) { - context.JSON(http.StatusOK, gin.H{"status": "ok"}) - }) +// Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。 +type Options struct { + AdminUsername string + AdminPasswordBcrypt string + Sessions *auth.Manager +} - return router +// NewRouter 返回当前服务范围内的完整 HTTP 路由。 +func NewRouter(options Options) (*gin.Engine, error) { + if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil { + return nil, errors.New("server authentication options are incomplete") + } + + router := gin.New() + router.Use(gin.Recovery()) + router.Use(securityHeaders()) + router.GET("/healthz", healthz) + router.GET("/login", loginPage(options)) + router.POST("/login", login(options)) + router.POST("/logout", logout(options)) + router.GET("/tasks", tasksPage(options)) + + return router, nil +} + +func healthz(context *gin.Context) { + context.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +func securityHeaders() gin.HandlerFunc { + return func(context *gin.Context) { + context.Header("Cache-Control", "no-store") + context.Header("X-Content-Type-Options", "nosniff") + context.Header("Referrer-Policy", "no-referrer") + context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") + context.Next() + } +} + +func loginPage(options Options) gin.HandlerFunc { + return func(context *gin.Context) { + csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request) + if authenticated { + context.Redirect(http.StatusSeeOther, "/tasks") + return + } + + renderLogin(context, http.StatusOK, csrfToken, returnTo(context.Query("return_to")), "", "") + } +} + +func login(options Options) gin.HandlerFunc { + return func(context *gin.Context) { + limitFormBody(context) + csrfToken := context.PostForm("csrf_token") + returnPath := returnTo(context.PostForm("return_to")) + username := context.PostForm("username") + password := context.PostForm("password") + + if _, ok := options.Sessions.VerifyCSRF(context.Request, csrfToken); !ok { + newCSRF, _ := options.Sessions.Ensure(context.Writer, context.Request) + renderLogin(context, http.StatusForbidden, newCSRF, returnPath, "", "请求已过期,请重新登录。") + return + } + + usernameMatches := subtle.ConstantTimeCompare([]byte(options.AdminUsername), []byte(username)) == 1 + passwordMatches := bcrypt.CompareHashAndPassword([]byte(options.AdminPasswordBcrypt), []byte(password)) == nil + if !usernameMatches || !passwordMatches { + csrf, _ := options.Sessions.Ensure(context.Writer, context.Request) + renderLogin(context, http.StatusUnauthorized, csrf, returnPath, "", "账号或密码不正确,请检查后重试。") + return + } + + options.Sessions.RotateAuthenticated(context.Writer, context.Request) + context.Redirect(http.StatusSeeOther, returnPath) + } +} + +func logout(options Options) gin.HandlerFunc { + return func(context *gin.Context) { + limitFormBody(context) + authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.PostForm("csrf_token")) + if !ok || !authenticated { + context.Status(http.StatusForbidden) + return + } + + options.Sessions.Logout(context.Writer, context.Request) + context.Redirect(http.StatusSeeOther, "/login") + } +} + +func tasksPage(options Options) gin.HandlerFunc { + return func(context *gin.Context) { + csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request) + if !authenticated { + context.Redirect(http.StatusSeeOther, "/login?return_to="+url.QueryEscape(context.Request.URL.RequestURI())) + return + } + + context.Header("Content-Type", "text/html; charset=utf-8") + if err := webui.RenderTasks(context.Writer, webui.TasksData{CSRFToken: csrfToken}); err != nil { + _ = context.Error(err) + } + } +} + +func renderLogin(context *gin.Context, status int, csrfToken, returnPath, username, message string) { + context.Header("Content-Type", "text/html; charset=utf-8") + context.Status(status) + if err := webui.RenderLogin(context.Writer, webui.LoginData{ + CSRFToken: csrfToken, + ReturnTo: returnPath, + Username: username, + Error: message, + }); err != nil { + _ = context.Error(err) + } +} + +func limitFormBody(context *gin.Context) { + context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxFormBytes) +} + +func returnTo(value string) string { + if value == "/tasks" || strings.HasPrefix(value, "/tasks/") || strings.HasPrefix(value, "/tasks?") { + if strings.Contains(value, "\\") || strings.Contains(value, "%") || strings.HasPrefix(value, "//") { + return "/tasks" + } + parsed, err := url.ParseRequestURI(value) + if err == nil && parsed.IsAbs() == false && parsed.Host == "" && hasSafeTaskPath(parsed.Path) { + return value + } + } + + return "/tasks" +} + +func hasSafeTaskPath(path string) bool { + for _, segment := range strings.Split(path, "/") { + if segment == "." || segment == ".." { + return false + } + } + return true } diff --git a/admin/internal/server/router_test.go b/admin/internal/server/router_test.go index 056f660..54aa508 100644 --- a/admin/internal/server/router_test.go +++ b/admin/internal/server/router_test.go @@ -3,26 +3,291 @@ package server_test import ( "net/http" "net/http/httptest" + "net/url" + "regexp" + "strings" "testing" + "cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/server" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" ) -func TestHealthz(t *testing.T) { +var csrfPattern = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`) + +func TestHealthzIsPublic(t *testing.T) { + router, _ := newRouter(t) request := httptest.NewRequest(http.MethodGet, "/healthz", nil) response := httptest.NewRecorder() - server.NewRouter().ServeHTTP(response, request) + router.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("healthz status = %d, want %d", response.Code, http.StatusOK) } - if contentType := response.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" { t.Fatalf("healthz content type = %q, want application/json; charset=utf-8", contentType) } - if body := response.Body.String(); body != "{\"status\":\"ok\"}" { t.Fatalf("healthz body = %q, want {\"status\":\"ok\"}", body) } + assertSecurityHeaders(t, response) +} + +func TestTasksRequiresLoginAndBlocksOpenRedirects(t *testing.T) { + router, _ := newRouter(t) + + tasks := serve(router, http.MethodGet, "/tasks", nil, nil) + if tasks.Code != http.StatusSeeOther { + t.Fatalf("GET /tasks status = %d, want %d", tasks.Code, http.StatusSeeOther) + } + if location := tasks.Header().Get("Location"); location != "/login?return_to=%2Ftasks" { + t.Fatalf("GET /tasks location = %q, want login return path", location) + } + + for _, target := range []string{"https://example.invalid", "//example.invalid", `\\example.invalid`, "/other", "/tasks/..", "/tasks/../other", "/tasks/%2e%2e", "%2F%2Fevil.invalid", "%252F%252Fevil.invalid"} { + response := serve(router, http.MethodGet, "/login?return_to="+url.QueryEscape(target), nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("GET /login return_to=%q status = %d, want 200", target, response.Code) + } + if strings.Contains(response.Body.String(), target) || !strings.Contains(response.Body.String(), `name="return_to" value="/tasks"`) { + t.Fatalf("GET /login accepted unsafe return_to %q", target) + } + } + + encodedPath := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%252F..", nil, nil) + if !strings.Contains(encodedPath.Body.String(), `name="return_to" value="/tasks"`) { + t.Fatal("encoded parent path was accepted as return_to") + } + encodedQuery := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fnext%3D%252Ftasks%252F..", nil, nil) + if !strings.Contains(encodedQuery.Body.String(), `name="return_to" value="/tasks"`) { + t.Fatal("encoded query bypass was accepted as return_to") + } +} + +func TestLoginRotatesSessionAndCSRF(t *testing.T) { + router, _ := newRouter(t) + initial := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fview%3Dmine", nil, nil) + oldCookie := sessionCookie(t, initial) + oldCSRF := csrfToken(t, initial.Body.String()) + + login := serve(router, http.MethodPost, "/login", url.Values{ + "csrf_token": {oldCSRF}, + "return_to": {"/tasks?view=mine"}, + "username": {"admin"}, + "password": {"test-password"}, + }, oldCookie) + if login.Code != http.StatusSeeOther || login.Header().Get("Location") != "/tasks?view=mine" { + t.Fatalf("successful login = (%d, %q), want 303 /tasks?view=mine", login.Code, login.Header().Get("Location")) + } + newCookie := sessionCookie(t, login) + if newCookie.Value == oldCookie.Value { + t.Fatal("successful login reused the anonymous session cookie") + } + + tasks := serve(router, http.MethodGet, "/tasks", nil, newCookie) + if tasks.Code != http.StatusOK { + t.Fatalf("GET /tasks after login status = %d, want 200", tasks.Code) + } + if newCSRF := csrfToken(t, tasks.Body.String()); newCSRF == oldCSRF { + t.Fatal("successful login reused the anonymous CSRF token") + } + for _, forbidden := range []string{"建单", "试选", "拼多多", "规格", "单价", "证据"} { + if strings.Contains(tasks.Body.String(), forbidden) { + t.Fatalf("task shell must not expose deferred feature content %q", forbidden) + } + } + assertSecurityHeaders(t, initial) + assertSecurityHeaders(t, tasks) +} + +func TestLoginPageIncludesAccessibleFormBasics(t *testing.T) { + router, _ := newRouter(t) + page := serve(router, http.MethodGet, "/login", nil, nil) + body := page.Body.String() + for _, want := range []string{ + `