fierj
PublicTiny 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)