mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-25 03:34:14 +00:00
implement searching. provide read only connection and authorizercallback
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user