Add CSRF protection to admin panel

Fixes #14

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthew Knight 2026-02-17 15:53:31 -08:00
parent 29cbe1a52b
commit 4a0af136d5
No known key found for this signature in database
8 changed files with 40 additions and 21 deletions

View File

@ -76,6 +76,7 @@ func main() {
DB: db, DB: db,
Renderer: renderer, Renderer: renderer,
Auth: authService, Auth: authService,
SessionStore: sessionStore,
EmailClient: emailClient, EmailClient: emailClient,
ForgejoClient: forgejoClient, ForgejoClient: forgejoClient,
Config: cfg, Config: cfg,

View File

@ -1,6 +1,8 @@
package admin package admin
import ( import (
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/mattnite/forgejo-tickets/internal/auth" "github.com/mattnite/forgejo-tickets/internal/auth"
"github.com/mattnite/forgejo-tickets/internal/config" "github.com/mattnite/forgejo-tickets/internal/config"
@ -15,6 +17,7 @@ type Dependencies struct {
DB *gorm.DB DB *gorm.DB
Renderer *templates.Renderer Renderer *templates.Renderer
Auth *auth.Service Auth *auth.Service
SessionStore *auth.PGStore
EmailClient *email.Client EmailClient *email.Client
ForgejoClient *forgejo.Client ForgejoClient *forgejo.Client
Config *config.Config Config *config.Config
@ -30,30 +33,38 @@ func NewRouter(deps Dependencies) *gin.Engine {
tsAuth := &TailscaleAuth{allowedUsers: deps.Config.TailscaleAllowedUsers} tsAuth := &TailscaleAuth{allowedUsers: deps.Config.TailscaleAllowedUsers}
r.Use(tsAuth.Middleware) r.Use(tsAuth.Middleware)
dashboardHandler := &DashboardHandler{deps: deps} csrfSecret := []byte(deps.Config.SessionSecret)
r.GET("/", dashboardHandler.Index) isSecure := strings.HasPrefix(deps.Config.BaseURL, "https")
csrfMiddleware := middleware.CSRF(csrfSecret, isSecure)
userHandler := &UserHandler{deps: deps} csrf := r.Group("/")
r.GET("/users", userHandler.List) csrf.Use(csrfMiddleware)
r.GET("/users/pending", userHandler.PendingList) {
r.GET("/users/new", userHandler.NewForm) dashboardHandler := &DashboardHandler{deps: deps}
r.GET("/users/:id", userHandler.Detail) csrf.GET("/", dashboardHandler.Index)
r.POST("/users", userHandler.Create)
r.POST("/users/:id/approve", userHandler.Approve)
r.POST("/users/:id/reject", userHandler.Reject)
r.POST("/users/:id/repos", userHandler.UpdateRepos)
ticketHandler := &TicketHandler{deps: deps} userHandler := &UserHandler{deps: deps}
r.GET("/tickets", ticketHandler.List) csrf.GET("/users", userHandler.List)
r.GET("/tickets/:id", ticketHandler.Detail) csrf.GET("/users/pending", userHandler.PendingList)
r.POST("/tickets/:id/status", ticketHandler.UpdateStatus) csrf.GET("/users/new", userHandler.NewForm)
csrf.GET("/users/:id", userHandler.Detail)
csrf.POST("/users", userHandler.Create)
csrf.POST("/users/:id/approve", userHandler.Approve)
csrf.POST("/users/:id/reject", userHandler.Reject)
csrf.POST("/users/:id/repos", userHandler.UpdateRepos)
repoHandler := &RepoHandler{deps: deps} ticketHandler := &TicketHandler{deps: deps}
r.GET("/repos", repoHandler.List) csrf.GET("/tickets", ticketHandler.List)
r.GET("/repos/new", repoHandler.NewForm) csrf.GET("/tickets/:id", ticketHandler.Detail)
r.POST("/repos", repoHandler.Create) csrf.POST("/tickets/:id/status", ticketHandler.UpdateStatus)
r.GET("/repos/:id/edit", repoHandler.EditForm)
r.POST("/repos/:id", repoHandler.Update) repoHandler := &RepoHandler{deps: deps}
csrf.GET("/repos", repoHandler.List)
csrf.GET("/repos/new", repoHandler.NewForm)
csrf.POST("/repos", repoHandler.Create)
csrf.GET("/repos/:id/edit", repoHandler.EditForm)
csrf.POST("/repos/:id", repoHandler.Update)
}
return r return r
} }

View File

@ -64,6 +64,7 @@
{{end}} {{end}}
<form method="POST" action="/repos/{{.Repo.ID}}" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200"> <form method="POST" action="/repos/{{.Repo.ID}}" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700">Display Name</label> <label for="name" class="block text-sm font-medium text-gray-700">Display Name</label>
<input type="text" name="name" id="name" required value="{{.Repo.Name}}" <input type="text" name="name" id="name" required value="{{.Repo.Name}}"

View File

@ -17,6 +17,7 @@
{{end}} {{end}}
<form method="POST" action="/repos" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200"> <form method="POST" action="/repos" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
<input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}">
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700">Display Name</label> <label for="name" class="block text-sm font-medium text-gray-700">Display Name</label>
<input type="text" name="name" id="name" required placeholder="Billing App" <input type="text" name="name" id="name" required placeholder="Billing App"

View File

@ -60,6 +60,7 @@
<!-- Status Update --> <!-- Status Update -->
<div class="mt-6 pt-4 border-t border-gray-200"> <div class="mt-6 pt-4 border-t border-gray-200">
<form method="POST" action="/tickets/{{.Ticket.ID}}/status" class="flex items-center gap-3"> <form method="POST" action="/tickets/{{.Ticket.ID}}/status" class="flex items-center gap-3">
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<label for="status" class="text-sm font-medium text-gray-700">Update Status:</label> <label for="status" class="text-sm font-medium text-gray-700">Update Status:</label>
<select name="status" id="status" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"> <select name="status" id="status" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<option value="open" {{if eq (print .Ticket.Status) "open"}}selected{{end}}>Open</option> <option value="open" {{if eq (print .Ticket.Status) "open"}}selected{{end}}>Open</option>

View File

@ -31,6 +31,7 @@
<h2 class="text-lg font-semibold text-gray-900 mb-4">Project Access</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4">Project Access</h2>
{{if .AllRepos}} {{if .AllRepos}}
<form method="POST" action="/users/{{.User.ID}}/repos" class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200 mb-8"> <form method="POST" action="/users/{{.User.ID}}/repos" class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200 mb-8">
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<div class="space-y-2"> <div class="space-y-2">
{{range .AllRepos}} {{range .AllRepos}}
<label class="flex items-center gap-2"> <label class="flex items-center gap-2">

View File

@ -18,6 +18,7 @@
{{end}} {{end}}
<form method="POST" action="/users" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200"> <form method="POST" action="/users" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
<input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}">
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700">Name</label> <label for="name" class="block text-sm font-medium text-gray-700">Name</label>
<input type="text" name="name" id="name" required <input type="text" name="name" id="name" required

View File

@ -27,9 +27,11 @@
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<form method="POST" action="/users/{{.ID}}/approve"> <form method="POST" action="/users/{{.ID}}/approve">
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<button type="submit" class="rounded-md bg-green-600 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-green-500">Approve</button> <button type="submit" class="rounded-md bg-green-600 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-green-500">Approve</button>
</form> </form>
<form method="POST" action="/users/{{.ID}}/reject" onsubmit="return confirm('Are you sure you want to reject this request? The account will be deleted.')"> <form method="POST" action="/users/{{.ID}}/reject" onsubmit="return confirm('Are you sure you want to reject this request? The account will be deleted.')">
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-red-500">Reject</button> <button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-red-500">Reject</button>
</form> </form>
</div> </div>