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