package ui import ( "encoding/base64" "encoding/hex" "fmt" "io" "net" "net/url" "regexp" "strconv" "strings" "sync" "sync/atomic" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" internalhttp "github.com/shiv/internal/http" "github.com/shiv/internal/logger" "github.com/shiv/internal/store" "github.com/shiv/internal/ui/widgets" ) var intruderMarkerRegex = regexp.MustCompile(`\$<[^>]+>`) func applyPayloadEncoding(payload, encoding string) string { switch encoding { case "URL encode": return url.QueryEscape(payload) case "Double URL encode": return url.QueryEscape(url.QueryEscape(payload)) case "Base64": return base64.StdEncoding.EncodeToString([]byte(payload)) case "Hex": return hex.EncodeToString([]byte(payload)) default: return payload } } type intruderResult struct { payload string statusCode int size int durationMs int64 err string rawResp string rawReq string } type intruderTab struct { win fyne.Window projectStore *store.Store repeater *repeaterTab loot *lootTab reqEditor *widgets.TextViewEntry payloadEntry *widget.Entry filterEntry *widget.Entry startBtn *widget.Button stopBtn *widget.Button progressLabel *widget.Label responsePane *widgets.TextView requestPane *widgets.TextView sendToRepeaterBtn *widget.Button sendToLootBtn *widget.Button viewFullBtn *widget.Button loadPayloadsBtn *widget.Button payloadEncoding string // "none", "url", "double-url", "base64", "hex" selectedResult *intruderResult config store.IntruderConfig mu sync.RWMutex results []intruderResult filtered []intruderResult table *widgets.DataTable running atomic.Bool stopChan chan struct{} } func newIntruderTab(win fyne.Window, projectStore *store.Store, repeater *repeaterTab, loot *lootTab) *intruderTab { return &intruderTab{ win: win, projectStore: projectStore, repeater: repeater, loot: loot, config: projectStore.LoadIntruderConfig(), } } var intruderColumns = []widgets.DataTableColumn{ {Header: "Payload", Width: 200}, {Header: "Status", Width: 80}, {Header: "Size", Width: 100}, {Header: "Duration", Width: 100}, } func (t *intruderTab) showConfigDialog() { delayEntry := widget.NewEntry() delayEntry.SetText(strconv.Itoa(t.config.DelayMs)) delayEntry.SetPlaceHolder("0") stopOnStatusEntry := widget.NewEntry() if t.config.StopOnStatus != 0 { stopOnStatusEntry.SetText(strconv.Itoa(t.config.StopOnStatus)) } stopOnStatusEntry.SetPlaceHolder("0 = disabled") maxRedirectsEntry := widget.NewEntry() maxRedirectsEntry.SetText(strconv.Itoa(t.config.MaxRedirects)) maxRedirectsEntry.SetPlaceHolder("10") followRedirectsSelect := widget.NewSelect([]string{"never", "always", "in-scope"}, nil) followRedirectsSelect.SetSelected(t.config.FollowRedirects) timeoutEntry := widget.NewEntry() timeoutEntry.SetText(strconv.Itoa(t.config.TimeoutMs)) timeoutEntry.SetPlaceHolder("30000") encodingSelect := widget.NewSelect( []string{"None", "URL encode", "Double URL encode", "Base64", "Hex"}, nil, ) enc := t.payloadEncoding if enc == "" { enc = "None" } encodingSelect.SetSelected(enc) form := widget.NewForm( widget.NewFormItem("Delay between requests (ms)", delayEntry), widget.NewFormItem("Stop on status code", stopOnStatusEntry), widget.NewFormItem("Follow redirects", followRedirectsSelect), widget.NewFormItem("Max redirects", maxRedirectsEntry), widget.NewFormItem("Timeout (ms)", timeoutEntry), widget.NewFormItem("Payload encoding", encodingSelect), ) sized := container.NewGridWrap(fyne.NewSize(420, 310), form) configDialog := dialog.NewCustomConfirm("Attack Configuration", "Save", "Cancel", sized, func(confirmed bool) { if !confirmed { return } if delay, err := strconv.Atoi(strings.TrimSpace(delayEntry.Text)); err == nil && delay >= 0 { t.config.DelayMs = delay } if statusText := strings.TrimSpace(stopOnStatusEntry.Text); statusText == "" || statusText == "0" { t.config.StopOnStatus = 0 } else if status, err := strconv.Atoi(statusText); err == nil && status > 0 { t.config.StopOnStatus = status } if maxRedirects, err := strconv.Atoi(strings.TrimSpace(maxRedirectsEntry.Text)); err == nil && maxRedirects >= 0 { t.config.MaxRedirects = maxRedirects } t.config.FollowRedirects = followRedirectsSelect.Selected if timeout, err := strconv.Atoi(strings.TrimSpace(timeoutEntry.Text)); err == nil && timeout > 0 { t.config.TimeoutMs = timeout } t.payloadEncoding = encodingSelect.Selected t.config.RawRequest = t.reqEditor.GetText() t.config.Payloads = t.payloadEntry.Text t.projectStore.SaveIntruderConfig(t.config) }, t.win) closeOnEscape(t.win, configDialog.Dismiss) configDialog.Show() } func (t *intruderTab) buildRequestPane() fyne.CanvasObject { t.reqEditor = widgets.NewTextViewEntry() t.reqEditor.SetWindow(t.win) t.reqEditor.SetPlaceHolder("Paste raw HTTP request here, mark injection points with $\n\nExample:\nGET /search?q=$ HTTP/1.1\nHost: example.com") t.reqEditor.SetText(t.config.RawRequest) t.payloadEntry = widget.NewMultiLineEntry() t.payloadEntry.SetPlaceHolder("One payload per line:\nadmin\npassword\n' OR 1=1--") t.payloadEntry.SetMinRowsVisible(8) t.payloadEntry.SetText(t.config.Payloads) t.loadPayloadsBtn = widget.NewButtonWithIcon("Load from file", theme.FolderOpenIcon(), func() { fileDialog := dialog.NewFileOpen(func(readCloser fyne.URIReadCloser, err error) { if err != nil || readCloser == nil { return } defer readCloser.Close() data, err := io.ReadAll(readCloser) if err != nil { logger.Error("intruder: read payloads file: %v", err) dialog.ShowError(err, t.win) return } t.payloadEntry.SetText(string(data)) }, t.win) fileDialog.SetFilter(storage.NewExtensionFileFilter([]string{".txt"})) fileDialog.Show() }) payloadPane := container.NewBorder( paneHeader("Payloads"), nil, nil, nil, t.payloadEntry, ) configSplit := container.NewHSplit( container.NewBorder(paneHeader("Request"), nil, nil, nil, t.reqEditor.Build()), payloadPane, ) configSplit.SetOffset(0.6) return configSplit } func (t *intruderTab) tableCellValue(row, col int) string { t.mu.RLock() defer t.mu.RUnlock() if row >= len(t.filtered) { return "" } result := t.filtered[row] switch col { case 0: return result.payload case 1: if result.err != "" { return "ERR" } return fmt.Sprintf("%d", result.statusCode) case 2: if result.err != "" { return "-" } return fmt.Sprintf("%db", result.size) case 3: if result.err != "" { return "-" } return fmt.Sprintf("%dms", result.durationMs) } return "" } func (t *intruderTab) tableCellStyle(row, col int) widget.Importance { if col != 1 { return widget.MediumImportance } t.mu.RLock() defer t.mu.RUnlock() if row >= len(t.filtered) { return widget.MediumImportance } result := t.filtered[row] if result.err != "" { return widget.DangerImportance } switch { case result.statusCode >= 500: return widget.DangerImportance case result.statusCode >= 400: return widget.WarningImportance case result.statusCode >= 200 && result.statusCode < 300: return widget.SuccessImportance } return widget.MediumImportance } func (t *intruderTab) tableOnSelect(row int) { t.mu.RLock() if row >= len(t.filtered) { t.mu.RUnlock() return } result := t.filtered[row] t.mu.RUnlock() t.selectedResult = &result t.responsePane.SetText(result.rawResp) t.requestPane.SetText(result.rawReq) t.sendToRepeaterBtn.Enable() t.sendToLootBtn.Enable() if len(result.rawResp) > internalhttp.MaxDisplayBytes { t.viewFullBtn.Show() } else { t.viewFullBtn.Hide() } } func (t *intruderTab) buildResultsTable() fyne.CanvasObject { t.filterEntry = widget.NewEntry() t.filterEntry.SetPlaceHolder("Filter results — payload, status...") t.filterEntry.OnChanged = func(_ string) { t.applyFilter() } t.table = widgets.NewDataTable() t.table.SetWindow(t.win) t.table.Columns = intruderColumns t.table.RowCount = func() int { t.mu.RLock() defer t.mu.RUnlock() return len(t.filtered) } t.table.CellValue = t.tableCellValue t.table.CellStyle = t.tableCellStyle t.table.RowID = func(row int) int64 { return int64(row) } t.table.OnSelect = t.tableOnSelect t.table.MenuItems = func(row int) []widgets.ContextMenuItem { t.mu.RLock() if row >= len(t.filtered) { t.mu.RUnlock() return nil } result := t.filtered[row] t.mu.RUnlock() return t.contextMenuItems(result) } return container.NewBorder( container.NewBorder(nil, nil, newBoldLabel("Results"), nil, t.filterEntry), nil, nil, nil, t.table.Build(), ) } func (t *intruderTab) buildResultDetailPane() fyne.CanvasObject { t.responsePane = widgets.NewTextView() t.responsePane.SetWindow(t.win) t.responsePane.SetPlaceHolder("Select a result to view response...") t.requestPane = widgets.NewTextView() t.requestPane.SetWindow(t.win) t.requestPane.SetPlaceHolder("Select a result to view request...") copyReqBtn := widget.NewButton("Copy", func() { if t.selectedResult != nil { fyne.CurrentApp().Clipboard().SetContent(t.selectedResult.rawReq) } }) copyRespBtn := widget.NewButton("Copy", func() { if t.selectedResult != nil { fyne.CurrentApp().Clipboard().SetContent(t.selectedResult.rawResp) } }) t.viewFullBtn = widget.NewButton("View Full", func() { if t.selectedResult != nil { showFullBodyDialog([]byte(t.selectedResult.rawResp), t.win) } }) t.viewFullBtn.Hide() detailPane := container.NewHSplit( container.NewBorder( paneHeader("Request", copyReqBtn), nil, nil, nil, t.requestPane.Build(), ), container.NewBorder( paneHeader("Response", t.viewFullBtn, copyRespBtn), nil, nil, nil, t.responsePane.Build(), ), ) return detailPane } func (t *intruderTab) clearResults() { t.mu.Lock() t.results = nil t.filtered = nil t.mu.Unlock() t.table.Refresh() t.progressLabel.SetText("") t.responsePane.SetText("") t.requestPane.SetText("") t.selectedResult = nil t.sendToRepeaterBtn.Disable() t.sendToLootBtn.Disable() } func (t *intruderTab) sendSelectedToRepeater() { if t.selectedResult == nil { return } host, port, useTLS := internalhttp.ParseHostFromRaw(t.selectedResult.rawReq) path := PathOf(internalhttp.ExtractURL(t.selectedResult.rawReq)) if len(path) > 20 { path = path[:20] + "..." } t.repeater.AddTab(fmt.Sprintf("Intruder %s", path), host, port, useTLS, t.selectedResult.rawReq) } func (t *intruderTab) buildToolbar() fyne.CanvasObject { t.progressLabel = widget.NewLabel("") t.progressLabel.Importance = widget.LowImportance t.startBtn = widget.NewButtonWithIcon("Start Attack", theme.MediaPlayIcon(), func() { t.startAttack() }) t.startBtn.Importance = widget.HighImportance t.stopBtn = widget.NewButtonWithIcon("Stop", theme.MediaStopIcon(), func() { t.stopAttack() }) t.stopBtn.Disable() configBtn := widget.NewButtonWithIcon("Config", AppIcon("settings"), func() { t.showConfigDialog() }) t.sendToRepeaterBtn = widget.NewButtonWithIcon("Repeater", theme.MailForwardIcon(), func() { t.sendSelectedToRepeater() }) t.sendToRepeaterBtn.Disable() t.sendToLootBtn = widget.NewButtonWithIcon("Loot", AppIcon("loot"), func() { if t.selectedResult != nil { t.loot.showAddDialog(nil, t.selectedResult.rawReq, t.selectedResult.rawResp) } }) t.sendToLootBtn.Disable() clearBtn := widget.NewButtonWithIcon("Clear Results", AppIcon("delete"), func() { t.clearResults() }) return container.NewHBox(t.startBtn, t.stopBtn, configBtn, clearBtn, t.progressLabel, layout.NewSpacer(), t.loadPayloadsBtn) } func (t *intruderTab) build() fyne.CanvasObject { requestPane := t.buildRequestPane() detailPane := t.buildResultDetailPane() tablePane := t.buildResultsTable() toolbar := t.buildToolbar() resultsSplit := container.NewHSplit(tablePane, detailPane) resultsSplit.SetOffset(0.4) mainSplit := container.NewVSplit(requestPane, resultsSplit) mainSplit.SetOffset(0.45) return container.NewBorder(toolbar, nil, nil, nil, mainSplit) } func (t *intruderTab) followRedirect(rawResp string, originalHost string) (string, bool) { firstLine := strings.SplitN(rawResp, "\r\n", 2)[0] parts := strings.Fields(firstLine) if len(parts) < 2 { return "", false } var statusCode int fmt.Sscanf(parts[1], "%d", &statusCode) if statusCode < 300 || statusCode >= 400 { return "", false } location := "" for _, line := range strings.Split(rawResp, "\r\n") { if strings.HasPrefix(strings.ToLower(line), "location:") { location = strings.TrimSpace(line[9:]) break } } if location == "" { return "", false } switch t.config.FollowRedirects { case "never": return "", false case "in-scope": // location may be an absolute URL — extract the hostname before the scope check. scopeHost := location if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") { rest := location[strings.Index(location, "//")+2:] if slash := strings.Index(rest, "/"); slash >= 0 { scopeHost = rest[:slash] } else { scopeHost = rest } } if !t.projectStore.InScope(scopeHost) { return "", false } } var redirectHost, redirectPath string var useTLS bool if strings.HasPrefix(location, "https://") { rest := location[8:] useTLS = true if slash := strings.Index(rest, "/"); slash >= 0 { redirectHost = rest[:slash] redirectPath = rest[slash:] } else { redirectHost = rest redirectPath = "/" } } else if strings.HasPrefix(location, "http://") { rest := location[7:] useTLS = false if slash := strings.Index(rest, "/"); slash >= 0 { redirectHost = rest[:slash] redirectPath = rest[slash:] } else { redirectHost = rest redirectPath = "/" } } else { redirectHost = originalHost redirectPath = location useTLS = strings.HasSuffix(originalHost, ":443") } port := 443 if !useTLS { port = 80 } if host, portStr, err := net.SplitHostPort(redirectHost); err == nil { redirectHost = host fmt.Sscanf(portStr, "%d", &port) } redirectReq := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s\r\n\r\n", redirectPath, redirectHost) result, err := internalhttp.SendRaw(internalhttp.RawRequestOptions{ Host: redirectHost, Port: port, TLS: useTLS, RawReq: redirectReq, TimeoutMs: t.config.TimeoutMs, }) if err != nil { return "", false } return result.Raw, true } func (t *intruderTab) startAttack() { rawReq := t.reqEditor.GetText() if rawReq == "" { dialog.ShowInformation("Error", "Please enter a request.", t.win) return } markers := intruderMarkerRegex.FindAllString(rawReq, -1) if len(markers) == 0 { dialog.ShowInformation("Error", "No injection points found. Mark them with $.", t.win) return } payloadsText := strings.TrimSpace(t.payloadEntry.Text) if payloadsText == "" { dialog.ShowInformation("Error", "Please enter at least one payload.", t.win) return } payloads := strings.Split(payloadsText, "\n") var cleanPayloads []string for _, payload := range payloads { payload = strings.TrimSpace(payload) if payload != "" { cleanPayloads = append(cleanPayloads, payload) } } if len(cleanPayloads) == 0 { dialog.ShowInformation("Error", "No valid payloads found.", t.win) return } t.config.RawRequest = rawReq t.config.Payloads = t.payloadEntry.Text t.projectStore.SaveIntruderConfig(t.config) t.mu.Lock() t.results = nil t.filtered = nil t.mu.Unlock() t.table.ClearSelection() t.table.Refresh() t.responsePane.SetText("") t.requestPane.SetText("") t.selectedResult = nil t.sendToRepeaterBtn.Disable() t.sendToLootBtn.Disable() t.running.Store(true) t.stopChan = make(chan struct{}) t.startBtn.Disable() t.stopBtn.Enable() config := t.config encoding := t.payloadEncoding total := len(cleanPayloads) * len(markers) done := 0 go func() { for _, marker := range markers { for _, payload := range cleanPayloads { select { case <-t.stopChan: fyne.Do(func() { t.progressLabel.SetText(fmt.Sprintf("Stopped after %d/%d requests", done, total)) t.startBtn.Enable() t.stopBtn.Disable() }) return default: } if config.DelayMs > 0 { time.Sleep(time.Duration(config.DelayMs) * time.Millisecond) } encodedPayload := applyPayloadEncoding(payload, encoding) injected := strings.ReplaceAll(rawReq, marker, encodedPayload) host, port, useTLS := internalhttp.ParseHostFromRaw(injected) var result intruderResult result.payload = payload result.rawReq = injected if host == "" { result.err = "no Host header found" } else { start := time.Now() resp, err := internalhttp.SendRaw(internalhttp.RawRequestOptions{ Host: host, Port: port, TLS: useTLS, RawReq: injected, TimeoutMs: config.TimeoutMs, }) elapsed := time.Since(start).Milliseconds() if err != nil { result.err = err.Error() } else { rawResp := resp.Raw if config.FollowRedirects != "never" && config.MaxRedirects > 0 { for redirectCount := 0; redirectCount < config.MaxRedirects; redirectCount++ { redirectResp, followed := t.followRedirect(rawResp, host) if !followed { break } rawResp = redirectResp } } result.durationMs = elapsed result.size = len(rawResp) result.rawResp = rawResp firstLine := strings.SplitN(rawResp, "\r\n", 2)[0] parts := strings.Fields(firstLine) if len(parts) >= 2 { fmt.Sscanf(parts[1], "%d", &result.statusCode) } } } done++ shouldStop := config.StopOnStatus != 0 && result.statusCode == config.StopOnStatus resultCopy := result fyne.Do(func() { t.mu.Lock() t.results = append(t.results, resultCopy) t.mu.Unlock() t.applyFilter() t.progressLabel.SetText(fmt.Sprintf("%d/%d", done, total)) }) if shouldStop { fyne.Do(func() { t.progressLabel.SetText(fmt.Sprintf("Stopped — status %d matched after %d requests", config.StopOnStatus, done)) t.startBtn.Enable() t.stopBtn.Disable() }) return } } } fyne.Do(func() { t.running.Store(false) t.startBtn.Enable() t.stopBtn.Disable() t.progressLabel.SetText(fmt.Sprintf("Done — %d requests", done)) }) }() } func (t *intruderTab) stopAttack() { if t.stopChan != nil { close(t.stopChan) } t.running.Store(false) } func (t *intruderTab) applyFilter() { query := strings.ToLower(t.filterEntry.Text) t.mu.Lock() var filtered []intruderResult for _, result := range t.results { if query != "" { searchable := strings.ToLower(fmt.Sprintf("%s %d %s", result.payload, result.statusCode, result.err)) if !strings.Contains(searchable, query) { continue } } filtered = append(filtered, result) } t.filtered = filtered t.mu.Unlock() t.table.Refresh() } func (t *intruderTab) contextMenuItems(result intruderResult) []widgets.ContextMenuItem { return []widgets.ContextMenuItem{ { Label: "Send to Repeater", Action: func() { host, port, useTLS := internalhttp.ParseHostFromRaw(result.rawReq) path := PathOf(internalhttp.ExtractURL(result.rawReq)) if len(path) > 20 { path = path[:20] + "..." } t.repeater.AddTab(fmt.Sprintf("Intruder %s", path), host, port, useTLS, result.rawReq) }, }, { Label: "Send to Loot", Action: func() { t.loot.showAddDialog(nil, result.rawReq, result.rawResp) }, }, { Label: "Copy Request", Action: func() { fyne.CurrentApp().Clipboard().SetContent(result.rawReq) }, }, { Label: "Copy Response", Action: func() { fyne.CurrentApp().Clipboard().SetContent(result.rawResp) }, }, } }