PHASE-12: Security Hardening for Pad Cloud (#67)

* feat: enforce RBAC role checks on all mutation endpoints (TASK-150)

Add requireMinRole helper and role enforcement to 30+ mutation handlers.
Viewers are now blocked from all state-changing operations, editors can
mutate items/docs/comments/views but not collections/webhooks/workspace
settings, and only owners can perform administrative operations.

Includes 11 integration tests with real auth covering viewer/editor/owner
access across items, collections, documents, comments, agent roles,
item links, and workspace operations.

* fix: scope search results to user's workspaces (TASK-151)

Search without a ?workspace= param previously returned results from all
workspaces in the database. Now the handler resolves the authenticated
user's workspace memberships and passes their IDs to the store query,
ensuring results only include items from workspaces the user belongs to.

Fresh installs (no users) retain unscoped search for backward compat.
Includes integration test proving cross-workspace isolation.

* fix: add webhook URL validation and SSRF protection (TASK-152)

Webhook creation now validates URLs before accepting them: only HTTP(S)
schemes allowed, embedded credentials rejected, private/reserved IPs
blocked (loopback, RFC1918, link-local, cloud metadata 169.254.169.254),
and hostnames are DNS-resolved to verify they don't point to private IPs.

Defense-in-depth check also added to the dispatcher's deliver function
so existing webhooks with unsafe URLs are blocked at delivery time.

* feat: add CSRF protection with double-submit cookie pattern (TASK-153)

Implements CSRF middleware that validates X-CSRF-Token header matches
the pad_csrf cookie on all state-changing API requests. Bearer token
auth, auth endpoints, and fresh installs are exempt. The frontend
client reads the CSRF cookie and attaches the header on mutations.

* feat: add per-endpoint rate limiting middleware (TASK-154)

Adds IP-based rate limiting for auth endpoints (5/min login, 3/hr
password reset, 5/hr registration) and user-based limits for API
(100/min) and search (30/min). Uses golang.org/x/time/rate with
automatic stale-entry cleanup. Adds chi RealIP middleware for
correct client IP behind proxies. Returns 429 with Retry-After.

* fix: sanitize error responses and remove PII from logs (TASK-155)

Replace all writeError(500, err.Error()) calls with writeInternalError
that logs the real error server-side and returns a generic message to
clients. Remove email addresses, user IDs, and password reset tokens
from log output to prevent PII leakage.

* feat: add security headers, configurable CORS, and secure cookies (TASK-160)

Add SecurityHeaders middleware (CSP, X-Frame-Options, nosniff,
Referrer-Policy, Permissions-Policy). Make CORS origins configurable
via PAD_CORS_ORIGINS env var. Add PAD_SECURE_COOKIES for TLS
deployments (sets Secure flag on session/CSRF cookies and enables
HSTS). Also adds X-CSRF-Token to CORS allowed headers.

* fix: address PR review — lazy router init and trusted IP for rate limits

Fix two issues flagged by Codex:

1. CORS/HSTS config was ignored because setupRouter() ran in New()
   before SetCORSOrigins/SetSecureCookies were called. Now uses
   sync.Once to lazily build the router on first ServeHTTP/Listen.

2. Rate limiter read X-Real-IP directly from untrusted headers,
   allowing clients to spoof IPs. Now uses RemoteAddr only (which
   chimiddleware.RealIP already sanitizes from trusted proxy headers).
This commit is contained in:
xarmian
2026-04-05 10:26:00 -04:00
committed by GitHub
parent 367116b3a0
commit 8aa6481421
40 changed files with 1880 additions and 136 deletions
+2
View File
@@ -191,6 +191,8 @@ func serveCmd() *cobra.Command {
srv := server.New(s)
srv.SetVersion(version, commit, buildTime)
srv.SetBaseURL(cfg.BaseURL())
srv.SetCORSOrigins(cfg.CORSOrigins)
srv.SetSecureCookies(cfg.SecureCookies)
// Attach event bus for real-time SSE
srv.SetEventBus(events.New())
+1
View File
@@ -20,6 +20,7 @@ require (
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+2
View File
@@ -44,6 +44,8 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+10
View File
@@ -36,6 +36,10 @@ type Config struct {
MailerooAPIKey string `toml:"maileroo_api_key"`
EmailFrom string `toml:"email_from"` // Sender address (e.g. noreply@getpad.dev)
EmailFromName string `toml:"email_from_name"` // Sender display name (e.g. Pad)
// Security
CORSOrigins string `toml:"cors_origins"` // Comma-separated allowed origins (e.g. "https://app.pad.dev,https://admin.pad.dev")
SecureCookies bool `toml:"secure_cookies"` // Set Secure flag on cookies (requires TLS)
}
func DefaultConfig() *Config {
@@ -118,6 +122,12 @@ func Load() (*Config, error) {
if v := os.Getenv("PAD_EMAIL_FROM_NAME"); v != "" {
cfg.EmailFromName = v
}
if v := os.Getenv("PAD_CORS_ORIGINS"); v != "" {
cfg.CORSOrigins = v
}
if v := os.Getenv("PAD_SECURE_COOKIES"); v == "true" || v == "1" {
cfg.SecureCookies = true
}
return cfg, nil
}
+2 -2
View File
@@ -32,7 +32,7 @@ func (s *Server) handleListWorkspaceActivity(w http.ResponseWriter, r *http.Requ
activities, err := s.store.ListWorkspaceActivity(workspaceID, params)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if activities == nil {
@@ -69,7 +69,7 @@ func (s *Server) handleListDocumentActivity(w http.ResponseWriter, r *http.Reque
activities, err := s.store.ListDocumentActivity(doc.ID, params)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if activities == nil {
+14 -5
View File
@@ -18,7 +18,7 @@ func (s *Server) handleListAgentRoles(w http.ResponseWriter, r *http.Request) {
roles, err := s.store.ListAgentRoles(workspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -27,6 +27,9 @@ func (s *Server) handleListAgentRoles(w http.ResponseWriter, r *http.Request) {
// handleCreateAgentRole creates a new agent role in a workspace.
func (s *Server) handleCreateAgentRole(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -45,7 +48,7 @@ func (s *Server) handleCreateAgentRole(w http.ResponseWriter, r *http.Request) {
role, err := s.store.CreateAgentRole(workspaceID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -62,7 +65,7 @@ func (s *Server) handleGetAgentRole(w http.ResponseWriter, r *http.Request) {
roleID := chi.URLParam(r, "roleID")
role, err := s.store.GetAgentRole(workspaceID, roleID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if role == nil {
@@ -75,6 +78,9 @@ func (s *Server) handleGetAgentRole(w http.ResponseWriter, r *http.Request) {
// handleUpdateAgentRole updates an existing agent role.
func (s *Server) handleUpdateAgentRole(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -89,7 +95,7 @@ func (s *Server) handleUpdateAgentRole(w http.ResponseWriter, r *http.Request) {
role, err := s.store.UpdateAgentRole(workspaceID, roleID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if role == nil {
@@ -102,6 +108,9 @@ func (s *Server) handleUpdateAgentRole(w http.ResponseWriter, r *http.Request) {
// handleDeleteAgentRole removes an agent role from a workspace.
func (s *Server) handleDeleteAgentRole(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -113,7 +122,7 @@ func (s *Server) handleDeleteAgentRole(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "Agent role not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
+16 -5
View File
@@ -81,9 +81,13 @@ func (s *Server) createAuthSession(w http.ResponseWriter, user *models.User, ttl
Path: "/",
MaxAge: int(ttl.Seconds()),
HttpOnly: true,
Secure: s.secureCookies,
SameSite: http.SameSiteLaxMode,
})
// Set CSRF cookie alongside the session cookie
setCSRFCookie(w, int(ttl.Seconds()), s.secureCookies)
return token, nil
}
@@ -377,9 +381,13 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.secureCookies,
SameSite: http.SameSiteLaxMode,
})
// Clear CSRF cookie on logout
clearCSRFCookie(w)
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
})
@@ -529,7 +537,7 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
// Generate reset token
token, err := s.store.CreatePasswordReset(user.ID)
if err != nil {
log.Printf("Failed to create password reset for %s: %v", input.Email, err)
log.Printf("Failed to create password reset: %v", err)
writeJSON(w, http.StatusOK, okResponse)
return
}
@@ -539,12 +547,11 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
resetURL := s.baseURL + "/reset-password/" + token
go func() {
if err := s.email.SendPasswordReset(context.Background(), user.Email, user.Name, resetURL); err != nil {
log.Printf("Failed to send password reset email to %s: %v", user.Email, err)
log.Printf("Failed to send password reset email: %v", err)
}
}()
} else {
resetURL := s.baseURL + "/reset-password/" + token
log.Printf("Password reset token generated for %s (email not configured). Reset URL: %s", input.Email, resetURL)
log.Printf("Password reset token generated (email not configured). Use pad auth reset-password to manage.")
}
writeJSON(w, http.StatusOK, okResponse)
@@ -591,7 +598,7 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
// Invalidate all existing sessions (force logout everywhere)
if err := s.store.DeleteUserSessions(user.ID); err != nil {
log.Printf("Failed to invalidate sessions for user %s after password reset: %v", user.ID, err)
log.Printf("Failed to invalidate sessions after password reset: %v", err)
}
// Create a fresh session so the user is logged in
@@ -607,9 +614,13 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
Path: "/",
MaxAge: int(webSessionTTL.Seconds()),
HttpOnly: true,
Secure: s.secureCookies,
SameSite: http.SameSiteLaxMode,
})
// Set CSRF cookie alongside the new session
setCSRFCookie(w, int(webSessionTTL.Seconds()), s.secureCookies)
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"user": map[string]interface{}{
+7
View File
@@ -343,6 +343,13 @@ func doRequestWithCookie(srv *Server, method, path string, body interface{}, tok
Name: "pad_session",
Value: token,
})
// Include CSRF token for the double-submit cookie pattern
const testCSRF = "test-csrf-token"
req.AddCookie(&http.Cookie{
Name: "pad_csrf",
Value: testCSRF,
})
req.Header.Set("X-CSRF-Token", testCSRF)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
return rr
+16 -7
View File
@@ -18,7 +18,7 @@ func (s *Server) handleListCollections(w http.ResponseWriter, r *http.Request) {
colls, err := s.store.ListCollections(workspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if colls == nil {
@@ -28,6 +28,9 @@ func (s *Server) handleListCollections(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleCreateCollection(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -50,7 +53,7 @@ func (s *Server) handleCreateCollection(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusConflict, "conflict", "A collection with this name already exists")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -66,7 +69,7 @@ func (s *Server) handleGetCollection(w http.ResponseWriter, r *http.Request) {
collSlug := chi.URLParam(r, "collSlug")
coll, err := s.store.GetCollectionBySlug(workspaceID, collSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if coll == nil {
@@ -78,6 +81,9 @@ func (s *Server) handleGetCollection(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleUpdateCollection(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -86,7 +92,7 @@ func (s *Server) handleUpdateCollection(w http.ResponseWriter, r *http.Request)
collSlug := chi.URLParam(r, "collSlug")
coll, err := s.store.GetCollectionBySlug(workspaceID, collSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if coll == nil {
@@ -106,7 +112,7 @@ func (s *Server) handleUpdateCollection(w http.ResponseWriter, r *http.Request)
updated, err := s.store.UpdateCollection(coll.ID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if updated == nil {
@@ -126,6 +132,9 @@ func (s *Server) handleUpdateCollection(w http.ResponseWriter, r *http.Request)
}
func (s *Server) handleDeleteCollection(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -134,7 +143,7 @@ func (s *Server) handleDeleteCollection(w http.ResponseWriter, r *http.Request)
collSlug := chi.URLParam(r, "collSlug")
coll, err := s.store.GetCollectionBySlug(workspaceID, collSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if coll == nil {
@@ -151,7 +160,7 @@ func (s *Server) handleDeleteCollection(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusBadRequest, "bad_request", "Cannot delete a default collection")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
+22 -7
View File
@@ -22,7 +22,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if item == nil {
@@ -32,7 +32,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
comments, err := s.store.ListComments(item.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if comments == nil {
@@ -60,6 +60,9 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
// handleCreateComment adds a new comment to an item.
func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -68,7 +71,7 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if item == nil {
@@ -103,7 +106,7 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
comment, err := s.store.CreateComment(workspaceID, item.ID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -119,6 +122,9 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
// handleDeleteComment removes a comment.
func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -138,7 +144,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -147,6 +153,9 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
// handleCreateReply creates a reply to an existing comment.
func (s *Server) handleCreateReply(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -191,7 +200,7 @@ func (s *Server) handleCreateReply(w http.ResponseWriter, r *http.Request) {
comment, err := s.store.CreateComment(workspaceID, parentComment.ItemID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -202,6 +211,9 @@ func (s *Server) handleCreateReply(w http.ResponseWriter, r *http.Request) {
// handleAddReaction adds an emoji reaction to a comment.
func (s *Server) handleAddReaction(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -233,7 +245,7 @@ func (s *Server) handleAddReaction(w http.ResponseWriter, r *http.Request) {
reaction, err := s.store.AddReaction(commentID, userID, actor, input.Emoji)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -247,6 +259,9 @@ func (s *Server) handleAddReaction(w http.ResponseWriter, r *http.Request) {
// handleRemoveReaction removes an emoji reaction from a comment.
func (s *Server) handleRemoveReaction(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
+2 -2
View File
@@ -137,7 +137,7 @@ func (s *Server) handleGetDashboard(w http.ResponseWriter, r *http.Request) {
// Build a schema map for terminal status lookups
collections, err := s.store.ListCollections(workspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
schemaMap := buildSchemaMap(collections)
@@ -156,7 +156,7 @@ func (s *Server) handleGetDashboard(w http.ResponseWriter, r *http.Request) {
// Summary: items grouped by collection slug and status field
allItems, err := s.store.ListItems(workspaceID, models.ItemListParams{})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
+24 -9
View File
@@ -35,7 +35,7 @@ func (s *Server) handleListDocuments(w http.ResponseWriter, r *http.Request) {
docs, err := s.store.ListDocuments(workspaceID, params)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if docs == nil {
@@ -45,6 +45,9 @@ func (s *Server) handleListDocuments(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -75,7 +78,7 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, "conflict", "A document with this title already exists in this workspace")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -96,6 +99,9 @@ func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
_, doc, ok := s.getWorkspaceDocument(w, r)
if !ok {
return
@@ -122,7 +128,7 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, "conflict", "A document with this title already exists in this workspace")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if updated == nil {
@@ -147,13 +153,16 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
_, doc, ok := s.getWorkspaceDocument(w, r)
if !ok {
return
}
if err := s.store.DeleteDocument(doc.ID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -165,6 +174,9 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleRestoreDocument(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
// Restore needs special handling — doc is soft-deleted so getWorkspaceDocument won't find it.
// Verify workspace exists, then restore by ID.
workspaceID, ok := s.getWorkspaceID(w, r)
@@ -195,6 +207,9 @@ func (s *Server) handleRestoreDocument(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleQuickSave(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -229,7 +244,7 @@ func (s *Server) handleQuickSave(w http.ResponseWriter, r *http.Request) {
doc, err := s.store.QuickSave(workspaceID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -258,7 +273,7 @@ func (s *Server) handleBulkRead(w http.ResponseWriter, r *http.Request) {
docs, err := s.store.BulkRead(input.IDs)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if docs == nil {
@@ -275,7 +290,7 @@ func (s *Server) handleGetBacklinks(w http.ResponseWriter, r *http.Request) {
backlinks, err := s.store.GetBacklinks(workspaceID, doc.Title)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if backlinks == nil {
@@ -303,7 +318,7 @@ func (s *Server) handleGetLinks(w http.ResponseWriter, r *http.Request) {
}
linkedDocs, err := s.store.GetLinks(workspaceID, doc.Content)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if linkedDocs == nil {
@@ -331,7 +346,7 @@ func (s *Server) handleGetContext(w http.ResponseWriter, r *http.Request) {
docs, err := s.store.GetContext(workspaceID, types, includeContent)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if docs == nil {
+1 -1
View File
@@ -26,7 +26,7 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
ws, err := s.store.GetWorkspaceBySlug(slug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if ws == nil {
+13 -7
View File
@@ -20,7 +20,7 @@ func (s *Server) handleGetItemLinks(w http.ResponseWriter, r *http.Request) {
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if item == nil {
@@ -30,7 +30,7 @@ func (s *Server) handleGetItemLinks(w http.ResponseWriter, r *http.Request) {
links, err := s.store.GetItemLinks(item.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if links == nil {
@@ -42,6 +42,9 @@ func (s *Server) handleGetItemLinks(w http.ResponseWriter, r *http.Request) {
// handleCreateItemLink creates a new link between two items.
func (s *Server) handleCreateItemLink(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -50,7 +53,7 @@ func (s *Server) handleCreateItemLink(w http.ResponseWriter, r *http.Request) {
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if item == nil {
@@ -79,7 +82,7 @@ func (s *Server) handleCreateItemLink(w http.ResponseWriter, r *http.Request) {
// Verify target item exists
target, err := s.store.GetItem(input.TargetID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if target == nil || target.WorkspaceID != workspaceID {
@@ -97,7 +100,7 @@ func (s *Server) handleCreateItemLink(w http.ResponseWriter, r *http.Request) {
actor, _ := actorFromRequest(r)
link, err := s.store.SetPhaseLink(workspaceID, item.ID, target.ID, actor)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusCreated, link)
@@ -114,7 +117,7 @@ func (s *Server) handleCreateItemLink(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -123,6 +126,9 @@ func (s *Server) handleCreateItemLink(w http.ResponseWriter, r *http.Request) {
// handleDeleteItemLink removes a link between items.
func (s *Server) handleDeleteItemLink(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
_, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -134,7 +140,7 @@ func (s *Server) handleDeleteItemLink(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "Link not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
+8 -5
View File
@@ -19,7 +19,7 @@ func (s *Server) handleListItemVersions(w http.ResponseWriter, r *http.Request)
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if item == nil {
@@ -29,7 +29,7 @@ func (s *Server) handleListItemVersions(w http.ResponseWriter, r *http.Request)
versions, err := s.store.ListItemVersionsResolved(item.ID, item.Content)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if versions == nil {
@@ -41,6 +41,9 @@ func (s *Server) handleListItemVersions(w http.ResponseWriter, r *http.Request)
// handleRestoreItemVersion restores an item's content from a specific version.
func (s *Server) handleRestoreItemVersion(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -51,7 +54,7 @@ func (s *Server) handleRestoreItemVersion(w http.ResponseWriter, r *http.Request
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if item == nil {
@@ -62,7 +65,7 @@ func (s *Server) handleRestoreItemVersion(w http.ResponseWriter, r *http.Request
// Get all resolved versions to find the target
versions, err := s.store.ListItemVersionsResolved(item.ID, item.Content)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -90,7 +93,7 @@ func (s *Server) handleRestoreItemVersion(w http.ResponseWriter, r *http.Request
updated, err := s.store.UpdateItem(item.ID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
+38 -23
View File
@@ -31,7 +31,7 @@ func (s *Server) handleListItems(w http.ResponseWriter, r *http.Request) {
}
result, err := s.store.ListItems(workspaceID, params)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if result == nil {
@@ -52,7 +52,7 @@ func (s *Server) handleListCollectionItems(w http.ResponseWriter, r *http.Reques
collSlug := chi.URLParam(r, "collSlug")
coll, err := s.store.GetCollectionBySlug(workspaceID, collSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if coll == nil {
@@ -69,7 +69,7 @@ func (s *Server) handleListCollectionItems(w http.ResponseWriter, r *http.Reques
result, err := s.store.ListItems(workspaceID, params)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if result == nil {
@@ -82,6 +82,9 @@ func (s *Server) handleListCollectionItems(w http.ResponseWriter, r *http.Reques
// handleCreateItem creates a new item in a collection, validating fields against the schema.
func (s *Server) handleCreateItem(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -90,7 +93,7 @@ func (s *Server) handleCreateItem(w http.ResponseWriter, r *http.Request) {
collSlug := chi.URLParam(r, "collSlug")
coll, err := s.store.GetCollectionBySlug(workspaceID, collSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if coll == nil {
@@ -163,7 +166,7 @@ func (s *Server) handleCreateItem(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, "conflict", "An item with this title already exists")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -182,7 +185,7 @@ func (s *Server) handleCreateItem(w http.ResponseWriter, r *http.Request) {
s.dispatchWebhook(workspaceID, "item.created", item)
if err := s.enrichItemForResponse(item); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -199,7 +202,7 @@ func (s *Server) handleGetItem(w http.ResponseWriter, r *http.Request) {
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if item == nil {
@@ -208,7 +211,7 @@ func (s *Server) handleGetItem(w http.ResponseWriter, r *http.Request) {
}
if err := s.enrichItemForResponse(item); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -217,6 +220,9 @@ func (s *Server) handleGetItem(w http.ResponseWriter, r *http.Request) {
// handleUpdateItem updates an existing item (fields, content, or both).
func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -225,7 +231,7 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if item == nil {
@@ -317,7 +323,7 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
updated, err := s.store.UpdateItem(item.ID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if updated == nil {
@@ -376,7 +382,7 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
}
if err := s.enrichItemForResponse(updated); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -385,6 +391,9 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
// handleDeleteItem archives (soft-deletes) an item.
func (s *Server) handleDeleteItem(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -393,7 +402,7 @@ func (s *Server) handleDeleteItem(w http.ResponseWriter, r *http.Request) {
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if item == nil {
@@ -402,7 +411,7 @@ func (s *Server) handleDeleteItem(w http.ResponseWriter, r *http.Request) {
}
if err := s.store.DeleteItem(item.ID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -416,6 +425,9 @@ func (s *Server) handleDeleteItem(w http.ResponseWriter, r *http.Request) {
// handleRestoreItem restores an archived item.
func (s *Server) handleRestoreItem(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -426,7 +438,7 @@ func (s *Server) handleRestoreItem(w http.ResponseWriter, r *http.Request) {
// We need to find the item even if deleted (for restore).
item, err := s.store.ResolveItemIncludeDeleted(workspaceID, itemSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if item == nil {
@@ -440,7 +452,7 @@ func (s *Server) handleRestoreItem(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "Item not found or not archived")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -449,7 +461,7 @@ func (s *Server) handleRestoreItem(w http.ResponseWriter, r *http.Request) {
s.publishItemEventWithName(events.ItemRestored, workspaceID, restored.ID, restored.Title, restored.CollectionSlug, actor, actorNameFromRequest(r), source)
if err := s.enrichItemForResponse(restored); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -458,6 +470,9 @@ func (s *Server) handleRestoreItem(w http.ResponseWriter, r *http.Request) {
// handleMoveItem moves an item to a different collection with field migration.
func (s *Server) handleMoveItem(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -545,7 +560,7 @@ func (s *Server) handleMoveItem(w http.ResponseWriter, r *http.Request) {
// Move the item
moved, err := s.store.MoveItem(item.ID, targetColl.ID, string(fieldsJSON))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -559,7 +574,7 @@ func (s *Server) handleMoveItem(w http.ResponseWriter, r *http.Request) {
s.dispatchWebhook(workspaceID, "item.moved", moved)
if err := s.enrichItemForResponse(moved); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -597,7 +612,7 @@ func (s *Server) handlePhasesProgress(w http.ResponseWriter, r *http.Request) {
progress, err := s.store.GetAllPhasesProgress(workspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, progress)
@@ -613,7 +628,7 @@ func (s *Server) handleGetItemTasks(w http.ResponseWriter, r *http.Request) {
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if item == nil {
@@ -623,7 +638,7 @@ func (s *Server) handleGetItemTasks(w http.ResponseWriter, r *http.Request) {
tasks, err := s.store.GetTasksForPhase(item.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if tasks == nil {
@@ -926,7 +941,7 @@ func (s *Server) handleListItemActivity(w http.ResponseWriter, r *http.Request)
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if item == nil {
@@ -947,7 +962,7 @@ func (s *Server) handleListItemActivity(w http.ResponseWriter, r *http.Request)
activities, err := s.store.ListDocumentActivity(item.ID, params)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if activities == nil {
+9 -9
View File
@@ -17,14 +17,14 @@ func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
members, err := s.store.ListWorkspaceMembers(workspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
// Include pending invitations, enriched with join URLs
invitations, err := s.store.ListWorkspaceInvitations(workspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -91,7 +91,7 @@ func (s *Server) handleInviteMember(w http.ResponseWriter, r *http.Request) {
// Check if user with this email already exists
existingUser, err := s.store.GetUserByEmail(input.Email)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -103,7 +103,7 @@ func (s *Server) handleInviteMember(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.store.AddWorkspaceMember(workspaceID, existingUser.ID, input.Role); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
@@ -119,7 +119,7 @@ func (s *Server) handleInviteMember(w http.ResponseWriter, r *http.Request) {
// User doesn't exist — create an invitation
inv, err := s.store.CreateInvitation(workspaceID, input.Email, input.Role, inviterID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -149,7 +149,7 @@ func (s *Server) handleInviteMember(w http.ResponseWriter, r *http.Request) {
wsName = ws.Name
}
if err := s.email.SendInvitation(context.Background(), inv.Email, inviterName, wsName, joinURL); err != nil {
log.Printf("Failed to send invitation email to %s: %v", inv.Email, err)
log.Printf("Failed to send invitation email: %v", err)
}
}()
}
@@ -248,7 +248,7 @@ func (s *Server) handleAcceptInvitation(w http.ResponseWriter, r *http.Request)
inv, err := s.store.GetInvitationByCode(code)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if inv == nil {
@@ -264,13 +264,13 @@ func (s *Server) handleAcceptInvitation(w http.ResponseWriter, r *http.Request)
// Add user to workspace
if err := s.store.AddWorkspaceMember(inv.WorkspaceID, user.ID, inv.Role); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
// Mark invitation as accepted
if err := s.store.AcceptInvitation(inv.ID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
+433
View File
@@ -0,0 +1,433 @@
package server
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/xarmian/pad/internal/models"
)
// rbacTestEnv holds everything needed for RBAC tests:
// a server with an admin, workspace, and users with different roles.
type rbacTestEnv struct {
srv *Server
wsSlug string
ownerToken string
editorToken string
viewerToken string
}
func setupRBACEnv(t *testing.T) *rbacTestEnv {
t.Helper()
srv := testServer(t)
// Bootstrap admin user
ownerToken := bootstrapFirstUser(t, srv, "owner@test.com", "Owner")
// Create workspace
rr := doRequestWithCookie(srv, "POST", "/api/v1/workspaces", map[string]string{
"name": "RBAC Test",
}, ownerToken)
if rr.Code != http.StatusCreated {
t.Fatalf("create workspace: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var ws models.Workspace
parseJSON(t, rr, &ws)
// Register editor user
rr = doRequestWithCookie(srv, "POST", "/api/v1/auth/register", map[string]string{
"email": "editor@test.com",
"name": "Editor",
"password": "password123",
}, ownerToken)
if rr.Code != http.StatusCreated {
t.Fatalf("register editor: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
// Register viewer user
rr = doRequestWithCookie(srv, "POST", "/api/v1/auth/register", map[string]string{
"email": "viewer@test.com",
"name": "Viewer",
"password": "password123",
}, ownerToken)
if rr.Code != http.StatusCreated {
t.Fatalf("register viewer: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
// Look up users and add to workspace with roles
editorUser, err := srv.store.GetUserByEmail("editor@test.com")
if err != nil || editorUser == nil {
t.Fatal("failed to find editor user")
}
viewerUser, err := srv.store.GetUserByEmail("viewer@test.com")
if err != nil || viewerUser == nil {
t.Fatal("failed to find viewer user")
}
if err := srv.store.AddWorkspaceMember(ws.ID, editorUser.ID, "editor"); err != nil {
t.Fatalf("add editor member: %v", err)
}
if err := srv.store.AddWorkspaceMember(ws.ID, viewerUser.ID, "viewer"); err != nil {
t.Fatalf("add viewer member: %v", err)
}
// Log in as editor
editorToken := loginUser(t, srv, "editor@test.com", "password123")
// Log in as viewer
viewerToken := loginUser(t, srv, "viewer@test.com", "password123")
return &rbacTestEnv{
srv: srv,
wsSlug: ws.Slug,
ownerToken: ownerToken,
editorToken: editorToken,
viewerToken: viewerToken,
}
}
func loginUser(t *testing.T, srv *Server, email, password string) string {
t.Helper()
var bodyReader io.Reader
data, _ := json.Marshal(map[string]string{"email": email, "password": password})
bodyReader = bytes.NewReader(data)
req := httptest.NewRequest("POST", "/api/v1/auth/login", bodyReader)
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.0.2.1:1234"
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("login %s: expected 200, got %d: %s", email, rr.Code, rr.Body.String())
}
var resp map[string]interface{}
parseJSON(t, rr, &resp)
token, _ := resp["token"].(string)
if token == "" {
t.Fatalf("login %s: no token in response", email)
}
return token
}
func TestRBAC_ViewerBlockedFromItemMutations(t *testing.T) {
env := setupRBACEnv(t)
// Create an item as owner first
rr := doRequestWithCookie(env.srv, "POST", "/api/v1/workspaces/"+env.wsSlug+"/collections/docs/items", map[string]interface{}{
"title": "Test Item",
"content": "Content",
}, env.ownerToken)
if rr.Code != http.StatusCreated {
t.Fatalf("setup: create item failed: %d %s", rr.Code, rr.Body.String())
}
var item map[string]interface{}
parseJSON(t, rr, &item)
itemSlug := item["slug"].(string)
tests := []struct {
name string
method string
path string
body interface{}
}{
{"create item", "POST", "/api/v1/workspaces/" + env.wsSlug + "/collections/docs/items",
map[string]interface{}{"title": "New Item"}},
{"update item", "PATCH", "/api/v1/workspaces/" + env.wsSlug + "/items/" + itemSlug,
map[string]interface{}{"title": "Updated"}},
{"delete item", "DELETE", "/api/v1/workspaces/" + env.wsSlug + "/items/" + itemSlug, nil},
{"restore item", "POST", "/api/v1/workspaces/" + env.wsSlug + "/items/" + itemSlug + "/restore", nil},
{"move item", "POST", "/api/v1/workspaces/" + env.wsSlug + "/items/" + itemSlug + "/move",
map[string]interface{}{"collection_slug": "ideas"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rr := doRequestWithCookie(env.srv, tt.method, tt.path, tt.body, env.viewerToken)
if rr.Code != http.StatusForbidden {
t.Errorf("expected 403 for viewer %s, got %d: %s", tt.name, rr.Code, rr.Body.String())
}
})
}
}
func TestRBAC_EditorAllowedItemMutations(t *testing.T) {
env := setupRBACEnv(t)
// Editor can create items
rr := doRequestWithCookie(env.srv, "POST", "/api/v1/workspaces/"+env.wsSlug+"/collections/docs/items", map[string]interface{}{
"title": "Editor Item",
"content": "Content",
}, env.editorToken)
if rr.Code != http.StatusCreated {
t.Errorf("expected 201 for editor create item, got %d: %s", rr.Code, rr.Body.String())
}
var item map[string]interface{}
parseJSON(t, rr, &item)
itemSlug := item["slug"].(string)
// Editor can update items (update content, not title, to preserve slug)
rr = doRequestWithCookie(env.srv, "PATCH", "/api/v1/workspaces/"+env.wsSlug+"/items/"+itemSlug, map[string]interface{}{
"content": "Updated by editor",
}, env.editorToken)
if rr.Code != http.StatusOK {
t.Errorf("expected 200 for editor update item, got %d: %s", rr.Code, rr.Body.String())
}
// Editor can delete items
rr = doRequestWithCookie(env.srv, "DELETE", "/api/v1/workspaces/"+env.wsSlug+"/items/"+itemSlug, nil, env.editorToken)
if rr.Code != http.StatusNoContent {
t.Errorf("expected 204 for editor delete item, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestRBAC_EditorBlockedFromOwnerOperations(t *testing.T) {
env := setupRBACEnv(t)
tests := []struct {
name string
method string
path string
body interface{}
}{
{"create collection", "POST", "/api/v1/workspaces/" + env.wsSlug + "/collections",
map[string]interface{}{"name": "Custom", "schema": `{"fields":[]}`}},
{"update workspace", "PATCH", "/api/v1/workspaces/" + env.wsSlug,
map[string]interface{}{"name": "Updated"}},
{"delete workspace", "DELETE", "/api/v1/workspaces/" + env.wsSlug, nil},
{"export workspace", "GET", "/api/v1/workspaces/" + env.wsSlug + "/export", nil},
{"create webhook", "POST", "/api/v1/workspaces/" + env.wsSlug + "/webhooks",
map[string]interface{}{"url": "http://example.com", "events": []string{"item.created"}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rr := doRequestWithCookie(env.srv, tt.method, tt.path, tt.body, env.editorToken)
if rr.Code != http.StatusForbidden {
t.Errorf("expected 403 for editor %s, got %d: %s", tt.name, rr.Code, rr.Body.String())
}
})
}
}
func TestRBAC_OwnerAllowedEverything(t *testing.T) {
env := setupRBACEnv(t)
// Owner can create items
rr := doRequestWithCookie(env.srv, "POST", "/api/v1/workspaces/"+env.wsSlug+"/collections/docs/items", map[string]interface{}{
"title": "Owner Item",
"content": "Content",
}, env.ownerToken)
if rr.Code != http.StatusCreated {
t.Errorf("expected 201 for owner create item, got %d: %s", rr.Code, rr.Body.String())
}
// Owner can create collections
rr = doRequestWithCookie(env.srv, "POST", "/api/v1/workspaces/"+env.wsSlug+"/collections", map[string]interface{}{
"name": "Custom",
"schema": `{"fields":[]}`,
}, env.ownerToken)
if rr.Code != http.StatusCreated {
t.Errorf("expected 201 for owner create collection, got %d: %s", rr.Code, rr.Body.String())
}
// Owner can update workspace
rr = doRequestWithCookie(env.srv, "PATCH", "/api/v1/workspaces/"+env.wsSlug, map[string]interface{}{
"name": "Updated by Owner",
}, env.ownerToken)
if rr.Code != http.StatusOK {
t.Errorf("expected 200 for owner update workspace, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestRBAC_ViewerBlockedFromDocumentMutations(t *testing.T) {
env := setupRBACEnv(t)
// Create a doc as owner
rr := doRequestWithCookie(env.srv, "POST", "/api/v1/workspaces/"+env.wsSlug+"/documents", map[string]interface{}{
"title": "Test Doc",
"content": "Content",
}, env.ownerToken)
if rr.Code != http.StatusCreated {
t.Fatalf("setup: create doc failed: %d %s", rr.Code, rr.Body.String())
}
var doc map[string]interface{}
parseJSON(t, rr, &doc)
docID := doc["id"].(string)
tests := []struct {
name string
method string
path string
body interface{}
}{
{"create document", "POST", "/api/v1/workspaces/" + env.wsSlug + "/documents",
map[string]interface{}{"title": "New Doc"}},
{"update document", "PATCH", "/api/v1/workspaces/" + env.wsSlug + "/documents/" + docID,
map[string]interface{}{"content": "Updated"}},
{"delete document", "DELETE", "/api/v1/workspaces/" + env.wsSlug + "/documents/" + docID, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rr := doRequestWithCookie(env.srv, tt.method, tt.path, tt.body, env.viewerToken)
if rr.Code != http.StatusForbidden {
t.Errorf("expected 403 for viewer %s, got %d: %s", tt.name, rr.Code, rr.Body.String())
}
})
}
}
func TestRBAC_ViewerBlockedFromCommentCreation(t *testing.T) {
env := setupRBACEnv(t)
// Create item as owner
rr := doRequestWithCookie(env.srv, "POST", "/api/v1/workspaces/"+env.wsSlug+"/collections/docs/items", map[string]interface{}{
"title": "Commented Item",
}, env.ownerToken)
if rr.Code != http.StatusCreated {
t.Fatalf("setup: create item failed: %d %s", rr.Code, rr.Body.String())
}
var item map[string]interface{}
parseJSON(t, rr, &item)
itemSlug := item["slug"].(string)
rr = doRequestWithCookie(env.srv, "POST", "/api/v1/workspaces/"+env.wsSlug+"/items/"+itemSlug+"/comments", map[string]interface{}{
"body": "Hello from viewer",
}, env.viewerToken)
if rr.Code != http.StatusForbidden {
t.Errorf("expected 403 for viewer create comment, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestRBAC_ViewerCanReadEverything(t *testing.T) {
env := setupRBACEnv(t)
// Create items/data as owner
doRequestWithCookie(env.srv, "POST", "/api/v1/workspaces/"+env.wsSlug+"/collections/docs/items", map[string]interface{}{
"title": "Readable",
}, env.ownerToken)
// Viewer can list collections
rr := doRequestWithCookie(env.srv, "GET", "/api/v1/workspaces/"+env.wsSlug+"/collections", nil, env.viewerToken)
if rr.Code != http.StatusOK {
t.Errorf("expected 200 for viewer list collections, got %d", rr.Code)
}
// Viewer can list items
rr = doRequestWithCookie(env.srv, "GET", "/api/v1/workspaces/"+env.wsSlug+"/collections/docs/items", nil, env.viewerToken)
if rr.Code != http.StatusOK {
t.Errorf("expected 200 for viewer list items, got %d", rr.Code)
}
// Viewer can get workspace
rr = doRequestWithCookie(env.srv, "GET", "/api/v1/workspaces/"+env.wsSlug, nil, env.viewerToken)
if rr.Code != http.StatusOK {
t.Errorf("expected 200 for viewer get workspace, got %d", rr.Code)
}
}
func TestRBAC_EditorBlockedFromAgentRoleMutations(t *testing.T) {
env := setupRBACEnv(t)
rr := doRequestWithCookie(env.srv, "POST", "/api/v1/workspaces/"+env.wsSlug+"/agent-roles", map[string]interface{}{
"name": "Test Role",
}, env.editorToken)
if rr.Code != http.StatusForbidden {
t.Errorf("expected 403 for editor create agent role, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestRBAC_ViewerBlockedFromItemLinkMutations(t *testing.T) {
env := setupRBACEnv(t)
// Create two items as owner
rr := doRequestWithCookie(env.srv, "POST", "/api/v1/workspaces/"+env.wsSlug+"/collections/docs/items", map[string]interface{}{
"title": "Item A",
}, env.ownerToken)
if rr.Code != http.StatusCreated {
t.Fatalf("setup: create item A failed: %d %s", rr.Code, rr.Body.String())
}
var itemA map[string]interface{}
parseJSON(t, rr, &itemA)
itemASlug := itemA["slug"].(string)
rr = doRequestWithCookie(env.srv, "POST", "/api/v1/workspaces/"+env.wsSlug+"/collections/docs/items", map[string]interface{}{
"title": "Item B",
}, env.ownerToken)
if rr.Code != http.StatusCreated {
t.Fatalf("setup: create item B failed: %d %s", rr.Code, rr.Body.String())
}
var itemB map[string]interface{}
parseJSON(t, rr, &itemB)
itemBID := itemB["id"].(string)
rr = doRequestWithCookie(env.srv, "POST", "/api/v1/workspaces/"+env.wsSlug+"/items/"+itemASlug+"/links", map[string]interface{}{
"target_id": itemBID,
"link_type": "blocks",
}, env.viewerToken)
if rr.Code != http.StatusForbidden {
t.Errorf("expected 403 for viewer create item link, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestRBAC_SearchScopedToUserWorkspaces(t *testing.T) {
env := setupRBACEnv(t)
// Create an item in the workspace the editor belongs to
doRequestWithCookie(env.srv, "POST", "/api/v1/workspaces/"+env.wsSlug+"/collections/docs/items", map[string]interface{}{
"title": "Visible Secret",
"content": "This should be found by the editor",
}, env.ownerToken)
// Create a second workspace that the editor does NOT belong to
rr := doRequestWithCookie(env.srv, "POST", "/api/v1/workspaces", map[string]string{
"name": "Private Workspace",
}, env.ownerToken)
if rr.Code != http.StatusCreated {
t.Fatalf("create private workspace: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var privateWS map[string]interface{}
parseJSON(t, rr, &privateWS)
privateSlug := privateWS["slug"].(string)
// Create an item in the private workspace
doRequestWithCookie(env.srv, "POST", "/api/v1/workspaces/"+privateSlug+"/collections/docs/items", map[string]interface{}{
"title": "Hidden Secret",
"content": "This should NOT be found by the editor",
}, env.ownerToken)
// Editor searches without workspace param — should only see their workspace's items
rr = doRequestWithCookie(env.srv, "GET", "/api/v1/search?q=Secret", nil, env.editorToken)
if rr.Code != http.StatusOK {
t.Fatalf("search: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp struct {
Results []map[string]interface{} `json:"results"`
Total int `json:"total"`
}
parseJSON(t, rr, &resp)
// Should find only the visible item, not the one in the private workspace
if resp.Total != 1 {
t.Errorf("expected 1 result (only from editor's workspace), got %d", resp.Total)
for _, r := range resp.Results {
if item, ok := r["item"].(map[string]interface{}); ok {
t.Logf(" found: %v", item["title"])
}
}
}
// Owner searches without workspace param — should see items from both workspaces
rr = doRequestWithCookie(env.srv, "GET", "/api/v1/search?q=Secret", nil, env.ownerToken)
if rr.Code != http.StatusOK {
t.Fatalf("owner search: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
parseJSON(t, rr, &resp)
if resp.Total != 2 {
t.Errorf("expected 2 results for owner (both workspaces), got %d", resp.Total)
}
}
+9 -3
View File
@@ -8,6 +8,9 @@ import (
// handleRoleBoardReorder updates role_sort_order for items within a lane.
func (s *Server) handleRoleBoardReorder(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -20,7 +23,7 @@ func (s *Server) handleRoleBoardReorder(w http.ResponseWriter, r *http.Request)
}
if err := s.store.UpdateRoleSortOrder(workspaceID, updates); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -29,6 +32,9 @@ func (s *Server) handleRoleBoardReorder(w http.ResponseWriter, r *http.Request)
// handleRoleBoardLaneReorder updates sort_order for roles (lane ordering).
func (s *Server) handleRoleBoardLaneReorder(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -41,7 +47,7 @@ func (s *Server) handleRoleBoardLaneReorder(w http.ResponseWriter, r *http.Reque
}
if err := s.store.UpdateAgentRoleOrder(workspaceID, updates); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -62,7 +68,7 @@ func (s *Server) handleRoleBoard(w http.ResponseWriter, r *http.Request) {
lanes, err := s.store.GetRoleBoardItems(workspaceID, params)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
+26 -1
View File
@@ -18,9 +18,34 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
Workspace: r.URL.Query().Get("workspace"),
}
// When no specific workspace is given, scope search to the user's
// workspaces so results never leak across workspace boundaries.
if params.Workspace == "" {
user := currentUser(r)
if user != nil {
workspaces, err := s.store.GetUserWorkspaces(user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve user workspaces")
return
}
for _, ws := range workspaces {
params.WorkspaceIDs = append(params.WorkspaceIDs, ws.ID)
}
// If user has no workspaces, return empty results
if len(params.WorkspaceIDs) == 0 {
writeJSON(w, http.StatusOK, map[string]interface{}{
"results": []store.SearchResult{},
"total": 0,
})
return
}
}
// If no user (fresh install, no auth), allow unscoped search
}
results, err := s.store.Search(params)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if results == nil {
+3 -3
View File
@@ -55,7 +55,7 @@ func (s *Server) handleListItemTimeline(w http.ResponseWriter, r *http.Request)
comments, err := s.store.ListCommentsBeforeTime(item.ID, before, beforeID, perSource)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -77,13 +77,13 @@ func (s *Server) handleListItemTimeline(w http.ResponseWriter, r *http.Request)
activities, err := s.store.ListDocumentActivityBeforeTime(item.ID, before, beforeID, perSource)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
versions, err := s.store.ListItemVersionsBeforeTime(item.ID, before, beforeID, perSource)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
+6 -6
View File
@@ -33,7 +33,7 @@ func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
userID := currentUserID(r)
token, err := s.store.CreateAPIToken(userID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -49,7 +49,7 @@ func (s *Server) handleListTokens(w http.ResponseWriter, r *http.Request) {
tokens, err := s.store.ListAPITokens(workspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if tokens == nil {
@@ -72,7 +72,7 @@ func (s *Server) handleDeleteToken(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "Token not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -91,7 +91,7 @@ func (s *Server) handleListUserTokens(w http.ResponseWriter, r *http.Request) {
tokens, err := s.store.ListUserAPITokens(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if tokens == nil {
@@ -122,7 +122,7 @@ func (s *Server) handleCreateUserToken(w http.ResponseWriter, r *http.Request) {
token, err := s.store.CreateAPIToken(userID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -143,7 +143,7 @@ func (s *Server) handleDeleteUserToken(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "Token not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
+3 -3
View File
@@ -19,7 +19,7 @@ func (s *Server) handleListVersions(w http.ResponseWriter, r *http.Request) {
// Resolve diffs so API consumers always get full content
versions, err := s.store.ListVersionsResolved(doc.ID, doc.Content)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if versions == nil {
@@ -39,7 +39,7 @@ func (s *Server) handleGetVersion(w http.ResponseWriter, r *http.Request) {
// Resolve diffs to return full content
version, err := s.store.GetVersionResolved(versionID, doc.ID, doc.Content)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if version == nil {
@@ -59,7 +59,7 @@ func (s *Server) handleGetDiff(w http.ResponseWriter, r *http.Request) {
// Use resolved versions so diffs work correctly
versions, err := s.store.ListVersionsResolved(doc.ID, doc.Content)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
+15 -6
View File
@@ -19,7 +19,7 @@ func (s *Server) handleListViews(w http.ResponseWriter, r *http.Request) {
collSlug := chi.URLParam(r, "collSlug")
coll, err := s.store.GetCollectionBySlug(workspaceID, collSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if coll == nil {
@@ -29,7 +29,7 @@ func (s *Server) handleListViews(w http.ResponseWriter, r *http.Request) {
views, err := s.store.ListViews(workspaceID, coll.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if views == nil {
@@ -41,6 +41,9 @@ func (s *Server) handleListViews(w http.ResponseWriter, r *http.Request) {
// handleCreateView creates a new saved view for a collection.
func (s *Server) handleCreateView(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -49,7 +52,7 @@ func (s *Server) handleCreateView(w http.ResponseWriter, r *http.Request) {
collSlug := chi.URLParam(r, "collSlug")
coll, err := s.store.GetCollectionBySlug(workspaceID, collSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if coll == nil {
@@ -72,7 +75,7 @@ func (s *Server) handleCreateView(w http.ResponseWriter, r *http.Request) {
view, err := s.store.CreateView(workspaceID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -81,6 +84,9 @@ func (s *Server) handleCreateView(w http.ResponseWriter, r *http.Request) {
// handleUpdateView modifies an existing saved view.
func (s *Server) handleUpdateView(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
_, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -100,7 +106,7 @@ func (s *Server) handleUpdateView(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "View not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -109,6 +115,9 @@ func (s *Server) handleUpdateView(w http.ResponseWriter, r *http.Request) {
// handleDeleteView removes a saved view.
func (s *Server) handleDeleteView(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
_, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -121,7 +130,7 @@ func (s *Server) handleDeleteView(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "View not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
+20 -4
View File
@@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/xarmian/pad/internal/models"
"github.com/xarmian/pad/internal/webhooks"
)
// dispatchWebhook fires a webhook event if a dispatcher is configured.
@@ -20,6 +21,9 @@ func (s *Server) dispatchWebhook(workspaceID, event string, data interface{}) {
// handleCreateWebhook registers a new webhook for a workspace.
func (s *Server) handleCreateWebhook(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -36,9 +40,15 @@ func (s *Server) handleCreateWebhook(w http.ResponseWriter, r *http.Request) {
return
}
// Validate URL to prevent SSRF attacks
if err := webhooks.ValidateWebhookURL(input.URL); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid webhook URL: "+err.Error())
return
}
hook, err := s.store.CreateWebhook(workspaceID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -54,7 +64,7 @@ func (s *Server) handleListWebhooks(w http.ResponseWriter, r *http.Request) {
hooks, err := s.store.ListWebhooks(workspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if hooks == nil {
@@ -66,6 +76,9 @@ func (s *Server) handleListWebhooks(w http.ResponseWriter, r *http.Request) {
// handleDeleteWebhook removes a webhook by ID.
func (s *Server) handleDeleteWebhook(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
_, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -77,7 +90,7 @@ func (s *Server) handleDeleteWebhook(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "Webhook not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -86,6 +99,9 @@ func (s *Server) handleDeleteWebhook(w http.ResponseWriter, r *http.Request) {
// handleTestWebhook sends a test payload to the specified webhook.
func (s *Server) handleTestWebhook(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
_, ok := s.getWorkspaceID(w, r)
if !ok {
return
@@ -94,7 +110,7 @@ func (s *Server) handleTestWebhook(w http.ResponseWriter, r *http.Request) {
webhookID := chi.URLParam(r, "webhookID")
hook, err := s.store.GetWebhook(webhookID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if hook == nil {
+14 -5
View File
@@ -101,7 +101,7 @@ func (s *Server) handleListWorkspaces(w http.ResponseWriter, r *http.Request) {
if user != nil && user.Role != "admin" {
workspaces, err := s.store.GetUserWorkspaces(user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if workspaces == nil {
@@ -114,7 +114,7 @@ func (s *Server) handleListWorkspaces(w http.ResponseWriter, r *http.Request) {
// Admin users (or fresh-install with no users) see all workspaces.
workspaces, err := s.store.ListWorkspaces()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if workspaces == nil {
@@ -141,7 +141,7 @@ func (s *Server) handleCreateWorkspace(w http.ResponseWriter, r *http.Request) {
ws, err := s.store.CreateWorkspace(input)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
@@ -163,7 +163,7 @@ func (s *Server) handleGetWorkspace(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
ws, err := s.store.GetWorkspaceBySlug(slug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if ws == nil {
@@ -174,6 +174,9 @@ func (s *Server) handleGetWorkspace(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleUpdateWorkspace(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
slug := chi.URLParam(r, "slug")
var input models.WorkspaceUpdate
@@ -188,7 +191,7 @@ func (s *Server) handleUpdateWorkspace(w http.ResponseWriter, r *http.Request) {
ws, err := s.store.UpdateWorkspace(slug, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return
}
if ws == nil {
@@ -202,6 +205,9 @@ func (s *Server) handleUpdateWorkspace(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleDeleteWorkspace(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
slug := chi.URLParam(r, "slug")
err := s.store.DeleteWorkspace(slug)
if err != nil {
@@ -212,6 +218,9 @@ func (s *Server) handleDeleteWorkspace(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleExportWorkspace(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
slug := chi.URLParam(r, "slug")
export, err := s.store.ExportWorkspace(slug)
if err != nil {
+10
View File
@@ -283,6 +283,16 @@ func requireRole(r *http.Request, minRole string) bool {
return roleLevel(role) >= roleLevel(minRole)
}
// requireMinRole checks role and writes a 403 if insufficient.
// Returns true if the request should continue, false if it was rejected.
func requireMinRole(w http.ResponseWriter, r *http.Request, minRole string) bool {
if requireRole(r, minRole) {
return true
}
writeError(w, http.StatusForbidden, "forbidden", "Insufficient permissions")
return false
}
// roleLevel returns a numeric level for role comparison.
// Higher values indicate more permissions.
func roleLevel(role string) int {
+117
View File
@@ -0,0 +1,117 @@
package server
import (
"crypto/rand"
"encoding/hex"
"net/http"
"strings"
)
const (
csrfCookie = "pad_csrf"
csrfHeader = "X-CSRF-Token"
csrfTokenLen = 32 // 32 bytes = 64 hex chars
)
// CSRFProtect implements the double-submit cookie pattern for CSRF protection.
// It validates that state-changing requests (POST, PATCH, PUT, DELETE) from
// cookie-authenticated sessions include a matching CSRF token in both the
// cookie and the X-CSRF-Token header.
//
// Requests authenticated via Bearer tokens (API tokens / CLI) are exempt
// because they are not vulnerable to CSRF attacks — the browser never
// attaches Authorization headers automatically.
//
// Safe methods (GET, HEAD, OPTIONS) are always allowed through.
func (s *Server) CSRFProtect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Safe methods are exempt
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
// Non-API paths are exempt (SPA static files, etc.)
if !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
// Auth endpoints that need to work before a CSRF token exists
// (login, register, bootstrap, password reset)
if strings.HasPrefix(r.URL.Path, "/api/v1/auth/") {
next.ServeHTTP(w, r)
return
}
// Bearer token requests are not vulnerable to CSRF — skip
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
next.ServeHTTP(w, r)
return
}
// No users exist (fresh install) — skip CSRF
count, err := s.store.UserCount()
if err != nil || count == 0 {
next.ServeHTTP(w, r)
return
}
// Cookie-based session: require CSRF token
cookie, err := r.Cookie(csrfCookie)
if err != nil || cookie.Value == "" {
writeError(w, http.StatusForbidden, "csrf_error", "Missing CSRF token")
return
}
headerToken := r.Header.Get(csrfHeader)
if headerToken == "" {
writeError(w, http.StatusForbidden, "csrf_error", "Missing CSRF header")
return
}
if cookie.Value != headerToken {
writeError(w, http.StatusForbidden, "csrf_error", "CSRF token mismatch")
return
}
next.ServeHTTP(w, r)
})
}
// setCSRFCookie writes a new CSRF token cookie. The cookie is NOT HttpOnly
// so that JavaScript can read it and send it back as a header.
func setCSRFCookie(w http.ResponseWriter, ttl int, secure bool) {
token := generateCSRFToken()
http.SetCookie(w, &http.Cookie{
Name: csrfCookie,
Value: token,
Path: "/",
MaxAge: ttl,
HttpOnly: false, // Must be readable by JS
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// clearCSRFCookie removes the CSRF cookie (e.g. on logout).
func clearCSRFCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: csrfCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
})
}
// generateCSRFToken returns a cryptographically random hex string.
func generateCSRFToken() string {
b := make([]byte, csrfTokenLen)
if _, err := rand.Read(b); err != nil {
panic("csrf: failed to generate random token: " + err.Error())
}
return hex.EncodeToString(b)
}
+249
View File
@@ -0,0 +1,249 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCSRF_SafeMethodsAllowed(t *testing.T) {
srv := testServer(t)
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
req := httptest.NewRequest(method, "/api/v1/workspaces", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code == http.StatusForbidden {
body := w.Body.String()
if strings.Contains(body, "csrf") || strings.Contains(body, "CSRF") {
t.Errorf("%s should not be blocked by CSRF, got 403 with body: %s", method, body)
}
}
}
}
func TestCSRF_BearerTokenExempt(t *testing.T) {
srv := testServer(t)
// Bootstrap admin so auth is required
token := bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
// POST with Bearer token (session token), no CSRF — should NOT get CSRF error
req := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces",
strings.NewReader(`{"name":"test"}`))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.0.2.1:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code == http.StatusForbidden {
body := w.Body.String()
if strings.Contains(body, "csrf") || strings.Contains(body, "CSRF") {
t.Errorf("Bearer token request should be CSRF-exempt, got 403: %s", body)
}
}
}
func TestCSRF_AuthEndpointsExempt(t *testing.T) {
srv := testServer(t)
// Auth endpoints should work without CSRF token
endpoints := []string{
"/api/v1/auth/login",
"/api/v1/auth/register",
"/api/v1/auth/logout",
"/api/v1/auth/forgot-password",
"/api/v1/auth/reset-password",
}
for _, ep := range endpoints {
req := httptest.NewRequest(http.MethodPost, ep, strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.0.2.1:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code == http.StatusForbidden {
body := w.Body.String()
if strings.Contains(body, "csrf") || strings.Contains(body, "CSRF") {
t.Errorf("%s should be CSRF-exempt, got 403: %s", ep, body)
}
}
}
}
func TestCSRF_MissingTokenBlocked(t *testing.T) {
srv := testServer(t)
// Bootstrap and login to get session token
bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
sessionToken := loginUser(t, srv, "admin@test.com", "password123")
// POST with session cookie but no CSRF token at all
req := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces",
strings.NewReader(`{"name":"test"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.0.2.1:1234"
req.AddCookie(&http.Cookie{Name: "pad_session", Value: sessionToken})
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for missing CSRF token, got %d: %s", w.Code, w.Body.String())
}
}
func TestCSRF_MismatchBlocked(t *testing.T) {
srv := testServer(t)
bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
sessionToken := loginUser(t, srv, "admin@test.com", "password123")
// POST with session cookie + CSRF cookie but WRONG header value
req := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces",
strings.NewReader(`{"name":"test"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", "wrong-token")
req.RemoteAddr = "192.0.2.1:1234"
req.AddCookie(&http.Cookie{Name: "pad_session", Value: sessionToken})
req.AddCookie(&http.Cookie{Name: "pad_csrf", Value: "correct-token"})
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for CSRF mismatch, got %d: %s", w.Code, w.Body.String())
}
}
func TestCSRF_MatchingTokenAllowed(t *testing.T) {
srv := testServer(t)
bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
sessionToken := loginUser(t, srv, "admin@test.com", "password123")
// POST with matching CSRF cookie + header
csrfVal := "matching-csrf-token"
req := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces",
strings.NewReader(`{"name":"csrftest"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", csrfVal)
req.RemoteAddr = "192.0.2.1:1234"
req.AddCookie(&http.Cookie{Name: "pad_session", Value: sessionToken})
req.AddCookie(&http.Cookie{Name: "pad_csrf", Value: csrfVal})
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
// Should NOT be blocked by CSRF
if w.Code == http.StatusForbidden {
body := w.Body.String()
if strings.Contains(body, "csrf") || strings.Contains(body, "CSRF") {
t.Errorf("matching CSRF token should be allowed, got 403: %s", body)
}
}
// Workspace create should succeed (201)
if w.Code != http.StatusCreated {
t.Errorf("expected 201 for workspace create with valid CSRF, got %d: %s", w.Code, w.Body.String())
}
}
func TestCSRF_FreshInstallExempt(t *testing.T) {
srv := testServer(t)
// No users → fresh install → CSRF should be skipped
req := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces",
strings.NewReader(`{"name":"test"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.0.2.1:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code == http.StatusForbidden {
body := w.Body.String()
if strings.Contains(body, "csrf") || strings.Contains(body, "CSRF") {
t.Errorf("fresh install should be CSRF-exempt, got 403: %s", body)
}
}
}
func TestCSRF_LoginSetsCSRFCookie(t *testing.T) {
srv := testServer(t)
bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
// Login
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login",
strings.NewReader(`{"email":"admin@test.com","password":"password123"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.0.2.1:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("login failed: %d %s", w.Code, w.Body.String())
}
// Check that CSRF cookie is set
var foundCSRF bool
for _, c := range w.Result().Cookies() {
if c.Name == "pad_csrf" {
foundCSRF = true
if c.HttpOnly {
t.Error("CSRF cookie must not be HttpOnly")
}
if c.Value == "" {
t.Error("CSRF cookie value must not be empty")
}
}
}
if !foundCSRF {
t.Error("login response should set pad_csrf cookie")
}
}
func TestCSRF_LogoutClearsCSRFCookie(t *testing.T) {
srv := testServer(t)
bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
sessionToken := loginUser(t, srv, "admin@test.com", "password123")
// Logout (auth endpoints are CSRF-exempt)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
req.AddCookie(&http.Cookie{Name: "pad_session", Value: sessionToken})
req.RemoteAddr = "192.0.2.1:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
// Check CSRF cookie is cleared
for _, c := range w.Result().Cookies() {
if c.Name == "pad_csrf" {
if c.MaxAge >= 0 {
t.Errorf("expected CSRF cookie to be cleared (MaxAge < 0), got MaxAge=%d", c.MaxAge)
}
}
}
}
func TestCSRF_AllMutationMethodsBlocked(t *testing.T) {
srv := testServer(t)
bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
sessionToken := loginUser(t, srv, "admin@test.com", "password123")
// All state-changing methods should be blocked without CSRF
for _, method := range []string{http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete} {
req := httptest.NewRequest(method, "/api/v1/workspaces",
strings.NewReader(`{"name":"test"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.0.2.1:1234"
req.AddCookie(&http.Cookie{Name: "pad_session", Value: sessionToken})
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("%s without CSRF should be 403, got %d", method, w.Code)
}
}
}
+205
View File
@@ -0,0 +1,205 @@
package server
import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/time/rate"
)
// rateLimitConfig holds the rate and burst for a limiter.
type rateLimitConfig struct {
Rate rate.Limit // events per second
Burst int // max burst
}
// ipRateLimiter tracks per-key rate limiters with automatic cleanup.
type ipRateLimiter struct {
mu sync.Mutex
limiters map[string]*rateLimiterEntry
config rateLimitConfig
}
type rateLimiterEntry struct {
limiter *rate.Limiter
lastSeen time.Time
}
func newIPRateLimiter(cfg rateLimitConfig) *ipRateLimiter {
rl := &ipRateLimiter{
limiters: make(map[string]*rateLimiterEntry),
config: cfg,
}
// Background cleanup of stale entries every 5 minutes
go rl.cleanup()
return rl
}
func (rl *ipRateLimiter) getLimiter(key string) *rate.Limiter {
rl.mu.Lock()
defer rl.mu.Unlock()
entry, exists := rl.limiters[key]
if !exists {
limiter := rate.NewLimiter(rl.config.Rate, rl.config.Burst)
rl.limiters[key] = &rateLimiterEntry{
limiter: limiter,
lastSeen: time.Now(),
}
return limiter
}
entry.lastSeen = time.Now()
return entry.limiter
}
func (rl *ipRateLimiter) cleanup() {
for {
time.Sleep(5 * time.Minute)
rl.mu.Lock()
for key, entry := range rl.limiters {
if time.Since(entry.lastSeen) > 30*time.Minute {
delete(rl.limiters, key)
}
}
rl.mu.Unlock()
}
}
// RateLimiters holds all the rate limiters used by the server.
type RateLimiters struct {
// Auth endpoints: strict limits per IP
Auth *ipRateLimiter
// Password reset: per-IP
PasswordReset *ipRateLimiter
// Registration: per-IP
Register *ipRateLimiter
// API: per-user (authenticated)
API *ipRateLimiter
// Search: per-user or per-IP
Search *ipRateLimiter
}
// NewRateLimiters creates rate limiters with sensible defaults.
func NewRateLimiters() *RateLimiters {
return &RateLimiters{
// Login: 5 attempts per minute per IP (= 5/60 per second, burst 5)
Auth: newIPRateLimiter(rateLimitConfig{
Rate: rate.Limit(5.0 / 60.0),
Burst: 5,
}),
// Password reset: 3 per hour per IP (= 3/3600 per second, burst 3)
PasswordReset: newIPRateLimiter(rateLimitConfig{
Rate: rate.Limit(3.0 / 3600.0),
Burst: 3,
}),
// Registration: 5 per hour per IP (= 5/3600 per second, burst 5)
Register: newIPRateLimiter(rateLimitConfig{
Rate: rate.Limit(5.0 / 3600.0),
Burst: 5,
}),
// API: 100 requests per minute per user/IP (= 100/60 per second, burst 20)
API: newIPRateLimiter(rateLimitConfig{
Rate: rate.Limit(100.0 / 60.0),
Burst: 20,
}),
// Search: 30 requests per minute per user/IP (= 30/60 per second, burst 10)
Search: newIPRateLimiter(rateLimitConfig{
Rate: rate.Limit(30.0 / 60.0),
Burst: 10,
}),
}
}
// RateLimit is the general-purpose rate limiting middleware.
// It applies different limits based on the endpoint being hit.
func (s *Server) RateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.rateLimiters == nil {
next.ServeHTTP(w, r)
return
}
path := r.URL.Path
// Only rate-limit API endpoints
if !strings.HasPrefix(path, "/api/") {
next.ServeHTTP(w, r)
return
}
ip := clientIP(r)
// Auth-specific rate limits
if strings.HasPrefix(path, "/api/v1/auth/") {
var limiter *ipRateLimiter
switch {
case path == "/api/v1/auth/login" || path == "/api/v1/auth/bootstrap":
limiter = s.rateLimiters.Auth
case path == "/api/v1/auth/forgot-password" || path == "/api/v1/auth/reset-password":
limiter = s.rateLimiters.PasswordReset
case path == "/api/v1/auth/register":
limiter = s.rateLimiters.Register
default:
// Other auth endpoints (session check, logout) — use general API limit
limiter = s.rateLimiters.API
}
if limiter != nil && !limiter.getLimiter(ip).Allow() {
writeTooManyRequests(w)
return
}
next.ServeHTTP(w, r)
return
}
// Search endpoint
if path == "/api/v1/search" {
key := rateLimitKey(r, ip)
if !s.rateLimiters.Search.getLimiter(key).Allow() {
writeTooManyRequests(w)
return
}
next.ServeHTTP(w, r)
return
}
// General API rate limit
key := rateLimitKey(r, ip)
if !s.rateLimiters.API.getLimiter(key).Allow() {
writeTooManyRequests(w)
return
}
next.ServeHTTP(w, r)
})
}
// rateLimitKey returns a key for rate limiting: user ID if authenticated, IP otherwise.
func rateLimitKey(r *http.Request, ip string) string {
if user := currentUser(r); user != nil {
return "user:" + user.ID
}
return "ip:" + ip
}
// clientIP extracts the client IP from RemoteAddr. This is safe because
// chimiddleware.RealIP runs earlier in the chain and overwrites RemoteAddr
// with the trusted value from X-Real-IP / X-Forwarded-For. We deliberately
// do NOT read proxy headers here to prevent clients from spoofing their IP
// to bypass rate limits.
func clientIP(r *http.Request) string {
host := r.RemoteAddr
if idx := strings.LastIndex(host, ":"); idx != -1 {
return host[:idx]
}
return host
}
// writeTooManyRequests sends a 429 response with a Retry-After header.
func writeTooManyRequests(w http.ResponseWriter) {
w.Header().Set("Retry-After", strconv.Itoa(60)) // suggest retry after 60s
writeError(w, http.StatusTooManyRequests, "rate_limited", "Too many requests. Please try again later.")
}
@@ -0,0 +1,148 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestRateLimit_AuthEndpointLimited(t *testing.T) {
srv := testServer(t)
// Bootstrap so auth endpoints actually process (not just "setup required")
bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
// Login attempts should be rate-limited after burst (5)
for i := 0; i < 5; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login",
strings.NewReader(`{"email":"wrong@test.com","password":"wrong"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "10.0.0.1:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
// These should go through (even if returning 401)
if w.Code == http.StatusTooManyRequests {
t.Fatalf("request %d should not be rate-limited yet", i+1)
}
}
// The 6th should be rate-limited
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login",
strings.NewReader(`{"email":"wrong@test.com","password":"wrong"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "10.0.0.1:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429 after burst, got %d", w.Code)
}
// Check Retry-After header
if w.Header().Get("Retry-After") == "" {
t.Error("expected Retry-After header on 429 response")
}
}
func TestRateLimit_DifferentIPsNotAffected(t *testing.T) {
srv := testServer(t)
bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
// Exhaust rate limit for IP 10.0.0.1
for i := 0; i < 6; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login",
strings.NewReader(`{"email":"wrong@test.com","password":"wrong"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "10.0.0.1:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
}
// Different IP should still be allowed
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login",
strings.NewReader(`{"email":"wrong@test.com","password":"wrong"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "10.0.0.2:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code == http.StatusTooManyRequests {
t.Error("different IP should not be rate-limited")
}
}
func TestRateLimit_SearchEndpointLimited(t *testing.T) {
srv := testServer(t)
// Search limiter has burst=10, so first 10 should succeed
for i := 0; i < 10; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=test", nil)
req.RemoteAddr = "10.0.0.3:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code == http.StatusTooManyRequests {
t.Fatalf("request %d should not be rate-limited yet (search burst=10)", i+1)
}
}
// The 11th should be rate-limited
req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=test", nil)
req.RemoteAddr = "10.0.0.3:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429 after search burst, got %d", w.Code)
}
}
func TestRateLimit_NonAPIPathsExempt(t *testing.T) {
srv := testServer(t)
// Non-API paths should not be rate-limited
for i := 0; i < 50; i++ {
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.RemoteAddr = "10.0.0.4:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code == http.StatusTooManyRequests {
t.Fatalf("non-API request %d should not be rate-limited", i+1)
}
}
}
func TestClientIP(t *testing.T) {
// clientIP only reads RemoteAddr (proxy headers are handled by chimiddleware.RealIP)
tests := []struct {
name string
remoteAddr string
want string
}{
{"with port", "192.168.1.1:1234", "192.168.1.1"},
{"no port", "10.0.0.1", "10.0.0.1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = tt.remoteAddr
got := clientIP(req)
if got != tt.want {
t.Errorf("clientIP() = %q, want %q", got, tt.want)
}
})
}
}
func TestClientIP_IgnoresProxyHeaders(t *testing.T) {
// Ensure clientIP does NOT trust X-Real-IP or X-Forwarded-For
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "192.168.1.1:1234"
req.Header.Set("X-Real-IP", "10.0.0.99")
req.Header.Set("X-Forwarded-For", "10.0.0.88")
got := clientIP(req)
if got != "192.168.1.1" {
t.Errorf("clientIP should ignore proxy headers, got %q", got)
}
}
+62
View File
@@ -0,0 +1,62 @@
package server
import (
"net/http"
"strings"
)
// SecurityHeaders adds standard security headers to all responses.
// These protect against common web vulnerabilities like XSS, clickjacking,
// and MIME type sniffing.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// Prevent the browser from MIME-sniffing the content type
h.Set("X-Content-Type-Options", "nosniff")
// Prevent the page from being embedded in frames (clickjacking protection)
h.Set("X-Frame-Options", "DENY")
// Control referrer information sent with requests
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Restrict browser features the app doesn't need
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
// CSP: allow self-sourced scripts/styles, plus inline styles for Svelte
h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
// StrictTransportSecurity adds HSTS header when secure cookies are enabled
// (indicating the server is behind TLS).
func StrictTransportSecurity(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
next.ServeHTTP(w, r)
})
}
// parseCORSOrigins parses a comma-separated list of origins into a slice.
// Returns default localhost origins if the input is empty.
func parseCORSOrigins(origins string) []string {
if origins == "" {
return []string{"http://localhost:*", "http://127.0.0.1:*"}
}
var result []string
for _, origin := range strings.Split(origins, ",") {
origin = strings.TrimSpace(origin)
if origin != "" {
result = append(result, origin)
}
}
if len(result) == 0 {
return []string{"http://localhost:*", "http://127.0.0.1:*"}
}
return result
}
@@ -0,0 +1,66 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestSecurityHeaders(t *testing.T) {
srv := testServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
req.RemoteAddr = "192.0.2.1:1234"
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
headers := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
}
for name, expected := range headers {
got := w.Header().Get(name)
if got != expected {
t.Errorf("%s = %q, want %q", name, got, expected)
}
}
// CSP should be set
csp := w.Header().Get("Content-Security-Policy")
if csp == "" {
t.Error("Content-Security-Policy header not set")
}
// HSTS should NOT be set when secureCookies is false (default)
if hsts := w.Header().Get("Strict-Transport-Security"); hsts != "" {
t.Errorf("HSTS should not be set when secureCookies is off, got %q", hsts)
}
}
func TestParseCORSOrigins(t *testing.T) {
tests := []struct {
input string
want []string
}{
{"", []string{"http://localhost:*", "http://127.0.0.1:*"}},
{"https://app.pad.dev", []string{"https://app.pad.dev"}},
{"https://app.pad.dev, https://admin.pad.dev", []string{"https://app.pad.dev", "https://admin.pad.dev"}},
{" , ", []string{"http://localhost:*", "http://127.0.0.1:*"}}, // empty after trim
}
for _, tt := range tests {
got := parseCORSOrigins(tt.input)
if len(got) != len(tt.want) {
t.Errorf("parseCORSOrigins(%q) = %v, want %v", tt.input, got, tt.want)
continue
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("parseCORSOrigins(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
}
}
}
}
+59 -18
View File
@@ -7,6 +7,7 @@ import (
"log"
"net/http"
"strings"
"sync"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
@@ -20,22 +21,27 @@ import (
)
type Server struct {
store *store.Store
router *chi.Mux
webFS fs.FS // embedded web UI static files (optional)
events *events.Bus // real-time event bus (optional)
webhooks *webhooks.Dispatcher // webhook dispatcher (optional)
email *email.Sender // transactional email sender (optional)
baseURL string // public base URL for generating links (e.g. invite URLs)
version string // release version (e.g. "dev", "1.2.3")
commit string // git commit hash
buildTime string // build timestamp
store *store.Store
router *chi.Mux
routerOnce sync.Once // ensures setupRouter runs once, after all config
webFS fs.FS // embedded web UI static files (optional)
events *events.Bus // real-time event bus (optional)
webhooks *webhooks.Dispatcher // webhook dispatcher (optional)
email *email.Sender // transactional email sender (optional)
rateLimiters *RateLimiters // per-endpoint rate limiters
baseURL string // public base URL for generating links (e.g. invite URLs)
corsOrigins string // comma-separated CORS origins (empty = localhost defaults)
secureCookies bool // set Secure flag on cookies (for TLS deployments)
version string // release version (e.g. "dev", "1.2.3")
commit string // git commit hash
buildTime string // build timestamp
}
func New(s *store.Store) *Server {
srv := &Server{store: s}
srv.setupRouter()
return srv
return &Server{
store: s,
rateLimiters: NewRateLimiters(),
}
}
// SetVersion stores the build version info for the health endpoint.
@@ -65,6 +71,16 @@ func (s *Server) SetEmailSender(e *email.Sender) {
s.email = e
}
// SetCORSOrigins configures allowed CORS origins (comma-separated).
func (s *Server) SetCORSOrigins(origins string) {
s.corsOrigins = origins
}
// SetSecureCookies enables the Secure flag on all cookies.
func (s *Server) SetSecureCookies(secure bool) {
s.secureCookies = secure
}
// reconfigureEmail reads email settings from the platform_settings table
// and updates (or creates) the email sender. Called after admin settings change.
func (s *Server) reconfigureEmail() {
@@ -95,18 +111,25 @@ func (s *Server) setupRouter() {
r := chi.NewRouter()
// Middleware
r.Use(chimiddleware.RealIP)
r.Use(chimiddleware.Logger)
r.Use(chimiddleware.Recoverer)
r.Use(chimiddleware.RequestID)
r.Use(SecurityHeaders)
if s.secureCookies {
r.Use(StrictTransportSecurity)
}
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:*", "http://127.0.0.1:*"},
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowedOrigins: parseCORSOrigins(s.corsOrigins),
AllowedMethods: []string{"GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Use(s.TokenAuth)
r.Use(s.SessionAuth)
r.Use(s.RateLimit)
r.Use(s.CSRFProtect)
r.Use(s.RequireAuth)
r.Use(jsonContentType)
@@ -338,11 +361,21 @@ func (s *Server) spaHandler() http.Handler {
})
}
// ensureRouter lazily initializes the router on first use, so all Set*
// configuration is applied before the middleware chain is built.
func (s *Server) ensureRouter() {
s.routerOnce.Do(func() {
s.setupRouter()
})
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.ensureRouter()
s.router.ServeHTTP(w, r)
}
func (s *Server) ListenAndServe(addr string) error {
s.ensureRouter()
log.Printf("Pad server listening on %s", addr)
return http.ListenAndServe(addr, s.router)
}
@@ -374,6 +407,14 @@ func writeError(w http.ResponseWriter, status int, code, message string) {
})
}
// writeInternalError logs the real error server-side and sends a generic
// message to the client. This prevents leaking SQL errors, file paths,
// and other internal details.
func writeInternalError(w http.ResponseWriter, err error) {
log.Printf("internal error: %v", err)
writeError(w, http.StatusInternalServerError, "internal_error", "An internal error occurred")
}
func decodeJSON(r *http.Request, v interface{}) error {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
@@ -386,7 +427,7 @@ func (s *Server) getWorkspaceID(w http.ResponseWriter, r *http.Request) (string,
slug := chi.URLParam(r, "slug")
ws, err := s.store.GetWorkspaceBySlug(slug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return "", false
}
if ws == nil {
@@ -406,7 +447,7 @@ func (s *Server) getWorkspaceDocument(w http.ResponseWriter, r *http.Request) (s
docID := chi.URLParam(r, "docID")
doc, err := s.store.GetDocument(docID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
writeInternalError(w, err)
return "", nil, false
}
if doc == nil || doc.WorkspaceID != workspaceID {
+22 -2
View File
@@ -13,9 +13,19 @@ type SearchResult struct {
Rank float64 `json:"rank"`
}
// placeholders returns a comma-separated string of SQL placeholders: "?, ?, ?"
func placeholders(n int) string {
if n <= 0 {
return ""
}
s := strings.Repeat("?, ", n)
return s[:len(s)-2] // trim trailing ", "
}
type SearchParams struct {
Query string
Workspace string // workspace slug, optional
Query string
Workspace string // workspace slug, optional — scopes to single workspace
WorkspaceIDs []string // workspace IDs to scope results to (used when no specific workspace is given)
}
func (s *Store) Search(params SearchParams) ([]SearchResult, error) {
@@ -43,6 +53,11 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) {
if params.Workspace != "" {
refQuery += ` AND i.workspace_id = (SELECT id FROM workspaces WHERE slug = ? AND deleted_at IS NULL)`
refArgs = append(refArgs, params.Workspace)
} else if len(params.WorkspaceIDs) > 0 {
refQuery += ` AND i.workspace_id IN (` + placeholders(len(params.WorkspaceIDs)) + `)`
for _, id := range params.WorkspaceIDs {
refArgs = append(refArgs, id)
}
}
refRows, err := s.db.Query(refQuery, refArgs...)
@@ -104,6 +119,11 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) {
)
`
args = append(args, params.Workspace)
} else if len(params.WorkspaceIDs) > 0 {
query += ` AND i.workspace_id IN (` + placeholders(len(params.WorkspaceIDs)) + `)`
for _, id := range params.WorkspaceIDs {
args = append(args, id)
}
}
query += " ORDER BY rank LIMIT 50"
+12 -2
View File
@@ -30,8 +30,9 @@ type WebhookPayload struct {
// Dispatcher sends webhook HTTP POST notifications for workspace events.
type Dispatcher struct {
store WebhookStore
client *http.Client
store WebhookStore
client *http.Client
SkipSSRF bool // Skip SSRF validation (for tests only)
}
// NewDispatcher creates a Dispatcher with the given store.
@@ -79,6 +80,15 @@ func (d *Dispatcher) Dispatch(workspaceID, event string, data interface{}) {
// deliver sends a single HTTP POST to the webhook URL.
func (d *Dispatcher) deliver(hook models.Webhook, body []byte) {
// Defense in depth: re-validate URL before making the request
if !d.SkipSSRF {
if err := ValidateWebhookURL(hook.URL); err != nil {
log.Printf("webhooks: blocked delivery to %s: %v", hook.URL, err)
d.store.UpdateWebhookFailure(hook.ID, true)
return
}
}
req, err := http.NewRequest(http.MethodPost, hook.URL, bytes.NewReader(body))
if err != nil {
log.Printf("webhooks: failed to create request for %s: %v", hook.URL, err)
+5
View File
@@ -80,6 +80,7 @@ func TestDispatcher_Dispatch(t *testing.T) {
})
d := NewDispatcher(store)
d.SkipSSRF = true
d.Dispatch("ws-1", "item.created", map[string]string{"title": "Test Item"})
store.waitForUpdate()
@@ -124,6 +125,7 @@ func TestDispatcher_EventFiltering(t *testing.T) {
})
d := NewDispatcher(store)
d.SkipSSRF = true
// This event should NOT match — no goroutine launched, no store update
d.Dispatch("ws-1", "item.deleted", map[string]string{"title": "Test"})
@@ -156,6 +158,7 @@ func TestDispatcher_WildcardEvent(t *testing.T) {
})
d := NewDispatcher(store)
d.SkipSSRF = true
d.Dispatch("ws-1", "item.deleted", map[string]string{"title": "Test"})
store.waitForUpdate()
@@ -189,6 +192,7 @@ func TestDispatcher_InactiveWebhookSkipped(t *testing.T) {
})
d := NewDispatcher(store)
d.SkipSSRF = true
d.Dispatch("ws-1", "item.created", map[string]string{"title": "Test"})
// Since the hook is inactive, no goroutine is launched
@@ -214,6 +218,7 @@ func TestDispatcher_FailureOnNon2xx(t *testing.T) {
})
d := NewDispatcher(store)
d.SkipSSRF = true
d.Dispatch("ws-1", "item.created", map[string]string{"title": "Test"})
store.waitForUpdate()
+107
View File
@@ -0,0 +1,107 @@
package webhooks
import (
"fmt"
"net"
"net/url"
"strings"
)
// ValidateWebhookURL checks that a webhook URL is safe to call.
// It rejects non-HTTP(S) schemes, URLs with credentials, private/reserved
// IPs (loopback, link-local, RFC1918, cloud metadata), and hostnames that
// resolve to private IPs.
func ValidateWebhookURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
// Scheme must be http or https
switch u.Scheme {
case "http", "https":
// ok
default:
return fmt.Errorf("unsupported scheme %q: only http and https are allowed", u.Scheme)
}
// Reject URLs with embedded credentials
if u.User != nil {
return fmt.Errorf("URLs with embedded credentials are not allowed")
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("URL must have a hostname")
}
// Check if host is a literal IP
if ip := net.ParseIP(host); ip != nil {
if isPrivateIP(ip) {
return fmt.Errorf("webhook URLs must not target private or reserved IP addresses")
}
return nil
}
// Host is a name — resolve it and check all resulting IPs
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("failed to resolve hostname %q: %w", host, err)
}
for _, ip := range ips {
if isPrivateIP(ip) {
return fmt.Errorf("hostname %q resolves to private/reserved IP %s", host, ip)
}
}
return nil
}
// isPrivateIP returns true if the IP is in a private, reserved, or
// otherwise non-routable range.
func isPrivateIP(ip net.IP) bool {
// Loopback (127.0.0.0/8, ::1)
if ip.IsLoopback() {
return true
}
// Link-local (169.254.0.0/16, fe80::/10)
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
// Unspecified (0.0.0.0, ::)
if ip.IsUnspecified() {
return true
}
// RFC1918 private ranges
privateRanges := []struct {
network string
}{
{"10.0.0.0/8"},
{"172.16.0.0/12"},
{"192.168.0.0/16"},
// IPv6 unique local (fc00::/7)
{"fc00::/7"},
// Cloud metadata (AWS, GCP, Azure)
{"169.254.169.254/32"},
}
for _, r := range privateRanges {
_, cidr, err := net.ParseCIDR(r.network)
if err != nil {
continue
}
if cidr.Contains(ip) {
return true
}
}
// Also catch common cloud metadata IPv6 variants
if strings.EqualFold(ip.String(), "fd00::") {
return true
}
return false
}
+86
View File
@@ -0,0 +1,86 @@
package webhooks
import (
"net"
"testing"
)
func TestValidateWebhookURL(t *testing.T) {
tests := []struct {
name string
url string
wantErr bool
}{
// Valid URLs
{"valid https", "https://example.com/webhook", false},
{"valid http", "http://example.com/callback", false},
{"valid with port", "https://example.com:8080/hook", false},
{"valid with path", "https://example.com/api/v1/webhook", false},
// Invalid schemes
{"ftp scheme", "ftp://example.com/hook", true},
{"javascript scheme", "javascript:alert(1)", true},
{"file scheme", "file:///etc/passwd", true},
{"no scheme", "example.com/hook", true},
// Embedded credentials
{"with credentials", "https://user:pass@example.com/hook", true},
// Private IPs
{"loopback IPv4", "http://127.0.0.1/hook", true},
{"loopback IPv6", "http://[::1]/hook", true},
{"private 10.x", "http://10.0.0.1/hook", true},
{"private 172.16.x", "http://172.16.0.1/hook", true},
{"private 192.168.x", "http://192.168.1.1/hook", true},
{"cloud metadata", "http://169.254.169.254/latest/meta-data/", true},
{"link-local", "http://169.254.1.1/hook", true},
{"unspecified", "http://0.0.0.0/hook", true},
// Hostnames resolving to private IPs
{"localhost", "http://localhost/hook", true},
// Empty/invalid
{"empty url", "", true},
{"no host", "http:///path", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateWebhookURL(tt.url)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateWebhookURL(%q) error = %v, wantErr = %v", tt.url, err, tt.wantErr)
}
})
}
}
func TestIsPrivateIP(t *testing.T) {
tests := []struct {
ip string
private bool
}{
{"127.0.0.1", true},
{"10.0.0.1", true},
{"172.16.0.1", true},
{"192.168.0.1", true},
{"169.254.169.254", true},
{"0.0.0.0", true},
{"::1", true},
{"8.8.8.8", false},
{"1.1.1.1", false},
{"93.184.216.34", false},
}
for _, tt := range tests {
t.Run(tt.ip, func(t *testing.T) {
ip := net.ParseIP(tt.ip)
if ip == nil {
t.Fatalf("failed to parse IP: %s", tt.ip)
}
got := isPrivateIP(ip)
if got != tt.private {
t.Errorf("isPrivateIP(%s) = %v, want %v", tt.ip, got, tt.private)
}
})
}
}
+16 -1
View File
@@ -45,9 +45,24 @@ class PadApiError extends Error {
}
}
function getCSRFToken(): string | null {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(/(?:^|;\s*)pad_csrf=([^;]+)/);
return match ? match[1] : null;
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
// Attach CSRF token for state-changing requests
const method = options?.method?.toUpperCase();
if (method && method !== 'GET' && method !== 'HEAD') {
const csrf = getCSRFToken();
if (csrf) headers['X-CSRF-Token'] = csrf;
}
const resp = await fetch(BASE + path, {
headers: { 'Content-Type': 'application/json' },
headers,
credentials: 'same-origin',
...options
});