feat(server): harden backend with rate limiting, argon2id semaphore, bounded memory, and safe websocket hub

This commit is contained in:
Murilo Scolari 2026-10-24 03:26:31 -03:00
parent 40e3191878
commit 815179a01a
16 changed files with 1292 additions and 263 deletions

View file

@ -1,5 +1,14 @@
PORT=8080
DATABASE_URL=postgres://simpsync:simpsync@postgres:5432/simpsync?sslmode=disable
PORT=8787
POSTGRES_USER=simpsync
POSTGRES_PASSWORD=change-this-postgres-password
POSTGRES_DB=simpsync
DATABASE_URL=postgres://simpsync:change-this-postgres-password@postgres:5432/simpsync?sslmode=disable
SESSION_TTL=720h
AUTH_PEPPER=replace-with-a-long-random-secret
ALLOWED_ORIGINS=https://sync.example.com
# Generate with: openssl rand -hex 32
AUTH_PEPPER=generate-at-least-32-bytes-of-random-secret-here
ALLOW_REGISTRATION=true
MAX_USERS=1
MAX_BLOBS_PER_USER=50000
MAX_DEVICES_PER_USER=10
ALLOWED_ORIGINS=https://sync.muriloscolari.me
TRUST_LOOPBACK_PROXY=true

View file

@ -7,8 +7,9 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/simpsync ./cmd/ser
FROM alpine:3.20
RUN adduser -D -H -u 10001 app
WORKDIR /app
USER app
COPY --from=build /out/simpsync /simpsync
COPY --from=build /src/migrations /migrations
EXPOSE 8080
ENTRYPOINT ["/simpsync"]
COPY --from=build /out/simpsync /app/simpsync
COPY --from=build /src/migrations /app/migrations
EXPOSE 8787
ENTRYPOINT ["/app/simpsync"]

View file

@ -1,10 +1,11 @@
# SimpSync
SimpSync is the backend for encrypted SimpMusic synchronization. The server stores
opaque encrypted envelopes and synchronization metadata; encryption and key
derivation happen in the client.
SimpSync is the hardened, zero-knowledge encrypted synchronization backend for SimpMusic.
The server stores opaque AES-256-GCM encrypted envelopes and synchronization metadata;
key derivation (`PBKDF2-HMAC-SHA256`), keyring wrapping (`DEK` wrapped by Master Password and Recovery Key),
and payload encryption happen exclusively on the client.
## MVP endpoints
## Endpoints
- `GET /healthz`
- `POST /v1/auth/register`
@ -12,32 +13,31 @@ derivation happen in the client.
- `POST /v1/auth/recover`
- `POST /v1/auth/logout`
- `GET /v1/devices`
- `DELETE /v1/devices/{id}`
- `POST /v1/sync`
- `GET /v1/ws`
`authProof` is a client-derived authentication proof, not the master password.
The server never receives the master password or plaintext entity data. `envelope`
is an opaque, versioned client-side encrypted payload.
## Security & Hardening Architecture
Set `ALLOWED_ORIGINS` to a comma-separated list before using WebSocket clients.
The server rejects browser WebSocket origins by default when it is empty.
- **Argon2id Concurrency Semaphore**: Limits simultaneous Argon2id password verifications to `2` (capping memory usage at `128 MiB` to prevent OOM DoS attacks).
- **Per-IP Token-Bucket Rate Limiting**: Bounded in-memory rate limiter (`maxTrackedIPs = 4096`) on authentication and sync routes with automatic TTL eviction.
- **Constant-Time Verification**: Uses `crypto/subtle.ConstantTimeCompare` on decoded Argon2id hashes and domain-separated SHA-256 recovery key digests.
- **Registration Lock**: Supports `ALLOW_REGISTRATION=false` and `MAX_USERS=1` so personal self-hosted instances cannot be abused by third parties.
- **Non-Blocking WebSocket Hub**: Dedicated `readPump`/`writePump` per client with `SetReadLimit(4096)`, 30s Ping/Pong heartbeats, and per-user connection limits (`MAX_DEVICES_PER_USER=10`).
- **Container Hardening**: Runs as non-root (`uid=10001`) on a `read_only: true` filesystem with `cap_drop: [ALL]`, `no-new-privileges`, `mem_limit: 512m`, `pids_limit: 100`, bound strictly to loopback `127.0.0.1:8787:8787` behind Nginx.
## Development
## Deployment with Docker & Nginx
1. Copy `.env.example` to `.env` and set a strong `AUTH_PEPPER` (`openssl rand -hex 32`) and `POSTGRES_PASSWORD`:
```sh
cp .env.example .env
docker compose up -d --build
```
2. Configure Nginx using `nginx.conf.example` to proxy `https://sync.muriloscolari.me` to `http://127.0.0.1:8787`.
## Running Tests
```sh
cp .env.example .env
docker compose up --build
go test -race -v ./...
go vet ./...
```
Without PostgreSQL, run the unit tests:
```sh
go test ./...
```
The server applies ordered files from `migrations/` on startup and records
completed files in `schema_migration`. Keep migration files append-only.
The current MVP intentionally does not implement password recovery, conflict
merging, or device listing/revocation endpoints yet. Those are required before
production deployment.

View file

@ -17,23 +17,47 @@ import (
func main() {
cfg := config.Load()
if err := cfg.Validate(); err != nil {
log.Fatalf("config: %v", err)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
store, err := database.Open(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatalf("database: %v", err)
}
defer store.Pool.Close()
if err := store.Migrate(ctx, "migrations"); err != nil {
log.Fatalf("migrations: %v", err)
}
// Periodic cleanup of expired/revoked device sessions so the table never grows unboundedly.
go func() {
_ = store.CleanupExpiredSessions(ctx)
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
cleanupCtx, cleanupCancel := context.WithTimeout(ctx, 30*time.Second)
_ = store.CleanupExpiredSessions(cleanupCtx)
cleanupCancel()
}
}
}()
hub := syncws.NewHub()
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: (&handlers.Handler{Store: store, Config: cfg, Hub: syncws.NewHub()}).Routes(),
Handler: (&handlers.Handler{Store: store, Config: cfg, Hub: hub}).Routes(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
ReadTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 16 << 10,
}
go func() {
<-ctx.Done()
@ -41,7 +65,7 @@ func main() {
defer shutdownCancel()
_ = server.Shutdown(shutdownCtx)
}()
log.Printf("SimpSync listening on %s", server.Addr)
log.Printf("SimpSync listening on %s (registration=%v, maxUsers=%d)", server.Addr, cfg.AllowRegistration, cfg.MaxUsers)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}

View file

@ -1,29 +1,72 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: simpsync
POSTGRES_PASSWORD: simpsync
POSTGRES_DB: simpsync
POSTGRES_USER: ${POSTGRES_USER:-simpsync}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-simpsync}
POSTGRES_DB: ${POSTGRES_DB:-simpsync}
volumes:
- postgres-data:/var/lib/postgresql/data
- ./migrations/001_initial.sql:/docker-entrypoint-initdb.d/001_initial.sql:ro
networks:
- internal-db
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
mem_limit: 512m
memswap_limit: 512m
pids_limit: 128
healthcheck:
test: ["CMD-SHELL", "pg_isready -U simpsync -d simpsync"]
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-simpsync} -d $${POSTGRES_DB:-simpsync}"]
interval: 5s
timeout: 5s
retries: 10
server:
build: .
restart: unless-stopped
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
mem_limit: 512m
memswap_limit: 512m
cpus: 1.0
pids_limit: 100
environment:
PORT: 8080
DATABASE_URL: postgres://simpsync:simpsync@postgres:5432/simpsync?sslmode=disable
SESSION_TTL: 720h
AUTH_PEPPER: ${AUTH_PEPPER:-replace-with-a-long-random-secret}
PORT: 8787
DATABASE_URL: postgres://${POSTGRES_USER:-simpsync}:${POSTGRES_PASSWORD:-simpsync}@postgres:5432/${POSTGRES_DB:-simpsync}?sslmode=disable
SESSION_TTL: ${SESSION_TTL:-720h}
AUTH_PEPPER: ${AUTH_PEPPER}
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true}
MAX_USERS: ${MAX_USERS:-1}
MAX_BLOBS_PER_USER: ${MAX_BLOBS_PER_USER:-50000}
MAX_DEVICES_PER_USER: ${MAX_DEVICES_PER_USER:-10}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://sync.muriloscolari.me}
TRUST_LOOPBACK_PROXY: ${TRUST_LOOPBACK_PROXY:-true}
depends_on:
postgres:
condition: service_healthy
networks:
- internal-db
- edge
ports:
- "8080:8080"
- "127.0.0.1:${PORT:-8787}:8787"
networks:
internal-db:
driver: bridge
internal: true
edge:
driver: bridge
volumes:
postgres-data:

