Files
alterminal/internal/auth/loginnext_test.go
T
2026-10-03 10:44:29 +08:00

109 lines
3.2 KiB
Go

package auth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// SafeNext 僅接受站內路徑,阻擋外站與協定相對 URL(open redirect)。
func TestSafeNext(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"站內路徑", "/authorize?client_id=x", "/authorize?client_id=x"},
{"未提供", "", "/"},
{"外站絕對 URL", "https://evil.example/phish", "/"},
{"協定相對 URL", "//evil.example", "/"},
{"相對路徑", "admin/keys", "/"},
{"僅 scheme", "javascript:alert(1)", "/"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := SafeNext(tt.in); got != tt.want {
t.Fatalf("SafeNext(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
// GET /login?next=... 應在表單保留 next;已登入時導向 next 而非 /。
func TestLoginPageNext(t *testing.T) {
next := "/authorize%3Fclient_id%3Dabc" // 已編碼的 query 值
t.Run("表單含隱藏 next 欄位", func(t *testing.T) {
rec := httptest.NewRecorder()
LoginPageHandler(nil)(rec, httptest.NewRequest(http.MethodGet, "/login?next="+next, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), `name="next"`) {
t.Fatalf("登入表單應保留 next 隱藏欄位: %s", rec.Body.String())
}
})
t.Run("未帶 next 時不出現隱藏欄位", func(t *testing.T) {
rec := httptest.NewRecorder()
LoginPageHandler(nil)(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
if strings.Contains(rec.Body.String(), `name="next"`) {
t.Fatal("無 next 時不需要隱藏欄位")
}
})
}
// 表單登入成功後導向 next;惡意的 next 一律回到 /。
func TestLoginHandlerFormNextRedirect(t *testing.T) {
db, err := openTestDB()
if err != nil {
t.Skipf("資料庫不可用,略過整合測試: %v", err)
}
suffix, err := NewToken(6)
if err != nil {
t.Fatal(err)
}
u := &User{Username: "next-" + suffix, Email: "next-" + suffix + "@example.com"}
if err := u.SetPassword("sup3r-secret"); err != nil {
t.Fatal(err)
}
if err := db.Create(u).Error; err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
db.Delete(&Session{}, "user_id = ?", u.ID)
db.Delete(&User{}, u.ID)
})
login := func(next string) *httptest.ResponseRecorder {
body := "csrf_token=token-A&username=" + u.Username + "&password=sup3r-secret"
if next != "" {
body += "&next=" + next
}
rec := httptest.NewRecorder()
LoginHandler(db)(rec, formPost(body, &http.Cookie{Name: CSRFCookieName, Value: "token-A"}))
return rec
}
t.Run("合法 next 導向原路徑", func(t *testing.T) {
rec := login("%2Fauthorize%3Fclient_id%3Dabc")
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "/authorize?client_id=abc" {
t.Fatalf("Location = %q, want /authorize?client_id=abc", loc)
}
})
t.Run("惡意 next 導向首頁", func(t *testing.T) {
rec := login("https%3A%2F%2Fevil.example")
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/" {
t.Fatalf("Location = %q, want /", loc)
}
})
}