mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 01:32:33 +02:00
[37mfix: security hardening and code quality[0m
[37m- SQL query mode uses read-only SQLite connection with PRAGMA query_only=ON[0m [37m- Lua sandbox removes dofile/loadfile/load after OpenBase to block file access[0m [37m- Plugin manager sorts by priority once at load time; GetPlugins is a plain copy[0m [37m- Proxy appends [body truncated] marker when body hits size limit[0m [37m- App startup exits with os.Exit(1) on DB open failure[0m [37m- tickCmd uses tea.Tick instead of time.Sleep in a goroutine[0m [37m- ErrMsg with non-nil error shows notification then quits[0m [37m- DB stores path for use by read-only query connection[0m [37m- WAL journal mode + NORMAL synchronous set in migrate()[0m [37m- config.go uses errors.Is(err, os.ErrNotExist)[0m [37m- main.go uses os.UserHomeDir() and removes racy port pre-check[0m [37m- findings renderer is cached and rebuilt only on width change[0m [37mCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>[0m
This commit is contained in:
+6
-13
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
@@ -56,7 +55,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if *flagAddDefaultPlugins {
|
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 != "" {
|
if *flagConfig != "" {
|
||||||
cfgPath = *flagConfig
|
cfgPath = *flagConfig
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if *flagAddDefaultConfig {
|
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 != "" {
|
if *flagConfig != "" {
|
||||||
cfgPath = *flagConfig
|
cfgPath = *flagConfig
|
||||||
}
|
}
|
||||||
@@ -95,7 +96,8 @@ func main() {
|
|||||||
os.Exit(1)
|
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 != "" {
|
if *flagConfig != "" {
|
||||||
cfgPath = *flagConfig
|
cfgPath = *flagConfig
|
||||||
}
|
}
|
||||||
@@ -119,15 +121,6 @@ func main() {
|
|||||||
config.Global.App.UpstreamProxy = *flagUpstreamProxy
|
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)
|
style.Init(config.Global)
|
||||||
icons.Init(config.Global)
|
icons.Init(config.Global)
|
||||||
keys.Init(config.Global)
|
keys.Init(config.Global)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -67,7 +68,7 @@ func Load(path string) error {
|
|||||||
viper.SetConfigType("yaml")
|
viper.SetConfigType("yaml")
|
||||||
viper.SetConfigFile(path)
|
viper.SetConfigFile(path)
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
if !os.IsNotExist(err) {
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
|
path string
|
||||||
dedupMu sync.Mutex
|
dedupMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ func Open(path string) (*DB, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
d := &DB{conn: conn}
|
d := &DB{conn: conn, path: path}
|
||||||
if err := d.migrate(); err != nil {
|
if err := d.migrate(); err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -26,6 +27,9 @@ func Open(path string) (*DB, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) migrate() 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(`
|
_, err := d.conn.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS entries (
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
+11
-1
@@ -110,9 +110,19 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) {
|
|||||||
|
|
||||||
// QueryEntries runs a WHERE expression supplied by the user against the entries
|
// QueryEntries runs a WHERE expression supplied by the user against the entries
|
||||||
// table (e.g. "status_code = 404" or "host LIKE '%example.com%'").
|
// 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) {
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
|
|||||||
name string
|
name string
|
||||||
fn lua.LGFunction
|
fn lua.LGFunction
|
||||||
}{
|
}{
|
||||||
{lua.LoadLibName, lua.OpenPackage},
|
|
||||||
{lua.BaseLibName, lua.OpenBase},
|
{lua.BaseLibName, lua.OpenBase},
|
||||||
{lua.TabLibName, lua.OpenTable},
|
{lua.TabLibName, lua.OpenTable},
|
||||||
{lua.StringLibName, lua.OpenString},
|
{lua.StringLibName, lua.OpenString},
|
||||||
@@ -27,6 +26,10 @@ func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
|
|||||||
L.Push(lua.LString(lib.name))
|
L.Push(lua.LString(lib.name))
|
||||||
L.Call(1, 0)
|
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)
|
registerUtilities(L, mgr, p)
|
||||||
return L
|
return L
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ func (m *Manager) LoadFromDir(dir string) error {
|
|||||||
m.plugins = append(m.plugins, p)
|
m.plugins = append(m.plugins, p)
|
||||||
m.mu.Unlock()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +160,6 @@ func (m *Manager) GetPlugins() []*Plugin {
|
|||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
out := make([]*Plugin, len(m.plugins))
|
out := make([]*Plugin, len(m.plugins))
|
||||||
copy(out, m.plugins)
|
copy(out, m.plugins)
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Priority > out[j].Priority })
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ func (a *interceptAddon) Response(f *goproxy.Flow) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("proxy: reading response body: %v", err)
|
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.Body = body
|
||||||
f.Response.BodyReader = nil
|
f.Response.BodyReader = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -31,10 +32,9 @@ const tickInterval = 2 * time.Second
|
|||||||
type tickMsg struct{}
|
type tickMsg struct{}
|
||||||
|
|
||||||
func tickCmd() tea.Cmd {
|
func tickCmd() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return tea.Tick(tickInterval, func(time.Time) tea.Msg {
|
||||||
time.Sleep(tickInterval)
|
|
||||||
return tickMsg{}
|
return tickMsg{}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var sidebarEntries = pageRegistry
|
var sidebarEntries = pageRegistry
|
||||||
@@ -94,14 +94,17 @@ func New(broker *intercept.Broker, name, path string) Model {
|
|||||||
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
|
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
|
||||||
}
|
}
|
||||||
|
|
||||||
if d, err := db.Open(path); err == nil {
|
d, err := db.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "db: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
m.database = d
|
m.database = d
|
||||||
broker.SetDB(d)
|
broker.SetDB(d)
|
||||||
m.history.SetDB(d)
|
m.history.SetDB(d)
|
||||||
m.replay.SetDB(d)
|
m.replay.SetDB(d)
|
||||||
m.findingsPage.SetDB(d)
|
m.findingsPage.SetDB(d)
|
||||||
mgr.SetDB(d)
|
mgr.SetDB(d)
|
||||||
}
|
|
||||||
|
|
||||||
pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
|
pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
|
||||||
if err := mgr.LoadFromDir(pluginsDir); err != nil {
|
if err := mgr.LoadFromDir(pluginsDir); err != nil {
|
||||||
|
|||||||
@@ -104,6 +104,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case proxyPkg.ErrMsg:
|
case proxyPkg.ErrMsg:
|
||||||
if msg.Err != nil {
|
if msg.Err != nil {
|
||||||
log.Printf("proxy error: %v", msg.Err)
|
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
|
return m, nil
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ type Model struct {
|
|||||||
pager paginator.Model
|
pager paginator.Model
|
||||||
help help.Model
|
help help.Model
|
||||||
|
|
||||||
|
renderer *glamour.TermRenderer
|
||||||
|
rendererWidth int
|
||||||
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
}
|
}
|
||||||
@@ -77,6 +80,11 @@ func (m *Model) recalcSizes() {
|
|||||||
m.bodyViewport.SetWidth(inner)
|
m.bodyViewport.SetWidth(inner)
|
||||||
m.bodyViewport.SetHeight(bodyVH)
|
m.bodyViewport.SetHeight(bodyVH)
|
||||||
|
|
||||||
|
if m.rendererWidth != inner {
|
||||||
|
m.renderer = nil
|
||||||
|
m.rendererWidth = 0
|
||||||
|
}
|
||||||
|
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
}
|
}
|
||||||
@@ -110,12 +118,12 @@ func (m *Model) refreshBody() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
f := m.findings[m.cursor]
|
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.SetContent(rendered)
|
||||||
m.bodyViewport.GotoTop()
|
m.bodyViewport.GotoTop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderMarkdown(src string, width int) string {
|
func (m *Model) renderMarkdownCached(src string, width int) string {
|
||||||
if src == "" {
|
if src == "" {
|
||||||
return style.S.Faint.Render(util.CenterLines("(ㆆ _ ㆆ)", "no description"))
|
return style.S.Faint.Render(util.CenterLines("(ㆆ _ ㆆ)", "no description"))
|
||||||
}
|
}
|
||||||
@@ -130,14 +138,21 @@ func renderMarkdown(src string, width int) string {
|
|||||||
if width < 10 {
|
if width < 10 {
|
||||||
width = 80
|
width = 80
|
||||||
}
|
}
|
||||||
|
// Rebuild renderer if width changed or not yet built.
|
||||||
|
if m.renderer == nil || m.rendererWidth != width {
|
||||||
r, err := glamour.NewTermRenderer(
|
r, err := glamour.NewTermRenderer(
|
||||||
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
|
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
|
||||||
glamour.WithWordWrap(width),
|
glamour.WithWordWrap(width),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
|
m.renderer = r
|
||||||
|
m.rendererWidth = width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.renderer == nil {
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
out, err := r.Render(buf.String())
|
out, err := m.renderer.Render(buf.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/anotherhadi/spilltea/internal/db"
|
"github.com/anotherhadi/spilltea/internal/db"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type panel int
|
type panel int
|
||||||
@@ -62,7 +63,12 @@ func (m Model) CurrentRaw() string {
|
|||||||
return m.entries[m.cursor].RequestRaw
|
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.
|
// RefreshCmd returns the appropriate load command given the current search state.
|
||||||
// The app model should call this instead of LoadEntriesCmd directly so that
|
// The app model should call this instead of LoadEntriesCmd directly so that
|
||||||
|
|||||||
Reference in New Issue
Block a user