Compare commits

..

5 Commits

Author SHA1 Message Date
hobokenchicken 738b9b17ff fix: add unsafe-inline to Tauri script-src CSP
Release Desktop Apps / build-linux (push) Successful in 4m50s
Release Desktop Apps / build-windows (push) Successful in 16m59s
Release Desktop Apps / release (push) Successful in 11s
2026-07-20 13:42:21 -04:00
hobokenchicken 44370f41e5 ops: .dockerignore, non-root user, network isolation, deploy rollback
- .dockerignore excludes .git, node_modules, certs, *.zip
- Dockerfile runs as non-root appuser
- compose.yml isolates frontend (Caddy only) and backend (DB/cache/media) networks
- deploy.sh snapshots binary before pull and rolls back on failed health check
2026-07-20 13:15:19 -04:00
hobokenchicken 1d6c9bdfe6 perf: rate limiter cleanup goroutine, DB pool constraints
- ipLimiter evicts stale entries after 10 min of inactivity via 5 min sweep
- DB pool capped at 25 max open, 5 idle, 15 min lifetime
2026-07-20 13:13:58 -04:00
hobokenchicken 57aec2c6b3 sec: hash tokens in DB, set Tauri CSP, escape quotes in XSS guard
- Session and email verification/reset tokens stored as SHA-256 hash in DB
  (raw token stays client-side in cookie/email link)
