New Sign in

fierj

Public

Tiny personal git forge

7a8d655704529279fec3058c1ec0104385ab8d74
diff --git a/auth.go b/auth.go
index bdc43d0..e6a9f31 100644
--- a/auth.go
+++ b/auth.go
@@ -1,7 +1,9 @@
 package main
 
 import (
+	"context"
 	"crypto/hmac"
+	"crypto/rand"
 	"crypto/sha256"
 	"encoding/hex"
 	"encoding/json"
@@ -179,6 +181,70 @@ func clearAuthCookie(w http.ResponseWriter) {
 	})
 }
 
+// ----- CSRF protection -----
+
+const csrfCookieName = "fierj_csrf"
+
+func generateCSRFToken() string {
+	b := make([]byte, 32)
+	rand.Read(b)
+	return hex.EncodeToString(b)
+}
+
+// csrfToken extracts the CSRF token from the cookie, generating a new one if absent.
+// Returns the token and a boolean indicating whether a Set-Cookie header is needed.
+func csrfToken(r *http.Request) (string, bool) {
+	cookie, err := r.Cookie(csrfCookieName)
+	if err == nil && cookie.Value != "" {
+		return cookie.Value, false
+	}
+	return generateCSRFToken(), true
+}
+
+func setCSRFCookie(w http.ResponseWriter, token string) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     csrfCookieName,
+		Value:    token,
+		Path:     "/",
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+		MaxAge:   7 * 24 * 3600, // 7 days
+	})
+}
+
+// csrfMiddleware sets a CSRF cookie on GET/HEAD and validates the double-submit
+// token on state-changing methods (POST, PUT, PATCH, DELETE).
+func csrfMiddleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		token, needsCookie := csrfToken(r)
+
+		// Always set the cookie if it's missing — this also refreshes it.
+		if needsCookie {
+			setCSRFCookie(w, token)
+		}
+
+		// On state-changing methods, verify the double-submit.
+		switch r.Method {
+		case "POST", "PUT", "PATCH", "DELETE":
+			formToken := r.FormValue("csrf_token")
+			if formToken == "" || token == "" || !hmac.Equal([]byte(formToken), []byte(token)) {
+				http.Error(w, "invalid CSRF token", http.StatusForbidden)
+				return
+			}
+		}
+
+		// Stash the token so handlers can pass it to templates.
+		ctx := context.WithValue(r.Context(), contextKey("csrf"), token)
+		next.ServeHTTP(w, r.WithContext(ctx))
+	})
+}
+
+// CSRF returns the CSRF token for the current request.
+func CSRF(r *http.Request) string {
+	t, _ := r.Context().Value(contextKey("csrf")).(string)
+	return t
+}
+
 // ----- Middleware -----
 
 // AuthUser extracts the logged-in username from the cookie, or returns "".
diff --git a/auth_handlers.go b/auth_handlers.go
index af19a8d..fbd3bde 100644
--- a/auth_handlers.go
+++ b/auth_handlers.go
@@ -11,7 +11,7 @@ import (
 
 func LoginGet(tmpl *template.Template) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
-		tmpl.ExecuteTemplate(w, "auth_login.html", map[string]any{"User": User(r)})
+		tmpl.ExecuteTemplate(w, "auth_login.html", map[string]any{"User": User(r), "CSRF": CSRF(r)})
 	}
 }
 
@@ -24,6 +24,7 @@ func LoginPost(users UserStore, secret []byte, tmpl *template.Template) http.Han
 			tmpl.ExecuteTemplate(w, "auth_login.html", map[string]any{
 				"Error": "Invalid username or password.",
 				"User":  User(r),
+				"CSRF":  CSRF(r),
 			})
 			return
 		}
@@ -44,7 +45,7 @@ func Logout() http.HandlerFunc {
 
 func SetupGet(tmpl *template.Template) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
-		tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"User": User(r)})
+		tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"User": User(r), "CSRF": CSRF(r)})
 	}
 }
 
