diff --git a/internal/config/config.go b/internal/config/config.go index 749c067..8421a6c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,6 +41,7 @@ type Config struct { DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"` DefaultCaptureResponse bool `mapstructure:"default_capture_response"` AutoForwardRegex []string `mapstructure:"auto_forward_regex"` + QueueSize int `mapstructure:"queue_size"` } `mapstructure:"intercept"` Replay struct { @@ -82,7 +83,7 @@ func WriteDefaultConfig(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("create config dir: %w", err) } - if err := os.WriteFile(path, defaultConfig, 0o644); err != nil { + if err := os.WriteFile(path, defaultConfig, 0o600); err != nil { return fmt.Errorf("write config: %w", err) } return nil diff --git a/internal/config/default_config.yaml b/internal/config/default_config.yaml index 8f11392..36ae8d4 100644 --- a/internal/config/default_config.yaml +++ b/internal/config/default_config.yaml @@ -5,13 +5,14 @@ app: project_dir: ~/.local/share/spilltea plugins_dir: ~/.config/spilltea/plugins upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888 - proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled) + proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled). Also run: chmod 600 ~/.config/spilltea/config.yaml max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB) external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait) intercept: default_intercept_enabled: true default_capture_response: false + queue_size: 64 # max pending intercepted requests/responses before the proxy blocks auto_forward_regex: - '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$' diff --git a/internal/db/db.go b/internal/db/db.go index 8052b25..ced3d12 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -9,6 +9,7 @@ import ( type DB struct { conn *sql.DB + roConn *sql.DB path string dedupMu sync.Mutex } @@ -27,6 +28,17 @@ func Open(path string) (*DB, error) { conn.Close() return nil, err } + roConn, err := sql.Open("sqlite", path) + if err != nil { + conn.Close() + return nil, err + } + if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil { + conn.Close() + roConn.Close() + return nil, err + } + d.roConn = roConn return d, nil } @@ -94,6 +106,7 @@ func (d *DB) Close() error { if d == nil { return nil } + _ = d.roConn.Close() return d.conn.Close() } diff --git a/internal/db/entries.go b/internal/db/entries.go index ccb7bc7..74d983a 100644 --- a/internal/db/entries.go +++ b/internal/db/entries.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "database/sql" "fmt" + "log" "strings" "time" ) @@ -63,7 +64,11 @@ func (d *DB) InsertEntry(e Entry, body string) (Entry, error) { if err != nil { return e, err } - e.ID, _ = res.LastInsertId() + var idErr error + e.ID, idErr = res.LastInsertId() + if idErr != nil { + log.Printf("db: LastInsertId: %v", idErr) + } return e, nil } @@ -113,19 +118,11 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) { // QueryEntries runs a WHERE expression supplied by the user against the entries // table (e.g. "status_code = 404" or "host LIKE '%example.com%'"). -// It opens a dedicated read-only connection so that any DML or DDL in the -// user-supplied expression is rejected by SQLite before it can execute. +// Uses the persistent read-only connection (PRAGMA query_only=ON) so that any +// DML or DDL in the user-supplied expression is rejected by SQLite before it executes. func (d *DB) QueryEntries(where string) ([]Entry, error) { - roConn, err := sql.Open("sqlite", d.path) - if err != nil { - return nil, err - } - defer roConn.Close() - if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil { - return nil, err - } q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where) - rows, err := roConn.Query(q) + rows, err := d.roConn.Query(q) if err != nil { return nil, err } diff --git a/internal/intercept/broker.go b/internal/intercept/broker.go index 08073ec..f04bc01 100644 --- a/internal/intercept/broker.go +++ b/internal/intercept/broker.go @@ -58,9 +58,13 @@ func (b *Broker) SetOnNewEntry(cb func(db.Entry)) { } func NewBroker() *Broker { + size := config.Global.Intercept.QueueSize + if size <= 0 { + size = 64 + } b := &Broker{ - Incoming: make(chan *PendingRequest, 64), - IncomingResponse: make(chan *PendingResponse, 64), + Incoming: make(chan *PendingRequest, size), + IncomingResponse: make(chan *PendingResponse, size), } b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex) return b diff --git a/internal/plugins/manager.go b/internal/plugins/manager.go index 2a115e7..d6c957a 100644 --- a/internal/plugins/manager.go +++ b/internal/plugins/manager.go @@ -182,7 +182,9 @@ func (m *Manager) TogglePlugin(name string) { configText := found.ConfigText found.mu.Unlock() if m.db != nil { - _ = m.db.SavePluginState(name, enabled, configText) + if err := m.db.SavePluginState(name, enabled, configText); err != nil { + log.Printf("plugin %s: save state: %v", name, err) + } } if !enabled { return @@ -195,7 +197,9 @@ func (m *Manager) TogglePlugin(name string) { if ret == lua.LFalse { p.Enabled = false if m.db != nil { - _ = m.db.SavePluginState(p.Name, false, p.ConfigText) + if err := m.db.SavePluginState(p.Name, false, p.ConfigText); err != nil { + log.Printf("plugin %s: save state: %v", p.Name, err) + } } } } @@ -241,7 +245,9 @@ func (m *Manager) SaveConfig(name, configText string) { _, hasOnConfig := found.hooks["on_config"] found.mu.Unlock() if m.db != nil { - _ = m.db.SavePluginState(name, enabled, configText) + if err := m.db.SavePluginState(name, enabled, configText); err != nil { + log.Printf("plugin %s: save state: %v", name, err) + } } if !hasOnConfig { return @@ -282,7 +288,9 @@ func (m *Manager) RunOnStart() { if ret == lua.LFalse { p.Enabled = false if m.db != nil { - _ = m.db.SavePluginState(p.Name, false, p.ConfigText) + if err := m.db.SavePluginState(p.Name, false, p.ConfigText); err != nil { + log.Printf("plugin %s: save state: %v", p.Name, err) + } } } } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index c91741f..cac8d36 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -1,6 +1,7 @@ package proxy import ( + "crypto/subtle" "encoding/base64" "fmt" "io" @@ -46,7 +47,6 @@ func (a *interceptAddon) Request(f *goproxy.Flow) { switch a.plugins.RunSyncOnRequest(f) { case intercept.Drop: f.Response = dropResponse() - go a.plugins.RunAsyncOnRequest(f) return case intercept.Forward: go a.plugins.RunAsyncOnRequest(f) @@ -133,7 +133,9 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error { wantUser, wantPass := parts[0], parts[1] p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) { user, pass, ok := parseBasicProxyAuth(req.Header.Get("Proxy-Authorization")) - if !ok || user != wantUser || pass != wantPass { + userOK := subtle.ConstantTimeCompare([]byte(user), []byte(wantUser)) + passOK := subtle.ConstantTimeCompare([]byte(pass), []byte(wantPass)) + if !ok || userOK&passOK != 1 { res.Header().Set("Proxy-Authenticate", `Basic realm="spilltea"`) return false, fmt.Errorf("invalid credentials") } diff --git a/internal/style/style.go b/internal/style/style.go index 2999917..2672e52 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -28,6 +28,12 @@ type Styles struct { PagerDotActive string PagerDotInactive string + + methodGet lipgloss.Style + methodPost lipgloss.Style + methodPutPatch lipgloss.Style + methodDelete lipgloss.Style + methodDefault lipgloss.Style } var S *Styles @@ -46,6 +52,7 @@ func Init(cfg *config.Config) { primary := lipgloss.Color("#" + c.Base0D) // Accent: primary purple := lipgloss.Color("#" + c.Base0E) // Purple: editing + methodBase := lipgloss.NewStyle().Bold(true).Width(7) S = &Styles{ Primary: primary, Success: success, @@ -74,6 +81,12 @@ func Init(cfg *config.Config) { PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(), PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(), + + methodGet: methodBase.Foreground(success), + methodPost: methodBase.Foreground(warning), + methodPutPatch: methodBase.Foreground(primary), + methodDelete: methodBase.Foreground(errCol), + methodDefault: methodBase.Foreground(text), } } @@ -90,17 +103,16 @@ func NewHelp() help.Model { } func (s *Styles) Method(method string) lipgloss.Style { - base := lipgloss.NewStyle().Bold(true).Width(7) switch method { case "GET": - return base.Foreground(s.Success) + return s.methodGet case "POST": - return base.Foreground(s.Warning) + return s.methodPost case "PUT", "PATCH": - return base.Foreground(s.Primary) + return s.methodPutPatch case "DELETE": - return base.Foreground(s.Error) + return s.methodDelete default: - return base.Foreground(s.Text) + return s.methodDefault } } diff --git a/internal/ui/findings/view.go b/internal/ui/findings/view.go index 602b0a5..c740a39 100644 --- a/internal/ui/findings/view.go +++ b/internal/ui/findings/view.go @@ -58,13 +58,7 @@ func (m *Model) renderList() string { ) } - start, end := m.pager.GetSliceBounds(len(m.findings)) - if start < 0 { - start = 0 - } - if end < start { - end = start - } + start, end := util.PageBounds(m.pager, len(m.findings)) var sb strings.Builder for i, f := range m.findings[start:end] { diff --git a/internal/ui/history/view.go b/internal/ui/history/view.go index 4513cf7..a29acf4 100644 --- a/internal/ui/history/view.go +++ b/internal/ui/history/view.go @@ -96,13 +96,7 @@ func (m *Model) renderList() string { ) } - start, end := m.pager.GetSliceBounds(len(m.entries)) - if start < 0 { - start = 0 - } - if end < start { - end = start - } + start, end := util.PageBounds(m.pager, len(m.entries)) var sb strings.Builder for i, e := range m.entries[start:end] { diff --git a/internal/ui/intercept/view.go b/internal/ui/intercept/view.go index 7f87e73..f52aa82 100644 --- a/internal/ui/intercept/view.go +++ b/internal/ui/intercept/view.go @@ -109,13 +109,7 @@ func (m *Model) renderList() string { } s := style.S - start, end := m.pager.GetSliceBounds(len(m.queue)) - if start < 0 { - start = 0 - } - if end < start { - end = start - } + start, end := util.PageBounds(m.pager, len(m.queue)) var sb strings.Builder for i, req := range m.queue[start:end] { @@ -165,13 +159,7 @@ func (m *Model) renderResponseList() string { } s := style.S - start, end := m.responsePager.GetSliceBounds(len(m.responseQueue)) - if start < 0 { - start = 0 - } - if end < start { - end = start - } + start, end := util.PageBounds(m.responsePager, len(m.responseQueue)) var sb strings.Builder for i, resp := range m.responseQueue[start:end] { diff --git a/internal/ui/plugins/view.go b/internal/ui/plugins/view.go index 4abba6d..a4a1435 100644 --- a/internal/ui/plugins/view.go +++ b/internal/ui/plugins/view.go @@ -143,13 +143,7 @@ func (m *Model) renderList() string { ) } - start, end := m.pager.GetSliceBounds(len(m.filtered)) - if start < 0 { - start = 0 - } - if end < start { - end = start - } + start, end := util.PageBounds(m.pager, len(m.filtered)) var sb strings.Builder for i, p := range m.filtered[start:end] { diff --git a/internal/ui/replay/view.go b/internal/ui/replay/view.go index 3fba86a..44d1654 100644 --- a/internal/ui/replay/view.go +++ b/internal/ui/replay/view.go @@ -88,13 +88,7 @@ func (m *Model) renderList() string { } s := style.S - start, end := m.pager.GetSliceBounds(len(m.entries)) - if start < 0 { - start = 0 - } - if end < start { - end = start - } + start, end := util.PageBounds(m.pager, len(m.entries)) var sb strings.Builder for i, e := range m.entries[start:end] { diff --git a/internal/util/editor.go b/internal/util/editor.go index 3313ce1..d729d08 100644 --- a/internal/util/editor.go +++ b/internal/util/editor.go @@ -1,6 +1,7 @@ package util import ( + "log" "os" "os/exec" @@ -27,7 +28,9 @@ func OpenExternalEditor(content string) tea.Cmd { return nil } tmpPath := f.Name() - _, _ = f.WriteString(content) + if _, err := f.WriteString(content); err != nil { + log.Printf("editor: writing temp file: %v", err) + } f.Close() return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg { defer os.Remove(tmpPath) diff --git a/internal/util/util.go b/internal/util/util.go index 6c5f6d9..703cdff 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -3,6 +3,7 @@ package util import ( "strings" + "charm.land/bubbles/v2/paginator" "charm.land/lipgloss/v2" ) @@ -28,6 +29,18 @@ func CenterLines(lines ...string) string { return strings.Join(centered, "\n") } +// PageBounds returns clamped start/end indices for rendering a paginated list. +func PageBounds(p paginator.Model, total int) (start, end int) { + start, end = p.GetSliceBounds(total) + if start < 0 { + start = 0 + } + if end < start { + end = start + } + return +} + // InferScheme returns "http" for port 80, "https" otherwise. func InferScheme(host string) string { if strings.HasSuffix(host, ":80") { diff --git a/plugins/trufflehog.lua b/plugins/trufflehog.lua index 2521830..d67d68c 100644 --- a/plugins/trufflehog.lua +++ b/plugins/trufflehog.lua @@ -15,9 +15,7 @@ Findings are deduplicated per host+path+body content so repeated requests do not } function on_start() - local handle = io.popen("command -v trufflehog 2>/dev/null") - local result = handle and handle:read("*a") or "" - if handle then handle:close() end + local result, _ = shell_pipe("command -v trufflehog 2>/dev/null") if not result or result:match("^%s*$") then log("trufflehog is not installed or not in PATH") notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error")