more appview cleanup and test coverage

This commit is contained in:
Evan Jarrett
2025-10-17 21:12:05 -05:00
parent 80b65ee619
commit f4b84ca75f
16 changed files with 2358 additions and 275 deletions
-102
View File
@@ -1,102 +0,0 @@
package main
import (
"database/sql"
"os"
"path/filepath"
"testing"
"atcr.io/pkg/appview/db"
)
func TestAuthorizerBlocksSensitiveTables(t *testing.T) {
// Create temporary database
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
// Set environment for database path
os.Setenv("ATCR_UI_DATABASE_PATH", dbPath)
defer os.Unsetenv("ATCR_UI_DATABASE_PATH")
// Initialize database (creates schema)
database, err := db.InitDB(dbPath)
if err != nil {
t.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Create some test data in sensitive tables
_, err = database.Exec(`
INSERT INTO oauth_sessions (session_key, account_did, session_id, session_data, created_at, updated_at)
VALUES ('test-key', 'did:plc:test', 'test-session', 'secret-token-data', datetime('now'), datetime('now'))
`)
if err != nil {
t.Fatalf("Failed to insert test data: %v", err)
}
_, err = database.Exec(`
INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen)
VALUES ('did:plc:test', 'test.user', 'https://pds.example.com', '', datetime('now'))
`)
if err != nil {
t.Fatalf("Failed to insert test user: %v", err)
}
// Open read-only connection with authorizer (using our custom driver)
readOnlyDB, err := sql.Open("sqlite3_readonly_public", "file:"+dbPath+"?mode=ro")
if err != nil {
t.Fatalf("Failed to open read-only database: %v", err)
}
defer readOnlyDB.Close()
// Test 1: Should be able to read from public tables (users)
t.Run("AllowPublicTableRead", func(t *testing.T) {
var handle string
err := readOnlyDB.QueryRow("SELECT handle FROM users WHERE did = ?", "did:plc:test").Scan(&handle)
if err != nil {
t.Errorf("Should be able to read from public table 'users': %v", err)
}
if handle != "test.user" {
t.Errorf("Expected handle 'test.user', got '%s'", handle)
}
})
// Test 2: Should NOT be able to read from sensitive tables (oauth_sessions)
t.Run("BlockSensitiveTableRead", func(t *testing.T) {
var sessionData string
err := readOnlyDB.QueryRow("SELECT session_data FROM oauth_sessions WHERE session_key = ?", "test-key").Scan(&sessionData)
if err == nil {
t.Errorf("Should NOT be able to read from sensitive table 'oauth_sessions', but got data: %s", sessionData)
}
// SQLite returns "not authorized" error when authorizer denies access
if err != nil && err.Error() != "not authorized" {
t.Logf("Got expected error (but different message): %v", err)
}
})
// Test 3: Should NOT be able to read from ui_sessions
t.Run("BlockUISessionsTableRead", func(t *testing.T) {
rows, err := readOnlyDB.Query("SELECT * FROM ui_sessions LIMIT 1")
if err == nil {
rows.Close()
t.Error("Should NOT be able to read from sensitive table 'ui_sessions'")
}
})
// Test 4: Should NOT be able to read from devices
t.Run("BlockDevicesTableRead", func(t *testing.T) {
rows, err := readOnlyDB.Query("SELECT * FROM devices LIMIT 1")
if err == nil {
rows.Close()
t.Error("Should NOT be able to read from sensitive table 'devices'")
}
})
// Test 5: Should NOT be able to write to any table (read-only mode + authorizer)
t.Run("BlockAllWrites", func(t *testing.T) {
_, err := readOnlyDB.Exec("INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen) VALUES ('did:plc:test2', 'test2', 'https://pds.example.com', '', datetime('now'))")
if err == nil {
t.Error("Should NOT be able to write to any table in read-only mode")
}
})
}
-233
View File
@@ -1,233 +0,0 @@
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/url"
"os"
"strconv"
"time"
"github.com/distribution/distribution/v3/configuration"
)
// loadConfigFromEnv builds a complete configuration from environment variables
// This follows the same pattern as the hold service (no config files, only env vars)
func loadConfigFromEnv() (*configuration.Configuration, error) {
config := &configuration.Configuration{}
// Version
config.Version = configuration.MajorMinorVersion(0, 1)
// Logging
config.Log = buildLogConfig()
// HTTP server
httpConfig, err := buildHTTPConfig()
if err != nil {
return nil, fmt.Errorf("failed to build HTTP config: %w", err)
}
config.HTTP = httpConfig
// Storage (fake in-memory placeholder - all real storage is proxied)
config.Storage = buildStorageConfig()
// Middleware (ATProto resolver)
defaultHoldDID := os.Getenv("ATCR_DEFAULT_HOLD_DID")
if defaultHoldDID == "" {
return nil, fmt.Errorf("ATCR_DEFAULT_HOLD_DID is required")
}
config.Middleware = buildMiddlewareConfig(defaultHoldDID)
// Auth
baseURL := getBaseURL(httpConfig.Addr)
authConfig, err := buildAuthConfig(baseURL)
if err != nil {
return nil, fmt.Errorf("failed to build auth config: %w", err)
}
config.Auth = authConfig
// Health checks
config.Health = buildHealthConfig()
return config, nil
}
// buildLogConfig creates logging configuration from environment variables
func buildLogConfig() configuration.Log {
level := getEnvOrDefault("ATCR_LOG_LEVEL", "info")
formatter := getEnvOrDefault("ATCR_LOG_FORMATTER", "text")
return configuration.Log{
Level: configuration.Loglevel(level),
Formatter: formatter,
Fields: map[string]any{
"service": "atcr-appview",
},
}
}
// buildHTTPConfig creates HTTP server configuration from environment variables
func buildHTTPConfig() (configuration.HTTP, error) {
addr := getEnvOrDefault("ATCR_HTTP_ADDR", ":5000")
debugAddr := getEnvOrDefault("ATCR_DEBUG_ADDR", ":5001")
// HTTP secret - only needed for multipart uploads in distribution's storage driver
// Since AppView is stateless and routes all storage through middleware, this isn't
// actually used, but we generate a random secret for defense in depth
httpSecret := os.Getenv("REGISTRY_HTTP_SECRET")
if httpSecret == "" {
// Generate a random 32-byte secret
randomBytes := make([]byte, 32)
if _, err := rand.Read(randomBytes); err != nil {
return configuration.HTTP{}, fmt.Errorf("failed to generate random secret: %w", err)
}
httpSecret = hex.EncodeToString(randomBytes)
}
return configuration.HTTP{
Addr: addr,
Secret: httpSecret,
Headers: map[string][]string{
"X-Content-Type-Options": {"nosniff"},
},
Debug: configuration.Debug{
Addr: debugAddr,
},
}, nil
}
// buildStorageConfig creates a fake in-memory storage config
// This is required for distribution validation but is never actually used
// All storage is routed through middleware to ATProto (manifests) and hold services (blobs)
func buildStorageConfig() configuration.Storage {
storage := configuration.Storage{}
// Use in-memory storage as a placeholder
storage["inmemory"] = configuration.Parameters{}
// Disable upload purging
// NOTE: Must use map[any]any for uploadpurging (not configuration.Parameters)
// because distribution's validation code does a type assertion to map[any]any
storage["maintenance"] = configuration.Parameters{
"uploadpurging": map[any]any{
"enabled": false,
"age": 7 * 24 * time.Hour, // 168h
"interval": 24 * time.Hour, // 24h
"dryrun": false,
},
}
return storage
}
// buildMiddlewareConfig creates middleware configuration
func buildMiddlewareConfig(defaultHoldDID string) map[string][]configuration.Middleware {
// Check test mode
testMode := os.Getenv("TEST_MODE") == "true"
return map[string][]configuration.Middleware{
"registry": {
{
Name: "atproto-resolver",
Options: configuration.Parameters{
"default_hold_did": defaultHoldDID,
"test_mode": testMode,
},
},
},
}
}
// buildAuthConfig creates authentication configuration from environment variables
func buildAuthConfig(baseURL string) (configuration.Auth, error) {
// Token configuration
privateKeyPath := getEnvOrDefault("ATCR_AUTH_KEY_PATH", "/var/lib/atcr/auth/private-key.pem")
certPath := getEnvOrDefault("ATCR_AUTH_CERT_PATH", "/var/lib/atcr/auth/private-key.crt")
// Token expiration in seconds (default: 5 minutes)
expirationStr := getEnvOrDefault("ATCR_TOKEN_EXPIRATION", "300")
expiration, err := strconv.Atoi(expirationStr)
if err != nil {
return configuration.Auth{}, fmt.Errorf("invalid ATCR_TOKEN_EXPIRATION: %w", err)
}
// Auto-derive service name from base URL or use env var
serviceName := getServiceName(baseURL)
// Auto-derive realm from base URL
realm := baseURL + "/auth/token"
return configuration.Auth{
"token": configuration.Parameters{
"realm": realm,
"service": serviceName,
"issuer": serviceName,
"rootcertbundle": certPath,
"privatekey": privateKeyPath,
"expiration": expiration,
},
}, nil
}
// buildHealthConfig creates health check configuration
func buildHealthConfig() configuration.Health {
return configuration.Health{
StorageDriver: configuration.StorageDriver{
Enabled: true,
Interval: 10 * time.Second,
Threshold: 3,
},
}
}
// getBaseURL determines the base URL for the service
// Priority: ATCR_BASE_URL env var, then derived from HTTP addr
func getBaseURL(httpAddr string) string {
baseURL := os.Getenv("ATCR_BASE_URL")
if baseURL != "" {
return baseURL
}
// Auto-detect from HTTP addr
if httpAddr[0] == ':' {
// Just a port, assume localhost
return fmt.Sprintf("http://127.0.0.1%s", httpAddr)
}
// Full address provided
return fmt.Sprintf("http://%s", httpAddr)
}
// getServiceName extracts service name from base URL or uses env var
func getServiceName(baseURL string) string {
// Check env var first
if serviceName := os.Getenv("ATCR_SERVICE_NAME"); serviceName != "" {
return serviceName
}
// Try to extract from base URL
parsed, err := url.Parse(baseURL)
if err == nil && parsed.Hostname() != "" {
hostname := parsed.Hostname()
// Strip localhost/127.0.0.1 and use default
if hostname == "localhost" || hostname == "127.0.0.1" {
return "atcr.io"
}
return hostname
}
// Default fallback
return "atcr.io"
}
// getEnvOrDefault gets an environment variable or returns a default value
func getEnvOrDefault(key, defaultValue string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultValue
}
+16 -164
View File
@@ -9,14 +9,12 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/distribution/distribution/v3/configuration"
"github.com/distribution/distribution/v3/registry"
"github.com/distribution/distribution/v3/registry/handlers"
sqlite3 "github.com/mattn/go-sqlite3"
"github.com/spf13/cobra"
"atcr.io/pkg/appview/middleware"
@@ -32,34 +30,6 @@ import (
"github.com/gorilla/mux"
)
// Define sensitive tables that should never be accessible from public queries
var sensitiveTables = map[string]bool{
"oauth_sessions": true, // OAuth tokens
"ui_sessions": true, // Session IDs
"oauth_auth_requests": true, // OAuth state
"devices": true, // Device secret hashes
"pending_device_auth": true, // Pending device secrets
}
// readOnlyAuthorizerCallback blocks access to sensitive tables
func readOnlyAuthorizerCallback(action int, arg1, arg2, dbName string) int {
// arg1 contains the table name for most operations
tableName := arg1
// Block any access to sensitive tables
if action == sqlite3.SQLITE_READ || action == sqlite3.SQLITE_UPDATE ||
action == sqlite3.SQLITE_INSERT || action == sqlite3.SQLITE_DELETE ||
action == sqlite3.SQLITE_SELECT {
if sensitiveTables[tableName] {
fmt.Printf("SECURITY: Blocked access to sensitive table '%s' (action=%d)\n", tableName, action)
return sqlite3.SQLITE_DENY
}
}
// Allow everything else
return sqlite3.SQLITE_OK
}
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the ATCR registry server",
@@ -72,15 +42,6 @@ See .env.appview.example for available environment variables.`,
}
func init() {
// Register a custom SQLite driver with authorizer for read-only public queries
sql.Register("sqlite3_readonly_public",
&sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
conn.RegisterAuthorizer(readOnlyAuthorizerCallback)
return nil
},
})
// Replace the default serve command with our custom one
for i, cmd := range registry.RootCmd.Commands() {
if cmd.Name() == "serve" {
@@ -93,7 +54,7 @@ func init() {
func serveRegistry(cmd *cobra.Command, args []string) error {
// Load configuration from environment variables
fmt.Println("Loading configuration from environment variables...")
config, err := loadConfigFromEnv()
config, err := appview.LoadConfigFromEnv()
if err != nil {
return fmt.Errorf("failed to load config from environment: %w", err)
}
@@ -101,7 +62,12 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
// Initialize UI database first (required for all stores)
fmt.Println("Initializing UI database...")
uiDatabase, uiReadOnlyDB, uiSessionStore := initializeDatabase()
uiEnabled := os.Getenv("ATCR_UI_ENABLED") != "false"
dbPath := os.Getenv("ATCR_UI_DATABASE_PATH")
if dbPath == "" {
dbPath = "/var/lib/atcr/ui.db"
}
uiDatabase, uiReadOnlyDB, uiSessionStore := db.InitializeDatabase(uiEnabled, dbPath)
if uiDatabase == nil {
return fmt.Errorf("failed to initialize UI database - required for session storage")
}
@@ -158,7 +124,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
// Expected format: "did:web:hold01.atcr.io"
// To find a hold's DID, visit: https://hold01.atcr.io/.well-known/did.json
// The extraction function normalizes URLs to DIDs for consistency
defaultHoldDID := extractDefaultHoldDID(config)
defaultHoldDID := appview.ExtractDefaultHoldDID(config)
// Initialize UI routes with OAuth app, refresher, and device store
uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID)
@@ -304,10 +270,10 @@ func initializeAuthKeys(config *configuration.Configuration) error {
return nil
}
privateKeyPath := getStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem")
issuerName := getStringParam(tokenParams, "issuer", "atcr.io")
service := getStringParam(tokenParams, "service", "atcr.io")
expirationSecs := getIntParam(tokenParams, "expiration", 300)
privateKeyPath := appview.GetStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem")
issuerName := appview.GetStringParam(tokenParams, "issuer", "atcr.io")
service := appview.GetStringParam(tokenParams, "service", "atcr.io")
expirationSecs := appview.GetIntParam(tokenParams, "expiration", 300)
// Create issuer (this will generate the key if it doesn't exist)
_, err := token.NewIssuer(
@@ -331,10 +297,10 @@ func createTokenIssuer(config *configuration.Configuration) (*token.Issuer, erro
return nil, fmt.Errorf("token auth not configured")
}
privateKeyPath := getStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem")
issuerName := getStringParam(tokenParams, "issuer", "atcr.io")
service := getStringParam(tokenParams, "service", "atcr.io")
expirationSecs := getIntParam(tokenParams, "expiration", 300)
privateKeyPath := appview.GetStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem")
issuerName := appview.GetStringParam(tokenParams, "issuer", "atcr.io")
service := appview.GetStringParam(tokenParams, "service", "atcr.io")
expirationSecs := appview.GetIntParam(tokenParams, "expiration", 300)
return token.NewIssuer(
privateKeyPath,
@@ -344,120 +310,6 @@ func createTokenIssuer(config *configuration.Configuration) (*token.Issuer, erro
)
}
// Helper functions to extract values from config parameters
func getStringParam(params configuration.Parameters, key, defaultValue string) string {
if v, ok := params[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return defaultValue
}
func getIntParam(params configuration.Parameters, key string, defaultValue int) int {
if v, ok := params[key]; ok {
if i, ok := v.(int); ok {
return i
}
}
return defaultValue
}
// extractDefaultHoldDID extracts the default hold DID from middleware config
// Returns a DID (e.g., "did:web:hold01.atcr.io")
// To find a hold's DID, visit: https://hold-url/.well-known/did.json
func extractDefaultHoldDID(config *configuration.Configuration) string {
// Navigate through: middleware.registry[].options.default_hold_did
registryMiddleware, ok := config.Middleware["registry"]
if !ok {
return ""
}
// Find atproto-resolver middleware
for _, mw := range registryMiddleware {
// Check if this is the atproto-resolver
if mw.Name != "atproto-resolver" {
continue
}
// Extract options - options is configuration.Parameters which is map[string]any
if mw.Options != nil {
if holdDID, ok := mw.Options["default_hold_did"].(string); ok {
return holdDID
}
}
}
return ""
}
// initializeDatabase initializes the SQLite database and session store
// Returns: (read-write DB, read-only DB, session store)
func initializeDatabase() (*sql.DB, *sql.DB, *db.SessionStore) {
// Check if UI is enabled (optional configuration)
uiEnabled := os.Getenv("ATCR_UI_ENABLED")
if uiEnabled == "false" {
return nil, nil, nil
}
// Get database path
dbPath := os.Getenv("ATCR_UI_DATABASE_PATH")
if dbPath == "" {
dbPath = "/var/lib/atcr/ui.db"
}
// Ensure directory exists
dbDir := filepath.Dir(dbPath)
if err := os.MkdirAll(dbDir, 0700); err != nil {
fmt.Printf("Warning: Failed to create UI database directory: %v\n", err)
return nil, nil, nil
}
// Initialize read-write database (for writes and auth operations)
database, err := db.InitDB(dbPath)
if err != nil {
fmt.Printf("Warning: Failed to initialize UI database: %v\n", err)
return nil, nil, nil
}
// Open read-only connection for public queries (search, user pages, etc.)
// Uses custom driver with SQLite authorizer that blocks sensitive tables
// This prevents accidental writes and blocks access to sensitive tables even if SQL injection occurs
readOnlyDB, err := sql.Open("sqlite3_readonly_public", "file:"+dbPath+"?mode=ro")
if err != nil {
fmt.Printf("Warning: Failed to open read-only database connection: %v\n", err)
return nil, nil, nil
}
fmt.Printf("UI database (readonly) initialized at %s\n", dbPath)
// Create SQLite-backed session store
sessionStore := db.NewSessionStore(database)
// Start cleanup goroutines for all SQLite stores
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
ctx := context.Background()
// Cleanup UI sessions
sessionStore.Cleanup()
// Cleanup OAuth sessions (older than 30 days)
oauthStore := db.NewOAuthStore(database)
oauthStore.CleanupOldSessions(ctx, 30*24*time.Hour)
oauthStore.CleanupExpiredAuthRequests(ctx)
// Cleanup device pending auths
deviceStore := db.NewDeviceStore(database)
deviceStore.CleanupExpired()
}
}()
return database, readOnlyDB, sessionStore
}
// initializeUIRoutes initializes the web UI routes
// database: read-write connection for auth and writes
// readOnlyDB: read-only connection for public queries (search, user pages, etc.)