Create every SQLite file 0600 (closes #255)
All checks were successful
check / check (push) Successful in 3m24s
All checks were successful
check / check (push) Successful in 3m24s
webhooker.db holds target configuration in plaintext — bearer tokens, API keys, Slack webhook URLs — and the session encryption key, and it was created 0644. The 0750 data directory was therefore the only barrier, and a Docker bind mount supplies that directory at 0755, which removes it: every local user on the host could read every stored credential. The mode is settled in OpenSQLite, the single open path all three tiers share, so the main database, the per-webhook event databases and the archive databases are covered in one place. 0644 comes from SQLite itself: robust_open substitutes SQLITE_DEFAULT_FILE_PERMISSIONS whenever it is handed mode 0, and findCreateFileMode yields 0 for a main database opened by URI with no `modeof` parameter. A chmod after opening would leave a window in which the credentials are on disk world-readable, so OpenSQLite creates the file itself at 0600 before the driver sees the path. That also settles the WAL sidecars, which carry the same rows and would otherwise leave the fix worthless. SQLite derives both from the main database file — `-wal` through findCreateFileMode, which stats the path with the suffix stripped, and `-shm` in unixOpenSharedMemory from an fstat of the open database descriptor — so a main file at 0600 produces sidecars at 0600. Verified by stat rather than by reading the driver: a pre-change build leaves webhooker.db, -wal and -shm all 644; this one leaves all three 600, in a 0755 bind mount, for all three tiers, with delivery and restart working. Existing files are chmodded on open, so a directory an earlier build left 0644 — including a developer's scratch directory — is fixed without any migration machinery. This is not encryption at rest. An unattended process needs a key it can read without a human, so the key lands beside the data and an attacker who can read the database can read it too. The data directory stays 0750: the group bit may matter to a deployment, and with the files at 0600 the directory is no longer the barrier. README: the Docker section now states the DATA_DIR ownership requirement where the bind mount is documented. A `-v` source path Docker creates is root:root, and the container runs as UID 1000, so it fails to start on the lock file; the chown that fixes it appeared only under Restore.
This commit is contained in:
@@ -2,8 +2,11 @@ package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // Pure Go SQLite driver
|
||||
@@ -72,6 +75,90 @@ const (
|
||||
sqliteConnMaxIdleTime = time.Minute
|
||||
)
|
||||
|
||||
// SQLiteFilePerm is the mode every SQLite file this service owns is
|
||||
// created with and held at: owner read/write, nothing for group or
|
||||
// other.
|
||||
//
|
||||
// These files hold credentials in plaintext. The main database stores
|
||||
// `targets.config` — bearer tokens, API keys, Slack webhook URLs — and
|
||||
// the session encryption key. SQLite left to itself creates them 0644
|
||||
// (see reserveSQLiteFile), which made the 0750 data directory the only
|
||||
// barrier; a bind-mounted directory supplied at 0755 removes it and
|
||||
// every local user on the host can read every stored credential.
|
||||
//
|
||||
// This is a file-mode fix and not encryption at rest. An unattended
|
||||
// process needs a key it can read without a human, so the key lands
|
||||
// beside the data and an attacker who can read the database can read
|
||||
// it too. See https://git.eeqj.de/sneak/webhooker/issues/212.
|
||||
const SQLiteFilePerm fs.FileMode = 0o600
|
||||
|
||||
// reserveSQLiteFile puts path at SQLiteFilePerm before the driver ever
|
||||
// touches it, and tightens any sidecar already on disk.
|
||||
//
|
||||
// The mode has to be settled here rather than by a chmod after opening,
|
||||
// because SQLite picks it: robust_open substitutes
|
||||
// SQLITE_DEFAULT_FILE_PERMISSIONS (0644) whenever it is handed mode 0,
|
||||
// and findCreateFileMode yields 0 for a main database opened by URI
|
||||
// with no `modeof` parameter. A chmod afterwards would leave a window
|
||||
// in which the credentials are on disk world-readable.
|
||||
//
|
||||
// Creating the file ourselves also settles the sidecars, which is the
|
||||
// half that could quietly not work. SQLite does not create those at a
|
||||
// mode we choose — it derives both from the main database file:
|
||||
// `-wal` through findCreateFileMode, which stats the path with the
|
||||
// suffix stripped, and `-shm` in unixOpenSharedMemory from an fstat of
|
||||
// the already-open database descriptor. A main file at 0600 therefore
|
||||
// produces sidecars at 0600. A zero-length file is a valid empty
|
||||
// database, so reserving it changes nothing else.
|
||||
//
|
||||
// create says whether the caller is opening in a mode that may create
|
||||
// the database. When it is false a missing file is left missing, so
|
||||
// SQLite still reports the absence rather than this function
|
||||
// materializing an empty database the caller asked not to create.
|
||||
//
|
||||
// Chmod of a file that already exists is what tightens a data
|
||||
// directory an earlier build left at 0644 — including a developer's
|
||||
// own scratch directory — without any migration machinery.
|
||||
func reserveSQLiteFile(path string, create bool) error {
|
||||
if create {
|
||||
// gosec G304: the path is the database file the caller asked
|
||||
// to open, and the driver is about to open the same path
|
||||
// anyway. Creating it here is what fixes its mode.
|
||||
f, err := os.OpenFile( //nolint:gosec // see above
|
||||
path, os.O_RDWR|os.O_CREATE, SQLiteFilePerm,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating %s: %w", path, err)
|
||||
}
|
||||
|
||||
err = f.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("closing %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// O_CREATE leaves an existing file's mode alone, and umask can only
|
||||
// have narrowed a new one. Chmod settles both cases at exactly
|
||||
// SQLiteFilePerm.
|
||||
for _, p := range append(
|
||||
[]string{path}, sqliteSidecarPaths(path)...,
|
||||
) {
|
||||
err := os.Chmod(p, SQLiteFilePerm)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("securing %s: %w", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sqliteSidecarPaths returns the files SQLite maintains beside a
|
||||
// database under WAL. They carry the same rows as the database itself,
|
||||
// so a fix that tightens only the main file has fixed nothing.
|
||||
func sqliteSidecarPaths(path string) []string {
|
||||
return []string{path + "-wal", path + "-shm"}
|
||||
}
|
||||
|
||||
// SQLiteDSN builds the connection string for one database file.
|
||||
//
|
||||
// mode is the SQLite URI open mode: "rwc" to create the file when it
|
||||
@@ -138,9 +225,17 @@ func SQLiteDSN(path, mode string) string {
|
||||
// durability settings and pool bounds applied. mode is the SQLite URI
|
||||
// open mode ("rwc" or "rw").
|
||||
//
|
||||
// The file and its WAL sidecars are settled at SQLiteFilePerm before
|
||||
// the driver sees the path; see reserveSQLiteFile.
|
||||
//
|
||||
// The handle is returned rather than a *gorm.DB because the callers
|
||||
// wrap it in gorm themselves with their own logger.
|
||||
func OpenSQLite(path, mode string) (*sql.DB, error) {
|
||||
err := reserveSQLiteFile(path, mode == SQLiteModeCreate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sqlDB, err := sql.Open("sqlite", SQLiteDSN(path, mode))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
|
||||
Reference in New Issue
Block a user