Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/server/templates/tokens.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ <h1 class="truncate text-2xl font-semibold tracking-normal">Tokens</h1>

<section class="grid gap-6 py-6 lg:grid-cols-[20rem_1fr]">
<form method="post" action="/tokens" class="h-fit rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<h2 class="text-sm font-semibold uppercase text-slate-500 dark:text-slate-400">New API token</h2>
{{if .Error}}<div class="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-200">{{.Error}}</div>{{end}}
{{if .Created}}<div class="mt-3 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200"><div class="font-medium">Copy this token now.</div><code class="mt-2 block break-all rounded bg-white px-2 py-1 font-mono text-xs text-slate-900 dark:bg-slate-900 dark:text-slate-100">{{.Created}}</code></div>{{end}}
Expand All @@ -33,6 +34,7 @@ <h2 class="text-sm font-semibold uppercase text-slate-500 dark:text-slate-400">N
</div>
{{if not .RevokedAt}}
<form method="post" action="/tokens/{{.ID}}/revoke">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="rounded-md border border-slate-200 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800">Revoke</button>
</form>
{{end}}
Expand Down
123 changes: 120 additions & 3 deletions internal/server/ui_account_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/url"
"strings"
"testing"
"time"
)

func TestUITokensPageCreatesAndRevokesToken(t *testing.T) {
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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))
Expand All @@ -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, "<code")
if codeStart < 0 {
t.Fatalf("created token code missing: %s", body)
}
valueStart := strings.Index(body[codeStart:], ">")
if valueStart < 0 {
t.Fatalf("created token code malformed: %s", body)
}
valueStart += codeStart + 1
valueEnd := strings.Index(body[valueStart:], "</code>")
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)
Expand Down
2 changes: 1 addition & 1 deletion internal/server/ui_account_pages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
})
}
7 changes: 4 additions & 3 deletions internal/server/ui_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading