- Trusted proxy config option for proxy-aware IP extraction used by rate limiting and audit logs; validates proxy IP before trusting X-Forwarded-For / X-Real-IP headers - TOTP replay protection via counter-based validation to reject reused codes within the same time step (±30s) - RateLimit middleware updated to extract client IP from proxy headers without IP spoofing risk - New tests for ClientIP proxy logic (spoofed headers, fallback) and extended rate-limit proxy coverage - HTMX error banner script integrated into web UI base - .gitignore updated for mciasdb build artifact Security: resolves CRIT-01 (TOTP replay attack) and DEF-03 (proxy-unaware rate limiting); gRPC TOTP enrollment aligned with REST via StorePendingTOTP Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
134 lines
4.0 KiB
Go
134 lines
4.0 KiB
Go
// Package db provides the SQLite database access layer for MCIAS.
|
|
//
|
|
// Security design:
|
|
// - All queries use parameterized statements; no string concatenation.
|
|
// - Foreign keys are enforced (PRAGMA foreign_keys = ON).
|
|
// - WAL mode is enabled for safe concurrent reads during writes.
|
|
// - The audit log is append-only: no update or delete operations are provided.
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite" // register the sqlite3 driver
|
|
)
|
|
|
|
// memCounter generates unique names for in-memory shared-cache databases.
|
|
var memCounter atomic.Int64
|
|
|
|
// DB wraps a *sql.DB with MCIAS-specific helpers.
|
|
type DB struct {
|
|
sql *sql.DB
|
|
// path is the DSN used to open this database. For in-memory databases
|
|
// (originally ":memory:") it is a unique shared-cache URI of the form
|
|
// file:mcias_N?mode=memory&cache=shared so that a second connection can be
|
|
// opened to the same in-memory database (needed by the migration runner).
|
|
path string
|
|
}
|
|
|
|
// Open opens (or creates) the SQLite database at path and configures it for
|
|
// MCIAS use (WAL mode, foreign keys, busy timeout).
|
|
func Open(path string) (*DB, error) {
|
|
// Translate bare ":memory:" to a named shared-cache in-memory URI.
|
|
// This allows the migration runner to open a second connection to the
|
|
// same in-memory database without sharing the *sql.DB handle (which
|
|
// would be closed by golang-migrate when the migrator is done).
|
|
if path == ":memory:" {
|
|
path = fmt.Sprintf("file:mcias_%d?mode=memory&cache=shared", memCounter.Add(1))
|
|
}
|
|
|
|
// The modernc.org/sqlite driver is registered as "sqlite".
|
|
sqlDB, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("db: open sqlite: %w", err)
|
|
}
|
|
|
|
// Use a single connection for writes; reads can use the pool.
|
|
sqlDB.SetMaxOpenConns(1)
|
|
|
|
db := &DB{sql: sqlDB, path: path}
|
|
if err := db.configure(); err != nil {
|
|
_ = sqlDB.Close()
|
|
return nil, err
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// configure applies PRAGMAs that must be set on every connection.
|
|
func (db *DB) configure() error {
|
|
pragmas := []string{
|
|
"PRAGMA journal_mode=WAL",
|
|
"PRAGMA foreign_keys=ON",
|
|
"PRAGMA busy_timeout=5000",
|
|
// Security (DEF-07): FULL synchronous mode ensures every write is
|
|
// flushed to disk before SQLite considers it committed. With WAL
|
|
// mode + NORMAL, a power failure between a write and the next
|
|
// checkpoint could lose the most recent committed transactions,
|
|
// including token issuance and revocation records — which must be
|
|
// durable. The performance cost is negligible for a single-node
|
|
// personal SSO server.
|
|
"PRAGMA synchronous=FULL",
|
|
}
|
|
for _, p := range pragmas {
|
|
if _, err := db.sql.Exec(p); err != nil {
|
|
return fmt.Errorf("db: configure pragma %q: %w", p, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Close closes the underlying database connection.
|
|
func (db *DB) Close() error {
|
|
return db.sql.Close()
|
|
}
|
|
|
|
// Ping verifies the database connection is alive.
|
|
func (db *DB) Ping(ctx context.Context) error {
|
|
return db.sql.PingContext(ctx)
|
|
}
|
|
|
|
// SQL returns the underlying *sql.DB for use in tests or advanced queries.
|
|
// Prefer the typed methods on DB for all production code.
|
|
func (db *DB) SQL() *sql.DB {
|
|
return db.sql
|
|
}
|
|
|
|
// now returns the current UTC time formatted as ISO-8601.
|
|
func now() string {
|
|
return time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
// parseTime parses an ISO-8601 UTC time string returned by SQLite.
|
|
func parseTime(s string) (time.Time, error) {
|
|
t, err := time.Parse(time.RFC3339, s)
|
|
if err != nil {
|
|
// Try without timezone suffix (some SQLite defaults).
|
|
t, err = time.Parse("2006-01-02T15:04:05", s)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("db: parse time %q: %w", s, err)
|
|
}
|
|
return t.UTC(), nil
|
|
}
|
|
return t.UTC(), nil
|
|
}
|
|
|
|
// ErrNotFound is returned when a requested record does not exist.
|
|
var ErrNotFound = errors.New("db: record not found")
|
|
|
|
// nullableTime converts a *string from SQLite into a *time.Time.
|
|
func nullableTime(s *string) (*time.Time, error) {
|
|
if s == nil {
|
|
return nil, nil
|
|
}
|
|
t, err := parseTime(*s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &t, nil
|
|
}
|