From 9401aa669eee05fe5339f20566954cff55301732 Mon Sep 17 00:00:00 2001 From: vmfunc Date: Tue, 9 Jun 2026 18:00:45 -0700 Subject: [PATCH] feat(scan): add cors, open-redirect and reflected-xss probes three active web-vuln probes wired into the per-target loop: - cors: crafts attacker origins (evil sentinel, null, prefix/suffix bypass, http downgrade) and flags responses that reflect them in access-control-allow-origin, ranking reflection+credentials high. - redirect: injects a controlled sentinel host plus bypass variants (//, https:/, backslash, null-byte, userinfo @) into redirect-prone params and catches 30x location, meta-refresh and js redirects that resolve off-site. - xss: injects a unique canary wrapped in breaking chars, classifies the reflection context (html/attribute/script) and reports only the chars that survive unescaped where they matter, so escaped reflections don't false-positive. all route through httpx.Client so proxy/-H/-cookie/-rate-limit apply. hermetic httptest coverage plus integration testbed entries. --- README.md | 6 + docs/usage.md | 27 +++ internal/config/config.go | 6 + internal/scan/cors.go | 236 +++++++++++++++++++++ internal/scan/cors_test.go | 140 ++++++++++++ internal/scan/integration_test.go | 65 ++++++ internal/scan/redirect.go | 305 ++++++++++++++++++++++++++ internal/scan/redirect_test.go | 163 ++++++++++++++ internal/scan/xss.go | 342 ++++++++++++++++++++++++++++++ internal/scan/xss_test.go | 153 +++++++++++++ man/sif.1 | 9 + sif.go | 30 +++ 12 files changed, 1482 insertions(+) create mode 100644 internal/scan/cors.go create mode 100644 internal/scan/cors_test.go create mode 100644 internal/scan/redirect.go create mode 100644 internal/scan/redirect_test.go create mode 100644 internal/scan/xss.go create mode 100644 internal/scan/xss_test.go diff --git a/README.md b/README.md index 906c50d..d780762 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,9 @@ makepkg -si # sql recon + lfi scanning ./sif -u https://example.com -sql -lfi +# web vuln probes (cors, open redirect, reflected xss) +./sif -u https://example.com -cors -redirect -xss + # framework detection (with cve lookup) ./sif -u https://example.com -framework @@ -170,6 +173,9 @@ sif has a modular architecture. modules are defined in yaml and can be extended | `-securitytrails` | domain discovery + target expansion (requires SECURITYTRAILS_API_KEY) | | `-sql` | sql recon | | `-lfi` | local file inclusion | +| `-cors` | cors misconfiguration probe | +| `-redirect` | open redirect probe | +| `-xss` | reflected xss probe | | `-framework` | framework detection with cve lookup | ### http options diff --git a/docs/usage.md b/docs/usage.md index 6f49b74..c58e16a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -154,6 +154,30 @@ export SHODAN_API_KEY=your-api-key ./sif -u https://example.com -lfi ``` +### cors probe + +`-cors` - probe for cors misconfigurations (reflected/permissive origins) + +```bash +./sif -u https://example.com -cors +``` + +### open redirect probe + +`-redirect` - probe redirect-prone params for open redirects + +```bash +./sif -u https://example.com/login?next=home -redirect +``` + +### reflected xss probe + +`-xss` - inject a canary into params and report unescaped reflections + +```bash +./sif -u https://example.com/search?q=test -xss +``` + ### framework detection `-framework` - detect web frameworks with version and cve lookup @@ -339,6 +363,9 @@ the first time you run a new release sif also prints that release's notes once. -git \ -sql \ -lfi \ + -cors \ + -redirect \ + -xss \ -am ``` diff --git a/internal/config/config.go b/internal/config/config.go index 7e6755d..c692925 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,9 @@ type Settings struct { SecurityTrails bool SQL bool LFI bool + CORS bool + Redirect bool + XSS bool Framework bool Modules string // Comma-separated list of module IDs to run ModuleTags string // Run modules matching these tags @@ -107,6 +110,9 @@ func Parse() *Settings { flagSet.BoolVar(&settings.SecurityTrails, "securitytrails", false, "Enable SecurityTrails domain discovery (requires SECURITYTRAILS_API_KEY env var)"), flagSet.BoolVar(&settings.SQL, "sql", false, "Enable SQL reconnaissance (admin panels, error disclosure)"), flagSet.BoolVar(&settings.LFI, "lfi", false, "Enable LFI (Local File Inclusion) reconnaissance"), + flagSet.BoolVar(&settings.CORS, "cors", false, "Enable CORS misconfiguration probe"), + flagSet.BoolVar(&settings.Redirect, "redirect", false, "Enable open redirect probe"), + flagSet.BoolVar(&settings.XSS, "xss", false, "Enable reflected XSS probe"), flagSet.BoolVar(&settings.Framework, "framework", false, "Enable framework detection"), ) diff --git a/internal/scan/cors.go b/internal/scan/cors.go new file mode 100644 index 0000000..3828628 --- /dev/null +++ b/internal/scan/cors.go @@ -0,0 +1,236 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + charmlog "github.com/charmbracelet/log" + "github.com/dropalldatabases/sif/internal/httpx" + "github.com/dropalldatabases/sif/internal/logger" + "github.com/dropalldatabases/sif/internal/output" +) + +// CORSResult collects every cors misconfiguration found on the target. +type CORSResult struct { + Findings []CORSFinding `json:"findings,omitempty"` +} + +// CORSFinding is a single reflecting/permissive cors response. +type CORSFinding struct { + URL string `json:"url"` + OriginTested string `json:"origin_tested"` + AllowOrigin string `json:"allow_origin"` + AllowCredentials bool `json:"allow_credentials"` + Severity string `json:"severity"` + Note string `json:"note"` +} + +// corsMaxRedirects caps the redirect chain so we read the cors headers off the +// host we actually asked about, not whatever it bounces us to. +const corsMaxRedirects = 3 + +// the sentinel attacker origin; if it comes back in Access-Control-Allow-Origin +// the target reflects arbitrary origins and any site can read the response. +const corsEvilOrigin = "https://sif-cors-probe.evil.com" + +// corsOrigin is a header to inject + why it matters. {host} expands to the +// target host so the prefix/suffix bypasses key off the real name. +var corsOrigins = []struct { + origin string // crafted Origin header, {host} -> target host + note string // why this case is interesting + reflects bool // true when a literal echo of this origin is exploitable +}{ + // arbitrary attacker origin - the classic "reflects anything" bug + {corsEvilOrigin, "arbitrary origin reflected", true}, + // the literal null origin (sandboxed iframes, redirects, file://) is forgeable + {"null", "null origin allowed", true}, + // suffix bypass: attacker registers {host}.evil.com, naive endswith checks pass + {"https://{host}.evil.com", "suffix bypass (attacker subdomain)", true}, + // prefix bypass: attacker registers evil-{host}, naive startswith checks pass + {"https://evil-{host}", "prefix bypass", true}, + // embedded bypass: {host} appears inside an attacker domain + {"https://evil.com.{host}", "embedded-host bypass", true}, + // scheme downgrade: http origin trusted lets a mitm read cross-origin data + {"http://{host}", "http scheme downgrade trusted", true}, +} + +// CORS probes the target for cross-origin resource sharing misconfigurations. +func CORS(targetURL string, timeout time.Duration, threads int, logdir string) (*CORSResult, error) { + log := output.Module("CORS") + log.Start() + + spin := output.NewSpinner("Scanning for CORS misconfigurations") + spin.Start() + + sanitizedURL := stripScheme(targetURL) + + if logdir != "" { + if err := logger.WriteHeader(sanitizedURL, logdir, "CORS misconfiguration probe"); err != nil { + spin.Stop() + log.Error("error creating log file: %v", err) + return nil, fmt.Errorf("create cors log: %w", err) + } + } + + parsedURL, err := url.Parse(targetURL) + if err != nil { + spin.Stop() + return nil, fmt.Errorf("parse url: %w", err) + } + host := parsedURL.Host + + client := httpx.Client(timeout) + client.CheckRedirect = func(_ *http.Request, via []*http.Request) error { + if len(via) >= corsMaxRedirects { + return http.ErrUseLastResponse + } + return nil + } + + result := &CORSResult{Findings: make([]CORSFinding, 0, len(corsOrigins))} + + var mu sync.Mutex + var wg sync.WaitGroup + + // one origin per worker item; the set is small so a buffered channel is plenty + originChan := make(chan int, len(corsOrigins)) + for i := 0; i < len(corsOrigins); i++ { + originChan <- i + } + close(originChan) + + wg.Add(threads) + for t := 0; t < threads; t++ { + go func() { + defer wg.Done() + for idx := range originChan { + spec := corsOrigins[idx] + // {host} is the seam that turns a template into a real attacker origin + origin := strings.ReplaceAll(spec.origin, "{host}", host) + + finding, ok := probeCORS(client, targetURL, origin, spec.note) + if !ok { + continue + } + + mu.Lock() + result.Findings = append(result.Findings, finding) + mu.Unlock() + + spin.Stop() + log.Warn("cors %s: origin %s reflected (creds=%t)", + renderCORSSeverity(finding.Severity), + output.Highlight.Render(origin), + finding.AllowCredentials) + spin.Start() + + if logdir != "" { + logger.Write(sanitizedURL, logdir, + fmt.Sprintf("CORS: %s - origin [%s] reflected as [%s] creds=%t\n", + finding.Note, origin, finding.AllowOrigin, finding.AllowCredentials)) + } + } + }() + } + wg.Wait() + + spin.Stop() + + if len(result.Findings) == 0 { + log.Info("no cors misconfigurations detected") + log.Complete(0, "found") + return nil, nil //nolint:nilnil // no finding is not an error, mirrors the other scanners + } + + log.Complete(len(result.Findings), "found") + return result, nil +} + +// probeCORS sends one request with the crafted Origin and decides whether the +// response trusts it. It returns the finding and true only when the server +// reflects the origin (or "null"/"*" with credentials), which is the exploitable +// shape - a server that ignores Origin or returns its own host is fine. +func probeCORS(client *http.Client, targetURL, origin, note string) (CORSFinding, bool) { + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, targetURL, http.NoBody) + if err != nil { + charmlog.Debugf("cors: build request for %s: %v", targetURL, err) + return CORSFinding{}, false + } + req.Header.Set("Origin", origin) + + resp, err := client.Do(req) + if err != nil { + charmlog.Debugf("cors: request %s with origin %s: %v", targetURL, origin, err) + return CORSFinding{}, false + } + // headers are all we need; drain nothing, just close. + resp.Body.Close() + + allowOrigin := resp.Header.Get("Access-Control-Allow-Origin") + if allowOrigin == "" { + return CORSFinding{}, false + } + + allowCreds := strings.EqualFold(resp.Header.Get("Access-Control-Allow-Credentials"), "true") + + // a wildcard with credentials is forbidden by browsers, so it isn't directly + // exploitable; a plain wildcard exposes only public data. neither is a finding. + if allowOrigin == "*" { + return CORSFinding{}, false + } + + // the bug is reflection: the server echoed our attacker origin back. if it + // returned something else (its own host) it isn't trusting us. + reflected := allowOrigin == origin + + if !reflected { + return CORSFinding{}, false + } + + return CORSFinding{ + URL: targetURL, + OriginTested: origin, + AllowOrigin: allowOrigin, + AllowCredentials: allowCreds, + Severity: corsSeverity(allowCreds), + Note: note, + }, true +} + +// corsSeverity ranks the finding: reflection + credentials lets an attacker read +// authenticated responses, which is the high-impact case. +func corsSeverity(allowCreds bool) string { + if allowCreds { + return "high" + } + return "medium" +} + +func renderCORSSeverity(severity string) string { + if severity == "high" { + return output.SeverityHigh.Render(severity) + } + return output.SeverityMedium.Render(severity) +} + +// ResultType identifies cors findings for the result registry. +func (r *CORSResult) ResultType() string { return "cors" } + +var _ ScanResult = (*CORSResult)(nil) diff --git a/internal/scan/cors_test.go b/internal/scan/cors_test.go new file mode 100644 index 0000000..ac5afce --- /dev/null +++ b/internal/scan/cors_test.go @@ -0,0 +1,140 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// reflectingCORS echoes the Origin into Access-Control-Allow-Origin and sets +// credentials, the exploitable misconfiguration. +func reflectingCORS() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + } + w.WriteHeader(http.StatusOK) + })) +} + +func TestCORS_ReflectsArbitraryOrigin(t *testing.T) { + srv := reflectingCORS() + defer srv.Close() + + result, err := CORS(srv.URL, 5*time.Second, 3, "") + if err != nil { + t.Fatalf("CORS: %v", err) + } + if result == nil || len(result.Findings) == 0 { + t.Fatalf("expected cors findings on reflecting server, got %+v", result) + } + + // the reflecting server echoes every crafted origin with credentials, + // so each finding should be high severity. + var sawEvil bool + for _, f := range result.Findings { + if f.OriginTested == corsEvilOrigin { + sawEvil = true + if !f.AllowCredentials { + t.Errorf("expected credentials flagged for evil origin, got %+v", f) + } + if f.Severity != "high" { + t.Errorf("expected high severity for reflection+creds, got %s", f.Severity) + } + } + } + if !sawEvil { + t.Errorf("expected the sentinel evil origin to be reflected, got %+v", result.Findings) + } +} + +func TestCORS_SeverityWithoutCredentials(t *testing.T) { + // reflects the origin but never grants credentials - medium, not high. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + result, err := CORS(srv.URL, 5*time.Second, 3, "") + if err != nil { + t.Fatalf("CORS: %v", err) + } + if result == nil || len(result.Findings) == 0 { + t.Fatalf("expected reflection findings, got %+v", result) + } + for _, f := range result.Findings { + if f.AllowCredentials { + t.Errorf("did not expect credentials, got %+v", f) + } + if f.Severity != "medium" { + t.Errorf("expected medium severity without creds, got %s", f.Severity) + } + } +} + +func TestCORS_NoFalsePositiveOnSafeServer(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + }{ + { + name: "ignores origin entirely", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + }, + { + name: "returns its own fixed origin", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "https://trusted.example.com") + w.WriteHeader(http.StatusOK) + }, + }, + { + name: "plain wildcard, no credentials", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(http.StatusOK) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(tt.handler) + defer srv.Close() + + result, err := CORS(srv.URL, 5*time.Second, 3, "") + if err != nil { + t.Fatalf("CORS: %v", err) + } + if result != nil && len(result.Findings) > 0 { + t.Errorf("expected no findings on safe server, got %+v", result.Findings) + } + }) + } +} + +func TestCORSResult_ResultType(t *testing.T) { + r := &CORSResult{} + if r.ResultType() != "cors" { + t.Errorf("expected result type 'cors', got %q", r.ResultType()) + } +} diff --git a/internal/scan/integration_test.go b/internal/scan/integration_test.go index 7894a7f..9e0072f 100644 --- a/internal/scan/integration_test.go +++ b/internal/scan/integration_test.go @@ -65,6 +65,32 @@ func newVulnApp() *httptest.Server { w.Write([]byte("phpMyAdmin")) }) + // reflecting-origin endpoint for the cors probe + mux.HandleFunc("/cors", func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + } + w.WriteHeader(http.StatusOK) + }) + + // open-redirect endpoint: echoes the next param into Location + mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) { + if next := r.URL.Query().Get("next"); next != "" { + w.Header().Set("Location", next) + w.WriteHeader(http.StatusFound) + return + } + w.WriteHeader(http.StatusOK) + }) + + // reflecting endpoint for the xss probe: echoes q raw into html text + mux.HandleFunc("/xss", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + //nolint:gosec // deliberate reflected-xss fixture for the probe under test + w.Write([]byte("
" + r.URL.Query().Get("q") + "
")) + }) + // homepage doubles as the cms fingerprint and the lfi sink mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { @@ -180,6 +206,45 @@ func TestIntegrationLFI(t *testing.T) { } } +func TestIntegrationCORS(t *testing.T) { + srv := newVulnApp() + defer srv.Close() + + result, err := CORS(srv.URL+"/cors", 5*time.Second, 3, "") + if err != nil { + t.Fatalf("CORS: %v", err) + } + if result == nil || len(result.Findings) == 0 { + t.Fatalf("expected a cors finding from the reflecting endpoint, got %+v", result) + } +} + +func TestIntegrationRedirect(t *testing.T) { + srv := newVulnApp() + defer srv.Close() + + result, err := Redirect(srv.URL+"/redirect", 5*time.Second, 4, "") + if err != nil { + t.Fatalf("Redirect: %v", err) + } + if result == nil || len(result.Findings) == 0 { + t.Fatalf("expected an open-redirect finding from the next sink, got %+v", result) + } +} + +func TestIntegrationXSS(t *testing.T) { + srv := newVulnApp() + defer srv.Close() + + result, err := XSS(srv.URL+"/xss", 5*time.Second, 4, "") + if err != nil { + t.Fatalf("XSS: %v", err) + } + if result == nil || len(result.Findings) == 0 { + t.Fatalf("expected a reflected-xss finding from the q sink, got %+v", result) + } +} + func TestIntegrationPorts(t *testing.T) { // a real listener stands in for an open port; a tiny server hands its number // to Ports via the commonPorts wordlist. diff --git a/internal/scan/redirect.go b/internal/scan/redirect.go new file mode 100644 index 0000000..7597c31 --- /dev/null +++ b/internal/scan/redirect.go @@ -0,0 +1,305 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" + + charmlog "github.com/charmbracelet/log" + "github.com/dropalldatabases/sif/internal/httpx" + "github.com/dropalldatabases/sif/internal/logger" + "github.com/dropalldatabases/sif/internal/output" +) + +// RedirectResult collects every open-redirect found on the target. +type RedirectResult struct { + Findings []RedirectFinding `json:"findings,omitempty"` + TestedParams int `json:"tested_params"` +} + +// RedirectFinding is a single param/payload that sends the user off-site. +type RedirectFinding struct { + URL string `json:"url"` + Parameter string `json:"parameter"` + Payload string `json:"payload"` + Location string `json:"location"` + Via string `json:"via"` // header, meta-refresh, or javascript + Severity string `json:"severity"` +} + +// redirectMaxBody caps the body we scan for meta/js redirects (100KB). +const redirectMaxBody = 1024 * 100 + +// the controlled sentinel host we steer redirects toward; a Location that lands +// on it proves the param is attacker-controlled. +const redirectSentinel = "sif-redirect-probe.evil.com" + +// params that commonly drive a server-side redirect. +var redirectParams = []string{ + "url", "next", "redirect", "redirect_uri", "redirect_url", + "return", "return_url", "returnurl", "returnto", "return_to", + "dest", "destination", "continue", "goto", "go", "target", + "to", "out", "view", "image_url", "checkout_url", "rurl", "u", +} + +// payload variants: a plain sentinel plus filter bypasses that browsers still +// resolve as an absolute off-site target. {host} expands to the sentinel. +var redirectPayloads = []string{ + "https://{host}", // plain absolute + "//{host}", // scheme-relative + "https:/{host}", // missing slash, browsers normalise it + "https:{host}", // no slashes + "/\\{host}", // backslash trick + "/%2f%2f{host}", // encoded scheme-relative + "https://{host}%00.x.com", // null-byte truncation + "https://x.com@{host}", // userinfo confusion - real host is after @ +} + +// meta refresh redirect: +var metaRefreshRe = regexp.MustCompile(`(?i)]+http-equiv=["']?refresh["']?[^>]+content=["'][^"']*url=([^"'>\s]+)`) + +// client-side redirects baked into a script body +var jsRedirectRe = regexp.MustCompile(`(?i)(?:location\.(?:href|replace|assign)\s*(?:=|\()|window\.location\s*=)\s*["']([^"']+)["']`) + +// Redirect probes the target's redirect-prone params for open-redirect. +func Redirect(targetURL string, timeout time.Duration, threads int, logdir string) (*RedirectResult, error) { + log := output.Module("REDIRECT") + log.Start() + + spin := output.NewSpinner("Scanning for open redirects") + spin.Start() + + sanitizedURL := stripScheme(targetURL) + + if logdir != "" { + if err := logger.WriteHeader(sanitizedURL, logdir, "open redirect probe"); err != nil { + spin.Stop() + log.Error("error creating log file: %v", err) + return nil, fmt.Errorf("create redirect log: %w", err) + } + } + + parsedURL, err := url.Parse(targetURL) + if err != nil { + spin.Stop() + return nil, fmt.Errorf("parse url: %w", err) + } + existingParams := parsedURL.Query() + + // merge target's own params with the common redirect names so we cover both + paramsToTest := make(map[string]bool, len(existingParams)+len(redirectParams)) + for param := range existingParams { + paramsToTest[param] = true + } + for _, param := range redirectParams { + paramsToTest[param] = true + } + + // don't auto-follow: a 30x Location is exactly what we want to inspect. + client := httpx.Client(timeout) + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + + result := &RedirectResult{ + Findings: make([]RedirectFinding, 0, 8), + TestedParams: len(paramsToTest), + } + + type workItem struct { + param string + payload string + } + workItems := make([]workItem, 0, len(paramsToTest)*len(redirectPayloads)) + for param := range paramsToTest { + for _, raw := range redirectPayloads { + workItems = append(workItems, workItem{param: param, payload: strings.ReplaceAll(raw, "{host}", redirectSentinel)}) + } + } + + log.Info("testing %d params with %d payloads", len(paramsToTest), len(redirectPayloads)) + + workChan := make(chan workItem, len(workItems)) + for _, item := range workItems { + workChan <- item + } + close(workChan) + + seen := make(map[string]bool) + var mu sync.Mutex + var wg sync.WaitGroup + + wg.Add(threads) + for t := 0; t < threads; t++ { + go func() { + defer wg.Done() + for item := range workChan { + testURL := buildRedirectURL(parsedURL, existingParams, item.param, item.payload) + + location, via, ok := probeRedirect(client, testURL) + if !ok { + continue + } + + key := item.param + "|" + item.payload + mu.Lock() + if seen[key] { + mu.Unlock() + continue + } + seen[key] = true + finding := RedirectFinding{ + URL: testURL, + Parameter: item.param, + Payload: item.payload, + Location: location, + Via: via, + Severity: "medium", + } + result.Findings = append(result.Findings, finding) + mu.Unlock() + + spin.Stop() + log.Warn("open redirect via %s in param %s -> %s", + output.SeverityMedium.Render(via), + output.Highlight.Render(item.param), + output.Status.Render(location)) + spin.Start() + + if logdir != "" { + logger.Write(sanitizedURL, logdir, + fmt.Sprintf("open redirect: param [%s] via %s -> [%s] (payload %s)\n", + item.param, via, location, item.payload)) + } + } + }() + } + wg.Wait() + + spin.Stop() + + if len(result.Findings) == 0 { + log.Info("no open redirects detected") + log.Complete(0, "found") + return nil, nil //nolint:nilnil // no finding is not an error, mirrors the other scanners + } + + log.Complete(len(result.Findings), "found") + return result, nil +} + +// buildRedirectURL rebuilds the target with the payload injected into one param, +// preserving the rest of the original query. +func buildRedirectURL(parsedURL *url.URL, existing url.Values, param, payload string) string { + testParams := url.Values{} + for k, v := range existing { + if k != param { + testParams[k] = v + } + } + testParams.Set(param, payload) + return fmt.Sprintf("%s://%s%s?%s", parsedURL.Scheme, parsedURL.Host, parsedURL.Path, testParams.Encode()) +} + +// probeRedirect requests testURL and reports the first off-site redirect it +// finds, whether that's a 30x Location header, a meta-refresh, or a js +// location assignment. via names the channel; ok is false when nothing points +// at the sentinel. +func probeRedirect(client *http.Client, testURL string) (location, via string, ok bool) { + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, testURL, http.NoBody) + if err != nil { + charmlog.Debugf("redirect: build request for %s: %v", testURL, err) + return "", "", false + } + resp, err := client.Do(req) + if err != nil { + charmlog.Debugf("redirect: request %s: %v", testURL, err) + return "", "", false + } + defer resp.Body.Close() + + // header redirect: a 30x whose Location resolves to the sentinel host + if resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest { + if loc := resp.Header.Get("Location"); pointsAtSentinel(loc) { + return loc, "header", true + } + } + + // body redirects: meta refresh or a client-side location assignment + body, err := io.ReadAll(io.LimitReader(resp.Body, redirectMaxBody)) + if err != nil { + return "", "", false + } + bodyStr := string(body) + + if m := metaRefreshRe.FindStringSubmatch(bodyStr); len(m) > 1 && pointsAtSentinel(m[1]) { + return m[1], "meta-refresh", true + } + if m := jsRedirectRe.FindStringSubmatch(bodyStr); len(m) > 1 && pointsAtSentinel(m[1]) { + return m[1], "javascript", true + } + + return "", "", false +} + +// pointsAtSentinel reports whether a redirect target lands on our controlled +// host. We resolve the value the way a browser would so scheme-relative ("//x") +// and backslash tricks are caught, then compare hostnames - a sentinel that only +// shows up in a path or query (still same-origin) is not a redirect off-site. +func pointsAtSentinel(location string) bool { + if location == "" { + return false + } + + // browsers treat backslashes in the authority as forward slashes + normalized := strings.ReplaceAll(location, "\\", "/") + + parsed, err := url.Parse(normalized) + if err != nil { + // unparseable but still naming the sentinel as the leading authority is a hit + return strings.HasPrefix(strings.TrimLeft(normalized, "/:"), redirectSentinel) + } + + // the resolved host is what the navigation actually targets + if strings.EqualFold(parsed.Hostname(), redirectSentinel) { + return true + } + + // scheme-relative "//host" parses with an empty scheme but a populated host + if parsed.Host != "" && strings.EqualFold(stripPort(parsed.Host), redirectSentinel) { + return true + } + + return false +} + +// stripPort drops a trailing :port so host comparisons ignore it. +func stripPort(host string) string { + if h, _, ok := strings.Cut(host, ":"); ok { + return h + } + return host +} + +// ResultType identifies open-redirect findings for the result registry. +func (r *RedirectResult) ResultType() string { return "redirect" } + +var _ ScanResult = (*RedirectResult)(nil) diff --git a/internal/scan/redirect_test.go b/internal/scan/redirect_test.go new file mode 100644 index 0000000..ddb83c5 --- /dev/null +++ b/internal/scan/redirect_test.go @@ -0,0 +1,163 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestRedirect_HeaderLocation(t *testing.T) { + // echoes the "next" param straight into Location, the textbook open redirect. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if next := r.URL.Query().Get("next"); next != "" { + w.Header().Set("Location", next) + w.WriteHeader(http.StatusFound) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + result, err := Redirect(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("Redirect: %v", err) + } + if result == nil || len(result.Findings) == 0 { + t.Fatalf("expected open redirect findings, got %+v", result) + } + + var sawHeader bool + for _, f := range result.Findings { + if f.Parameter == "next" && f.Via == "header" { + sawHeader = true + } + } + if !sawHeader { + t.Errorf("expected a header redirect via 'next', got %+v", result.Findings) + } +} + +func TestRedirect_MetaRefresh(t *testing.T) { + // body-based redirect: a meta refresh pointing at the injected url. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + dest := r.URL.Query().Get("url") + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + if dest != "" { + //nolint:gosec // deliberate open-redirect fixture for the probe under test + w.Write([]byte(``)) + return + } + w.Write([]byte("home")) + })) + defer srv.Close() + + result, err := Redirect(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("Redirect: %v", err) + } + if result == nil { + t.Fatalf("expected meta-refresh findings, got nil") + } + var sawMeta bool + for _, f := range result.Findings { + if f.Via == "meta-refresh" { + sawMeta = true + } + } + if !sawMeta { + t.Errorf("expected a meta-refresh redirect finding, got %+v", result.Findings) + } +} + +func TestRedirect_NoFalsePositive(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + }{ + { + name: "never redirects", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("home")) + }, + }, + { + name: "only redirects to a fixed safe path", + handler: func(w http.ResponseWriter, _ *http.Request) { + // ignores the param, always sends users to its own login page. + w.Header().Set("Location", "/login") + w.WriteHeader(http.StatusFound) + }, + }, + { + name: "reflects param into body but not as a redirect", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + // the value lands in plain text, no meta/js redirect mechanism. + //nolint:gosec // intentional reflection fixture; asserts no false positive + w.Write([]byte("