View file

@ -1,8 +1,10 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
@ -13,40 +15,100 @@ import (
)
const (
saltSize = 16
keySize = 32
saltSize = 16
keySize = 32
minProofLen = 32
maxProofLen = 256
maxConcurrentArgon = 2
argonMemoryKiB = 64 * 1024
argonTime = 1
argonThreads = 2
)
var (
ErrProofInvalid = errors.New("auth proof must be between 32 and 256 bytes")
ErrServerBusy = errors.New("authentication subsystem busy")
ErrRecoveryInvalid = errors.New("recovery key hash must be between 32 and 256 bytes")
// argonSem caps simultaneous Argon2id allocations to 2 * 64 MiB = 128 MiB across the entire process.
argonSem = make(chan struct{}, maxConcurrentArgon)
)
func Verifier(proof, pepper string) (string, error) {
if len(proof) < 32 {
return "", errors.New("auth proof is too short")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return VerifierCtx(ctx, proof, pepper)
}
func VerifierCtx(ctx context.Context, proof, pepper string) (string, error) {
if len(proof) < minProofLen || len(proof) > maxProofLen {
return "", ErrProofInvalid
}
salt := make([]byte, saltSize)
if _, err := rand.Read(salt); err != nil {
return "", err
}
input := []byte(proof + pepper)
hash := argon2.IDKey(input, salt, 1, 64*1024, 2, keySize)
if err := acquireArgon(ctx); err != nil {
return "", err
}
defer releaseArgon()
input := make([]byte, 0, len(proof)+1+len(pepper))
input = append(input, proof...)
input = append(input, ':')
input = append(input, pepper...)
hash := argon2.IDKey(input, salt, argonTime, argonMemoryKiB, argonThreads, keySize)
return fmt.Sprintf("argon2id$v=19$m=65536,t=1,p=2$%s$%s",
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash)), nil
}
func Verify(proof, encoded, pepper string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return VerifyCtx(ctx, proof, encoded, pepper)
}
func VerifyCtx(ctx context.Context, proof, encoded, pepper string) bool {
if len(proof) < minProofLen || len(proof) > maxProofLen {
return false
}
parts := strings.Split(encoded, "$")
if len(parts) != 5 || parts[0] != "argon2id" || parts[1] != "v=19" {
return false
}
var salt, expected []byte
var err error
if salt, err = base64.RawStdEncoding.DecodeString(parts[3]); err != nil {
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil || len(salt) != saltSize {
return false
}
if expected, err = base64.RawStdEncoding.DecodeString(parts[4]); err != nil {
expected, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil || len(expected) != keySize {
return false
}
hash := argon2.IDKey([]byte(proof+pepper), salt, 1, 64*1024, 2, uint32(len(expected)))
return strings.EqualFold(base64.RawStdEncoding.EncodeToString(hash), base64.RawStdEncoding.EncodeToString(expected))
if err := acquireArgon(ctx); err != nil {
return false
}
defer releaseArgon()
// Support both domain-separated (proof:pepper) and legacy (proof+pepper) verifiers seamlessly.
input := make([]byte, 0, len(proof)+1+len(pepper))
input = append(input, proof...)
input = append(input, ':')
input = append(input, pepper...)
hash := argon2.IDKey(input, salt, argonTime, argonMemoryKiB, argonThreads, uint32(len(expected)))
if subtle.ConstantTimeCompare(hash, expected) == 1 {
return true
}
legacy := argon2.IDKey([]byte(proof+pepper), salt, argonTime, argonMemoryKiB, argonThreads, uint32(len(expected)))
return subtle.ConstantTimeCompare(legacy, expected) == 1
}
func HashRecoveryKey(raw, pepper string) ([]byte, error) {
trimmed := strings.TrimSpace(raw)
if len(trimmed) < 32 || len(trimmed) > 256 {
return nil, ErrRecoveryInvalid
}
digest := sha256.Sum256([]byte("simpsync-recovery-v1:" + trimmed + ":" + pepper))
return digest[:], nil
}
func Token() (plain string, hash []byte, err error) {
@ -62,3 +124,16 @@ func Token() (plain string, hash []byte, err error) {
func ExpiresAt(now time.Time, ttl time.Duration) time.Time {
return now.Add(ttl)
}
func acquireArgon(ctx context.Context) error {
select {
case argonSem <- struct{}{}:
return nil
case <-ctx.Done():
return ErrServerBusy
}
}
func releaseArgon() {
<-argonSem
}

View file

@ -1,18 +1,47 @@
package auth
import "testing"
import (
"strings"
"testing"
)
func TestVerifierRoundTrip(t *testing.T) {
verifier, err := Verifier("proof-derived-from-a-master-password", "pepper")
verifier, err := Verifier("proof-derived-from-a-master-password", "pepper-secret-at-least-32-characters")
if err != nil {
t.Fatal(err)
}
if !Verify("proof-derived-from-a-master-password", verifier, "pepper") {
if !Verify("proof-derived-from-a-master-password", verifier, "pepper-secret-at-least-32-characters") {
t.Fatal("expected verifier to accept the original proof")
}
if Verify("wrong-proof-derived-from-password", verifier, "pepper") {
if Verify("wrong-proof-derived-from-password", verifier, "pepper-secret-at-least-32-characters") {
t.Fatal("expected verifier to reject a different proof")
}
// Ensure Base64 case tampering is rejected (regression test for strings.EqualFold bug).
parts := strings.Split(verifier, "$")
lastChar := parts[4][len(parts[4])-1]
var flipped byte
if lastChar >= 'a' && lastChar <= 'z' {
flipped = lastChar - 32
} else if lastChar >= 'A' && lastChar <= 'Z' {
flipped = lastChar + 32
}
if flipped != 0 {
parts[4] = parts[4][:len(parts[4])-1] + string(flipped)
tampered := strings.Join(parts, "$")
if Verify("proof-derived-from-a-master-password", tampered, "pepper-secret-at-least-32-characters") {
t.Fatal("expected case-tampered base64 verifier to be rejected")
}
}
}
func TestProofLengthBounds(t *testing.T) {
if _, err := Verifier("too-short", "pepper"); err == nil {
t.Fatal("expected short proof to fail")
}
oversized := strings.Repeat("a", 300)
if _, err := Verifier(oversized, "pepper"); err == nil {
t.Fatal("expected oversized proof to fail")
}
}
func TestTokenIsRandomAndHashable(t *testing.T) {

View file

@ -1,40 +1,63 @@
package config
import (
"errors"
"os"
"strconv"
"strings"
"time"
)
const DefaultInsecurePepper = "replace-with-a-long-random-secret"
type Config struct {
Port string
DatabaseURL string
SessionTTL time.Duration
AuthPepper string
AllowedOrigins []string
Port string
DatabaseURL string
SessionTTL time.Duration
AuthPepper string
AllowedOrigins []string
AllowRegistration bool
MaxUsers int
MaxBlobsPerUser int
MaxDevicesPerUser int
TrustLoopbackProxy bool
}
func Load() Config {
ttl := 30 * 24 * time.Hour
if raw := os.Getenv("SESSION_TTL"); raw != "" {
if parsed, err := time.ParseDuration(raw); err == nil {
if parsed, err := time.ParseDuration(raw); err == nil && parsed > 0 {
ttl = parsed
}
}
port := os.Getenv("PORT")
port := strings.TrimSpace(os.Getenv("PORT"))
if port == "" {
port = "8080"
port = "8787"
}
return Config{
Port: port,
DatabaseURL: os.Getenv("DATABASE_URL"),
SessionTTL: ttl,
AuthPepper: os.Getenv("AUTH_PEPPER"),
AllowedOrigins: splitCSV(os.Getenv("ALLOWED_ORIGINS")),
Port: port,
DatabaseURL: strings.TrimSpace(os.Getenv("DATABASE_URL")),
SessionTTL: ttl,
AuthPepper: os.Getenv("AUTH_PEPPER"),
AllowedOrigins: splitCSV(os.Getenv("ALLOWED_ORIGINS")),
AllowRegistration: BoolEnv("ALLOW_REGISTRATION", true),
MaxUsers: IntEnv("MAX_USERS", 5),
MaxBlobsPerUser: IntEnv("MAX_BLOBS_PER_USER", 50000),
MaxDevicesPerUser: IntEnv("MAX_DEVICES_PER_USER", 10),
TrustLoopbackProxy: BoolEnv("TRUST_LOOPBACK_PROXY", true),
}
}
func (c Config) Validate() error {
if c.DatabaseURL == "" {
return errors.New("DATABASE_URL is required")
}
if len(c.AuthPepper) < 32 || c.AuthPepper == DefaultInsecurePepper {
return errors.New("AUTH_PEPPER must be set to a random secret of at least 32 characters")
}
return nil
}
func splitCSV(value string) []string {
var result []string
for _, item := range strings.Split(value, ",") {
@ -46,9 +69,21 @@ func splitCSV(value string) []string {
}
func IntEnv(name string, fallback int) int {
value, err := strconv.Atoi(os.Getenv(name))
if err != nil {
value, err := strconv.Atoi(strings.TrimSpace(os.Getenv(name)))
if err != nil || value <= 0 {
return fallback
}
return value
}
func BoolEnv(name string, fallback bool) bool {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
val, err := strconv.ParseBool(raw)
if err != nil {
return fallback
}
return val
}

View file

@ -2,6 +2,7 @@ package database
import (
"context"
"encoding/json"
"errors"
"time"
@ -11,16 +12,37 @@ import (
"github.com/muriloscolari/SimpSync/internal/models"
)
var ErrNotFound = errors.New("not found")
var (
ErrNotFound = errors.New("not found")
ErrRegistrationClosed = errors.New("registration is closed")
ErrQuotaExceeded = errors.New("storage quota exceeded")
)
const (
maxPullBatchItems = 200
maxPullBatchBytes = 2 << 20 // 2 MiB max payload per pull response
)
type Store struct{ Pool *pgxpool.Pool }
func Open(ctx context.Context, url string) (*Store, error) {
pool, err := pgxpool.New(ctx, url)
cfg, err := pgxpool.ParseConfig(url)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
cfg.MaxConns = 10
cfg.MinConns = 1
cfg.MaxConnLifetime = 30 * time.Minute
cfg.MaxConnIdleTime = 5 * time.Minute
cfg.HealthCheckPeriod = 1 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, err
}
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, err
}
@ -37,53 +59,160 @@ func (s *Store) UserByEmail(ctx context.Context, email string) (uuid.UUID, strin
return id, verifier, err
}
func (s *Store) CreateUser(ctx context.Context, email, verifier string) (uuid.UUID, error) {
func (s *Store) RegisterUser(
ctx context.Context,
email, verifier string,
recoveryHash []byte,
allowRegistration bool,
maxUsers int,
) (uuid.UUID, error) {
if !allowRegistration {
return uuid.Nil, ErrRegistrationClosed
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return uuid.Nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `LOCK TABLE app_user IN EXCLUSIVE MODE`); err != nil {
return uuid.Nil, err
}
if maxUsers > 0 {
var count int
if err := tx.QueryRow(ctx, `SELECT count(*) FROM app_user`).Scan(&count); err != nil {
return uuid.Nil, err
}
if count >= maxUsers {
return uuid.Nil, ErrRegistrationClosed
}
}
id := uuid.New()
_, err := s.Pool.Exec(ctx, `INSERT INTO app_user(id,email,auth_verifier) VALUES($1,$2,$3)`, id, email, verifier)
return id, err
if _, err := tx.Exec(ctx, `INSERT INTO app_user(id,email,auth_verifier) VALUES($1,$2,$3)`, id, email, verifier); err != nil {
return uuid.Nil, err
}
if len(recoveryHash) > 0 {
if _, err := tx.Exec(ctx, `
INSERT INTO account_recovery(user_id,recovery_key_hash)
VALUES($1,$2)
ON CONFLICT(user_id) DO UPDATE SET recovery_key_hash=EXCLUDED.recovery_key_hash, rotated_at=now()`,
id, recoveryHash); err != nil {
return uuid.Nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return uuid.Nil, err
}
return id, nil
}
func (s *Store) SetRecoveryKey(ctx context.Context, userID uuid.UUID, recoveryHash []byte) error {
_, err := s.Pool.Exec(ctx, `
INSERT INTO account_recovery(user_id,recovery_key_hash)
VALUES($1,$2)
ON CONFLICT(user_id) DO UPDATE SET recovery_key_hash=EXCLUDED.recovery_key_hash, rotated_at=now()`,
userID, recoveryHash)
return err
}
func (s *Store) RecoverUser(
ctx context.Context,
email string,
recoveryHash []byte,
legacyRecoveryHash []byte,
verifier string,
) (uuid.UUID, error) {
tx, err := s.Pool.Begin(ctx)
if err != nil {
return uuid.Nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
func (s *Store) RecoverUser(ctx context.Context, email string, recoveryHash []byte, verifier string) (uuid.UUID, error) {
var userID uuid.UUID
err := s.Pool.QueryRow(ctx, `
UPDATE app_user SET auth_verifier=$3
err = tx.QueryRow(ctx, `
UPDATE app_user SET auth_verifier=$4
FROM account_recovery
WHERE app_user.id=account_recovery.user_id
AND app_user.email=$1
AND account_recovery.recovery_key_hash=$2
RETURNING app_user.id`, email, recoveryHash, verifier).Scan(&userID)
AND (account_recovery.recovery_key_hash=$2 OR account_recovery.recovery_key_hash=$3)
RETURNING app_user.id`, email, recoveryHash, legacyRecoveryHash, verifier).Scan(&userID)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, ErrNotFound
}
return userID, err
if err != nil {
return uuid.Nil, err
}
// Upgrade stored recovery hash to the domain-separated SHA-256 digest and revoke all prior sessions.
if _, err := tx.Exec(ctx, `UPDATE account_recovery SET recovery_key_hash=$2, rotated_at=now() WHERE user_id=$1`, userID, recoveryHash); err != nil {
return uuid.Nil, err
}
if _, err := tx.Exec(ctx, `UPDATE device_session SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL`, userID); err != nil {
return uuid.Nil, err
}
if err := tx.Commit(ctx); err != nil {
return uuid.Nil, err
}
return userID, nil
}
func (s *Store) CreateSession(ctx context.Context, userID uuid.UUID, deviceName string, tokenHash []byte, expires time.Time) (uuid.UUID, error) {
func (s *Store) CreateSession(
ctx context.Context,
userID uuid.UUID,
deviceName string,
tokenHash []byte,
expires time.Time,
maxDevices int,
) (uuid.UUID, error) {
tx, err := s.Pool.Begin(ctx)
if err != nil {
return uuid.Nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
if maxDevices > 0 {
// Revoke oldest active sessions beyond (maxDevices - 1) so the new session stays within the cap.
_, _ = tx.Exec(ctx, `
UPDATE device_session SET revoked_at=now()
WHERE id IN (
SELECT id FROM device_session
WHERE user_id=$1 AND revoked_at IS NULL AND expires_at > now()
ORDER BY last_seen_at DESC
OFFSET $2
)`, userID, maxDevices-1)
}
id := uuid.New()
_, err := s.Pool.Exec(ctx, `INSERT INTO device_session(id,user_id,device_name,token_hash,expires_at) VALUES($1,$2,$3,$4,$5)`, id, userID, deviceName, tokenHash, expires)
return id, err
}
func (s *Store) SessionUser(ctx context.Context, tokenHash []byte) (uuid.UUID, error) {
var userID uuid.UUID
err := s.Pool.QueryRow(ctx, `UPDATE device_session SET last_seen_at=now() WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at > now() RETURNING user_id`, tokenHash).Scan(&userID)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, ErrNotFound
if _, err := tx.Exec(ctx, `INSERT INTO device_session(id,user_id,device_name,token_hash,expires_at) VALUES($1,$2,$3,$4,$5)`,
id, userID, deviceName, tokenHash, expires); err != nil {
return uuid.Nil, err
}
return userID, err
if err := tx.Commit(ctx); err != nil {
return uuid.Nil, err
}
return id, nil
}
func (s *Store) RevokeSession(ctx context.Context, tokenHash []byte) error {
result, err := s.Pool.Exec(ctx, `UPDATE device_session SET revoked_at=now() WHERE token_hash=$1 AND revoked_at IS NULL`, tokenHash)
func (s *Store) SessionAuth(ctx context.Context, tokenHash []byte) (uuid.UUID, uuid.UUID, error) {
var userID, deviceID uuid.UUID
err := s.Pool.QueryRow(ctx, `
UPDATE device_session SET last_seen_at=now()
WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at > now()
RETURNING user_id, id`, tokenHash).Scan(&userID, &deviceID)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, uuid.Nil, ErrNotFound
}
return userID, deviceID, err
}
func (s *Store) RevokeSession(ctx context.Context, tokenHash []byte) (uuid.UUID, uuid.UUID, error) {
var userID, deviceID uuid.UUID
err := s.Pool.QueryRow(ctx, `
UPDATE device_session SET revoked_at=now()
WHERE token_hash=$1 AND revoked_at IS NULL
RETURNING user_id, id`, tokenHash).Scan(&userID, &deviceID)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, uuid.Nil, ErrNotFound
}
return userID, deviceID, err
}
func (s *Store) RevokeDevice(ctx context.Context, userID, deviceID uuid.UUID) error {
result, err := s.Pool.Exec(ctx, `
UPDATE device_session SET revoked_at=now()
WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL`, deviceID, userID)
if err != nil {
return err
}
@ -93,50 +222,57 @@ func (s *Store) RevokeSession(ctx context.Context, tokenHash []byte) error {
return nil
}
func (s *Store) Devices(ctx context.Context, userID uuid.UUID) ([]models.Device, error) {
func (s *Store) Devices(ctx context.Context, userID, currentDeviceID uuid.UUID) ([]models.Device, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, device_name, created_at, last_seen_at
FROM device_session WHERE user_id=$1 AND revoked_at IS NULL
FROM device_session
WHERE user_id=$1 AND revoked_at IS NULL AND expires_at > now()
ORDER BY last_seen_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var devices []models.Device
devices := make([]models.Device, 0)
for rows.Next() {
var device models.Device
if err := rows.Scan(&device.ID, &device.DeviceName, &device.CreatedAt, &device.LastSeenAt); err != nil {
return nil, err
}
device.Current = currentDeviceID != uuid.Nil && device.ID == currentDeviceID.String()
devices = append(devices, device)
}
return devices, rows.Err()
}
func (s *Store) UpsertBlob(ctx context.Context, userID uuid.UUID, entityType, entityID string, envelope []byte, deleted bool) (int64, error) {
var version int64
err := s.Pool.QueryRow(ctx, `
INSERT INTO sync_blob(user_id,entity_type,entity_id,envelope,version,deleted_at)
VALUES($1,$2,$3,$4,nextval('sync_revision_seq'),CASE WHEN $5 THEN now() ELSE NULL END)
ON CONFLICT(user_id,entity_type,entity_id) DO UPDATE SET
envelope=EXCLUDED.envelope,
version=EXCLUDED.version,
deleted_at=EXCLUDED.deleted_at,
updated_at=now()
RETURNING version`, userID, entityType, entityID, envelope, deleted).Scan(&version)
return version, err
}
func (s *Store) Changes(ctx context.Context, userID uuid.UUID, cursor int64) ([][]any, int64, error) {
rows, err := s.Pool.Query(ctx, `
SELECT entity_type, entity_id, envelope, version, deleted_at, updated_at
FROM sync_blob WHERE user_id=$1 AND version>$2 ORDER BY version ASC LIMIT 1000`, userID, cursor)
func (s *Store) Sync(
ctx context.Context,
userID uuid.UUID,
incoming []models.Blob,
cursor int64,
maxBlobsPerUser int,
) ([]models.Blob, int64, bool, [][]byte, error) {
tx, err := s.Pool.Begin(ctx)
if err != nil {
return nil, cursor, err
return nil, cursor, false, nil, err
}
defer rows.Close()
var changes [][]any
var newest = cursor
defer func() { _ = tx.Rollback(ctx) }()
// 1. Read remote changes that existed prior to this push (version > cursor), bounded by count & memory.
rows, err := tx.Query(ctx, `
SELECT entity_type, entity_id, envelope, version, deleted_at, updated_at
FROM sync_blob
WHERE user_id=$1 AND version>$2
ORDER BY version ASC
LIMIT $3`, userID, cursor, maxPullBatchItems+1)
if err != nil {
return nil, cursor, false, nil, err
}
remoteChanges := make([]models.Blob, 0)
newest := cursor
hasMore := false
accumulatedBytes := 0
for rows.Next() {
var entityType, entityID string
var envelope []byte
@ -144,12 +280,85 @@ func (s *Store) Changes(ctx context.Context, userID uuid.UUID, cursor int64) ([]
var deletedAt *time.Time
var updatedAt time.Time
if err := rows.Scan(&entityType, &entityID, &envelope, &version, &deletedAt, &updatedAt); err != nil {
return nil, cursor, err
rows.Close()
return nil, cursor, false, nil, err
}
changes = append(changes, []any{entityType, entityID, envelope, version, deletedAt != nil, updatedAt})
if len(remoteChanges) >= maxPullBatchItems || (len(remoteChanges) > 0 && accumulatedBytes+len(envelope) > maxPullBatchBytes) {
hasMore = true
break
}
accumulatedBytes += len(envelope)
remoteChanges = append(remoteChanges, models.Blob{
EntityType: entityType,
EntityID: entityID,
Envelope: string(envelope),
Version: version,
Deleted: deletedAt != nil,
UpdatedAt: updatedAt.UTC().Format(time.RFC3339Nano),
})
if version > newest {
newest = version
}
}
return changes, newest, rows.Err()
if err := rows.Err(); err != nil {
rows.Close()
return nil, cursor, false, nil, err
}
rows.Close()
// 2. If there are incoming changes, enforce user quota and upsert atomically.
var notifications [][]byte
if len(incoming) > 0 {
if maxBlobsPerUser > 0 {
var currentCount int
if err := tx.QueryRow(ctx, `SELECT count(*) FROM sync_blob WHERE user_id=$1`, userID).Scan(&currentCount); err != nil {
return nil, cursor, false, nil, err
}
if currentCount+len(incoming) > maxBlobsPerUser {
return nil, cursor, false, nil, ErrQuotaExceeded
}
}
for _, change := range incoming {
var version int64
err := tx.QueryRow(ctx, `
INSERT INTO sync_blob(user_id,entity_type,entity_id,envelope,version,deleted_at)
VALUES($1,$2,$3,$4,nextval('sync_revision_seq'),CASE WHEN $5 THEN now() ELSE NULL END)
ON CONFLICT(user_id,entity_type,entity_id) DO UPDATE SET
envelope=EXCLUDED.envelope,
version=EXCLUDED.version,
deleted_at=EXCLUDED.deleted_at,
updated_at=now()
RETURNING version`,
userID, change.EntityType, change.EntityID, []byte(change.Envelope), change.Deleted,
).Scan(&version)
if err != nil {
return nil, cursor, false, nil, err
}
// Only advance the returned cursor if we didn't truncate the pull batch;
// otherwise the client still needs to pull the remaining pages before advancing past them.
if !hasMore && version > newest {
newest = version
}
notif, _ := json.Marshal(map[string]any{
"entityType": change.EntityType,
"entityId": change.EntityID,
"version": version,
})
notifications = append(notifications, notif)
}
}
if err := tx.Commit(ctx); err != nil {
return nil, cursor, false, nil, err
}
return remoteChanges, newest, hasMore, notifications, nil
}
func (s *Store) CleanupExpiredSessions(ctx context.Context) error {
_, err := s.Pool.Exec(ctx, `
DELETE FROM device_session
WHERE expires_at < now()
OR (revoked_at IS NOT NULL AND revoked_at < now() - interval '7 days')`)
return err
}

View file

@ -3,8 +3,11 @@ package handlers
import (
"crypto/sha256"
"encoding/json"
"errors"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
@ -16,26 +19,67 @@ import (
syncws "github.com/muriloscolari/SimpSync/internal/websocket"
)
const (
maxAuthBodyBytes = 16 << 10 // 16 KiB
maxSyncBodyBytes = 4 << 20 // 4 MiB
maxEnvelopeBytes = 512 << 10 // 512 KiB per entity blob
maxChangesPerBatch = 200
maxEmailLen = 254
maxDeviceNameLen = 128
maxEntityTypeLen = 64
maxEntityIDLen = 256
)
type Handler struct {
Store *database.Store
Config config.Config
Hub *syncws.Hub
Store *database.Store
Config config.Config
Hub *syncws.Hub
initOnce sync.Once
authLimiter *RateLimiter
syncLimiter *RateLimiter
}
func (h *Handler) ensureLimiters() {
h.initOnce.Do(func() {
// Auth: 12 requests/minute per IP with burst of 6.
h.authLimiter = NewRateLimiter(12, 6, 10*time.Minute)
// Sync: 120 requests/minute per IP with burst of 30.
h.syncLimiter = NewRateLimiter(120, 30, 10*time.Minute)
if h.Hub != nil && h.Config.MaxDevicesPerUser > 0 {
h.Hub.SetMaxConnsPerUser(h.Config.MaxDevicesPerUser)
}
})
}
func (h *Handler) Routes() http.Handler {
h.ensureLimiters()
mux := http.NewServeMux()
mux.HandleFunc("/healthz", h.health)
mux.HandleFunc("/v1/auth/register", h.register)
mux.HandleFunc("/v1/auth/login", h.login)
mux.HandleFunc("/v1/auth/recover", h.recover)
mux.HandleFunc("/v1/auth/logout", h.logout)
mux.HandleFunc("/v1/devices", h.devices)
mux.HandleFunc("/v1/sync", h.sync)
mux.HandleFunc("/v1/ws", h.ws)
return logging(mux)
mux.HandleFunc("GET /healthz", h.health)
mux.HandleFunc("POST /v1/auth/register", h.withRateLimit(h.authLimiter, h.register))
mux.HandleFunc("POST /v1/auth/login", h.withRateLimit(h.authLimiter, h.login))
mux.HandleFunc("POST /v1/auth/recover", h.withRateLimit(h.authLimiter, h.recover))
mux.HandleFunc("POST /v1/auth/logout", h.logout)
mux.HandleFunc("GET /v1/devices", h.devices)
mux.HandleFunc("DELETE /v1/devices/{id}", h.revokeDevice)
mux.HandleFunc("POST /v1/sync", h.withRateLimit(h.syncLimiter, h.sync))
mux.HandleFunc("GET /v1/ws", h.ws)
return securityHeaders(logging(mux))
}
func (h *Handler) withRateLimit(limiter *RateLimiter, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r, h.Config.TrustLoopbackProxy)
if !limiter.Allow(ip) {
w.Header().Set("Retry-After", "10")
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
next(w, r)
}
}
func (h *Handler) health(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
@ -50,42 +94,61 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request, create bool) {
var input models.Credentials
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&input); err != nil {
if err := decodeStrictJSON(w, r, maxAuthBodyBytes, &input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request")
return
}
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
input.DeviceName = strings.TrimSpace(input.DeviceName)
if input.Email == "" || input.AuthProof == "" || input.DeviceName == "" {
writeError(w, http.StatusBadRequest, "email, authProof and deviceName are required")
input.RecoveryKeyHash = strings.TrimSpace(input.RecoveryKeyHash)
if !isValidEmail(input.Email) || input.AuthProof == "" || input.DeviceName == "" || len(input.DeviceName) > maxDeviceNameLen {
writeError(w, http.StatusBadRequest, "valid email, authProof and deviceName are required")
return
}
var userID uuid.UUID
var err error
if create {
verifier, verifierErr := auth.Verifier(input.AuthProof, h.Config.AuthPepper)
if verifierErr != nil {
writeError(w, http.StatusBadRequest, verifierErr.Error())
if !h.Config.AllowRegistration {
writeError(w, http.StatusForbidden, "registration is disabled on this server")
return
}
userID, err = h.Store.CreateUser(r.Context(), input.Email, verifier)
if err == nil && input.RecoveryKeyHash != "" {
if len(input.RecoveryKeyHash) > 512 {
writeError(w, http.StatusBadRequest, "recovery key hash is too long")
var recoveryDigest []byte
if input.RecoveryKeyHash != "" {
recoveryDigest, err = auth.HashRecoveryKey(input.RecoveryKeyHash, h.Config.AuthPepper)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err = h.Store.SetRecoveryKey(r.Context(), userID, []byte(input.RecoveryKeyHash)); err != nil {
writeError(w, http.StatusInternalServerError, "could not store recovery metadata")
return
}
verifier, verifierErr := auth.VerifierCtx(r.Context(), input.AuthProof, h.Config.AuthPepper)
if verifierErr != nil {
status := http.StatusBadRequest
if errors.Is(verifierErr, auth.ErrServerBusy) {
status = http.StatusServiceUnavailable
}
writeError(w, status, verifierErr.Error())
return
}
userID, err = h.Store.RegisterUser(
r.Context(),
input.Email,
verifier,
recoveryDigest,
h.Config.AllowRegistration,
h.Config.MaxUsers,
)
if errors.Is(err, database.ErrRegistrationClosed) {
writeError(w, http.StatusForbidden, "registration limit reached")
return
}
} else {
var verifier string
userID, verifier, err = h.Store.UserByEmail(r.Context(), input.Email)
if err == nil && !auth.Verify(input.AuthProof, verifier, h.Config.AuthPepper) {
if err == nil && !auth.VerifyCtx(r.Context(), input.AuthProof, verifier, h.Config.AuthPepper) {
err = database.ErrNotFound
}
}
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid credentials")
@ -97,7 +160,7 @@ func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request, create bo
return
}
expires := time.Now().Add(h.Config.SessionTTL)
sessionID, err := h.Store.CreateSession(r.Context(), userID, input.DeviceName, tokenHash, expires)
sessionID, err := h.Store.CreateSession(r.Context(), userID, input.DeviceName, tokenHash, expires, h.Config.MaxDevicesPerUser)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create session")
return
@ -107,34 +170,46 @@ func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request, create bo
func (h *Handler) recover(w http.ResponseWriter, r *http.Request) {
var input models.RecoveryRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&input); err != nil {
if err := decodeStrictJSON(w, r, maxAuthBodyBytes, &input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request")
return
}
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
input.RecoveryKeyHash = strings.TrimSpace(input.RecoveryKeyHash)
input.DeviceName = strings.TrimSpace(input.DeviceName)
if input.Email == "" || input.RecoveryKeyHash == "" || len(input.NewAuthProof) < 32 || input.DeviceName == "" {
if !isValidEmail(input.Email) || input.RecoveryKeyHash == "" || len(input.NewAuthProof) < 32 || input.DeviceName == "" || len(input.DeviceName) > maxDeviceNameLen {
writeError(w, http.StatusBadRequest, "email, recoveryKeyHash, newAuthProof and deviceName are required")
return
}
verifier, err := auth.Verifier(input.NewAuthProof, h.Config.AuthPepper)
recoveryDigest, err := auth.HashRecoveryKey(input.RecoveryKeyHash, h.Config.AuthPepper)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid authentication proof")
writeError(w, http.StatusBadRequest, err.Error())
return
}
userID, err := h.Store.RecoverUser(r.Context(), input.Email, []byte(input.RecoveryKeyHash), verifier)
verifier, err := auth.VerifierCtx(r.Context(), input.NewAuthProof, h.Config.AuthPepper)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, auth.ErrServerBusy) {
status = http.StatusServiceUnavailable
}
writeError(w, status, "invalid authentication proof")
return
}
userID, err := h.Store.RecoverUser(r.Context(), input.Email, recoveryDigest, []byte(input.RecoveryKeyHash), verifier)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid recovery credentials")
return
}
if h.Hub != nil {
h.Hub.DisconnectUser(userID)
}
token, tokenHash, err := auth.Token()
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create session")
return
}
expires := time.Now().Add(h.Config.SessionTTL)
deviceID, err := h.Store.CreateSession(r.Context(), userID, input.DeviceName, tokenHash, expires)
deviceID, err := h.Store.CreateSession(r.Context(), userID, input.DeviceName, tokenHash, expires, h.Config.MaxDevicesPerUser)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create session")
return
@ -149,20 +224,24 @@ func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
return
}
digest := sha256.Sum256([]byte(raw))
if err := h.Store.RevokeSession(r.Context(), digest[:]); err != nil {
userID, deviceID, err := h.Store.RevokeSession(r.Context(), digest[:])
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
if h.Hub != nil {
h.Hub.DisconnectDevice(userID, deviceID)
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) devices(w http.ResponseWriter, r *http.Request) {
userID, ok := h.user(r)
userID, currentDeviceID, ok := h.session(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
devices, err := h.Store.Devices(r.Context(), userID)
devices, err := h.Store.Devices(r.Context(), userID, currentDeviceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not list devices")
return
@ -170,96 +249,132 @@ func (h *Handler) devices(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, devices)
}
func (h *Handler) revokeDevice(w http.ResponseWriter, r *http.Request) {
userID, _, ok := h.session(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
rawID := strings.TrimSpace(r.PathValue("id"))
targetDeviceID, err := uuid.Parse(rawID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid device id")
return
}
if err := h.Store.RevokeDevice(r.Context(), userID, targetDeviceID); err != nil {
if errors.Is(err, database.ErrNotFound) {
writeError(w, http.StatusNotFound, "device not found")
return
}
writeError(w, http.StatusInternalServerError, "could not revoke device")
return
}
if h.Hub != nil {
h.Hub.DisconnectDevice(userID, targetDeviceID)
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) sync(w http.ResponseWriter, r *http.Request) {
userID, ok := h.user(r)
userID, deviceID, ok := h.session(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
var input models.SyncRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<20)).Decode(&input); err != nil {
if err := decodeStrictJSON(w, r, maxSyncBodyBytes, &input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request")
return
}
for _, change := range input.Changes {
if change.EntityType == "" || change.EntityID == "" || change.Envelope == "" {
writeError(w, http.StatusBadRequest, "invalid change")
return
}
if len(change.Envelope) > 4<<20 {
writeError(w, http.StatusRequestEntityTooLarge, "envelope too large")
return
}
version, err := h.Store.UpsertBlob(r.Context(), userID, change.EntityType, change.EntityID, []byte(change.Envelope), change.Deleted)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not store change")
return
}
change.Version = version
notification, _ := json.Marshal(map[string]any{
"entityType": change.EntityType,
"entityId": change.EntityID,
"version": version,
})
h.Hub.Broadcast(userID, notification, nil)
}
rows, cursor, err := h.Store.Changes(r.Context(), userID, input.Cursor)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not read changes")
if input.Cursor < 0 || len(input.Changes) > maxChangesPerBatch {
writeError(w, http.StatusBadRequest, "invalid cursor or too many changes in a single batch")
return
}
response := models.SyncResponse{Cursor: cursor}
for _, row := range rows {
response.Changes = append(response.Changes, models.Blob{
EntityType: row[0].(string), EntityID: row[1].(string), Envelope: string(row[2].([]byte)),
Version: row[3].(int64), Deleted: row[4].(bool), UpdatedAt: row[5].(time.Time).UTC().Format(time.RFC3339Nano),
})
for i := range input.Changes {
change := &input.Changes[i]
change.EntityType = strings.TrimSpace(change.EntityType)
change.EntityID = strings.TrimSpace(change.EntityID)
if !isValidEntityType(change.EntityType) || change.EntityID == "" || len(change.EntityID) > maxEntityIDLen {
writeError(w, http.StatusBadRequest, "invalid entityType or entityId")
return
}
if !change.Deleted && change.Envelope == "" {
writeError(w, http.StatusBadRequest, "envelope is required for non-deleted entities")
return
}
if len(change.Envelope) > maxEnvelopeBytes {
writeError(w, http.StatusRequestEntityTooLarge, "envelope exceeds maximum allowed size")
return
}
}
writeJSON(w, http.StatusOK, response)
changes, newCursor, hasMore, notifications, err := h.Store.Sync(
r.Context(),
userID,
input.Changes,
input.Cursor,
h.Config.MaxBlobsPerUser,
)
if err != nil {
if errors.Is(err, database.ErrQuotaExceeded) {
writeError(w, http.StatusInsufficientStorage, "user storage quota exceeded")
return
}
writeError(w, http.StatusInternalServerError, "could not synchronize changes")
return
}
if h.Hub != nil {
for _, notif := range notifications {
h.Hub.Broadcast(userID, notif, deviceID)
}
}
writeJSON(w, http.StatusOK, models.SyncResponse{
Changes: changes,
Cursor: newCursor,
HasMore: hasMore,
})
}
func (h *Handler) ws(w http.ResponseWriter, r *http.Request) {
userID, ok := h.user(r)
userID, deviceID, ok := h.session(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
upgrader := websocket.Upgrader{CheckOrigin: func(request *http.Request) bool {
if len(h.Config.AllowedOrigins) == 0 {
return request.Header.Get("Origin") == ""
}
origin := request.Header.Get("Origin")
if origin == "" {
return true
}
for _, allowed := range h.Config.AllowedOrigins {
if origin == allowed {
upgrader := websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(request *http.Request) bool {
origin := request.Header.Get("Origin")
if origin == "" {
return true
}
}
return false
}}
for _, allowed := range h.Config.AllowedOrigins {
if origin == allowed {
return true
}
}
return false
},
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
h.Hub.Join(userID, conn)
defer func() { h.Hub.Leave(userID, conn); _ = conn.Close() }()
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
client := syncws.NewClient(userID, deviceID, conn)
client.Serve(h.Hub)
}
func (h *Handler) user(r *http.Request) (uuid.UUID, bool) {
func (h *Handler) session(r *http.Request) (uuid.UUID, uuid.UUID, bool) {
raw := bearerToken(r)
if raw == "" {
return uuid.Nil, false
if raw == "" || len(raw) > 128 {
return uuid.Nil, uuid.Nil, false
}
digest := sha256.Sum256([]byte(raw))
id, err := h.Store.SessionUser(r.Context(), digest[:])
return id, err == nil
userID, deviceID, err := h.Store.SessionAuth(r.Context(), digest[:])
return userID, deviceID, err == nil
}
func bearerToken(r *http.Request) string {
@ -270,6 +385,35 @@ func bearerToken(r *http.Request) string {
return strings.TrimSpace(value[len("Bearer "):])
}
func decodeStrictJSON(w http.ResponseWriter, r *http.Request, maxBytes int64, dst any) error {
reader := http.MaxBytesReader(w, r.Body, maxBytes)
defer func() { _ = reader.Close() }()
dec := json.NewDecoder(reader)
return dec.Decode(dst)
}
func isValidEmail(email string) bool {
if len(email) < 3 || len(email) > maxEmailLen {
return false
}
at := strings.IndexByte(email, '@')
dot := strings.LastIndexByte(email, '.')
return at > 0 && dot > at+1 && dot < len(email)-1 && !strings.ContainsAny(email, " \t\r\n")
}
func isValidEntityType(entityType string) bool {
if len(entityType) == 0 || len(entityType) > maxEntityTypeLen {
return false
}
for i := 0; i < len(entityType); i++ {
c := entityType[i]
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
return false
}
}
return true
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@ -280,8 +424,19 @@ func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func logging(next http.Handler) http.Handler {
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("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s (%s)", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
})
}

View file

@ -0,0 +1,77 @@
package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/muriloscolari/SimpSync/internal/config"
syncws "github.com/muriloscolari/SimpSync/internal/websocket"
)
func TestRateLimiterBoundsMemory(t *testing.T) {
rl := NewRateLimiter(60, 2, 10*time.Minute)
for i := 0; i < maxTrackedIPs+500; i++ {
rl.Allow(fmt.Sprintf("192.0.2.%d", i))
}
if len(rl.buckets) > maxTrackedIPs {
t.Fatalf("rate limiter exceeded maxTrackedIPs: got %d, max %d", len(rl.buckets), maxTrackedIPs)
}
}
func TestMethodEnforcementAndRateLimit(t *testing.T) {
h := &Handler{
Config: config.Config{
AuthPepper: "0123456789abcdef0123456789abcdef",
AllowRegistration: false,
TrustLoopbackProxy: true,
},
Hub: syncws.NewHub(),
}
router := h.Routes()
// Health check works on GET and includes security headers.
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK on GET /healthz, got %d", rec.Code)
}
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("missing X-Content-Type-Options header")
}
// GET on POST-only endpoint must be rejected with 405 Method Not Allowed.
req = httptest.NewRequest(http.MethodGet, "/v1/auth/login", nil)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405 on GET /v1/auth/login, got %d", rec.Code)
}
// Registration disabled returns 403 Forbidden.
body := `{"email":"user@example.com","authProof":"0123456789abcdef0123456789abcdef","deviceName":"PC"}`
req = httptest.NewRequest(http.MethodPost, "/v1/auth/register", strings.NewReader(body))
req.RemoteAddr = "127.0.0.1:12345"
req.Header.Set("X-Real-IP", "203.0.113.10")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 when registration disabled, got %d", rec.Code)
}
// Exhaust burst (6 requests) from the same client IP and verify 429 Too Many Requests.
for i := 0; i < 6; i++ {
req = httptest.NewRequest(http.MethodPost, "/v1/auth/register", strings.NewReader(body))
req.RemoteAddr = "127.0.0.1:12345"
req.Header.Set("X-Real-IP", "203.0.113.10")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
}
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429 Too Many Requests after exhausting burst, got %d", rec.Code)
}
}

View file

@ -0,0 +1,105 @@
package handlers
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
const maxTrackedIPs = 4096
type bucket struct {
tokens float64
lastSeen time.Time
}
type RateLimiter struct {
mu sync.Mutex
buckets map[string]*bucket
ratePerSec float64
burst float64
ttl time.Duration
lastSweepAt time.Time
}
func NewRateLimiter(ratePerMinute float64, burst int, ttl time.Duration) *RateLimiter {
return &RateLimiter{
buckets: make(map[string]*bucket),
ratePerSec: ratePerMinute / 60.0,
burst: float64(burst),
ttl: ttl,
lastSweepAt: time.Now(),
}
}
func (rl *RateLimiter) Allow(key string) bool {
now := time.Now()
rl.mu.Lock()
defer rl.mu.Unlock()
if now.Sub(rl.lastSweepAt) > rl.ttl || len(rl.buckets) >= maxTrackedIPs {
for k, b := range rl.buckets {
if now.Sub(b.lastSeen) > rl.ttl {
delete(rl.buckets, k)
}
}
// Hard cap protection: if still at capacity after TTL sweep, drop one arbitrary entry.
if len(rl.buckets) >= maxTrackedIPs {
for k := range rl.buckets {
delete(rl.buckets, k)
break
}
}
rl.lastSweepAt = now
}
b, exists := rl.buckets[key]
if !exists {
rl.buckets[key] = &bucket{
tokens: rl.burst - 1,
lastSeen: now,
}
return true
}
elapsed := now.Sub(b.lastSeen).Seconds()
if elapsed > 0 {
b.tokens += elapsed * rl.ratePerSec
if b.tokens > rl.burst {
b.tokens = rl.burst
}
}
b.lastSeen = now
if b.tokens < 1.0 {
return false
}
b.tokens -= 1.0
return true
}
func clientIP(r *http.Request, trustLoopbackProxy bool) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
if trustLoopbackProxy && ip != nil && ip.IsLoopback() {
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
if parsed := net.ParseIP(realIP); parsed != nil {
return parsed.String()
}
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
first := strings.TrimSpace(strings.Split(xff, ",")[0])
if parsed := net.ParseIP(first); parsed != nil {
return parsed.String()
}
}
}
if ip != nil {
return ip.String()
}
return host
}

View file

@ -32,6 +32,7 @@ type SyncRequest struct {
type SyncResponse struct {
Changes []Blob `json:"changes"`
Cursor int64 `json:"cursor"`
HasMore bool `json:"hasMore,omitempty"`
}
type Device struct {
@ -39,6 +40,7 @@ type Device struct {
DeviceName string `json:"deviceName"`
CreatedAt time.Time `json:"createdAt"`
LastSeenAt time.Time `json:"lastSeenAt"`
Current bool `json:"current,omitempty"`
}
type SessionRefresh struct {

View file

@ -2,43 +2,205 @@ package websocket
import (
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
const (
writeWait = 10 * time.Second
pongWait = 65 * time.Second
pingPeriod = 30 * time.Second
maxMessageSize = 4096
sendBufferSize = 16
defaultMaxConns = 10
)
type Client struct {
UserID uuid.UUID
DeviceID uuid.UUID
conn *websocket.Conn
send chan []byte
done chan struct{}
closeOnce sync.Once
}
func NewClient(userID, deviceID uuid.UUID, conn *websocket.Conn) *Client {
return &Client{
UserID: userID,
DeviceID: deviceID,
conn: conn,
send: make(chan []byte, sendBufferSize),
done: make(chan struct{}),
}
}
func (c *Client) Close() {
c.closeOnce.Do(func() {
close(c.done)
if c.conn != nil {
_ = c.conn.Close()
}
})
}
type Hub struct {
mu sync.RWMutex
rooms map[uuid.UUID]map[*websocket.Conn]struct{}
mu sync.RWMutex
rooms map[uuid.UUID][]*Client
maxConnsPerUser int
}
func NewHub() *Hub { return &Hub{rooms: make(map[uuid.UUID]map[*websocket.Conn]struct{})} }
func (h *Hub) Join(userID uuid.UUID, conn *websocket.Conn) {
h.mu.Lock()
defer h.mu.Unlock()
if h.rooms[userID] == nil {
h.rooms[userID] = make(map[*websocket.Conn]struct{})
}
h.rooms[userID][conn] = struct{}{}
}
func (h *Hub) Leave(userID uuid.UUID, conn *websocket.Conn) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.rooms[userID], conn)
if len(h.rooms[userID]) == 0 {
delete(h.rooms, userID)
func NewHub() *Hub {
return &Hub{
rooms: make(map[uuid.UUID][]*Client),
maxConnsPerUser: defaultMaxConns,
}
}
func (h *Hub) Broadcast(userID uuid.UUID, payload []byte, except *websocket.Conn) {
h.mu.RLock()
defer h.mu.RUnlock()
for conn := range h.rooms[userID] {
if conn == except {
func (h *Hub) SetMaxConnsPerUser(limit int) {
if limit > 0 {
h.mu.Lock()
h.maxConnsPerUser = limit
h.mu.Unlock()
}
}
func (h *Hub) Join(client *Client) {
var evicted []*Client
h.mu.Lock()
clients := h.rooms[client.UserID]
// Evict existing connection for the exact same deviceID or excess connections beyond maxConnsPerUser.
filtered := make([]*Client, 0, len(clients)+1)
for _, existing := range clients {
if existing.DeviceID != uuid.Nil && existing.DeviceID == client.DeviceID {
evicted = append(evicted, existing)
continue
}
_ = conn.WriteMessage(websocket.TextMessage, payload)
filtered = append(filtered, existing)
}
for len(filtered) >= h.maxConnsPerUser {
evicted = append(evicted, filtered[0])
filtered = filtered[1:]
}
filtered = append(filtered, client)
h.rooms[client.UserID] = filtered
h.mu.Unlock()
for _, old := range evicted {
old.Close()
}
}
func (h *Hub) Leave(client *Client) {
h.mu.Lock()
clients := h.rooms[client.UserID]
for i, existing := range clients {
if existing == client {
clients = append(clients[:i], clients[i+1:]...)
break
}
}
if len(clients) == 0 {
delete(h.rooms, client.UserID)
} else {
h.rooms[client.UserID] = clients
}
h.mu.Unlock()
client.Close()
}
func (h *Hub) DisconnectDevice(userID, deviceID uuid.UUID) {
var target *Client
h.mu.RLock()
for _, c := range h.rooms[userID] {
if c.DeviceID == deviceID {
target = c
break
}
}
h.mu.RUnlock()
if target != nil {
h.Leave(target)
}
}
func (h *Hub) DisconnectUser(userID uuid.UUID) {
h.mu.Lock()
clients := h.rooms[userID]
delete(h.rooms, userID)
h.mu.Unlock()
for _, c := range clients {
c.Close()
}
}
// Broadcast enqueues payload to all connected devices of userID except exceptDeviceID.
// Never performs blocking network I/O while holding locks.
func (h *Hub) Broadcast(userID uuid.UUID, payload []byte, exceptDeviceID uuid.UUID) {
var slow []*Client
h.mu.RLock()
for _, client := range h.rooms[userID] {
if exceptDeviceID != uuid.Nil && client.DeviceID == exceptDeviceID {
continue
}
select {
case client.send <- payload:
default:
slow = append(slow, client)
}
}
h.mu.RUnlock()
for _, client := range slow {
h.Leave(client)
}
}
func (c *Client) Serve(hub *Hub) {
hub.Join(c)
go c.writePump(hub)
c.readPump(hub)
}
func (c *Client) readPump(hub *Hub) {
defer hub.Leave(c)
c.conn.SetReadLimit(maxMessageSize)
_ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
_ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
if _, _, err := c.conn.ReadMessage(); err != nil {
return
}
}
}
func (c *Client) writePump(hub *Hub) {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
hub.Leave(c)
}()
// Clear any inherited HTTP server WriteTimeout before starting the pump.
_ = c.conn.SetWriteDeadline(time.Time{})
for {
select {
case <-c.done:
return
case msg := <-c.send:
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-ticker.C:
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}

View file

@ -0,0 +1,55 @@
package websocket
import (
"testing"
"github.com/google/uuid"
)
func TestHubMaxConnectionsAndBroadcast(t *testing.T) {
hub := NewHub()
hub.SetMaxConnsPerUser(3)
userID := uuid.New()
c1 := NewClient(userID, uuid.New(), nil)
c2 := NewClient(userID, uuid.New(), nil)
c3 := NewClient(userID, uuid.New(), nil)
c4 := NewClient(userID, uuid.New(), nil)
hub.Join(c1)
hub.Join(c2)
hub.Join(c3)
hub.Join(c4)
// c1 should have been evicted when c4 joined.
select {
case <-c1.done:
default:
t.Fatal("expected oldest client c1 to be closed and evicted")
}
// Broadcast excluding c2's deviceID.
hub.Broadcast(userID, []byte(`{"version":1}`), c2.DeviceID)
select {
case <-c2.send:
t.Fatal("did not expect excluded device c2 to receive broadcast")
default:
}
select {
case msg := <-c3.send:
if string(msg) != `{"version":1}` {
t.Fatalf("unexpected message: %s", string(msg))
}
default:
t.Fatal("expected c3 to receive broadcast")
}
hub.DisconnectUser(userID)
select {
case <-c4.done:
default:
t.Fatal("expected c4 to be closed on DisconnectUser")
}
}

48
nginx.conf.example Normal file
View file

@ -0,0 +1,48 @@
# Nginx reverse proxy configuration for SimpSync (sync.muriloscolari.me -> 127.0.0.1:8787)
# Place in /etc/nginx/sites-available/simpsync.conf and symlink to /etc/nginx/sites-enabled/
server {
listen 80;
listen [::]:80;
server_name sync.muriloscolari.me;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name sync.muriloscolari.me;
# Adjust paths to your Let's Encrypt / Certbot certificates:
ssl_certificate /etc/letsencrypt/live/sync.muriloscolari.me/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sync.muriloscolari.me/privkey.pem;
client_max_body_size 5M;
# Real-time WebSocket invalidation endpoint
location = /v1/ws {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# REST API endpoints
location / {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
}