diff --git a/internal/server/templates/tokens.html b/internal/server/templates/tokens.html
index a560aa5..7196da9 100644
--- a/internal/server/templates/tokens.html
+++ b/internal/server/templates/tokens.html
@@ -11,6 +11,7 @@
Tokens
{{end}}
diff --git a/internal/server/ui_account_integration_test.go b/internal/server/ui_account_integration_test.go
index 6788204..f221ed4 100644
--- a/internal/server/ui_account_integration_test.go
+++ b/internal/server/ui_account_integration_test.go
@@ -9,6 +9,7 @@ import (
"net/url"
"strings"
"testing"
+ "time"
)
func TestUITokensPageCreatesAndRevokesToken(t *testing.T) {
@@ -20,9 +21,16 @@ func TestUITokensPageCreatesAndRevokesToken(t *testing.T) {
if !strings.Contains(body, "New API token") || !strings.Contains(body, "Tokens") {
t.Fatalf("tokens page missing form/header: %s", body)
}
+ csrfToken := uiCSRFTokenForTest("session", token)
+ if !strings.Contains(body, `name="csrf_token" value="`+csrfToken+`"`) {
+ t.Fatalf("tokens page missing session-bound CSRF field: %s", body)
+ }
- form := url.Values{"name": {"from ui"}}
- res := e.uiDoNoRedirect(t, http.MethodPost, "/tokens", token, strings.NewReader(form.Encode()))
+ form := url.Values{"name": {"from ui"}, "csrf_token": {csrfToken}}
+ res := e.uiDoNoRedirectWithHeaders(t, http.MethodPost, "/tokens", token, strings.NewReader(form.Encode()), map[string]string{
+ "Origin": e.ts.URL,
+ "X-CSRF-Token": "",
+ })
defer res.Body.Close()
body = readBody(t, res)
if res.StatusCode != http.StatusOK {
@@ -31,6 +39,10 @@ func TestUITokensPageCreatesAndRevokesToken(t *testing.T) {
if !strings.Contains(body, "Copy this token now.") {
t.Fatalf("body missing created token notice: %s", body)
}
+ rawToken := createdTokenValue(t, body)
+ if nextBody := e.uiGet(t, "/tokens", token); strings.Contains(nextBody, rawToken) || strings.Contains(nextBody, "Copy this token now.") {
+ t.Fatalf("created token was shown after its one-time response: %s", nextBody)
+ }
tokens, err := e.store.ListAuthTokens(e.ctx, user.ID)
if err != nil {
t.Fatalf("ListAuthTokens: %v", err)
@@ -45,7 +57,11 @@ func TestUITokensPageCreatesAndRevokesToken(t *testing.T) {
if created == nil {
t.Fatalf("created token missing: %+v", tokens)
}
- res = e.uiDoNoRedirect(t, http.MethodPost, "/tokens/"+created.ID.String()+"/revoke", token, strings.NewReader(url.Values{}.Encode()))
+ revokeForm := url.Values{"csrf_token": {csrfToken}}
+ res = e.uiDoNoRedirectWithHeaders(t, http.MethodPost, "/tokens/"+created.ID.String()+"/revoke", token, strings.NewReader(revokeForm.Encode()), map[string]string{
+ "Origin": e.ts.URL,
+ "X-CSRF-Token": "",
+ })
defer res.Body.Close()
if res.StatusCode != http.StatusSeeOther {
t.Fatalf("revoke code = %d body = %s", res.StatusCode, readBody(t, res))
@@ -61,6 +77,107 @@ func TestUITokensPageCreatesAndRevokesToken(t *testing.T) {
}
}
+func TestUITokenCreationCSRFVariants(t *testing.T) {
+ t.Parallel()
+ e := newHTTPEnv(t)
+ user, token := e.mustProjectMemberToken(t, "ui-token-csrf")
+ csrfToken := uiCSRFTokenForTest("session", token)
+
+ htmxForm := url.Values{"name": {"from htmx"}}
+ res := e.uiDoNoRedirectWithHeaders(t, http.MethodPost, "/tokens", token, strings.NewReader(htmxForm.Encode()), map[string]string{
+ "HX-Request": "true",
+ "Origin": e.ts.URL,
+ })
+ body := readBody(t, res)
+ res.Body.Close()
+ if res.StatusCode != http.StatusOK || !strings.Contains(body, "Copy this token now.") {
+ t.Fatalf("HTMX create token code = %d body = %s", res.StatusCode, body)
+ }
+ htmxRawToken := createdTokenValue(t, body)
+ if nextBody := e.uiGet(t, "/tokens", token); strings.Contains(nextBody, htmxRawToken) {
+ t.Fatalf("HTMX-created token was shown after its one-time response: %s", nextBody)
+ }
+
+ missingForm := url.Values{"name": {"missing csrf"}}
+ res = e.uiDoNoRedirectWithHeaders(t, http.MethodPost, "/tokens", token, strings.NewReader(missingForm.Encode()), map[string]string{
+ "Origin": e.ts.URL,
+ "X-CSRF-Token": "",
+ })
+ body = readBody(t, res)
+ res.Body.Close()
+ if res.StatusCode != http.StatusForbidden || !strings.Contains(body, "CSRF validation failed.") {
+ t.Fatalf("missing CSRF code = %d body = %s", res.StatusCode, body)
+ }
+
+ invalidForm := url.Values{"name": {"invalid csrf"}, "csrf_token": {csrfToken}}
+ res = e.uiDoNoRedirectWithHeaders(t, http.MethodPost, "/tokens", token, strings.NewReader(invalidForm.Encode()), map[string]string{
+ "Origin": e.ts.URL,
+ "X-CSRF-Token": "wrong",
+ })
+ body = readBody(t, res)
+ res.Body.Close()
+ if res.StatusCode != http.StatusForbidden || !strings.Contains(body, "CSRF validation failed.") {
+ t.Fatalf("invalid CSRF code = %d body = %s", res.StatusCode, body)
+ }
+
+ past := time.Now().Add(-time.Minute)
+ expired, err := e.store.CreateAuthToken(e.ctx, store.CreateAuthTokenParams{
+ UserID: user.ID,
+ Kind: model.AuthTokenKindSession,
+ Name: "expired session",
+ ExpiresAt: &past,
+ })
+ if err != nil {
+ t.Fatalf("CreateAuthToken expired session: %v", err)
+ }
+ expiredForm := url.Values{
+ "name": {"expired session attempt"},
+ "csrf_token": {uiCSRFTokenForTest("session", expired.RawToken)},
+ }
+ res = e.uiDoNoRedirectWithHeaders(t, http.MethodPost, "/tokens", expired.RawToken, strings.NewReader(expiredForm.Encode()), map[string]string{"Origin": e.ts.URL})
+ body = readBody(t, res)
+ res.Body.Close()
+ if res.StatusCode != http.StatusSeeOther || res.Header.Get("Location") != "/login?next=%2Ftokens" {
+ t.Fatalf("expired session code = %d location = %q body = %s", res.StatusCode, res.Header.Get("Location"), body)
+ }
+ if cookie := res.Header.Get("Set-Cookie"); !strings.Contains(cookie, uiCookieNameForTest+"=") || !strings.Contains(cookie, "Max-Age=0") {
+ t.Fatalf("expired session Set-Cookie = %q, want cleared session", cookie)
+ }
+
+ tokens, err := e.store.ListAuthTokens(e.ctx, user.ID)
+ if err != nil {
+ t.Fatalf("ListAuthTokens: %v", err)
+ }
+ for _, authToken := range tokens {
+ switch authToken.Name {
+ case "missing csrf", "invalid csrf", "expired session attempt":
+ t.Fatalf("rejected token creation persisted %+v", authToken)
+ }
+ }
+}
+
+func createdTokenValue(t *testing.T, body string) string {
+ t.Helper()
+ codeStart := strings.Index(body, "")
+ if valueStart < 0 {
+ t.Fatalf("created token code malformed: %s", body)
+ }
+ valueStart += codeStart + 1
+ valueEnd := strings.Index(body[valueStart:], "")
+ if valueEnd < 0 {
+ t.Fatalf("created token code closing tag missing: %s", body)
+ }
+ value := strings.TrimSpace(body[valueStart : valueStart+valueEnd])
+ if value == "" {
+ t.Fatalf("created token value empty: %s", body)
+ }
+ return value
+}
+
func TestUISettingsPageUpdatesProfileAndPassword(t *testing.T) {
t.Parallel()
e := newHTTPEnv(t)
diff --git a/internal/server/ui_account_pages.go b/internal/server/ui_account_pages.go
index 5607da7..c8d92c4 100644
--- a/internal/server/ui_account_pages.go
+++ b/internal/server/ui_account_pages.go
@@ -154,6 +154,6 @@ func (s *Server) renderUITokens(w http.ResponseWriter, r *http.Request, message,
s.renderUIShell(w, r, http.StatusOK, uiShellData{
User: currentUser(r),
Projects: projects,
- TokenPanel: &uiTokenPanelData{Tokens: tokens, Error: message, Created: created},
+ TokenPanel: &uiTokenPanelData{CSRFToken: uiSessionCSRFToken(r), Tokens: tokens, Error: message, Created: created},
})
}
diff --git a/internal/server/ui_types.go b/internal/server/ui_types.go
index c4be3da..e33d562 100644
--- a/internal/server/ui_types.go
+++ b/internal/server/ui_types.go
@@ -732,9 +732,10 @@ type uiNewIssuePanelData struct {
}
type uiTokenPanelData struct {
- Tokens []model.AuthToken
- Error string
- Created string
+ CSRFToken string
+ Tokens []model.AuthToken
+ Error string
+ Created string
}
type uiSettingsPanelData struct {