you searched for " + r.URL.Query().Get("next") + "

")) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(tt.handler) + defer srv.Close() + + result, err := Redirect(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("Redirect: %v", err) + } + if result != nil && len(result.Findings) > 0 { + t.Errorf("expected no findings, got %+v", result.Findings) + } + }) + } +} + +func TestPointsAtSentinel(t *testing.T) { + tests := []struct { + name string + location string + want bool + }{ + {"absolute https", "https://" + redirectSentinel + "/path", true}, + {"scheme-relative", "//" + redirectSentinel, true}, + {"backslash trick", "/\\" + redirectSentinel, true}, + {"with port", "https://" + redirectSentinel + ":443/", true}, + {"empty", "", false}, + {"same-site path", "/dashboard", false}, + {"sentinel only in path", "https://safe.example.com/" + redirectSentinel, false}, + {"sentinel only in query", "https://safe.example.com/?to=" + redirectSentinel, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := pointsAtSentinel(tt.location); got != tt.want { + t.Errorf("pointsAtSentinel(%q) = %v, want %v", tt.location, got, tt.want) + } + }) + } +} + +func TestRedirectResult_ResultType(t *testing.T) { + r := &RedirectResult{} + if r.ResultType() != "redirect" { + t.Errorf("expected result type 'redirect', got %q", r.ResultType()) + } +} diff --git a/internal/scan/xss.go b/internal/scan/xss.go new file mode 100644 index 0000000..8ccb98d --- /dev/null +++ b/internal/scan/xss.go @@ -0,0 +1,342 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + charmlog "github.com/charmbracelet/log" + "github.com/dropalldatabases/sif/internal/httpx" + "github.com/dropalldatabases/sif/internal/logger" + "github.com/dropalldatabases/sif/internal/output" +) + +// XSSResult collects every likely reflected-xss point on the target. +type XSSResult struct { + Findings []XSSFinding `json:"findings,omitempty"` + TestedParams int `json:"tested_params"` +} + +// XSSFinding is a reflection where one or more breaking chars survived +// unescaped in a context that makes injection plausible. +type XSSFinding struct { + URL string `json:"url"` + Parameter string `json:"parameter"` + Context string `json:"context"` // html, attribute, or script + SurvivedRaw []string `json:"survived_raw"` // breaking chars echoed unescaped + Severity string `json:"severity"` +} + +// xssMaxBody caps the body we scan for the canary (100KB). +const xssMaxBody = 1024 * 100 + +// canaryToken is a unique, alnum-only marker we can grep for unambiguously; it +// survives every output encoder so a missing reflection means no echo at all. +const canaryToken = "sifxss9173canary" //nolint:gosec // not a credential, just a reflection marker + +// the chars that let an attacker break out of a context; we inject the canary +// wrapped in each and check which come back raw. +var xssBreakChars = []string{"<", ">", "\"", "'", "`"} + +// params we test when the target carries none of its own. +var xssParams = []string{ + "q", "s", "search", "query", "id", "name", "page", + "keyword", "lang", "redirect", "url", "return", "ref", + "message", "msg", "error", "title", "text", "comment", +} + +// XSS probes the target's params for reflected cross-site scripting. +func XSS(targetURL string, timeout time.Duration, threads int, logdir string) (*XSSResult, error) { + log := output.Module("XSS") + log.Start() + + spin := output.NewSpinner("Scanning for reflected XSS") + spin.Start() + + sanitizedURL := stripScheme(targetURL) + + if logdir != "" { + if err := logger.WriteHeader(sanitizedURL, logdir, "reflected XSS probe"); err != nil { + spin.Stop() + log.Error("error creating log file: %v", err) + return nil, fmt.Errorf("create xss log: %w", err) + } + } + + parsedURL, err := url.Parse(targetURL) + if err != nil { + spin.Stop() + return nil, fmt.Errorf("parse url: %w", err) + } + existingParams := parsedURL.Query() + + paramsToTest := make(map[string]bool, len(existingParams)+len(xssParams)) + for param := range existingParams { + paramsToTest[param] = true + } + for _, param := range xssParams { + paramsToTest[param] = true + } + + client := httpx.Client(timeout) + client.CheckRedirect = func(_ *http.Request, via []*http.Request) error { + if len(via) >= corsMaxRedirects { + return http.ErrUseLastResponse + } + return nil + } + + result := &XSSResult{ + Findings: make([]XSSFinding, 0, 8), + TestedParams: len(paramsToTest), + } + + params := make([]string, 0, len(paramsToTest)) + for param := range paramsToTest { + params = append(params, param) + } + + log.Info("testing %d params with reflection canary", len(paramsToTest)) + + paramChan := make(chan string, len(params)) + for _, param := range params { + paramChan <- param + } + close(paramChan) + + seen := make(map[string]bool) + var mu sync.Mutex + var wg sync.WaitGroup + + wg.Add(threads) + for t := 0; t < threads; t++ { + go func() { + defer wg.Done() + for param := range paramChan { + finding, ok := probeXSS(client, parsedURL, existingParams, param) + if !ok { + continue + } + + mu.Lock() + if seen[param] { + mu.Unlock() + continue + } + seen[param] = true + result.Findings = append(result.Findings, finding) + mu.Unlock() + + spin.Stop() + log.Warn("reflected xss in param %s (%s context, raw: %s)", + output.Highlight.Render(param), + output.SeverityHigh.Render(finding.Context), + strings.Join(finding.SurvivedRaw, "")) + spin.Start() + + if logdir != "" { + logger.Write(sanitizedURL, logdir, + fmt.Sprintf("reflected XSS: param [%s] in %s context, unescaped chars [%s]\n", + param, finding.Context, strings.Join(finding.SurvivedRaw, ""))) + } + } + }() + } + wg.Wait() + + spin.Stop() + + if len(result.Findings) == 0 { + log.Info("no reflected xss detected") + log.Complete(0, "found") + return nil, nil //nolint:nilnil // no finding is not an error, mirrors the other scanners + } + + log.Complete(len(result.Findings), "found") + return result, nil +} + +// probeXSS injects a canary wrapped in the breaking chars into one param, then +// inspects the reflection: it classifies where the canary landed and which +// breaking chars came back unescaped there. ok is false unless at least one +// dangerous char survived in an exploitable context. +func probeXSS(client *http.Client, parsedURL *url.URL, existing url.Values, param string) (XSSFinding, bool) { + // wrap the canary so a single request tells us both that it reflected and + // which surrounding chars survived: "canary' `canary` + payload := fmt.Sprintf("<%s>\"%s'`%s`", canaryToken, canaryToken, canaryToken) + + testParams := url.Values{} + for k, v := range existing { + if k != param { + testParams[k] = v + } + } + testParams.Set(param, payload) + testURL := fmt.Sprintf("%s://%s%s?%s", parsedURL.Scheme, parsedURL.Host, parsedURL.Path, testParams.Encode()) + + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, testURL, http.NoBody) + if err != nil { + charmlog.Debugf("xss: build request for %s: %v", testURL, err) + return XSSFinding{}, false + } + resp, err := client.Do(req) + if err != nil { + charmlog.Debugf("xss: request %s: %v", testURL, err) + return XSSFinding{}, false + } + body, err := io.ReadAll(io.LimitReader(resp.Body, xssMaxBody)) + resp.Body.Close() + if err != nil { + return XSSFinding{}, false + } + bodyStr := string(body) + + // no echo of the canary at all means the param isn't reflected; bail early. + if !strings.Contains(bodyStr, canaryToken) { + return XSSFinding{}, false + } + + reflectCtx := classifyXSSContext(bodyStr) + survived := survivingBreakChars(bodyStr) + + // a reflection that escaped every dangerous char can't break out, so it's not + // reported - only raw chars that matter in the detected context count. + survived = relevantForContext(reflectCtx, survived) + if len(survived) == 0 { + return XSSFinding{}, false + } + + return XSSFinding{ + URL: testURL, + Parameter: param, + Context: reflectCtx, + SurvivedRaw: survived, + Severity: "high", + }, true +} + +// classifyXSSContext guesses where the canary was reflected. We look at the +// markup immediately around the token: a live tag means html text, a +// reflection inside a is a script context + for { + open := strings.Index(body, "") + if closeIdx < 0 { + break + } + segment := body[open : open+closeIdx] + if strings.Contains(segment, canaryToken) { + return "script" + } + body = body[open+closeIdx+len(""):] + } + + // default: echoed inside an html attribute value + return "attribute" +} + +// survivingBreakChars reports which dangerous chars came back next to the canary +// unescaped. We only trust occurrences adjacent to the token so unrelated chars +// elsewhere on the page don't create false positives. +func survivingBreakChars(body string) []string { + survived := make([]string, 0, len(xssBreakChars)) + markers := []string{ + "<" + canaryToken, // leading < survived + canaryToken + ">", // trailing > survived + "\"" + canaryToken, // leading " survived + canaryToken + "'", // trailing ' survived + "`" + canaryToken, // backtick wrap survived (token + ` and ` + token) + canaryToken + "`", + } + present := make(map[string]bool, len(xssBreakChars)) + for i := 0; i < len(markers); i++ { + if !strings.Contains(body, markers[i]) { + continue + } + switch { + case strings.HasPrefix(markers[i], "<"): + present["<"] = true + case strings.HasSuffix(markers[i], ">"): + present[">"] = true + case strings.HasPrefix(markers[i], "\""): + present["\""] = true + case strings.HasSuffix(markers[i], "'"): + present["'"] = true + default: + present["`"] = true + } + } + + // keep the canonical order for stable output + for i := 0; i < len(xssBreakChars); i++ { + if present[xssBreakChars[i]] { + survived = append(survived, xssBreakChars[i]) + } + } + return survived +} + +// relevantForContext filters surviving chars to the ones that actually enable a +// breakout in the detected context: angle brackets matter in html, quotes and +// backticks matter inside attributes/scripts. +func relevantForContext(reflectCtx string, survived []string) []string { + wanted := make(map[string]bool, len(survived)) + switch reflectCtx { + case "html": + wanted["<"] = true + wanted[">"] = true + case "attribute": + // breaking out of an attribute value needs the quote that delimits it; a + // bare backtick isn't a delimiter in html, so it doesn't count here. + wanted["\""] = true + wanted["'"] = true + case "script": + // a quote, backtick, or angle bracket all let you close/escape the script + wanted["\""] = true + wanted["'"] = true + wanted["`"] = true + wanted["<"] = true + wanted[">"] = true + } + + filtered := make([]string, 0, len(survived)) + for i := 0; i < len(survived); i++ { + if wanted[survived[i]] { + filtered = append(filtered, survived[i]) + } + } + return filtered +} + +// ResultType identifies reflected-xss findings for the result registry. +func (r *XSSResult) ResultType() string { return "xss" } + +var _ ScanResult = (*XSSResult)(nil) diff --git a/internal/scan/xss_test.go b/internal/scan/xss_test.go new file mode 100644 index 0000000..66ade5a --- /dev/null +++ b/internal/scan/xss_test.go @@ -0,0 +1,153 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "html" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// reflectsRaw echoes the named param straight into html text, so the breaking +// chars survive unescaped - a reflected xss sink. +func reflectsRaw(param string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + v := r.URL.Query().Get(param) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + //nolint:gosec // deliberate reflected-xss fixture for the probe under test + w.Write([]byte("
" + v + "
")) + })) +} + +func TestXSS_DetectsRawHTMLReflection(t *testing.T) { + srv := reflectsRaw("q") + defer srv.Close() + + result, err := XSS(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("XSS: %v", err) + } + if result == nil || len(result.Findings) == 0 { + t.Fatalf("expected reflected xss findings, got %+v", result) + } + + var found *XSSFinding + for i := range result.Findings { + if result.Findings[i].Parameter == "q" { + found = &result.Findings[i] + } + } + if found == nil { + t.Fatalf("expected a finding on param 'q', got %+v", result.Findings) + } + if found.Context != "html" { + t.Errorf("expected html context, got %s", found.Context) + } + if len(found.SurvivedRaw) == 0 { + t.Errorf("expected surviving breaking chars, got none") + } +} + +func TestXSS_NoFalsePositiveWhenEscaped(t *testing.T) { + // the server html-escapes the reflection, so no breaking char survives raw. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + v := r.URL.Query().Get("q") + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte("
" + html.EscapeString(v) + "
")) + })) + defer srv.Close() + + result, err := XSS(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("XSS: %v", err) + } + if result != nil && len(result.Findings) > 0 { + t.Errorf("expected no findings when reflection is escaped, got %+v", result.Findings) + } +} + +func TestXSS_NoFalsePositiveWhenNotReflected(t *testing.T) { + // never echoes the input back, so nothing is injectable. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte("static page")) + })) + defer srv.Close() + + result, err := XSS(srv.URL, 5*time.Second, 4, "") + if err != nil { + t.Fatalf("XSS: %v", err) + } + if result != nil && len(result.Findings) > 0 { + t.Errorf("expected no findings on static page, got %+v", result.Findings) + } +} + +func TestClassifyXSSContext(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + { + name: "live html tag", + body: "
<" + canaryToken + ">
", + want: "html", + }, + { + name: "inside script block", + body: "", + want: "script", + }, + { + name: "attribute value", + body: ``, + want: "attribute", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyXSSContext(tt.body); got != tt.want { + t.Errorf("classifyXSSContext() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSurvivingBreakChars(t *testing.T) { + // the canary is wrapped exactly as the probe injects it; all five chars survive. + body := "<" + canaryToken + ">\"" + canaryToken + "'`" + canaryToken + "`" + got := survivingBreakChars(body) + want := map[string]bool{"<": true, ">": true, "\"": true, "'": true, "`": true} + if len(got) != len(want) { + t.Fatalf("expected %d surviving chars, got %v", len(want), got) + } + for _, c := range got { + if !want[c] { + t.Errorf("unexpected surviving char %q", c) + } + } +} + +func TestXSSResult_ResultType(t *testing.T) { + r := &XSSResult{} + if r.ResultType() != "xss" { + t.Errorf("expected result type 'xss', got %q", r.ResultType()) + } +} diff --git a/man/sif.1 b/man/sif.1 index 968430e..4646086 100644 --- a/man/sif.1 +++ b/man/sif.1 @@ -86,6 +86,15 @@ sql reconnaissance (admin panels, error disclosure). .B \-lfi local file inclusion reconnaissance. .TP +.B \-cors +cors misconfiguration probe (reflected/permissive origins). +.TP +.B \-redirect +open redirect probe. +.TP +.B \-xss +reflected xss probe. +.TP .B \-framework framework detection with cve lookup. .TP diff --git a/sif.go b/sif.go index cee06fd..e1c6b09 100644 --- a/sif.go +++ b/sif.go @@ -391,6 +391,36 @@ func (app *App) Run() error { } } + if app.settings.CORS { + result, err := scan.CORS(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) + if err != nil { + log.Errorf("Error while running CORS probe: %s", err) + } else if result != nil { + moduleResults = append(moduleResults, NewModuleResult(result)) + scansRun = append(scansRun, "CORS") + } + } + + if app.settings.Redirect { + result, err := scan.Redirect(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) + if err != nil { + log.Errorf("Error while running open redirect probe: %s", err) + } else if result != nil { + moduleResults = append(moduleResults, NewModuleResult(result)) + scansRun = append(scansRun, "Open Redirect") + } + } + + if app.settings.XSS { + result, err := scan.XSS(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) + if err != nil { + log.Errorf("Error while running reflected XSS probe: %s", err) + } else if result != nil { + moduleResults = append(moduleResults, NewModuleResult(result)) + scansRun = append(scansRun, "Reflected XSS") + } + } + // Load and run modules if app.settings.AllModules || app.settings.Modules != "" || app.settings.ModuleTags != "" { loader, err := modules.NewLoader()