@@ -55,11 +56,11 @@ func SetupPost(users UserStore, usersPath string, secret []byte, tmpl *template.
 		confirm := r.FormValue("confirm")
 
 		if password != confirm {
-			tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"Error": "Passwords do not match.", "User": User(r)})
+			tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"Error": "Passwords do not match.", "User": User(r), "CSRF": CSRF(r)})
 			return
 		}
 		if err := users.Add(username, password); err != nil {
-			tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"Error": err.Error(), "User": User(r)})
+			tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"Error": err.Error(), "User": User(r), "CSRF": CSRF(r)})
 			return
 		}
 		// Save SSH key if provided.
@@ -67,7 +68,7 @@ func SetupPost(users UserStore, usersPath string, secret []byte, tmpl *template.
 			users.SetSSHKeys(username, []string{key})
 		}
 		if err := users.Save(usersPath); err != nil {
-			tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"Error": "Failed to save: " + err.Error(), "User": User(r)})
+			tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"Error": "Failed to save: " + err.Error(), "User": User(r), "CSRF": CSRF(r)})
 			return
 		}
 		setAuthCookie(w, username, secret)
@@ -92,6 +93,7 @@ func UsersGet(users UserStore, usersPath string, tmpl *template.Template) http.H
 			"Users":   userList,
 			"User":    User(r),
 			"SSHKeys": sshKeys,
+			"CSRF":    CSRF(r),
 		})
 	}
 }
diff --git a/main.go b/main.go
index db05552..5020831 100644
--- a/main.go
+++ b/main.go
@@ -86,8 +86,9 @@ func main() {
 	mux.HandleFunc("POST /{repo}/patches/{patchId}/merge", PatchMergePost(cfg, tmpl))
 	mux.HandleFunc("POST /{repo}/patches/{patchId}/close", PatchClosePost(cfg, tmpl))
 
-	// Wrap with auth middleware and setup redirect.
+	// Wrap with CSRF, auth middleware, and setup redirect.
 	var handler http.Handler = mux
+	handler = csrfMiddleware(handler)
 	if len(cookieSecret) > 0 {
 		handler = SetupRedirect(users)(handler)
 		handler = authMiddleware(cookieSecret)(handler)
diff --git a/patch_handlers.go b/patch_handlers.go
index 256e586..0883e59 100644
--- a/patch_handlers.go
+++ b/patch_handlers.go
@@ -68,7 +68,7 @@ func PatchNewPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
 		}
 		title := strings.TrimSpace(r.FormValue("title"))
 		if title == "" {
-			renderError(w, tmpl, http.StatusBadRequest, "Title is required")
+			renderError(w, r, tmpl, http.StatusBadRequest, "Title is required")
 			return
 		}
 		body := strings.TrimSpace(r.FormValue("body"))
@@ -85,20 +85,20 @@ func PatchNewPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
 			// Try file upload
 			file, header, ferr := r.FormFile("patch_file")
 			if ferr != nil {
-				renderError(w, tmpl, http.StatusBadRequest, "Either a branch or a .patch file is required")
+				renderError(w, r, tmpl, http.StatusBadRequest, "Either a branch or a .patch file is required")
 				return
 			}
 			defer file.Close()
 			patchContent, rerr := io.ReadAll(file)
 			if rerr != nil || len(patchContent) == 0 {
-				renderError(w, tmpl, http.StatusBadRequest, "Failed to read patch file")
+				renderError(w, r, tmpl, http.StatusBadRequest, "Failed to read patch file")
 				return
 			}
 			_ = header
 			p, err = createPatchFromFile(rp, title, body, author, authorName, patchContent)
 		}
 		if err != nil {
-			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			renderError(w, r, tmpl, http.StatusInternalServerError, err.Error())
 			return
 		}
 		http.Redirect(w, r, fmt.Sprintf("/%s/patches/%s", git.Name, p.ID), http.StatusSeeOther)
