readysite / docs / security.md
6.5 KB
security.md

Security

Security patterns and best practices for ReadySite applications.

Authentication

JWT Sessions

ReadySite uses HS256-signed JWT tokens stored in HTTP-only cookies.

// Cookie configuration
http.Cookie{
    Name:     "session",
    HttpOnly: true,                    // No JavaScript access
    Secure:   isSecure(r),             // HTTPS in production
    SameSite: http.SameSiteLaxMode,    // CSRF protection
    MaxAge:   30 days,
}

AUTH_SECRET is required for JWT signing:

Environment Behavior
Development Falls back to "dev-only-not-for-production" with warning
Production (ENV=production) Fatal error if AUTH_SECRET is missing or "change-me-in-production"
# Generate a strong secret
openssl rand -hex 32

Password Hashing

The website CMS uses bcrypt with default cost (12 rounds):

import "github.com/readysite/readysite/website/internal/access"

hash, err := access.HashPassword(password)      // bcrypt hash
ok := access.CheckPassword(password, hash)       // bcrypt verify

Error messages are generic ("Invalid email or password") to prevent user enumeration.

Magic Link Tokens (Hosting)

The hosting app uses passwordless authentication:

  1. User enters email, a token is generated
  2. Token is SHA-256 hashed before storage
  3. Plaintext token is sent via email link
  4. On verification, an atomic UPDATE claims the token (prevents TOCTOU races)
  5. Tokens expire after 10 minutes and are single-use
// Atomic token claiming prevents race conditions
result, err := models.DB.Exec(
    "UPDATE AuthToken SET Used = 1, UpdatedAt = ? WHERE TokenHash = ? AND Used = 0 AND ExpiresAt > ?",
    time.Now(), hashed, time.Now(),
)

Expired tokens are cleaned up hourly.

CSRF Protection

ReadySite relies on SameSite=Lax cookies for CSRF protection. No additional tokens are needed because:

  • Session cookies have SameSite: http.SameSiteLaxMode
  • HTMX form submissions respect browser SameSite enforcement
  • Cross-origin POST requests won't include the session cookie

Rate Limiting

Token Bucket Algorithm

Both apps use token bucket rate limiting per IP:

Limiter Rate Burst Used For
AuthLimiter 3/min 3 Login, magic links
APILimiter 60/min 60 API endpoints
ChatLimiter 10/min 10 AI chat
UploadLimiter 10/min 10 File uploads

Configurable Rate Limiter (Website)

The website CMS has a per-collection configurable limiter:

// Default: 100 requests per minute, 2x for authenticated users
var ResourceLimiter = NewConfigurableLimiter(100, "1m", 2.0)

Standard headers are set on responses:

  • X-RateLimit-Limit
  • X-RateLimit-Remaining
  • X-RateLimit-Reset

Usage in Controllers

if !access.AuthLimiter.Allow(r.RemoteAddr) {
    c.renderError(w, "Too many requests")
    return
}

SQL Injection Prevention

All database queries use parameterized queries with ? placeholders:

// CORRECT - parameterized (safe)
models.Users.First("WHERE Email = ?", email)
models.Posts.Search("WHERE UserID = ? AND Status = ?", userID, status)

// WRONG - string concatenation (SQL injection!)
models.Posts.Search("WHERE Name = '" + userInput + "'")  // NEVER DO THIS

The Collection[E] methods (Get, First, Search, Count) all pass arguments separately from the query string. Table and column names are code-controlled, never user-supplied.

Input Validation

Content Limits

Field Max Length
Title 200 characters
Description 1,000 characters
HTML content 1 MB
Name 100 characters
Schema 64 KB
Slug 50 characters

Slug Validation

Slugs must match ^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$ and cannot use reserved names:

// Reserved slugs
"admin", "api", "auth", "setup", "files", "preview", "static", "health"

Schema Validation

Collection schemas are validated for:

  • Valid JSON structure
  • Known field types only
  • No duplicate field names
  • Type-specific constraints

File Upload Security

Size Limits

const MaxFileSize = 10 * 1024 * 1024  // 10MB
r.Body = http.MaxBytesReader(w, r.Body, MaxFileSize)

Extension Whitelist

Only allowed extensions are accepted (images, documents, text files). Executable extensions are blocked.

MIME Type Validation

Files are validated at three levels:

  1. Extension check - must be in whitelist
  2. Content detection - http.DetectContentType() identifies actual content
  3. Match verification - detected MIME must match the claimed extension

Blocked MIME types include executables, scripts (PHP, Python, shell).

Filename Sanitization

// Removes control characters, quotes, newlines to prevent header injection
sanitized := content.SanitizeFilename(file.Name)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, sanitized))

Path Traversal Prevention

// Rejects ".." and non-alphanumeric characters
content.ValidatePath(path)

Access Control

Role-Based Permissions

Role Capabilities
admin Full access to everything
user Read/write own resources
viewer Read-only access

Permission Hierarchy

Permissions are hierarchical: admin > delete > write > read.

Having write permission implicitly grants read. Having admin grants everything.

ACL Rules

Access is checked in order:

  1. Admin bypass (always allowed)
  2. Public rules (accessible to everyone)
  3. User-specific rules
  4. Role-based rules

Open Redirect Prevention

Redirect URLs are validated to be relative paths:

if next := r.URL.Query().Get("next"); next != "" {
    if strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") {
        redirectTo = next  // Safe relative URL
    }
}

This prevents attackers from crafting login URLs that redirect to malicious sites.

XSS Prevention

  • Error messages use html.EscapeString() before rendering
  • Go's html/template package auto-escapes template output
  • Filenames are sanitized before inclusion in HTTP headers

Production Checklist

Item How
Set AUTH_SECRET openssl rand -hex 32
Set ENV=production Enables HTTPS cookies, disables dev features
Configure TLS Set TLS_CERT and TLS_KEY or use Caddy
Use file database Set DB_PATH for persistent storage
Configure rate limits Adjust per your expected traffic
Review ACL rules Set up appropriate access control
Secure file uploads Default limits are good for most cases
← Back