- Tauri CSP set: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:; connect-src 'self' wss: https:
- escapeHtml now handles double and single quotes to prevent attribute-based XSS
2026-07-20 13:13:09 -04:00
hobokenchicken d4ca5f576e sec: point coturn at live LE certs, remove old ./certs/ mount 2026-07-20 13:10:27 -04:00
10 changed files with 109 additions and 16 deletions
+8
View File
@@ -0,0 +1,8 @@
.git
node_modules
web/dist
certs
*.zip
dumpster-server
.env
.env.local
+25 -2
View File
@@ -8,8 +8,31 @@ export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
nvm use 22 nvm use 22
# Snapshot the current binary before pulling
if [ -f dumpster-server ]; then
cp dumpster-server dumpster-server.bak
echo "Backed up existing binary to dumpster-server.bak"
fi
git pull git pull
make build make build
systemctl restart dumpster
echo "Deploy complete — $(date)" # Health check after restart — roll back on failure
systemctl restart dumpster
echo "Waiting for app to become healthy..."
for i in $(seq 1 15); do
if curl -sf http://localhost:8080/ > /dev/null 2>&1; then
echo "Server is listening — deploy complete"
rm -f dumpster-server.bak
echo "Deploy complete — $(date)"
exit 0
fi
sleep 2
done
echo "Health check failed after 30s — rolling back..."
systemctl stop dumpster
cp dumpster-server.bak dumpster-server
systemctl start dumpster
echo "Rolled back to previous binary — $(date)"
exit 1
+3 -1
View File
@@ -16,8 +16,10 @@ RUN npm run build
FROM alpine:latest FROM alpine:latest
RUN apk --no-cache add ca-certificates RUN apk --no-cache add ca-certificates
RUN adduser -D -g '' appuser
WORKDIR /app WORKDIR /app
COPY --from=go-builder /bin/dumpster-server /app/dumpster-server COPY --from=go-builder /bin/dumpster-server /app/dumpster-server
COPY --from=web-builder /app/dist /srv/web COPY --from=web-builder /app/dist /srv/web
USER appuser
EXPOSE 8080 EXPOSE 8080
CMD ["sh", "-c", "if [ -z \"$DUMPSTER_SECRET\" ]; then export DUMPSTER_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32); fi; /app/dumpster-server"] CMD sh -c "if [ -z \"$DUMPSTER_SECRET\" ]; then export DUMPSTER_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32); fi; /app/dumpster-server"
+22 -3
View File
@@ -1,5 +1,9 @@
version: '3.8' version: '3.8'
networks:
frontend:
backend:
services: services:
app: app:
build: build:
@@ -32,6 +36,9 @@ services:
condition: service_healthy condition: service_healthy
valkey: valkey:
condition: service_healthy condition: service_healthy
networks:
- frontend
- backend
postgres: postgres:
image: postgres:16 image: postgres:16
@@ -49,6 +56,8 @@ services:
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks:
- backend
valkey: valkey:
image: valkey/valkey:8 image: valkey/valkey:8
@@ -62,6 +71,8 @@ services:
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 5 retries: 5
networks:
- backend
minio: minio:
image: minio/minio:latest image: minio/minio:latest
@@ -75,6 +86,8 @@ services:
- "9001:9001" - "9001:9001"
volumes: volumes:
- minio_data:/data - minio_data:/data
networks:
- backend
livekit: livekit:
image: livekit/livekit-server:latest image: livekit/livekit-server:latest
@@ -89,6 +102,8 @@ services:
- ./livekit.yaml:/etc/livekit.yaml:ro - ./livekit.yaml:/etc/livekit.yaml:ro
environment: environment:
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-secret}" LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-secret}"
networks:
- backend
coturn: coturn:
image: coturn/coturn:latest image: coturn/coturn:latest
@@ -99,8 +114,8 @@ services:
- "--max-port=50200" - "--max-port=50200"
- "--realm=${DUMPSTER_HOST:-localhost}" - "--realm=${DUMPSTER_HOST:-localhost}"
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}" - "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
- "--cert=/etc/coturn/certs/turn.crt" - "--cert=/etc/letsencrypt/live/turn.dustin.coffee/fullchain.pem"
- "--pkey=/etc/coturn/certs/turn.key" - "--pkey=/etc/letsencrypt/live/turn.dustin.coffee/privkey.pem"
- "--tls-listening-port=5349" - "--tls-listening-port=5349"
ports: ports:
- "3478:3478/tcp" - "3478:3478/tcp"
@@ -109,7 +124,9 @@ services:
- "5349:5349/udp" - "5349:5349/udp"
- "50101-50200:50101-50200/udp" - "50101-50200:50101-50200/udp"
volumes: volumes:
- ./certs:/etc/coturn/certs:ro - /etc/letsencrypt:/etc/letsencrypt:ro
networks:
- backend
caddy: caddy:
image: caddy:2 image: caddy:2
@@ -124,6 +141,8 @@ services:
- caddy_config:/config - caddy_config:/config
depends_on: depends_on:
- app - app
networks:
- frontend
volumes: volumes:
postgres_data: postgres_data:
+6 -4
View File
@@ -30,10 +30,11 @@ func (h *Handler) RequestVerification(w http.ResponseWriter, r *http.Request) {
} }
token := generateToken() token := generateToken()
tokenHash := hashToken(token)
_, err = h.db.ExecContext(r.Context(), ` _, err = h.db.ExecContext(r.Context(), `
INSERT INTO user_tokens (user_id, token, type, expires_at) INSERT INTO user_tokens (user_id, token, type, expires_at)
VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours') VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours')
`, userID, token) `, userID, tokenHash)
if err != nil { if err != nil {
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError) http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
return return
@@ -63,7 +64,7 @@ func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
DELETE FROM user_tokens DELETE FROM user_tokens
WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW() WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW()
RETURNING user_id RETURNING user_id
`, req.Token).Scan(&userID) `, hashToken(req.Token)).Scan(&userID)
if err != nil { if err != nil {
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest) http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
return return
@@ -100,10 +101,11 @@ func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
} }
token := generateToken() token := generateToken()
tokenHash := hashToken(token)
_, err = h.db.ExecContext(r.Context(), ` _, err = h.db.ExecContext(r.Context(), `
INSERT INTO user_tokens (user_id, token, type, expires_at) INSERT INTO user_tokens (user_id, token, type, expires_at)
VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour') VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour')
`, userID, token) `, userID, tokenHash)
if err != nil { if err != nil {
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError) http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
return return
@@ -134,7 +136,7 @@ func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
DELETE FROM user_tokens DELETE FROM user_tokens
WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW() WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW()
RETURNING user_id RETURNING user_id
`, req.Token).Scan(&userID) `, hashToken(req.Token)).Scan(&userID)
if err != nil { if err != nil {
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest) http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
return return
+12 -3
View File
@@ -3,6 +3,7 @@ package auth
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
@@ -20,17 +21,25 @@ func NewSessionStore(db *sql.DB, cfg *config.Config) *SessionStore {
return &SessionStore{db: db, cfg: cfg} return &SessionStore{db: db, cfg: cfg}
} }
// hashToken computes a SHA-256 hash of a token string.
// Tokens are stored hashed in the DB so a DB leak doesn't expose active sessions.
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
func (s *SessionStore) Create(ctx context.Context, userID string) (string, error) { func (s *SessionStore) Create(ctx context.Context, userID string) (string, error) {
b := make([]byte, 32) b := make([]byte, 32)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate token: %w", err) return "", fmt.Errorf("generate token: %w", err)
} }
token := hex.EncodeToString(b) token := hex.EncodeToString(b)
tokenHash := hashToken(token)
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO sessions (user_id, token, expires_at) INSERT INTO sessions (user_id, token, expires_at)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
`, userID, token, time.Now().Add(s.cfg.Session.Duration)) `, userID, tokenHash, time.Now().Add(s.cfg.Session.Duration))
if err != nil { if err != nil {
return "", fmt.Errorf("insert session: %w", err) return "", fmt.Errorf("insert session: %w", err)
} }
@@ -43,7 +52,7 @@ func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (stri
err := s.db.QueryRowContext(ctx, ` err := s.db.QueryRowContext(ctx, `
SELECT user_id FROM sessions SELECT user_id FROM sessions
WHERE token = $1 AND expires_at > NOW() WHERE token = $1 AND expires_at > NOW()
`, token).Scan(&userID) `, hashToken(token)).Scan(&userID)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -51,6 +60,6 @@ func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (stri
} }
func (s *SessionStore) Delete(ctx context.Context, token string) error { func (s *SessionStore) Delete(ctx context.Context, token string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, token) _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, hashToken(token))
return err return err
} }
+6
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"time"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
@@ -24,6 +25,11 @@ func New(cfg *config.Config) (*DB, error) {
return nil, fmt.Errorf("ping database: %w", err) return nil, fmt.Errorf("ping database: %w", err)
} }
// ponytail: prevent unbounded connection growth under burst load
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(15 * time.Minute)
return &DB{db}, nil return &DB{db}, nil
} }
+25 -1
View File
@@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"time"
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
@@ -12,16 +13,21 @@ import (
type ipLimiter struct { type ipLimiter struct {
mu sync.Mutex mu sync.Mutex
limiters map[string]*rate.Limiter limiters map[string]*rate.Limiter
lastUsed map[string]time.Time
rate rate.Limit rate rate.Limit
burst int burst int
} }
func newIPLimiter(r rate.Limit, burst int) *ipLimiter { func newIPLimiter(r rate.Limit, burst int) *ipLimiter {
return &ipLimiter{ l := &ipLimiter{
limiters: make(map[string]*rate.Limiter), limiters: make(map[string]*rate.Limiter),
lastUsed: make(map[string]time.Time),
rate: r, rate: r,
burst: burst, burst: burst,
} }
// ponytail: evict inactive limiters every 5 minutes to prevent unbounded map growth
go l.cleanup()
return l
} }
func (l *ipLimiter) getLimiter(ip string) *rate.Limiter { func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
@@ -33,9 +39,27 @@ func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
lim = rate.NewLimiter(l.rate, l.burst) lim = rate.NewLimiter(l.rate, l.burst)
l.limiters[ip] = lim l.limiters[ip] = lim
} }
l.lastUsed[ip] = time.Now()
return lim return lim
} }
// cleanup runs in a background goroutine and removes limiters that haven't
// been accessed in 10 minutes.
func (l *ipLimiter) cleanup() {
for {
time.Sleep(5 * time.Minute)
l.mu.Lock()
now := time.Now()
for ip, last := range l.lastUsed {
if now.Sub(last) > 10*time.Minute {
delete(l.limiters, ip)
delete(l.lastUsed, ip)
}
}
l.mu.Unlock()
}
}
// RateLimit returns middleware that limits requests per IP. // RateLimit returns middleware that limits requests per IP.
// requestsPerSecond is the sustained rate, burst is the max burst size. // requestsPerSecond is the sustained rate, burst is the max burst size.
func RateLimit(requestsPerSecond float64, burst int) func(http.Handler) http.Handler { func RateLimit(requestsPerSecond float64, burst int) func(http.Handler) http.Handler {
+1 -1
View File
@@ -23,7 +23,7 @@
} }
], ],
"security": { "security": {
"csp": null "csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' wss: https:;"
} }
}, },
"bundle": { "bundle": {
+1 -1
View File
@@ -155,7 +155,7 @@ function markdownToHtml(md: string): string {
} }
function escapeHtml(s: string): string { function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
} }
function inlineMarkdownToHtml(text: string): string { function inlineMarkdownToHtml(text: string): string {