Update project docs and problem notes
This commit is contained in:
456
docs/specs/security-specification.md
Normal file
456
docs/specs/security-specification.md
Normal file
@@ -0,0 +1,456 @@
|
||||
# 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
|
||||
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
|
||||
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
|
||||
Reference in New Issue
Block a user