@@ -115,7 +115,7 @@ func PatchView(cfg Config, tmpl *template.Template) http.HandlerFunc {
 		rp := repoPath(cfg, git.Name)
 		p, err := loadPatch(rp, patchID)
 		if err != nil {
-			renderError(w, tmpl, http.StatusNotFound, "Patch not found")
+			renderError(w, r, tmpl, http.StatusNotFound, "Patch not found")
 			return
 		}
 		diff, _ := patchDiff(rp, p)
@@ -145,12 +145,12 @@ func PatchMergePost(cfg Config, tmpl *template.Template) http.HandlerFunc {
 		rp := repoPath(cfg, git.Name)
 		p, err := loadPatch(rp, patchID)
 		if err != nil {
-			renderError(w, tmpl, http.StatusNotFound, "Patch not found")
+			renderError(w, r, tmpl, http.StatusNotFound, "Patch not found")
 			return
 		}
 		if err := mergePatch(rp, p); err != nil {
 			slog.Error("merge patch", "repo", git.Name, "patch", patchID, "error", err)
-			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			renderError(w, r, tmpl, http.StatusInternalServerError, err.Error())
 			return
 		}
 		http.Redirect(w, r, fmt.Sprintf("/%s/patches/%s", git.Name, patchID), http.StatusSeeOther)
@@ -167,7 +167,7 @@ func PatchClosePost(cfg Config, tmpl *template.Template) http.HandlerFunc {
 		rp := repoPath(cfg, git.Name)
 		if err := closePatch(rp, patchID); err != nil {
 			slog.Error("close patch", "repo", git.Name, "patch", patchID, "error", err)
-			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			renderError(w, r, tmpl, http.StatusInternalServerError, err.Error())
 			return
 		}
 		http.Redirect(w, r, fmt.Sprintf("/%s/patches/%s", git.Name, patchID), http.StatusSeeOther)
diff --git a/templates/auth_login.html b/templates/auth_login.html
index c496a59..36c72da 100644
--- a/templates/auth_login.html
+++ b/templates/auth_login.html
@@ -11,6 +11,7 @@
   {{end}}
 
   <form method="post">
+    <input type="hidden" name="csrf_token" value="{{.CSRF}}">
     <div class="form-group">
       <label for="username">Username</label>
       <input type="text" id="username" name="username" autofocus required>
diff --git a/templates/auth_setup.html b/templates/auth_setup.html
index 8a7a1e1..877d2fc 100644
--- a/templates/auth_setup.html
+++ b/templates/auth_setup.html
@@ -14,6 +14,7 @@
   {{end}}
 
   <form method="post">
+    <input type="hidden" name="csrf_token" value="{{.CSRF}}">
     <div class="form-group">
       <label for="username">Username</label>
       <input type="text" id="username" name="username" autofocus required>
diff --git a/templates/auth_users.html b/templates/auth_users.html
index 8fa0196..8c16f87 100644
--- a/templates/auth_users.html
+++ b/templates/auth_users.html
@@ -8,6 +8,7 @@
   <div class="card">
     <h2>Add user</h2>
     <form method="post">
+      <input type="hidden" name="csrf_token" value="{{.CSRF}}">
       <input type="hidden" name="action" value="add">
       <div class="form-group">
         <label for="username">Username</label>
@@ -25,6 +26,7 @@
   <div class="card">
     <h2>Change your password</h2>
     <form method="post">
+      <input type="hidden" name="csrf_token" value="{{.CSRF}}">
       <input type="hidden" name="action" value="change-password">
       <div class="form-group">
         <label for="new-password">New password</label>
@@ -45,6 +47,7 @@
     <div class="list-row">
       <code style="font-size:0.8rem;word-break:break-all;">{{$k}}</code>
       <form method="post" onsubmit="return confirm('Remove this key?')">
+        <input type="hidden" name="csrf_token" value="{{.CSRF}}">
         <input type="hidden" name="action" value="remove-ssh-key">
         <input type="hidden" name="index" value="{{$i}}">
         <button type="submit" class="btn-subtle btn-sm">Remove</button>
@@ -54,6 +57,7 @@
   </div>
   {{end}}
   <form method="post">
+    <input type="hidden" name="csrf_token" value="{{.CSRF}}">
     <input type="hidden" name="action" value="add-ssh-key">
     <div class="form-group">
       <label for="ssh_key">Add SSH public key</label>
@@ -72,6 +76,7 @@
       <span>{{.}}</span>
       {{if ne . $.User}}
       <form method="post" onsubmit="return confirm('Remove {{.}}?')">
+        <input type="hidden" name="csrf_token" value="{{$.CSRF}}">
         <input type="hidden" name="action" value="remove">
         <input type="hidden" name="username" value="{{.}}">
         <button type="submit" class="btn-subtle btn-sm">Remove</button>
diff --git a/templates/base.html b/templates/base.html
index b5531cd..3175c00 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -1150,6 +1150,7 @@
         {{if .User}}
         <a class="nav-link" href="/users">{{.User}}</a>
         <form method="post" action="/logout" class="nav-form">
+            <input type="hidden" name="csrf_token" value="{{.CSRF}}">
             <button type="submit" class="nav-link">Sign out</button>
         </form>
         {{else}}
diff --git a/templates/patch_new.html b/templates/patch_new.html
index 3c4527a..d46e470 100644
--- a/templates/patch_new.html
+++ b/templates/patch_new.html
@@ -6,6 +6,7 @@
 <div class="thread-new-form">
     <h2>New Patch</h2>
     <form method="POST" action="/{{.Repo}}/patches/new" enctype="multipart/form-data">
+        <input type="hidden" name="csrf_token" value="{{.CSRF}}">
         <div class="form-group">
             <label for="title">Title</label>
             <input type="text" id="title" name="title" placeholder="Brief description of the change" required>
diff --git a/templates/patch_view.html b/templates/patch_view.html
index 813759a..82db7e3 100644
--- a/templates/patch_view.html
+++ b/templates/patch_view.html
@@ -33,9 +33,11 @@
 {{if eq .Patch.State "open"}}
 <div class="thread-actions" style="margin-top: var(--space-lg);">
     <form method="POST" action="/{{.Repo}}/patches/{{.Patch.ID}}/merge" style="display:inline;">
+        <input type="hidden" name="csrf_token" value="{{.CSRF}}">
         <button type="submit" class="btn btn-primary">Merge</button>
     </form>
     <form method="POST" action="/{{.Repo}}/patches/{{.Patch.ID}}/close" style="display:inline;">
+        <input type="hidden" name="csrf_token" value="{{.CSRF}}">
         <button type="submit" class="btn btn-secondary">Close without merging</button>
     </form>
 </div>
diff --git a/templates/repo_new.html b/templates/repo_new.html
index 5f88440..06d69ca 100644
--- a/templates/repo_new.html
+++ b/templates/repo_new.html
@@ -2,6 +2,7 @@
 {{define "title"}}new repository - fierj{{end}}
 <h1 class="page-header">New Repository</h1>
 <form method="post" class="form-narrow">
+  <input type="hidden" name="csrf_token" value="{{.CSRF}}">
   <div class="form-group">
     <label for="import_url">Import from URL (optional)</label>
     <input type="text" id="import_url" name="import_url" placeholder="https://github.com/user/repo.git">
diff --git a/templates/repo_settings.html b/templates/repo_settings.html
index b791219..1808b84 100644
--- a/templates/repo_settings.html
+++ b/templates/repo_settings.html
@@ -6,6 +6,7 @@
 <h1 class="page-header" style="font-size:1.25rem;">Repository settings</h1>
 
 <form method="post" class="form-narrow">
+  <input type="hidden" name="csrf_token" value="{{.CSRF}}">
   <div class="form-group">
     <label for="description">Description</label>
     <input type="text" id="description" name="description" value="{{.Description}}">
diff --git a/templates/thread_new.html b/templates/thread_new.html
index ac0b93b..026db52 100644
--- a/templates/thread_new.html
+++ b/templates/thread_new.html
@@ -6,6 +6,7 @@
 <div class="thread-new-form">
     <h2>New Thread</h2>
     <form method="POST" action="/{{.Repo}}/threads/new">
+        <input type="hidden" name="csrf_token" value="{{.CSRF}}">
         <div class="form-group">
             <label for="title">Title</label>
             <input type="text" id="title" name="title" placeholder="Brief summary" required>
diff --git a/templates/thread_view.html b/templates/thread_view.html
index e5d6969..eed65a7 100644
--- a/templates/thread_view.html
+++ b/templates/thread_view.html
@@ -36,6 +36,7 @@
 <!-- Reply form -->
 <div class="thread-reply-form">
     <form method="POST" action="/{{.Repo}}/threads/{{.Thread.ID}}/reply">
+        <input type="hidden" name="csrf_token" value="{{.CSRF}}">
         <div class="form-group">
             <textarea name="body" rows="4" placeholder="Leave a comment..."></textarea>
         </div>
diff --git a/thread_handlers.go b/thread_handlers.go
index 2bf96a9..cc24233 100644
--- a/thread_handlers.go
+++ b/thread_handlers.go
@@ -71,14 +71,14 @@ func ThreadNewPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
 		title := strings.TrimSpace(r.FormValue("title"))
 		body := strings.TrimSpace(r.FormValue("body"))
 		if title == "" {
-			renderError(w, tmpl, http.StatusBadRequest, "Title is required")
+			renderError(w, r, tmpl, http.StatusBadRequest, "Title is required")
 			return
 		}
 		author, authorName := threadAuthor(r)
 		rp := repoPath(cfg, git.Name)
 		t, err := createThread(rp, title, body, author, authorName)
 		if err != nil {
-			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			renderError(w, r, tmpl, http.StatusInternalServerError, err.Error())
 			return
 		}
 		// Deliver to ActivityPub followers (non-blocking).
@@ -97,7 +97,7 @@ func ThreadView(cfg Config, tmpl *template.Template) http.HandlerFunc {
 		rp := repoPath(cfg, git.Name)
 		t, err := loadThread(rp, threadID)
 		if err != nil {
-			renderError(w, tmpl, http.StatusNotFound, "Thread not found")
+			renderError(w, r, tmpl, http.StatusNotFound, "Thread not found")
 			return
 		}
 		ref := git.DefaultBranch()
@@ -130,7 +130,7 @@ func ThreadReplyPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
 		author, authorName := threadAuthor(r)
 		rp := repoPath(cfg, git.Name)
 		if err := addReply(rp, threadID, body, author, authorName); err != nil {
-			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			renderError(w, r, tmpl, http.StatusInternalServerError, err.Error())
 			return
 		}
 		http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", git.Name, threadID), http.StatusSeeOther)
@@ -153,13 +153,13 @@ func closeOrReopenWithReply(cfg Config, repoName string, threadID string, r *htt
 	if body != "" {
 		author, authorName := threadAuthor(r)
 		if err := addReply(rp, threadID, body, author, authorName); err != nil {
-			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			renderError(w, r, tmpl, http.StatusInternalServerError, err.Error())
 			return
 		}
 	}
 
 	if err := action(rp, threadID); err != nil {
-		renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+		renderError(w, r, tmpl, http.StatusInternalServerError, err.Error())
 		return
 	}
 	http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", repoName, threadID), http.StatusSeeOther)