294 lines
10 KiB
Go
294 lines
10 KiB
Go
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"
|
|
)
|
|
|
|
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()
|
|
|
|
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{
|
|
`<label for="username">`,
|
|
`<label for="password">`,
|
|
`autocomplete="username"`,
|
|
`autocomplete="current-password"`,
|
|
`min-height:44px`,
|
|
`:focus-visible`,
|
|
`prefers-reduced-motion`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("login page is missing %q", want)
|
|
}
|
|
}
|
|
if strings.Contains(body, "http://") || strings.Contains(body, "https://") || strings.Contains(body, "<script") {
|
|
t.Fatal("login page must not load external resources or require client-side JavaScript")
|
|
}
|
|
|
|
failure := serve(router, http.MethodPost, "/login", url.Values{
|
|
"csrf_token": {csrfToken(t, body)},
|
|
"username": {"admin"},
|
|
"password": {"wrong"},
|
|
}, sessionCookie(t, page))
|
|
if !strings.Contains(failure.Body.String(), `role="alert"`) {
|
|
t.Fatal("login failure must announce its error")
|
|
}
|
|
}
|
|
|
|
func TestLoginCSRFAndCredentialFailuresAreSafe(t *testing.T) {
|
|
router, _ := newRouter(t)
|
|
page := serve(router, http.MethodGet, "/login", nil, nil)
|
|
cookie := sessionCookie(t, page)
|
|
|
|
withoutCSRF := serve(router, http.MethodPost, "/login", url.Values{
|
|
"username": {"admin"},
|
|
"password": {"test-password"},
|
|
}, cookie)
|
|
if withoutCSRF.Code != http.StatusForbidden || !strings.Contains(withoutCSRF.Body.String(), "请求已过期") {
|
|
t.Fatalf("login without CSRF = (%d, %q), want rejected form", withoutCSRF.Code, withoutCSRF.Body.String())
|
|
}
|
|
|
|
page = serve(router, http.MethodGet, "/login", nil, cookie)
|
|
badCredentials := serve(router, http.MethodPost, "/login", url.Values{
|
|
"csrf_token": {csrfToken(t, page.Body.String())},
|
|
"username": {"unknown"},
|
|
"password": {"wrong"},
|
|
}, cookie)
|
|
if badCredentials.Code != http.StatusUnauthorized {
|
|
t.Fatalf("login with invalid credentials status = %d, want 401", badCredentials.Code)
|
|
}
|
|
if body := badCredentials.Body.String(); !strings.Contains(body, "账号或密码不正确") || strings.Contains(body, "unknown") {
|
|
t.Fatalf("invalid login leaked account detail: %q", body)
|
|
}
|
|
}
|
|
|
|
func TestTamperedCookieCannotAccessTasks(t *testing.T) {
|
|
router, _ := newRouter(t)
|
|
page := serve(router, http.MethodGet, "/login", nil, nil)
|
|
cookie := sessionCookie(t, page)
|
|
|
|
tampered := *cookie
|
|
tampered.Value = flipCookieValue(t, cookie.Value)
|
|
response := serve(router, http.MethodGet, "/tasks", nil, &tampered)
|
|
if response.Code != http.StatusSeeOther {
|
|
t.Fatalf("tampered cookie status = %d, want 303", response.Code)
|
|
}
|
|
|
|
}
|
|
|
|
func assertSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
want := map[string]string{
|
|
"Cache-Control": "no-store",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Referrer-Policy": "no-referrer",
|
|
"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'",
|
|
}
|
|
for name, expected := range want {
|
|
if got := response.Header().Get(name); got != expected {
|
|
t.Fatalf("%s = %q, want %q", name, got, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
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:]
|
|
}
|
|
|
|
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
|
|
router, _ := newRouter(t)
|
|
loginPage := serve(router, http.MethodGet, "/login", nil, nil)
|
|
loginCookie := sessionCookie(t, loginPage)
|
|
login := serve(router, http.MethodPost, "/login", url.Values{
|
|
"csrf_token": {csrfToken(t, loginPage.Body.String())},
|
|
"username": {"admin"},
|
|
"password": {"test-password"},
|
|
}, loginCookie)
|
|
authenticatedCookie := sessionCookie(t, login)
|
|
|
|
missingCSRF := serve(router, http.MethodPost, "/logout", url.Values{}, authenticatedCookie)
|
|
if missingCSRF.Code != http.StatusForbidden {
|
|
t.Fatalf("logout without CSRF status = %d, want 403", missingCSRF.Code)
|
|
}
|
|
|
|
tasks := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
|
|
logout := serve(router, http.MethodPost, "/logout", url.Values{
|
|
"csrf_token": {csrfToken(t, tasks.Body.String())},
|
|
}, authenticatedCookie)
|
|
if logout.Code != http.StatusSeeOther || logout.Header().Get("Location") != "/login" {
|
|
t.Fatalf("logout = (%d, %q), want 303 /login", logout.Code, logout.Header().Get("Location"))
|
|
}
|
|
if cookie := sessionCookie(t, logout); cookie.MaxAge >= 0 {
|
|
t.Fatalf("logout cookie MaxAge = %d, want a deletion cookie", cookie.MaxAge)
|
|
}
|
|
|
|
reused := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
|
|
if reused.Code != http.StatusSeeOther {
|
|
t.Fatalf("revoked session status = %d, want 303", reused.Code)
|
|
}
|
|
}
|
|
|
|
func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
|
if err != nil {
|
|
t.Fatalf("generate bcrypt hash: %v", err)
|
|
}
|
|
manager := auth.NewManager([]byte(strings.Repeat("s", 32)), false)
|
|
router, err := server.NewRouter(server.Options{
|
|
AdminUsername: "admin",
|
|
AdminPasswordBcrypt: string(hash),
|
|
Sessions: manager,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewRouter: %v", err)
|
|
}
|
|
return router, manager
|
|
}
|
|
|
|
func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
|
|
var body *strings.Reader
|
|
if form == nil {
|
|
body = strings.NewReader("")
|
|
} else {
|
|
body = strings.NewReader(form.Encode())
|
|
}
|
|
request := httptest.NewRequest(method, target, body)
|
|
if form != nil {
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
}
|
|
if cookie != nil {
|
|
request.AddCookie(cookie)
|
|
}
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
return response
|
|
}
|
|
|
|
func sessionCookie(t *testing.T, response *httptest.ResponseRecorder) *http.Cookie {
|
|
t.Helper()
|
|
for _, cookie := range response.Result().Cookies() {
|
|
if cookie.Name == auth.CookieName {
|
|
return cookie
|
|
}
|
|
}
|
|
t.Fatalf("response did not set %s cookie", auth.CookieName)
|
|
return nil
|
|
}
|
|
|
|
func csrfToken(t *testing.T, body string) string {
|
|
t.Helper()
|
|
matches := csrfPattern.FindStringSubmatch(body)
|
|
if len(matches) != 2 || matches[1] == "" {
|
|
t.Fatalf("no CSRF token in response body: %q", body)
|
|
}
|
|
return matches[1]
|
|
}
|