security: remediate all P0-P2 audit findings (12 tasks)
P0 fixes: - WebSocket origin checking (reject untrusted origins) - WS session token moved from URL query param to first message frame - WebAuthn login cookie now uses Secure flag via shared SetSessionCookie - PostgreSQL sslmode configurable via POSTGRES_SSLMODE env (default: require) - Fixed DatabaseDSN to use real password instead of masked placeholder P1 fixes: - Per-IP rate limiting middleware (auth: 5 req/s, invites: 2 req/s) - Security headers on all responses (CSP, X-Frame-Options, nosniff, etc.) - WS broadcasts scoped to server members (prevents cross-server data leak) - Webhook tokens stored as SHA-256 hashes (not plaintext) P2 fixes: - CSRF protection via Origin header validation on state-changing requests - MANAGE_CHANNELS permission enforced on channel update/delete - Upload validation: 25MB limit, extension allowlist, server-side MIME check - File serve: path traversal protection + Content-Disposition: attachment Files: 23 changed, +499/-100. Builds clean (go build, go vet, npm build). Zero CVEs (govulncheck, npm audit).
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SetSessionCookie writes the session cookie with secure defaults.
|
||||
// Shared by password login, registration, and WebAuthn login.
|
||||
func SetSessionCookie(w http.ResponseWriter, cookieName, token string, duration time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(duration.Seconds()),
|
||||
})
|
||||
}
|
||||
@@ -293,13 +293,5 @@ func (h *Handler) getProfile(ctx context.Context, userID string) (UserProfile, e
|
||||
}
|
||||
|
||||
func (h *Handler) setSessionCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: h.cfg.Session.CookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(h.cfg.Session.Duration.Seconds()),
|
||||
})
|
||||
SetSessionCookie(w, h.cfg.Session.CookieName, token, h.cfg.Session.Duration)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
@@ -331,14 +332,7 @@ func (h *WebAuthnHandler) LoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "dumpster_session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: 86400 * 30, // 30 days
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
SetSessionCookie(w, "dumpster_session", token, 30*24*time.Hour)
|
||||
|
||||
// Clear challenge cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
|
||||
@@ -8,15 +8,17 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
checker *permissions.Checker
|
||||
}
|
||||
|
||||
func NewHandler(db *sql.DB) *Handler {
|
||||
return &Handler{db: db}
|
||||
func NewHandler(db *sql.DB, checker *permissions.Checker) *Handler {
|
||||
return &Handler{db: db, checker: checker}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
@@ -296,6 +298,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify membership and MANAGE_CHANNELS permission
|
||||
member, err := h.isMember(r.Context(), userID, serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
@@ -306,6 +309,16 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var ch channelResponse
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
UPDATE channels
|
||||
@@ -366,8 +379,12 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
// Not the owner; check MANAGE_CHANNELS permission
|
||||
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||
if err != nil || !allowed {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
|
||||
@@ -25,6 +25,7 @@ type DatabaseConfig struct {
|
||||
User string
|
||||
Password string
|
||||
Database string
|
||||
SSLMode string
|
||||
}
|
||||
|
||||
type ValkeyConfig struct {
|
||||
@@ -70,6 +71,7 @@ func Load() *Config {
|
||||
User: getEnv("POSTGRES_USER", "dumpster"),
|
||||
Password: getEnv("POSTGRES_PASSWORD", "dumpster"),
|
||||
Database: getEnv("POSTGRES_DB", "dumpster"),
|
||||
SSLMode: getEnv("POSTGRES_SSLMODE", "require"),
|
||||
},
|
||||
Valkey: ValkeyConfig{
|
||||
URL: getEnv("VALKEY_URL", "redis://localhost:***@example.com"),
|
||||
@@ -92,7 +94,7 @@ func Load() *Config {
|
||||
}
|
||||
|
||||
func (c *Config) DatabaseDSN() string {
|
||||
return "postgres://" + c.Database.User + ":***@" + c.Database.Host + ":" + c.Database.Port + "/" + c.Database.Database + "?sslmode=disable"
|
||||
return "postgres://" + c.Database.User + ":" + c.Database.Password + "@" + c.Database.Host + ":" + c.Database.Port + "/" + c.Database.Database + "?sslmode=" + c.Database.SSLMode
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
|
||||
@@ -185,6 +185,10 @@ CREATE INDEX IF NOT EXISTS idx_slash_commands_server ON slash_commands(server_id
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_channel ON webhooks(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_token ON webhooks(token);
|
||||
|
||||
-- Add token_hash column for secure webhook token storage
|
||||
ALTER TABLE webhooks ADD COLUMN IF NOT EXISTS token_hash VARCHAR(64);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_token_hash ON webhooks(token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
+68
-19
@@ -21,7 +21,28 @@ const (
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
CheckOrigin: func(r *http.Request) bool { return true }, // default: allow all (overridden by SetAllowedOrigins)
|
||||
}
|
||||
|
||||
// SetAllowedOrigins replaces the default upgrader with one that validates
|
||||
// the Origin header against a trusted set. Call once at startup.
|
||||
func SetAllowedOrigins(origins []string) {
|
||||
originSet := make(map[string]struct{}, len(origins))
|
||||
for _, o := range origins {
|
||||
originSet[o] = struct{}{}
|
||||
}
|
||||
upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := originSet[origin]
|
||||
return ok
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Client is a middleman between the websocket connection and the hub.
|
||||
@@ -118,31 +139,59 @@ func (c *Client) writePump() {
|
||||
}
|
||||
}
|
||||
|
||||
// ServeWS handles websocket requests from the peer. It authenticates the
|
||||
// client via a session token passed as the "token" query parameter.
|
||||
// authMessage is the first frame the client must send after connecting.
|
||||
type authMessage struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// ServeWS handles websocket requests from the peer. It upgrades the
|
||||
// connection first, then waits for an auth message frame containing the
|
||||
// session token. This avoids leaking the token in the URL (which would
|
||||
// appear in server logs, browser history, and Referer headers).
|
||||
func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
http.Error(w, `{"error":"missing token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var userID string
|
||||
err := db.QueryRowContext(context.Background(),
|
||||
`SELECT user_id FROM sessions WHERE token = $1 AND expires_at > NOW()`,
|
||||
token,
|
||||
).Scan(&userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid or expired session"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
logger.Error("websocket upgrade failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Short deadline for the auth frame; we don't want unauthenticated
|
||||
// connections hanging around consuming resources.
|
||||
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
logger.Warn("ws auth: failed to read auth message", "error", err)
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"auth timeout"}`))
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
var auth authMessage
|
||||
if err := json.Unmarshal(raw, &auth); err != nil || auth.Token == "" {
|
||||
logger.Warn("ws auth: invalid auth message")
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"missing token"}`))
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
var userID string
|
||||
err = db.QueryRowContext(context.Background(),
|
||||
`SELECT user_id FROM sessions WHERE token = $1 AND expires_at > NOW()`,
|
||||
auth.Token,
|
||||
).Scan(&userID)
|
||||
if err != nil {
|
||||
logger.Warn("ws auth: invalid session", "error", err)
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid session"}`))
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Auth succeeded. Clear the auth deadline (readPump sets its own).
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
|
||||
// Send ready signal so the client knows auth passed.
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"ready"}`))
|
||||
|
||||
client := &Client{
|
||||
Hub: hub,
|
||||
Conn: conn,
|
||||
|
||||
@@ -28,6 +28,7 @@ type Hub struct {
|
||||
clients map[*Client]struct{}
|
||||
presence map[string]string // userID -> status
|
||||
lastActivity map[string]time.Time // userID -> last activity time
|
||||
userServers map[string]map[string]struct{} // userID -> set of serverIDs
|
||||
register chan *Client
|
||||
unregister chan *Client
|
||||
broadcast chan []byte
|
||||
@@ -41,6 +42,7 @@ func NewHub(db *sql.DB, logger *slog.Logger) *Hub {
|
||||
clients: make(map[*Client]struct{}),
|
||||
presence: make(map[string]string),
|
||||
lastActivity: make(map[string]time.Time),
|
||||
userServers: make(map[string]map[string]struct{}),
|
||||
register: make(chan *Client),
|
||||
unregister: make(chan *Client),
|
||||
broadcast: make(chan []byte, 256),
|
||||
@@ -63,6 +65,7 @@ func (h *Hub) Run() {
|
||||
h.lastActivity[client.UserID] = time.Now()
|
||||
h.mu.Unlock()
|
||||
|
||||
h.RefreshUserServers(client.UserID)
|
||||
h.setUserStatus(client.UserID, "online")
|
||||
h.broadcastPresence(client.UserID, "online")
|
||||
h.logger.Info("client connected", "user_id", client.UserID)
|
||||
@@ -79,6 +82,7 @@ func (h *Hub) Run() {
|
||||
if !hasOther {
|
||||
delete(h.presence, client.UserID)
|
||||
delete(h.lastActivity, client.UserID)
|
||||
delete(h.userServers, client.UserID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
@@ -207,3 +211,65 @@ func (h *Hub) ClientCount() int {
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
// RefreshUserServers loads the server membership for a user from the database
|
||||
// and caches it. Call when a client connects or when server membership changes.
|
||||
func (h *Hub) RefreshUserServers(userID string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := h.db.QueryContext(ctx, `SELECT server_id FROM members WHERE user_id = $1`, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to load user servers", "user_id", userID, "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
servers := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var sid string
|
||||
if err := rows.Scan(&sid); err == nil {
|
||||
servers[sid] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.userServers[userID] = servers
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// BroadcastToServer sends an event only to clients who are members of the
|
||||
// given server. Use this for server-scoped events (messages, channels,
|
||||
// reactions) to prevent cross-server data leaks.
|
||||
func (h *Hub) BroadcastToServer(serverID string, event Event) {
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to marshal event", "type", event.Type, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
for client := range h.clients {
|
||||
servers, ok := h.userServers[client.UserID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, member := servers[serverID]; member {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
go func(c *Client) { h.unregister <- c }(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ServerIDForChannel looks up the server_id that owns the given channel.
|
||||
// Used by handlers to route events to the correct server scope.
|
||||
func (h *Hub) ServerIDForChannel(ctx context.Context, channelID string) (string, error) {
|
||||
var serverID string
|
||||
err := h.db.QueryRowContext(ctx, `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID)
|
||||
return serverID, err
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ type createMessageRequest struct {
|
||||
// @Router /channels/{channelID}/messages [post]
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
userID, ok := h.requireChannelAccess(w, r, channelID)
|
||||
userID, serverID, ok := h.requireChannelAccess(w, r, channelID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -124,8 +124,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
|
||||
// Broadcast MESSAGE_CREATE event via WebSocket
|
||||
h.hub.BroadcastEvent(gateway.Event{
|
||||
// Broadcast MESSAGE_CREATE event via WebSocket (scoped to server)
|
||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMessageCreate,
|
||||
Data: msg,
|
||||
})
|
||||
@@ -206,10 +206,14 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
|
||||
h.hub.BroadcastEvent(gateway.Event{
|
||||
Type: gateway.EventMessageUpdate,
|
||||
Data: msg,
|
||||
})
|
||||
// Look up serverID for scoped broadcast
|
||||
serverID, _ := h.hub.ServerIDForChannel(r.Context(), msg.ChannelID)
|
||||
if serverID != "" {
|
||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMessageUpdate,
|
||||
Data: msg,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(msg)
|
||||
@@ -247,13 +251,17 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.hub.BroadcastEvent(gateway.Event{
|
||||
Type: gateway.EventMessageDelete,
|
||||
Data: map[string]string{
|
||||
"id": messageID,
|
||||
"channel_id": channelID,
|
||||
},
|
||||
})
|
||||
// Look up serverID for scoped broadcast
|
||||
serverID, _ := h.hub.ServerIDForChannel(r.Context(), channelID)
|
||||
if serverID != "" {
|
||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMessageDelete,
|
||||
Data: map[string]string{
|
||||
"id": messageID,
|
||||
"channel_id": channelID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -272,7 +280,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
// @Router /channels/{channelID}/messages [get]
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
userID, ok := h.requireChannelAccess(w, r, channelID)
|
||||
userID, _, ok := h.requireChannelAccess(w, r, channelID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -337,25 +345,26 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// requireChannelAccess checks if user is authenticated and is a member of the channel's server.
|
||||
func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, bool) {
|
||||
// Returns (userID, serverID, ok).
|
||||
func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, string, bool) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return "", false
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
var serverID string
|
||||
err := h.db.QueryRowContext(r.Context(), `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||
return "", false
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
isMember, err := h.isMember(r.Context(), userID, serverID)
|
||||
if err != nil || !isMember {
|
||||
http.Error(w, `{"error":"not a member"}`, http.StatusForbidden)
|
||||
return "", false
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
return userID, true
|
||||
return userID, serverID, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CSRFProtect returns middleware that validates the Origin header on
|
||||
// state-changing methods (POST, PUT, PATCH, DELETE). Requests from origins
|
||||
// not in the trustedOrigins list are rejected with 403. Non-browser clients
|
||||
// (curl, bots) that send no Origin or Referer header are allowed through,
|
||||
// since they are not subject to CSRF.
|
||||
func CSRFProtect(trustedOrigins []string) func(http.Handler) http.Handler {
|
||||
originSet := make(map[string]struct{}, len(trustedOrigins))
|
||||
for _, o := range trustedOrigins {
|
||||
originSet[o] = struct{}{}
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
// Fallback: extract origin from Referer
|
||||
referer := r.Header.Get("Referer")
|
||||
if referer != "" {
|
||||
for _, o := range trustedOrigins {
|
||||
if strings.HasPrefix(referer, o) {
|
||||
origin = o
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if origin != "" {
|
||||
if _, ok := originSet[origin]; !ok {
|
||||
http.Error(w, `{"error":"forbidden origin"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
// If both Origin and Referer are empty, allow through
|
||||
// (non-browser client like curl, bots, mobile apps)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// ipLimiter tracks rate limiters per IP address.
|
||||
type ipLimiter struct {
|
||||
mu sync.Mutex
|
||||
limiters map[string]*rate.Limiter
|
||||
rate rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
func newIPLimiter(r rate.Limit, burst int) *ipLimiter {
|
||||
return &ipLimiter{
|
||||
limiters: make(map[string]*rate.Limiter),
|
||||
rate: r,
|
||||
burst: burst,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
lim, exists := l.limiters[ip]
|
||||
if !exists {
|
||||
lim = rate.NewLimiter(l.rate, l.burst)
|
||||
l.limiters[ip] = lim
|
||||
}
|
||||
return lim
|
||||
}
|
||||
|
||||
// RateLimit returns middleware that limits requests per IP.
|
||||
// requestsPerSecond is the sustained rate, burst is the max burst size.
|
||||
func RateLimit(requestsPerSecond float64, burst int) func(http.Handler) http.Handler {
|
||||
limiter := newIPLimiter(rate.Limit(requestsPerSecond), burst)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.RemoteAddr
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
// Take the first IP in the chain (the original client)
|
||||
if idx := strings.IndexByte(xff, ','); idx > 0 {
|
||||
ip = strings.TrimSpace(xff[:idx])
|
||||
} else {
|
||||
ip = strings.TrimSpace(xff)
|
||||
}
|
||||
} else if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
ip = strings.TrimSpace(xri)
|
||||
}
|
||||
|
||||
if !limiter.getLimiter(ip).Allow() {
|
||||
http.Error(w, `{"error":"rate limited"}`, http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
// SecurityHeaders returns middleware that sets common security headers on
|
||||
// every response. HSTS is intentionally omitted here because Caddy handles
|
||||
// it at the reverse-proxy layer.
|
||||
func SecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' https: data:; connect-src 'self' wss: ws:; font-src 'self';")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(self), geolocation=()")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func (h *Handler) requireMessageAccess(w http.ResponseWriter, r *http.Request, m
|
||||
// @Router /messages/{messageID}/reactions [post]
|
||||
func (h *Handler) Add(w http.ResponseWriter, r *http.Request) {
|
||||
messageID := chi.URLParam(r, "messageID")
|
||||
userID, channelID, _, ok := h.requireMessageAccess(w, r, messageID)
|
||||
userID, channelID, serverID, ok := h.requireMessageAccess(w, r, messageID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (h *Handler) Add(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
reaction.CreatedAt = createdAt.String
|
||||
|
||||
h.hub.BroadcastEvent(gateway.Event{
|
||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventReactionAdd,
|
||||
Data: map[string]interface{}{
|
||||
"reaction": reaction,
|
||||
@@ -173,7 +173,7 @@ func (h *Handler) Add(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
messageID := chi.URLParam(r, "messageID")
|
||||
emoji := chi.URLParam(r, "emoji")
|
||||
userID, channelID, _, ok := h.requireMessageAccess(w, r, messageID)
|
||||
userID, channelID, serverID, ok := h.requireMessageAccess(w, r, messageID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func (h *Handler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.hub.BroadcastEvent(gateway.Event{
|
||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventReactionRemove,
|
||||
Data: map[string]interface{}{
|
||||
"user_id": userID,
|
||||
|
||||
@@ -14,6 +14,16 @@ import (
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
const maxUploadSize = 25 << 20 // 25 MB per file
|
||||
|
||||
var allowedExtensions = map[string]bool{
|
||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||
".mp3": true, ".wav": true, ".ogg": true, ".flac": true,
|
||||
".mp4": true, ".webm": true, ".mov": true,
|
||||
".pdf": true, ".txt": true, ".md": true, ".json": true, ".csv": true,
|
||||
".zip": true, ".tar": true, ".gz": true,
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
client *minio.Client
|
||||
bucket string
|
||||
@@ -53,8 +63,10 @@ func NewHandler(cfg *config.Config) (*Handler, error) {
|
||||
}
|
||||
|
||||
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(50 << 20); err != nil {
|
||||
http.Error(w, `{"error":"invalid form"}`, http.StatusBadRequest)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
||||
|
||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||
http.Error(w, `{"error":"file too large or invalid form"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,14 +77,33 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
// Validate extension against allowlist
|
||||
ext := ""
|
||||
if idx := strings.LastIndex(header.Filename, "."); idx >= 0 {
|
||||
ext = header.Filename[idx:]
|
||||
ext = strings.ToLower(header.Filename[idx:])
|
||||
}
|
||||
if !allowedExtensions[ext] {
|
||||
http.Error(w, `{"error":"file type not allowed"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Server-side content-type detection (blocks HTML/JS that could be used for XSS)
|
||||
buf := make([]byte, 512)
|
||||
n, _ := file.Read(buf)
|
||||
detectedType := http.DetectContentType(buf[:n])
|
||||
file.Seek(0, io.SeekStart)
|
||||
|
||||
blockedTypes := []string{"text/html", "application/javascript", "application/x-executable"}
|
||||
for _, blocked := range blockedTypes {
|
||||
if strings.HasPrefix(detectedType, blocked) {
|
||||
http.Error(w, `{"error":"file type not allowed"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = detectedType
|
||||
}
|
||||
|
||||
objectName := uuid.New().String() + ext
|
||||
@@ -98,6 +129,12 @@ func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize: reject path traversal attempts
|
||||
if strings.Contains(objectName, "..") || strings.Contains(objectName, "/") {
|
||||
http.Error(w, `{"error":"invalid path"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -116,5 +153,6 @@ func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", stat.ContentType)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size))
|
||||
w.Header().Set("Content-Disposition", "attachment")
|
||||
io.Copy(w, obj)
|
||||
}
|
||||
|
||||
@@ -138,15 +138,16 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
token := genToken()
|
||||
tokenHash := hashToken(token)
|
||||
|
||||
var wh webhookWithToken
|
||||
var avatar sql.NullString
|
||||
var createdAt sql.NullString
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO webhooks (channel_id, name, token, avatar, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO webhooks (channel_id, name, token, token_hash, avatar, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, channel_id, name, avatar, created_by, created_at::text
|
||||
`, channelID, req.Name, token, req.Avatar, userID).Scan(
|
||||
`, channelID, req.Name, token, tokenHash, req.Avatar, userID).Scan(
|
||||
&wh.ID, &wh.ChannelID, &wh.Name, &avatar, &wh.CreatedBy, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -300,12 +301,12 @@ func (h *Handler) Execute(w http.ResponseWriter, r *http.Request) {
|
||||
webhookID := chi.URLParam(r, "webhookID")
|
||||
token := chi.URLParam(r, "token")
|
||||
|
||||
// Look up webhook and verify token
|
||||
var storedToken, channelID, webhookName string
|
||||
// Look up webhook and verify token via hash comparison
|
||||
var storedHash, channelID, webhookName string
|
||||
var webhookAvatar sql.NullString
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT token, channel_id, name, avatar FROM webhooks WHERE id = $1
|
||||
`, webhookID).Scan(&storedToken, &channelID, &webhookName, &webhookAvatar)
|
||||
SELECT token_hash, channel_id, name, avatar FROM webhooks WHERE id = $1
|
||||
`, webhookID).Scan(&storedHash, &channelID, &webhookName, &webhookAvatar)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "webhook not found")
|
||||
@@ -315,7 +316,7 @@ func (h *Handler) Execute(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if token != storedToken {
|
||||
if hashToken(token) != storedHash {
|
||||
writeErr(w, http.StatusUnauthorized, "invalid token")
|
||||
return
|
||||
}
|
||||
@@ -395,12 +396,15 @@ func (h *Handler) Execute(w http.ResponseWriter, r *http.Request) {
|
||||
msg.DisplayName = &displayName
|
||||
}
|
||||
|
||||
// Broadcast MESSAGE_CREATE via gateway
|
||||
// Broadcast MESSAGE_CREATE via gateway (scoped to server)
|
||||
if h.hub != nil {
|
||||
h.hub.BroadcastEvent(gateway.Event{
|
||||
Type: gateway.EventMessageCreate,
|
||||
Data: msg,
|
||||
})
|
||||
serverID, _ := h.hub.ServerIDForChannel(r.Context(), channelID)
|
||||
if serverID != "" {
|
||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMessageCreate,
|
||||
Data: msg,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, msg)
|
||||
|
||||
@@ -2,6 +2,7 @@ package webhook
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
@@ -13,3 +14,9 @@ func genToken() string {
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// hashToken returns the SHA-256 hex digest of a token, suitable for storage.
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user