# Security Specification ## Threat Model ### Assets 1. **Document Content**: Markdown files and attachments 2. **User Credentials**: Passkeys, passwords, session tokens 3. **Collaboration Data**: Comments, notifications, history 4. **System State**: Database, configuration, logs ### Threat Actors 1. **External Attacker**: No system access, attempts remote exploitation 2. **Malicious User**: Authenticated, attempts unauthorized access 3. **Compromised Client**: Legitimate user's device infected 4. **Insider**: Administrator or developer with system access ### Threats 1. **Supply Chain Attack**: Malicious dependency injection 2. **Authentication Bypass**: Session hijacking, credential theft 3. **Authorization Bypass**: Horizontal/vertical privilege escalation 4. **Data Exfiltration**: Unauthorized document access 5. **Data Tampering**: Modification without authorization 6. **Denial of Service**: Resource exhaustion 7. **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 `govulncheck` scan - Dependency review in CI - No build-time network access (vendored or proxy with checksum) ### 2. Authentication Security #### Passkey (WebAuthn) ```go // 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) ```go 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$$ 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 ```go 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 ```go 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 ```go 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 ```go 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 ```go 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 ```go 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 ```go 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 ```go 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 ```go 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 # 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 # 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 1. **Isolate**: Block IP, revoke sessions, disable account if compromised 2. **Assess**: Determine scope of breach, affected data 3. **Contain**: Patch vulnerability, rotate secrets 4. **Recover**: Restore from clean backup if necessary 5. **Document**: Post-incident review, update security measures ## Compliance ### GDPR - Data minimization: only email stored, no other PII - Right to erasure: `/api/user/delete` endpoint - Data portability: `/api/user/export` endpoint - 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