diff --git a/cmd/spilltea/main.go b/cmd/spilltea/main.go index 114960b..efad090 100644 --- a/cmd/spilltea/main.go +++ b/cmd/spilltea/main.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "net" "os" "path/filepath" "runtime/debug" @@ -56,7 +55,8 @@ func main() { } if *flagAddDefaultPlugins { - cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml") + home, _ := os.UserHomeDir() + cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml") if *flagConfig != "" { cfgPath = *flagConfig } @@ -78,7 +78,8 @@ func main() { } if *flagAddDefaultConfig { - cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml") + home, _ := os.UserHomeDir() + cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml") if *flagConfig != "" { cfgPath = *flagConfig } @@ -95,7 +96,8 @@ func main() { os.Exit(1) } - cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml") + home, _ := os.UserHomeDir() + cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml") if *flagConfig != "" { cfgPath = *flagConfig } @@ -119,15 +121,6 @@ func main() { config.Global.App.UpstreamProxy = *flagUpstreamProxy } - addr := fmt.Sprintf("%s:%d", config.Global.App.Host, config.Global.App.Port) - // Check if the proxy port is available before starting the UI. - ln, err := net.Listen("tcp", addr) - if err != nil { - fmt.Fprintf(os.Stderr, "proxy: cannot bind to %s: %v\n", addr, err) - os.Exit(1) - } - ln.Close() - style.Init(config.Global) icons.Init(config.Global) keys.Init(config.Global) diff --git a/internal/config/config.go b/internal/config/config.go index 82a7b24..c3db56d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( _ "embed" + "errors" "fmt" "os" "path/filepath" @@ -67,7 +68,7 @@ func Load(path string) error { viper.SetConfigType("yaml") viper.SetConfigFile(path) if err := viper.ReadInConfig(); err != nil { - if !os.IsNotExist(err) { + if !errors.Is(err, os.ErrNotExist) { return err } } diff --git a/internal/db/db.go b/internal/db/db.go index 6b1bacd..d2a2c19 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -9,6 +9,7 @@ import ( type DB struct { conn *sql.DB + path string dedupMu sync.Mutex } @@ -17,7 +18,7 @@ func Open(path string) (*DB, error) { if err != nil { return nil, err } - d := &DB{conn: conn} + d := &DB{conn: conn, path: path} if err := d.migrate(); err != nil { conn.Close() return nil, err @@ -26,6 +27,9 @@ func Open(path string) (*DB, error) { } func (d *DB) migrate() error { + if _, err := d.conn.Exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=OFF;`); err != nil { + return err + } _, err := d.conn.Exec(` CREATE TABLE IF NOT EXISTS entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/internal/db/entries.go b/internal/db/entries.go index ce1d8fa..ffd034b 100644 --- a/internal/db/entries.go +++ b/internal/db/entries.go @@ -110,9 +110,19 @@ 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. 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 FROM entries WHERE " + strings.TrimSpace(where) - rows, err := d.conn.Query(q) + rows, err := roConn.Query(q) if err != nil { return nil, err } diff --git a/internal/plugins/lua.go b/internal/plugins/lua.go index 4ee3ee1..69c6c3c 100644 --- a/internal/plugins/lua.go +++ b/internal/plugins/lua.go @@ -16,7 +16,6 @@ func newLuaState(mgr *Manager, p *Plugin) *lua.LState { name string fn lua.LGFunction }{ - {lua.LoadLibName, lua.OpenPackage}, {lua.BaseLibName, lua.OpenBase}, {lua.TabLibName, lua.OpenTable}, {lua.StringLibName, lua.OpenString}, @@ -27,6 +26,10 @@ func newLuaState(mgr *Manager, p *Plugin) *lua.LState { L.Push(lua.LString(lib.name)) L.Call(1, 0) } + // Remove filesystem-access functions to prevent plugins from reading/executing arbitrary files. + for _, name := range []string{"dofile", "loadfile", "load"} { + L.SetGlobal(name, lua.LNil) + } registerUtilities(L, mgr, p) return L } diff --git a/internal/plugins/manager.go b/internal/plugins/manager.go index a15db6e..21a9bff 100644 --- a/internal/plugins/manager.go +++ b/internal/plugins/manager.go @@ -81,6 +81,9 @@ func (m *Manager) LoadFromDir(dir string) error { m.plugins = append(m.plugins, p) m.mu.Unlock() } + m.mu.Lock() + sort.Slice(m.plugins, func(i, j int) bool { return m.plugins[i].Priority > m.plugins[j].Priority }) + m.mu.Unlock() return nil } @@ -157,7 +160,6 @@ func (m *Manager) GetPlugins() []*Plugin { defer m.mu.RUnlock() out := make([]*Plugin, len(m.plugins)) copy(out, m.plugins) - sort.Slice(out, func(i, j int) bool { return out[i].Priority > out[j].Priority }) return out } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index e7ac78f..2b83df4 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -69,6 +69,10 @@ func (a *interceptAddon) Response(f *goproxy.Flow) { if err != nil { log.Printf("proxy: reading response body: %v", err) } + if int64(len(body)) == limit { + log.Printf("proxy: response body truncated at %dMB for %s", config.Global.App.MaxBodySizeMB, f.Request.URL.Host) + body = append(body, []byte(fmt.Sprintf("\n\n[body truncated at %dMB]", config.Global.App.MaxBodySizeMB))...) + } f.Response.Body = body f.Response.BodyReader = nil } diff --git a/internal/ui/app/model.go b/internal/ui/app/model.go index 1751d19..e3ba673 100644 --- a/internal/ui/app/model.go +++ b/internal/ui/app/model.go @@ -1,6 +1,7 @@ package app import ( + "fmt" "log" "os" "path/filepath" @@ -31,10 +32,9 @@ const tickInterval = 2 * time.Second type tickMsg struct{} func tickCmd() tea.Cmd { - return func() tea.Msg { - time.Sleep(tickInterval) + return tea.Tick(tickInterval, func(time.Time) tea.Msg { return tickMsg{} - } + }) } var sidebarEntries = pageRegistry @@ -94,14 +94,17 @@ func New(broker *intercept.Broker, name, path string) Model { sidebarState: sidebarState(cfg.TUI.DefaultSidebarState), } - if d, err := db.Open(path); err == nil { - m.database = d - broker.SetDB(d) - m.history.SetDB(d) - m.replay.SetDB(d) - m.findingsPage.SetDB(d) - mgr.SetDB(d) + d, err := db.Open(path) + if err != nil { + fmt.Fprintf(os.Stderr, "db: %v\n", err) + os.Exit(1) } + m.database = d + broker.SetDB(d) + m.history.SetDB(d) + m.replay.SetDB(d) + m.findingsPage.SetDB(d) + mgr.SetDB(d) pluginsDir := config.ExpandPath(cfg.App.PluginsDir) if err := mgr.LoadFromDir(pluginsDir); err != nil { diff --git a/internal/ui/app/update.go b/internal/ui/app/update.go index 376b417..6570d60 100644 --- a/internal/ui/app/update.go +++ b/internal/ui/app/update.go @@ -104,6 +104,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case proxyPkg.ErrMsg: if msg.Err != nil { log.Printf("proxy error: %v", msg.Err) + return m, tea.Batch( + func() tea.Msg { + return notificationsUI.NotificationMsg{ + Title: "Proxy Error", + Body: msg.Err.Error(), + Kind: notificationsUI.KindError, + } + }, + tea.Quit, + ) } return m, nil diff --git a/internal/ui/findings/model.go b/internal/ui/findings/model.go index 0d70824..8ea778e 100644 --- a/internal/ui/findings/model.go +++ b/internal/ui/findings/model.go @@ -28,6 +28,9 @@ type Model struct { pager paginator.Model help help.Model + renderer *glamour.TermRenderer + rendererWidth int + width int height int } @@ -77,6 +80,11 @@ func (m *Model) recalcSizes() { m.bodyViewport.SetWidth(inner) m.bodyViewport.SetHeight(bodyVH) + if m.rendererWidth != inner { + m.renderer = nil + m.rendererWidth = 0 + } + m.refreshListViewport() m.refreshBody() } @@ -110,12 +118,12 @@ func (m *Model) refreshBody() { return } f := m.findings[m.cursor] - rendered := renderMarkdown(f.Description, m.bodyViewport.Width()) + rendered := m.renderMarkdownCached(f.Description, m.bodyViewport.Width()) m.bodyViewport.SetContent(rendered) m.bodyViewport.GotoTop() } -func renderMarkdown(src string, width int) string { +func (m *Model) renderMarkdownCached(src string, width int) string { if src == "" { return style.S.Faint.Render(util.CenterLines("(ㆆ _ ㆆ)", "no description")) } @@ -130,14 +138,21 @@ func renderMarkdown(src string, width int) string { if width < 10 { width = 80 } - r, err := glamour.NewTermRenderer( - glamour.WithStyles(style.GlamourStyleConfig(config.Global)), - glamour.WithWordWrap(width), - ) - if err != nil { + // Rebuild renderer if width changed or not yet built. + if m.renderer == nil || m.rendererWidth != width { + r, err := glamour.NewTermRenderer( + glamour.WithStyles(style.GlamourStyleConfig(config.Global)), + glamour.WithWordWrap(width), + ) + if err == nil { + m.renderer = r + m.rendererWidth = width + } + } + if m.renderer == nil { return buf.String() } - out, err := r.Render(buf.String()) + out, err := m.renderer.Render(buf.String()) if err != nil { return buf.String() } diff --git a/internal/ui/history/model.go b/internal/ui/history/model.go index 3923f0e..77c11a9 100644 --- a/internal/ui/history/model.go +++ b/internal/ui/history/model.go @@ -10,6 +10,7 @@ import ( "github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/style" + "github.com/anotherhadi/spilltea/internal/util" ) type panel int @@ -62,7 +63,12 @@ func (m Model) CurrentRaw() string { return m.entries[m.cursor].RequestRaw } -func (m Model) CurrentScheme() string { return "https" } +func (m Model) CurrentScheme() string { + if len(m.entries) == 0 || m.cursor >= len(m.entries) { + return "https" + } + return util.InferScheme(m.entries[m.cursor].Host) +} // RefreshCmd returns the appropriate load command given the current search state. // The app model should call this instead of LoadEntriesCmd directly so that