12 KiB
12 KiB
Security Specification
Threat Model
Assets
- Document Content: Markdown files and attachments
- User Credentials: Passkeys, passwords, session tokens
- Collaboration Data: Comments, notifications, history
- System State: Database, configuration, logs
Threat Actors
- External Attacker: No system access, attempts remote exploitation
- Malicious User: Authenticated, attempts unauthorized access
- Compromised Client: Legitimate user's device infected
- Insider: Administrator or developer with system access
Threats
- Supply Chain Attack: Malicious dependency injection
- Authentication Bypass: Session hijacking, credential theft
- Authorization Bypass: Horizontal/vertical privilege escalation
- Data Exfiltration: Unauthorized document access
- Data Tampering: Modification without authorization
- Denial of Service: Resource exhaustion
- Information Disclosure: Secrets in logs/responses
Security Controls
1. Supply Chain Security
Policy:
- Only dependencies with >1M downloads/month or official Go team
- All versions pinned in go.sum
- No transitive dependencies without review
- Prefer standard library when possible
Implementation:
# go.mod
require (
github.com/go-chi/chi/v5 v5.0.12
github.com/tursodatabase/libsql-client-go v0.0.0-20240416075031-555ce55511f7
// All versions pinned
)
# Build
RUN go mod verify # Verify checksums
RUN go mod vendor # Optional: vendor for air-gapped
Audit:
- Weekly
govulncheckscan - Dependency review in CI
- No build-time network access (vendored or proxy with checksum)
2. Authentication Security
Passkey (WebAuthn)
// Registration
func registerPasskey(user User) (*Credential, error) {
options, session, err := webauthn.BeginRegistration(user)
// Store session data server-side (never client-side)
// Return options to client
}
func verifyRegistration(sessionID string, response ProtocolCredentialCreation) (*Credential, error) {
session := getSessionData(sessionID)
credential, err := webauthn.FinishRegistration(user, session, response)
// Verify attestation
// Store credential: ID, public key, sign count
}
Password (Argon2id)
import "golang.org/x/crypto/argon2"
const (
timeCost = 3
memoryCost = 64 * 1024 // 64MB
parallelism = 4
saltLength = 16
keyLength = 32
)
func hashPassword(password string) (string, error) {
salt := make([]byte, saltLength)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, timeCost, memoryCost, parallelism, keyLength)
// Encode to string: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
return encodeHash(salt, hash), nil
}
func verifyPassword(password, encodedHash string) (bool, error) {
salt, hash, params, err := decodeHash(encodedHash)
if err != nil {
return false, err
}
computedHash := argon2.IDKey([]byte(password), salt, params.time, params.memory, params.parallelism, uint32(len(hash)))
// Constant-time comparison
return subtle.ConstantTimeCompare(hash, computedHash) == 1, nil
}
Session Management
func createSession(userID string, r *http.Request) (*Session, error) {
token := make([]byte, 32)
if _, err := rand.Read(token); err != nil {
return nil, err
}
session := &Session{
ID: uuid.New().String(),
UserID: userID,
TokenHash: sha256.Sum256(token),
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
IP: r.RemoteAddr,
UserAgent: r.UserAgent(),
}
// Store in database
if err := db.CreateSession(session); err != nil {
return nil, err
}
// Set cookie
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: base64.URLEncoding.EncodeToString(token),
Expires: session.ExpiresAt,
HttpOnly: true,
Secure: true, // HTTPS only
SameSite: http.SameSiteStrictMode,
Path: "/",
})
return session, nil
}
3. Authorization
Permission Model
type Permission string
const (
PermissionRead Permission = "read"
PermissionWrite Permission = "write"
PermissionAdmin Permission = "admin"
)
type ResourceType string
const (
ResourceGlobal ResourceType = "global"
ResourceCollection ResourceType = "collection"
ResourceDocument ResourceType = "document"
)
func checkPermission(userID string, resourceType ResourceType, resourceID string, required Permission) (bool, error) {
// Check explicit permission
perm, err := db.GetPermission(userID, resourceType, resourceID)
if err == nil && hasPermission(perm.Permission, required) {
return true, nil
}
// Check collection permission for documents
if resourceType == ResourceDocument {
collectionID := getDocumentCollection(resourceID)
perm, err = db.GetPermission(userID, ResourceCollection, collectionID)
if err == nil && hasPermission(perm.Permission, required) {
return true, nil
}
}
// Check global permission
perm, err = db.GetPermission(userID, ResourceGlobal, "")
if err == nil && hasPermission(perm.Permission, required) {
return true, nil
}
// Check document public status
if resourceType == ResourceDocument && required == PermissionRead {
doc, err := db.GetDocument(resourceID)
if err == nil && doc.PublicRead {
return true, nil
}
}
return false, nil
}
Middleware
func RequirePermission(resourceType ResourceType, required Permission) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r.Context())
resourceID := chi.URLParam(r, "id")
allowed, err := checkPermission(userID, resourceType, resourceID, required)
if err != nil || !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// Usage:
r.With(RequirePermission(ResourceDocument, PermissionRead)).
Get("/docs/{id}", getDocumentHandler)
4. Input Validation
Markdown Sanitization
import "github.com/yuin/goldmark"
import "github.com/yuin/goldmark/extension"
import "github.com/yuin/goldmark/renderer/html"
func renderMarkdown(source []byte) ([]byte, error) {
md := goldmark.New(
goldmark.WithExtensions(
extension.Table,
extension.Strikethrough,
extension.Linkify,
// No raw HTML extension — we control HTML output
),
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
html.WithUnsafe(), // DISABLED — no raw HTML passthrough
),
)
var buf bytes.Buffer
if err := md.Convert(source, &buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
File Upload Validation
func validateUpload(file io.Reader, filename string, maxSize int64) ([]byte, string, error) {
// Size limit
limited := io.LimitReader(file, maxSize+1)
data, err := io.ReadAll(limited)
if err != nil {
return nil, "", err
}
if int64(len(data)) > maxSize {
return nil, "", fmt.Errorf("file too large")
}
// Magic number validation
contentType := detectContentType(data)
allowedTypes := map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
"application/pdf": true,
}
if !allowedTypes[contentType] {
return nil, "", fmt.Errorf("file type not allowed")
}
// Extension whitelist
ext := strings.ToLower(filepath.Ext(filename))
allowedExts := map[string]bool{
".jpg": true, ".jpeg": true, ".png": true,
".gif": true, ".webp": true, ".pdf": true,
}
if !allowedExts[ext] {
return nil, "", fmt.Errorf("file extension not allowed")
}
// Compute hash
hash := sha256.Sum256(data)
hashStr := hex.EncodeToString(hash[:])
return data, hashStr, nil
}
Path Sanitization
func sanitizePath(userPath string) (string, error) {
// Clean the path
clean := filepath.Clean(userPath)
// Prevent traversal
if strings.Contains(clean, "..") {
return "", fmt.Errorf("path traversal detected")
}
// Ensure it's relative
if filepath.IsAbs(clean) {
return "", fmt.Errorf("absolute paths not allowed")
}
// Join with base and verify it's within base
fullPath := filepath.Join(baseDir, clean)
if !strings.HasPrefix(filepath.Clean(fullPath), filepath.Clean(baseDir)) {
return "", fmt.Errorf("path outside allowed directory")
}
return clean, nil
}
5. Transport Security
TLS Configuration
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS13,
CurvePreferences: []tls.CurveID{
tls.X25519,
tls.CurveP256,
},
CipherSuites: []uint16{
tls.TLS_AES_256_GCM_SHA384,
tls.TLS_CHACHA20_POLY1305_SHA256,
tls.TLS_AES_128_GCM_SHA256,
},
PreferServerCipherSuites: true,
}
Security Headers
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'nonce-{nonce}'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; "+
"connect-src 'self' wss:; "+
"frame-ancestors 'none'; "+
"base-uri 'self'; "+
"form-action 'self';")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
next.ServeHTTP(w, r)
})
}
6. Operational Security
Logging
func logAudit(event AuditEvent) {
entry := slog.With(
"event_type", event.Type,
"actor_id", redactIfSecret(event.ActorID),
"resource_type", event.ResourceType,
"resource_id", redactIfSecret(event.ResourceID),
"ip", event.IP,
"timestamp", event.Timestamp,
)
// Never log: passwords, tokens, session IDs, API keys
entry.Info("audit event")
}
func redactIfSecret(value string) string {
if looksLikeSecret(value) {
return "[REDACTED]"
}
return value
}
Container Security
# Dockerfile
FROM golang:1.22-alpine AS builder
# ... build ...
FROM alpine:3.19
RUN addgroup -g 1000 appgroup && \
adduser -u 1000 -G appgroup -s /bin/sh -D appuser
# Read-only root filesystem
COPY --from=builder /app/server /server
COPY --from=builder /app/web/dist /web
# Data directory (writable)
RUN mkdir /data && chown appuser:appgroup /data
VOLUME /data
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -q --spider http://localhost:8080/health || exit 1
ENTRYPOINT ["/server"]
Incident Response
Detection
- Automated alerts on: failed auth spikes, permission errors, large file uploads
- Log analysis for anomalies
- Health check failures
Response
- Isolate: Block IP, revoke sessions, disable account if compromised
- Assess: Determine scope of breach, affected data
- Contain: Patch vulnerability, rotate secrets
- Recover: Restore from clean backup if necessary
- Document: Post-incident review, update security measures
Compliance
GDPR
- Data minimization: only email stored, no other PII
- Right to erasure:
/api/user/deleteendpoint - Data portability:
/api/user/exportendpoint - Consent: explicit opt-in for email notifications
Audit Trail
All security-relevant events logged:
- Authentication attempts (success/failure)
- Permission changes
- Document access (if private)
- Admin actions
- Configuration changes
Retention: 90 days active, 1 year archive