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:
- User enters email, a token is generated
- Token is SHA-256 hashed before storage
- Plaintext token is sent via email link
- On verification, an atomic UPDATE claims the token (prevents TOCTOU races)
- 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-LimitX-RateLimit-RemainingX-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:
- Extension check - must be in whitelist
- Content detection -
http.DetectContentType()identifies actual content - 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:
- Admin bypass (always allowed)
- Public rules (accessible to everyone)
- User-specific rules
- 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/templatepackage 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 |