implement searching. provide read only connection and authorizercallback

This commit is contained in:
Evan Jarrett
2025-10-08 15:31:33 -05:00
parent 6b3223cf04
commit 780c5ad2d5
16 changed files with 491 additions and 112 deletions
+102
View File
@@ -0,0 +1,102 @@
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")
}
})
}
+88 -19
View File
@@ -15,6 +15,7 @@ import (
"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/auth/oauth"
@@ -30,6 +31,34 @@ 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 <config>",
Short: "Start the ATCR registry server",
@@ -39,6 +68,15 @@ var serveCmd = &cobra.Command{
}
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" {
@@ -65,7 +103,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
// Initialize UI database first (required for all stores)
fmt.Println("Initializing UI database...")
uiDatabase, uiSessionStore := initializeDatabase(config)
uiDatabase, uiReadOnlyDB, uiSessionStore := initializeDatabase()
if uiDatabase == nil {
return fmt.Errorf("failed to initialize UI database - required for session storage")
}
@@ -112,7 +150,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
middleware.SetGlobalDatabase(metricsDB)
// 7. Initialize UI routes with OAuth app, refresher, and device store
uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiSessionStore, oauthApp, refresher, baseURL, deviceStore)
uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore)
// 8. Create OAuth server
oauthServer := oauth.NewServer(oauthApp)
@@ -324,11 +362,12 @@ func extractDefaultHoldEndpoint(config *configuration.Configuration) string {
}
// initializeDatabase initializes the SQLite database and session store
func initializeDatabase(config *configuration.Configuration) (*sql.DB, *db.SessionStore) {
// 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
return nil, nil, nil
}
// Get database path
@@ -341,17 +380,27 @@ func initializeDatabase(config *configuration.Configuration) (*sql.DB, *db.Sessi
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
return nil, nil, nil
}
// Initialize database
// 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
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 initialized at %s\n", dbPath)
fmt.Printf("Read-only connection with authorizer created (blocks: oauth_sessions, ui_sessions, devices, etc.)\n")
// Create SQLite-backed session store
sessionStore := db.NewSessionStore(database)
@@ -377,11 +426,13 @@ func initializeDatabase(config *configuration.Configuration) (*sql.DB, *db.Sessi
}
}()
return database, sessionStore
return database, readOnlyDB, sessionStore
}
// initializeUIRoutes initializes the web UI routes
func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore) (*template.Template, *mux.Router) {
// database: read-write connection for auth and writes
// readOnlyDB: read-only connection for public queries (search, user pages, etc.)
func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore) (*template.Template, *mux.Router) {
// Check if UI is enabled
uiEnabled := os.Getenv("ATCR_UI_ENABLED")
if uiEnabled == "false" {
@@ -410,9 +461,10 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp
}).Methods("POST")
// Public routes (with optional auth for navbar)
// SECURITY: Public pages use read-only DB
router.Handle("/", appmiddleware.OptionalAuth(sessionStore, database)(
&uihandlers.HomeHandler{
DB: database,
DB: readOnlyDB,
Templates: templates,
RegistryURL: uihandlers.TrimRegistryURL(baseURL),
},
@@ -420,16 +472,33 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp
router.Handle("/api/recent-pushes", appmiddleware.OptionalAuth(sessionStore, database)(
&uihandlers.RecentPushesHandler{
DB: database,
DB: readOnlyDB,
Templates: templates,
RegistryURL: uihandlers.TrimRegistryURL(baseURL),
},
)).Methods("GET")
// API route for repository stats (public)
// SECURITY: Search uses read-only DB to prevent writes and limit access to sensitive tables
router.Handle("/search", appmiddleware.OptionalAuth(sessionStore, database)(
&uihandlers.SearchHandler{
DB: readOnlyDB,
Templates: templates,
RegistryURL: uihandlers.TrimRegistryURL(baseURL),
},
)).Methods("GET")
router.Handle("/api/search-results", appmiddleware.OptionalAuth(sessionStore, database)(
&uihandlers.SearchResultsHandler{
DB: readOnlyDB,
Templates: templates,
RegistryURL: uihandlers.TrimRegistryURL(baseURL),
},
)).Methods("GET")
// API route for repository stats (public, read-only)
router.Handle("/api/stats/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)(
&uihandlers.GetStatsHandler{
DB: database,
DB: readOnlyDB,
Directory: oauthApp.Directory(),
},
)).Methods("GET")
@@ -437,7 +506,7 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp
// API routes for stars (require authentication)
router.Handle("/api/stars/{handle}/{repository}", appmiddleware.RequireAuth(sessionStore, database)(
&uihandlers.StarRepositoryHandler{
DB: database,
DB: database, // Needs write access
Directory: oauthApp.Directory(),
Refresher: refresher,
},
@@ -445,7 +514,7 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp
router.Handle("/api/stars/{handle}/{repository}", appmiddleware.RequireAuth(sessionStore, database)(
&uihandlers.UnstarRepositoryHandler{
DB: database,
DB: database, // Needs write access
Directory: oauthApp.Directory(),
Refresher: refresher,
},
@@ -453,7 +522,7 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp
router.Handle("/api/stars/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)(
&uihandlers.CheckStarHandler{
DB: database,
DB: readOnlyDB, // Read-only check
Directory: oauthApp.Directory(),
Refresher: refresher,
},
@@ -461,7 +530,7 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp
router.Handle("/u/{handle}", appmiddleware.OptionalAuth(sessionStore, database)(
&uihandlers.UserPageHandler{
DB: database,
DB: readOnlyDB,
Templates: templates,
RegistryURL: uihandlers.TrimRegistryURL(baseURL),
},
@@ -469,7 +538,7 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp
router.Handle("/r/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)(
&uihandlers.RepositoryPageHandler{
DB: database,
DB: readOnlyDB,
Templates: templates,
RegistryURL: uihandlers.TrimRegistryURL(baseURL),
},
@@ -480,7 +549,7 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp
authRouter.Use(appmiddleware.RequireAuth(sessionStore, database))
authRouter.Handle("/images", &uihandlers.ImagesHandler{
DB: database,
DB: readOnlyDB, // Read-only: just displays user's images
Templates: templates,
RegistryURL: uihandlers.TrimRegistryURL(baseURL),
}).Methods("GET")