From ad1d06dd1ec6b0466a602406c26c9f0e2c1f823a Mon Sep 17 00:00:00 2001 From: suifri Date: Wed, 14 Oct 2026 19:08:34 +0300 Subject: [PATCH] [WTEL-10639]security(sqlite): restrict sqlite DSN connection strings --- app/app.go | 17 +- app/sql.go | 14 +- flow/sql.go | 19 +- go.mod | 2 +- go.sum | 2 + model/config.go | 57 ++- store/cachelayer/store.go | 262 +++++++++++- store/cachelayer/store_test.go | 702 +++++++++++++++++++++++++++++++++ 8 files changed, 1038 insertions(+), 37 deletions(-) create mode 100644 store/cachelayer/store_test.go diff --git a/app/app.go b/app/app.go index 6566e4d0..a8816781 100644 --- a/app/app.go +++ b/app/app.go @@ -120,10 +120,6 @@ func NewFlowManager() (outApp *FlowManager, outErr error) { cbr: NewCallbackResolver(), } - if config.ExternalSql { - fm.ExternalStore = cachelayer.NewExternalStoreManager() - } - logConfig := &wlog.LoggerConfiguration{ EnableConsole: config.Log.Console, ConsoleJson: false, @@ -162,6 +158,19 @@ func NewFlowManager() (outApp *FlowManager, outErr error) { wlog.RedirectStdLog(fm.log) wlog.InitGlobalLogger(fm.log) + if config.ExternalSQLSettings.Enabled { + if err := config.ExternalSQLSettings.Prepare(); err != nil { + config.ExternalSQLSettings.SanitazeOnPrepareFailure() + wlog.Error("prepare external sql settings", wlog.Err(err)) + } + + fm.ExternalStore = cachelayer.NewExternalStoreManager( + cachelayer.WithSQLLiteSandboxDir(config.ExternalSQLSettings.SandboxRootDirectory), + cachelayer.WithWriteOperationsEnabled(config.ExternalSQLSettings.WriteEnabled), + cachelayer.WithLogger(wlog.GlobalLogger().With(wlog.Namespace("external_sql"))), + ) + } + // Bind http.DefaultTransport to the proxy manager before any outbound // client is built, so every default-transport call site follows the // watched proxy settings without a restart. diff --git a/app/sql.go b/app/sql.go index 7d60912d..f60c8617 100644 --- a/app/sql.go +++ b/app/sql.go @@ -1,17 +1,25 @@ package app import ( + "context" + "net/http" + "github.com/webitel/flow_manager/model" "github.com/webitel/flow_manager/store/cachelayer" - "net/http" ) var ErrExternalStoreDisabled = model.NewAppError("App", "app.settings.sql.external.disabled", nil, "External store disabled", http.StatusForbidden) -func (fm *FlowManager) GetSqlDb(driver, dns string) (*cachelayer.ExternalDb, *model.AppError) { +func (fm *FlowManager) GetSqlDb(ctx context.Context, driver, dns string, writable bool) (*cachelayer.ExternalDb, *model.AppError) { if fm.ExternalStore == nil { return nil, ErrExternalStoreDisabled } - return fm.ExternalStore.Connect(driver, dns) + connectCfg := cachelayer.ExternalSQLStoreConnectConfig{ + Driver: driver, + DSN: dns, + Writable: writable, + } + + return fm.ExternalStore.Connect(ctx, &connectCfg) } diff --git a/flow/sql.go b/flow/sql.go index a1ee743a..d2de99a7 100644 --- a/flow/sql.go +++ b/flow/sql.go @@ -2,16 +2,18 @@ package flow import ( "context" - "github.com/webitel/flow_manager/model" "time" + + "github.com/webitel/flow_manager/model" ) type SqlArgs struct { - Driver string `json:"driver"` - Dns string `json:"dns"` - Query string `json:"query"` - Params []interface{} `json:"params"` - Timeout int `json:"timeout"` + Driver string `json:"driver"` + Dns string `json:"dns"` + Query string `json:"query"` + Params []interface{} `json:"params"` + Timeout int `json:"timeout"` + Writable bool `json:"writable"` } func (r *router) SqlHandler(ctx context.Context, scope *Flow, conn model.Connection, args interface{}) (model.Response, *model.AppError) { @@ -33,12 +35,13 @@ func (r *router) SqlHandler(ctx context.Context, scope *Flow, conn model.Connect return nil, ErrorRequiredParameter("sql", "dns") } - db, err := r.fm.GetSqlDb(req.Driver, req.Dns) + db, err := r.fm.GetSqlDb(ctx, req.Driver, req.Dns, req.Writable) if err != nil { return model.CallResponseError, err } - c, _ := context.WithTimeout(ctx, time.Duration(req.Timeout)*time.Millisecond) + c, cancel := context.WithTimeout(ctx, time.Duration(req.Timeout)*time.Millisecond) + defer cancel() result, err := db.Query(c, req.Query, req.Params) if err != nil { diff --git a/go.mod b/go.mod index c619f279..7c8f27b9 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/k3a/html2text v1.2.1 github.com/lib/pq v1.10.9 - github.com/mattn/go-sqlite3 v1.14.28 + github.com/mattn/go-sqlite3 v1.14.52 github.com/mbobakov/grpc-consul-resolver v1.5.3 github.com/mitchellh/mapstructure v1.5.0 github.com/pborman/uuid v1.2.1 diff --git a/go.sum b/go.sum index 1fc2f8cf..c1154d8e 100644 --- a/go.sum +++ b/go.sum @@ -219,6 +219,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U= +github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mbobakov/grpc-consul-resolver v1.5.3 h1:xL7nJm8qCvxgHMqlnF4naXruBUoHqfUWORl3UmwKByU= github.com/mbobakov/grpc-consul-resolver v1.5.3/go.mod h1:0wN8+McBocuk5mO9xlAfrmBSothm7sps43bFGubg0m4= diff --git a/model/config.go b/model/config.go index 09079fa2..ab8e5673 100644 --- a/model/config.go +++ b/model/config.go @@ -1,15 +1,21 @@ package model +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + const ( DATABASE_DRIVER_POSTGRES = "postgres" ) type Config struct { - ConfigFile *string `json:"-" flag:"config_file||JSON file configuration"` - Id string `json:"id" flag:"id|1|Service id" env:"ID"` - ExternalSql bool `json:"external_sql" flag:"external_sql|false|Enable external sql query" env:"EXTERNAL_SQL"` - AllowUseMQ bool `json:"allow_use_mq" flag:"allow_use_mq|false|Allow push message to MQ" env:"ALLOW_USE_MQ"` - Record struct { + ConfigFile *string `json:"-" flag:"config_file||JSON file configuration"` + Id string `json:"id" flag:"id|1|Service id" env:"ID"` + AllowUseMQ bool `json:"allow_use_mq" flag:"allow_use_mq|false|Allow push message to MQ" env:"ALLOW_USE_MQ"` + Record struct { Sample int `json:"sample" flag:"record_sample|0|Set the sample rate of the recording" env:"RECORD_SAMPLE"` } `json:"record"` DebugImap bool `json:"debug_imap" flag:"debug_imap|false|Debug IMAP protocol" env:"DEBUG_IMAP"` @@ -26,6 +32,7 @@ type Config struct { ChatTemplatesSettings ChatTemplatesSettings `json:"chat_templates_settings,omitempty"` Log LogSettings `json:"log"` Tls TLSConfig `json:"tls"` + ExternalSQLSettings ExternalSQLSettings `json:"external_sql_settings"` ProxyConfigFile string `json:"proxy_config_file" flag:"proxy_config_file||Proxy settings file (http_proxy/https_proxy/no_proxy) watched for changes" env:"PROXY_CONFIG_FILE"` } @@ -92,3 +99,43 @@ type MQSettings struct { Url string `json:"url" flag:"amqp|amqp://admin:admin@rabbit:5672?heartbeat=10|AMQP connection" env:"AMQP"` UseIM bool `json:"use_im" flag:"use_im|false|Use IM handlers" env:"USE_IM"` } + +type ExternalSQLSettings struct { + Enabled bool `json:"enabled" flag:"external_sql|false|Enable use of external SQL stores" env:"EXTERNAL_SQL"` + SandboxRootDirectory string `json:"root_directory" flag:"external_sql_sandbox_directory|/var/lib/webitel/webitel-flow-manager/sqlite|Root path for sandbox SQLLite access directory" env:"EXTERNAL_SQL_SANDBOX_DIRECTORY"` + WriteEnabled bool `json:"writable" flag:"external_sql_write_enabled|false|Is enabled write operations with external SQL stores" env:"EXTERNAL_SQL_WRITE_ENABLED"` +} + +var ErrEmptyExternalSQLSandbox = NewAppError("Config", "model.config.external_sql_settings.empty", nil, "empty external sql sandbox dir", 400) + +func (s *ExternalSQLSettings) Prepare() *AppError { + if strings.TrimSpace(s.SandboxRootDirectory) == "" { + return ErrEmptyExternalSQLSandbox + } + + if !filepath.IsAbs(s.SandboxRootDirectory) { + return NewAppError("Config", "model.config.external_sql_settings.not_absolute", nil, "sandbox root must be absolute", 400) + } + + real, err := filepath.EvalSymlinks(filepath.Clean(s.SandboxRootDirectory)) + if err != nil { + return NewAppError("Config", "model.config.external_sql_settings.sandbox_root", nil, fmt.Sprintf("sandbox root %+v", err), 400) + } + + fi, err := os.Stat(real) + if err != nil || !fi.IsDir() { + return NewAppError("Config", "model.config.external_sql_settings.sandbox_not_dir", nil, "sandbox root path must be an existing directory", 400) + } + + if fi.Mode().Perm()&0o002 != 0 { + return NewAppError("Config", "model.config.external_sql_settings.word_writable", nil, "sandbox root must not be a world writable", 400) + } + + s.SandboxRootDirectory = real + + return nil +} + +func (s *ExternalSQLSettings) SanitazeOnPrepareFailure() { + s.SandboxRootDirectory = "" +} diff --git a/store/cachelayer/store.go b/store/cachelayer/store.go index f625233b..0bed1e28 100644 --- a/store/cachelayer/store.go +++ b/store/cachelayer/store.go @@ -5,52 +5,144 @@ import ( "database/sql" "fmt" "net/http" + "net/url" + "os" + "path/filepath" + "strings" "time" _ "github.com/go-sql-driver/mysql" - _ "github.com/mattn/go-sqlite3" + "github.com/mattn/go-sqlite3" "github.com/webitel/flow_manager/model" "github.com/webitel/wlog" + "golang.org/x/sync/singleflight" ) +func init() { + sql.Register(SQLiteCustomDriverName, &sqlite3.SQLiteDriver{ + ConnectHook: func(sc *sqlite3.SQLiteConn) error { + sc.RegisterAuthorizer(func(op int, arg1, arg2, db string) int { + if op == sqlite3.SQLITE_ATTACH { + return sqlite3.SQLITE_DENY + } + + return sqlite3.SQLITE_OK + }) + + return nil + }, + }) +} + const ( CacheSize = 100 CacheExpire = 25 * 1000 ) +const ( + SQLiteModeRO string = "ro" + SQLiteModeRW string = "rw" + SQLiteCustomDriverName string = "sqlite3_sandboxed" +) + +type ExternalStoreManagerOption func(*ExternalStoreManager) + +func WithSQLLiteSandboxDir(sandbox string) ExternalStoreManagerOption { + return func(esm *ExternalStoreManager) { + esm.sqlLiteSandboxDir = sandbox + } +} + +func WithWriteOperationsEnabled(enabled bool) ExternalStoreManagerOption { + return func(esm *ExternalStoreManager) { + esm.writeEnabled = enabled + } +} + +func WithLogger(logger *wlog.Logger) ExternalStoreManagerOption { + return func(esm *ExternalStoreManager) { + esm.logger = logger + } +} + type ExternalStoreManager struct { - cache model.ObjectCache + sqlLiteSandboxDir string + writeEnabled bool + cache model.ObjectCache + logger *wlog.Logger + sf singleflight.Group } type ExternalDb struct { db *sql.DB } -func NewExternalStoreManager() *ExternalStoreManager { - return &ExternalStoreManager{ +type ExternalSQLStoreConnectConfig struct { + Driver string + DSN string + Writable bool +} + +func NewExternalStoreManager(options ...ExternalStoreManagerOption) *ExternalStoreManager { + m := &ExternalStoreManager{ cache: model.NewLruWithParams(CacheSize, "external", CacheExpire, ""), } + + for _, o := range options { + o(m) + } + + return m } -// TODO sync -func (e *ExternalStoreManager) Connect(driver, dns string) (*ExternalDb, *model.AppError) { - if d, ok := e.cache.Get(dns); ok { +func (e *ExternalStoreManager) Connect(_ context.Context, cfg *ExternalSQLStoreConnectConfig) (*ExternalDb, *model.AppError) { + cfg.Writable = cfg.Writable && e.writeEnabled + + connStr, werr := e.PrepareConnectionString(cfg) + if werr != nil { + return nil, werr + } + + key := cfg.Driver + "|" + connStr + + if d, ok := e.cache.Get(key); ok { return d.(*ExternalDb), nil } - db, err := sql.Open(driver, dns) + v, err, _ := e.sf.Do(key, func() (interface{}, error) { + if d, ok := e.cache.Get(key); ok { + return d, nil + } + + db, err := sql.Open(cfg.Driver, connStr) + if err != nil { + return nil, err + } + db.SetConnMaxIdleTime(time.Second * (CacheExpire - 60)) + + ext := &ExternalDb{db: db} + + e.cache.AddWithDefaultExpires(key, ext) + e.logger.Info("store cache db", wlog.String("driver", cfg.Driver)) + + return ext, nil + }) + if err != nil { return nil, model.NewAppError("Cache", "cache.connect.open_err", nil, err.Error(), http.StatusInternalServerError) } - db.SetConnMaxIdleTime(time.Second * (CacheExpire - 60)) + return v.(*ExternalDb), nil +} - e.cache.AddWithDefaultExpires(dns, &ExternalDb{ - db: db, - }) +func (e *ExternalStoreManager) PrepareConnectionString(connectCfg *ExternalSQLStoreConnectConfig) (string, *model.AppError) { + if !isSQLiteDriver(connectCfg.Driver) { + return connectCfg.DSN, nil + } + + connectCfg.Driver = SQLiteCustomDriverName - wlog.Info(fmt.Sprintf("store cache db %s", dns)) - return e.Connect(driver, dns) + return e.buildSQLiteDSN(connectCfg.DSN, connectCfg.Writable) } func (d *ExternalDb) Query(ctx context.Context, text string, params []interface{}) (map[string]interface{}, *model.AppError) { @@ -73,12 +165,25 @@ func (d *ExternalDb) Query(ctx context.Context, text string, params []interface{ colPtrs[i] = &cols[i] } - rows.Next() + if !rows.Next() { + if err := rows.Err(); err != nil { + return nil, model.NewAppError( + "Query", + "cachelayer.store.external_db.rows_next", + nil, + fmt.Sprintf("iterating rows result: %+v", err), + 500, + ) + } + + return make(map[string]any), nil + } + if err = rows.Scan(colPtrs...); err != nil { return nil, model.NewAppError("Cache.Query", "cache.query.scan.err", nil, err.Error(), http.StatusInternalServerError) } - result := make(map[string]interface{}) + result := make(map[string]any) for i, col := range cols { switch col.(type) { @@ -91,3 +196,128 @@ func (d *ExternalDb) Query(ctx context.Context, text string, params []interface{ return result, nil } + +func isSQLiteDriver(driver string) bool { + switch strings.ToLower(strings.TrimSpace(driver)) { + case "sqlite", "sqlite3", SQLiteCustomDriverName: + return true + default: + return false + } +} + +func (e *ExternalStoreManager) buildSQLiteDSN(userPath string, writable bool) (string, *model.AppError) { + if e.sqlLiteSandboxDir == "" { + return "", model.NewAppError( + "BuildSQLiteDSN", + "cachelayer.store.build_sqlite_dsn.empty", + nil, + "sqlite is disabled: sandbox directory not configured", + 400, + ) + } + + real, err := e.resolveSanbdoxPath(userPath, writable) + if err != nil { + return "", err + } + + mode := SQLiteModeRO + if writable { + mode = SQLiteModeRW + } + + u := url.URL{ + Scheme: "file", + OmitHost: true, + Path: real, + RawQuery: "mode=" + mode, + } + + return u.String(), nil +} + +func (e *ExternalStoreManager) resolveSanbdoxPath(p string, writable bool) (string, *model.AppError) { + if p = strings.TrimSpace(p); p == "" { + return "", model.NewAppError( + "ResolveSandboxPath", + "cachelayer.store.resolve_sandbox_path.empty_path", + nil, + "empty sqllite path", + 400, + ) + } + + lower := strings.ToLower(p) + if strings.HasPrefix(lower, ":") || strings.HasPrefix(lower, "file:") { + return "", model.NewAppError( + "ResolveSandboxPath", + "cachelayer.store.resolve_sandbox_path.not_allowed_params", + nil, + "memory/URI forms are not allowed", + 400, + ) + } + + if strings.ContainsAny(p, "\x00?#") { + return "", model.NewAppError( + "ResolveSandboxPath", + "cachelayer.store.resolve_sandbox_path.forbidden_characters", + nil, + "forbidden characters in path", + 400, + ) + } + + if !filepath.IsAbs(p) { + p = filepath.Join(e.sqlLiteSandboxDir, p) + } + + p = filepath.Clean(p) + + sandboxRoot := filepath.Clean(e.sqlLiteSandboxDir) + rootWithSep := sandboxRoot + string(os.PathSeparator) + if p != sandboxRoot && !strings.HasPrefix(p, rootWithSep) { + return "", model.NewAppError( + "ResolveSandboxPath", + "cachelayer.store.resolve_sandbox_path.escaping_sandbox", + nil, + "path escape sandbox", + 403, + ) + } + + real, err := filepath.EvalSymlinks(p) + if err != nil { + return "", model.NewAppError( + "ResolveSandboxPath", + "cachelayer.store.resolve_sandbox_path.eval_symlinks", + nil, + fmt.Sprintf("file existance: %+v", err), + 404, + ) + } + + if real != sandboxRoot && !strings.HasPrefix(real, rootWithSep) { + return "", model.NewAppError( + "ResolveSandboxPath", + "cachelayer.store.resolve_sandbox_path.escaping_sandbox", + nil, + "path escape sandbox via symlink", + 403, + ) + } + + fi, serr := os.Stat(real) + if serr != nil || !fi.Mode().IsRegular() { + return "", model.NewAppError( + "ResolveSandboxPath", + "cachelayer.store.resolve_sandbox_path.stat", + nil, + fmt.Sprintf("file stat: %+v", serr), + 400, + ) + } + + return real, nil +} diff --git a/store/cachelayer/store_test.go b/store/cachelayer/store_test.go new file mode 100644 index 00000000..a9341a6d --- /dev/null +++ b/store/cachelayer/store_test.go @@ -0,0 +1,702 @@ +package cachelayer + +import ( + "context" + "database/sql" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/webitel/wlog" +) + +type sandboxEnv struct { + root string + outside string +} + +func newSandboxEnv(t *testing.T) sandboxEnv { + t.Helper() + + base, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("eval tempdir: %v", err) + } + + env := sandboxEnv{ + root: filepath.Join(base, "sandbox"), + outside: filepath.Join(base, "outside"), + } + + for _, d := range []string{env.root, env.outside} { + if err := os.Mkdir(d, 0o750); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + } + + return env +} + +func (env sandboxEnv) manager(opts ...ExternalStoreManagerOption) *ExternalStoreManager { + all := append([]ExternalStoreManagerOption{WithSQLLiteSandboxDir(env.root), WithLogger(wlog.NewLogger(&wlog.LoggerConfiguration{}))}, opts...) + + return NewExternalStoreManager(all...) +} + +const defaultFixtureSQL = ` +CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT); +INSERT INTO items (id, name) VALUES (1, 'alpha'), (2, 'beta'); +` + +func createSQLiteDB(t *testing.T, path string) { + t.Helper() + createSQLiteDBWithSQL(t, path, defaultFixtureSQL) +} + +func createSQLiteDBWithSQL(t *testing.T, path, ddl string) { + t.Helper() + + db, err := sql.Open("sqlite3", path) + if err != nil { + t.Fatalf("open fixture %s: %v", path, err) + } + defer db.Close() + + if _, err := db.Exec(ddl); err != nil { + t.Fatalf("init fixture %s: %v", path, err) + } +} + +func mustSymlink(t *testing.T, target, link string) { + t.Helper() + + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks are not supported here: %v", err) + } +} + +func mustMkdir(t *testing.T, path string) { + t.Helper() + + if err := os.Mkdir(path, 0o750); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } +} + +func mustConnect(t *testing.T, m *ExternalStoreManager, dsn string, writable bool) *ExternalDb { + t.Helper() + + d, err := m.Connect(context.Background(), &ExternalSQLStoreConnectConfig{ + Driver: "sqlite3", + DSN: dsn, + Writable: writable, + }) + if err != nil { + t.Fatalf("connect %q (writable=%v): %v", dsn, writable, err) + } + + t.Cleanup(func() { _ = d.db.Close() }) + + return d +} + +func TestResolveSandboxPath_Allowed(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager() + + dbPath := filepath.Join(env.root, "a.db") + createSQLiteDB(t, dbPath) + + mustMkdir(t, filepath.Join(env.root, "sub")) + nested := filepath.Join(env.root, "sub", "b.db") + createSQLiteDB(t, nested) + + mustSymlink(t, dbPath, filepath.Join(env.root, "link.db")) + + tests := []struct { + name string + input string + want string + }{ + {"relative", "a.db", dbPath}, + {"absolute inside", dbPath, dbPath}, + {"nested relative", "sub/b.db", nested}, + {"dotdot that stays inside", "sub/../a.db", dbPath}, + {"surrounding whitespace", " a.db ", dbPath}, + {"symlink inside sandbox resolves to its target", "link.db", dbPath}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := m.resolveSanbdoxPath(tc.input, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestResolveSandboxPath_Denied(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager() + + createSQLiteDB(t, filepath.Join(env.root, "ok.db")) + + secret := filepath.Join(env.outside, "secret.db") + createSQLiteDB(t, secret) + + evilDir := env.root + "-evil" + mustMkdir(t, evilDir) + evil := filepath.Join(evilDir, "x.db") + createSQLiteDB(t, evil) + + mustMkdir(t, filepath.Join(env.root, "dir.db")) + mustSymlink(t, secret, filepath.Join(env.root, "escape.db")) + mustSymlink(t, env.outside, filepath.Join(env.root, "escape_dir")) + mustSymlink(t, filepath.Join(env.outside, "nope.db"), filepath.Join(env.root, "dangling.db")) + + tests := []struct { + name string + input string + }{ + {"empty", ""}, + {"only spaces", " "}, + {"memory", ":memory:"}, + {"colon prefix", ":anything"}, + {"file uri relative", "file:ok.db"}, + {"file uri upper case", "FILE:ok.db"}, + {"file uri absolute", "file:///etc/passwd"}, + {"query string", "ok.db?mode=rwc"}, + {"query string rw", "ok.db?mode=rw"}, + {"fragment", "ok.db#x"}, + {"nul byte", "ok.db\x00.txt"}, + + {"dotdot", "../outside/secret.db"}, + {"dotdot after missing dir", "sub/../../outside/secret.db"}, + {"absolute outside", secret}, + {"etc passwd", "/etc/passwd"}, + {"sibling with common prefix", evil}, + {"url-encoded dotdot", "%2e%2e/outside/secret.db"}, + + {"symlink to outside file", "escape.db"}, + {"symlink to outside dir", "escape_dir/secret.db"}, + {"dangling symlink", "dangling.db"}, + + {"sandbox root itself (relative)", "."}, + {"sandbox root itself (absolute)", env.root}, + {"directory", "dir.db"}, + {"missing file", "missing.db"}, + } + + for _, writable := range []bool{false, true} { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s/writable=%v", tc.name, writable), func(t *testing.T) { + got, err := m.resolveSanbdoxPath(tc.input, writable) + if err == nil { + t.Fatalf("expected denial, got path %q", got) + } + + if got != "" { + t.Fatalf("path must be empty on error, got %q", got) + } + }) + } + } +} + +func TestResolveSandboxPath_NoExistenceOracle(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager() + + existing := filepath.Join(env.outside, "secret.db") + createSQLiteDB(t, existing) + missing := filepath.Join(env.outside, "missing.db") + + _, e1 := m.resolveSanbdoxPath(existing, false) + _, e2 := m.resolveSanbdoxPath(missing, false) + + if e1 == nil || e2 == nil { + t.Fatalf("both must be denied: %v / %v", e1, e2) + } + + if e1.StatusCode != e2.StatusCode || e1.Id != e2.Id { + t.Fatalf("different responses leak file existence: (%d,%s) vs (%d,%s)", + e1.StatusCode, e1.Id, e2.StatusCode, e2.Id) + } + + for _, e := range []error{e1, e2} { + if strings.Contains(e.Error(), env.outside) { + t.Fatalf("error leaks real path: %v", e) + } + } +} + +func TestBuildSQLiteDSN_Disabled_WhenNoSandbox(t *testing.T) { + m := NewExternalStoreManager() + + dsn, err := m.buildSQLiteDSN("a.db", false) + if err == nil || dsn != "" { + t.Fatalf("sqlite must be disabled without sandbox, got dsn=%q err=%v", dsn, err) + } +} + +func TestBuildSQLiteDSN_Mode(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager() + + dbPath := filepath.Join(env.root, "a.db") + createSQLiteDB(t, dbPath) + + tests := []struct { + writable bool + mode string + }{ + {false, "ro"}, + {true, "rw"}, + } + + for _, tc := range tests { + t.Run(tc.mode, func(t *testing.T) { + dsn, err := m.buildSQLiteDSN("a.db", tc.writable) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + u, perr := url.Parse(dsn) + if perr != nil { + t.Fatalf("parse %q: %v", dsn, perr) + } + + if u.Scheme != "file" { + t.Fatalf("scheme = %q", u.Scheme) + } + + if u.Path != dbPath { + t.Fatalf("path = %q, want %q", u.Path, dbPath) + } + + q := u.Query() + if q.Get("mode") != tc.mode { + t.Fatalf("mode = %q, want %q", q.Get("mode"), tc.mode) + } + + if len(q) != 1 { + t.Fatalf("unexpected extra params: %v", q) + } + }) + } +} + +func TestBuildSQLiteDSN_SpecialCharsInFileName(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager() + + name := "my db & co.db" + dbPath := filepath.Join(env.root, name) + createSQLiteDB(t, dbPath) + + dsn, err := m.buildSQLiteDSN(name, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + u, perr := url.Parse(dsn) + if perr != nil { + t.Fatalf("parse: %v", perr) + } + + if u.Path != dbPath || len(u.Query()) != 1 || u.Query().Get("mode") != "ro" { + t.Fatalf("bad dsn %q (path=%q query=%v)", dsn, u.Path, u.Query()) + } + + d := mustConnect(t, m, name, false) + if _, qerr := d.Query(context.Background(), "SELECT 1 AS one", nil); qerr != nil { + t.Fatalf("query: %v", qerr) + } +} + +func TestPrepareConnectionString_Drivers(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager() + createSQLiteDB(t, filepath.Join(env.root, "a.db")) + + for _, drv := range []string{"sqlite3", "sqlite", "SQLITE3", " Sqlite "} { + t.Run("sqlite variant "+drv, func(t *testing.T) { + cfg := &ExternalSQLStoreConnectConfig{Driver: drv, DSN: "a.db"} + + dsn, err := m.PrepareConnectionString(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.Driver != SQLiteCustomDriverName { + t.Fatalf("driver must be replaced with sandboxed one, got %q", cfg.Driver) + } + + if !strings.HasPrefix(dsn, "file:") { + t.Fatalf("dsn must be built by service, got %q", dsn) + } + }) + } + + t.Run("non-sqlite is passed through", func(t *testing.T) { + raw := "user:pass@tcp(127.0.0.1:3306)/db" + cfg := &ExternalSQLStoreConnectConfig{Driver: "mysql", DSN: raw} + + dsn, err := m.PrepareConnectionString(cfg) + if err != nil || dsn != raw || cfg.Driver != "mysql" { + t.Fatalf("got dsn=%q driver=%q err=%v", dsn, cfg.Driver, err) + } + }) +} + +func TestConnect_ReadOnlyByDefault(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager(WithWriteOperationsEnabled(true)) + + createSQLiteDB(t, filepath.Join(env.root, "a.db")) + d := mustConnect(t, m, "a.db", false) + + row, err := d.Query(context.Background(), "SELECT id, name FROM items ORDER BY id", nil) + if err != nil { + t.Fatalf("select: %v", err) + } + + if row["id"] != "1" || row["name"] != "alpha" { + t.Fatalf("unexpected row: %v", row) + } + + _, werr := d.db.ExecContext(context.Background(), "INSERT INTO items (id, name) VALUES (3, 'gamma')") + if werr == nil || !strings.Contains(strings.ToLower(werr.Error()), "readonly") { + t.Fatalf("write must fail with readonly error, got: %v", werr) + } +} + +func TestConnect_WriteDowngraded_WhenServiceDisablesWrites(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager() + + createSQLiteDB(t, filepath.Join(env.root, "a.db")) + d := mustConnect(t, m, "a.db", true) + + _, werr := d.db.ExecContext(context.Background(), "INSERT INTO items (id, name) VALUES (3, 'gamma')") + if werr == nil { + t.Fatal("write must be impossible when service has writes disabled") + } +} + +func TestConnect_WriteAllowed_WhenEnabledAndRequested(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager(WithWriteOperationsEnabled(true)) + + createSQLiteDB(t, filepath.Join(env.root, "a.db")) + d := mustConnect(t, m, "a.db", true) + + if _, err := d.db.ExecContext(context.Background(), "INSERT INTO items (id, name) VALUES (3, 'gamma')"); err != nil { + t.Fatalf("write must succeed: %v", err) + } + + row, qerr := d.Query(context.Background(), "SELECT COUNT(*) AS c FROM items", nil) + if qerr != nil || row["c"] != "3" { + t.Fatalf("row=%v err=%v", row, qerr) + } +} + +func TestConnect_RW_DoesNotCreateMissingFile(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager(WithWriteOperationsEnabled(true)) + + d, err := m.Connect(context.Background(), &ExternalSQLStoreConnectConfig{ + Driver: "sqlite3", DSN: "new.db", Writable: true, + }) + if err == nil { + _ = d.db.Close() + t.Fatal("must not connect to a non-existing file") + } + + if _, serr := os.Stat(filepath.Join(env.root, "new.db")); serr == nil { + t.Fatal("file must not be created") + } +} + +func TestConnect_Cache(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager(WithWriteOperationsEnabled(true)) + createSQLiteDB(t, filepath.Join(env.root, "a.db")) + + ro1 := mustConnect(t, m, "a.db", false) + ro2 := mustConnect(t, m, "a.db", false) + rw := mustConnect(t, m, "a.db", true) + + if ro1 != ro2 { + t.Fatal("same config must hit the cache") + } + + if ro1 == rw { + t.Fatal("ro and rw connections must not share a cache entry") + } + + abs := mustConnect(t, m, filepath.Join(env.root, "a.db"), false) + if abs != ro1 { + t.Fatal("relative and absolute forms of the same file must share a cache entry") + } +} + +func TestConnect_Rejected(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager(WithWriteOperationsEnabled(true)) + + secret := filepath.Join(env.outside, "secret.db") + createSQLiteDB(t, secret) + createSQLiteDB(t, filepath.Join(env.root, "a.db")) + + inputs := []string{ + "", ":memory:", "file:a.db", "file:" + secret, "a.db?mode=rwc", + "../outside/secret.db", secret, "/etc/passwd", + } + + for _, writable := range []bool{false, true} { + for _, in := range inputs { + t.Run(fmt.Sprintf("%q/writable=%v", in, writable), func(t *testing.T) { + d, err := m.Connect(context.Background(), &ExternalSQLStoreConnectConfig{ + Driver: "sqlite3", DSN: in, Writable: writable, + }) + if err == nil { + _ = d.db.Close() + t.Fatal("expected rejection") + } + + if d != nil { + t.Fatal("db must be nil on rejection") + } + }) + } + } +} + +func TestConnect_SQLiteDisabled_WithoutSandbox(t *testing.T) { + m := NewExternalStoreManager(WithLogger(wlog.NewLogger(&wlog.LoggerConfiguration{}))) + + d, err := m.Connect(context.Background(), &ExternalSQLStoreConnectConfig{Driver: "sqlite3", DSN: "a.db"}) + if err == nil { + _ = d.db.Close() + t.Fatal("sqlite must be disabled when sandbox is not configured") + } +} + +func TestConnect_CannotBypassWithInternalDriverName(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager(WithWriteOperationsEnabled(true)) + + secret := filepath.Join(env.outside, "secret.db") + createSQLiteDB(t, secret) + + dsns := []string{ + secret, + "file:" + secret + "?mode=rw", + ":memory:", + } + + for _, dsn := range dsns { + t.Run(dsn, func(t *testing.T) { + d, err := m.Connect(context.Background(), &ExternalSQLStoreConnectConfig{ + Driver: SQLiteCustomDriverName, + DSN: dsn, + Writable: true, + }) + if err == nil { + _ = d.db.Close() + t.Fatalf("driver %q with user-supplied DSN bypasses sandbox validation", SQLiteCustomDriverName) + } + }) + } +} + +func TestConnect_Concurrent_SingleInstancePerKey(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager() + createSQLiteDB(t, filepath.Join(env.root, "a.db")) + + const n = 32 + + var ( + wg sync.WaitGroup + mu sync.Mutex + got = make(map[*ExternalDb]struct{}) + ) + + for i := 0; i < n; i++ { + wg.Add(1) + + go func() { + defer wg.Done() + + d, err := m.Connect(context.Background(), &ExternalSQLStoreConnectConfig{Driver: "sqlite3", DSN: "a.db"}) + if err != nil { + t.Errorf("connect: %v", err) + return + } + + mu.Lock() + got[d] = struct{}{} + mu.Unlock() + }() + } + + wg.Wait() + + if len(got) != 1 { + t.Fatalf("expected exactly 1 instance, got %d", len(got)) + } + + for d := range got { + _ = d.db.Close() + } +} + +func TestAuthorizer_BlocksAttach(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager(WithWriteOperationsEnabled(true)) + + createSQLiteDB(t, filepath.Join(env.root, "a.db")) + + secret := filepath.Join(env.outside, "secret.db") + createSQLiteDBWithSQL(t, secret, `CREATE TABLE secrets (v TEXT); INSERT INTO secrets VALUES ('TOP');`) + + before, err := os.ReadFile(secret) + if err != nil { + t.Fatalf("read secret: %v", err) + } + + tests := []struct { + name string + sql string + params []interface{} + }{ + {"plain", fmt.Sprintf("ATTACH DATABASE '%s' AS x", secret), nil}, + {"lower case", fmt.Sprintf("attach '%s' as x", secret), nil}, + {"leading comment", fmt.Sprintf("/* c */ ATTACH '%s' AS x", secret), nil}, + {"after another statement", fmt.Sprintf("SELECT 1; ATTACH '%s' AS x", secret), nil}, + {"bound parameter", "ATTACH DATABASE ? AS x", []interface{}{secret}}, + {"uri form", fmt.Sprintf("ATTACH 'file:%s?mode=rw' AS x", secret), nil}, + {"memory", "ATTACH ':memory:' AS x", nil}, + {"temp db", "ATTACH '' AS x", nil}, + } + + for _, writable := range []bool{false, true} { + d := mustConnect(t, m, "a.db", writable) + + for _, tc := range tests { + t.Run(fmt.Sprintf("%s/writable=%v", tc.name, writable), func(t *testing.T) { + if _, qerr := d.Query(context.Background(), tc.sql, tc.params); qerr == nil { + t.Fatal("ATTACH must be denied") + } + + if _, qerr := d.Query(context.Background(), "SELECT v FROM x.secrets", nil); qerr == nil { + t.Fatal("attached schema must not be reachable") + } + }) + } + } + + after, err := os.ReadFile(secret) + if err != nil { + t.Fatalf("read secret: %v", err) + } + + if string(before) != string(after) { + t.Fatal("outside file was modified") + } +} + +func TestSQLite_LoadExtensionDisabled(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager() + + createSQLiteDB(t, filepath.Join(env.root, "a.db")) + d := mustConnect(t, m, "a.db", false) + + if _, err := d.Query(context.Background(), "SELECT load_extension('/nonexistent/lib.so')", nil); err == nil { + t.Fatal("load_extension must not be available") + } +} + +func TestQuery_Semantics(t *testing.T) { + env := newSandboxEnv(t) + m := env.manager() + + createSQLiteDB(t, filepath.Join(env.root, "a.db")) + d := mustConnect(t, m, "a.db", false) + + ctx := context.Background() + + t.Run("empty result gives empty map", func(t *testing.T) { + row, err := d.Query(ctx, "SELECT id FROM items WHERE id = 999", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if row == nil || len(row) != 0 { + t.Fatalf("want empty non-nil map, got %#v", row) + } + }) + + t.Run("only first row is returned", func(t *testing.T) { + row, err := d.Query(ctx, "SELECT name FROM items ORDER BY id", nil) + if err != nil || row["name"] != "alpha" { + t.Fatalf("row=%v err=%v", row, err) + } + }) + + t.Run("bound parameters", func(t *testing.T) { + row, err := d.Query(ctx, "SELECT name FROM items WHERE id = ?", []interface{}{2}) + if err != nil || row["name"] != "beta" { + t.Fatalf("row=%v err=%v", row, err) + } + }) + + t.Run("sql injection via parameter is inert", func(t *testing.T) { + row, err := d.Query(ctx, "SELECT name FROM items WHERE name = ?", []interface{}{"x' OR '1'='1"}) + if err != nil || len(row) != 0 { + t.Fatalf("row=%v err=%v", row, err) + } + }) + + t.Run("blob is converted to string", func(t *testing.T) { + row, err := d.Query(ctx, "SELECT x'414243' AS b", nil) + if err != nil || row["b"] != "ABC" { + t.Fatalf("row=%v err=%v", row, err) + } + }) + + t.Run("NULL current representation", func(t *testing.T) { + // фіксуємо поточну поведінку: fmt.Sprint(nil) == "" + row, err := d.Query(ctx, "SELECT NULL AS n", nil) + if err != nil || row["n"] != "" { + t.Fatalf("row=%v err=%v", row, err) + } + }) + + t.Run("invalid sql returns error", func(t *testing.T) { + if _, err := d.Query(ctx, "SELEC nonsense", nil); err == nil { + t.Fatal("expected error") + } + }) + + t.Run("canceled context", func(t *testing.T) { + cctx, cancel := context.WithCancel(ctx) + cancel() + + if _, err := d.Query(cctx, "SELECT 1", nil); err == nil { + t.Fatal("expected error on canceled context") + } + }) +}