456 lines
12 KiB
Markdown
456 lines
12 KiB
Markdown
# 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$<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
|
|
```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
|