Files
sif/internal/scan/cors.go
T
vmfunc 0383a7bcd2 perf(scan): drain response bodies so pooled connections get reused
go only returns a conn to the idle pool when the body is read to EOF
before Close. the header-only and early-return scan paths closed an
unread body, leaking the conn and forcing a fresh dial each request.
route those close sites through httpx.DrainClose so the tuned pool from
phase 1 actually gets reused. body-read paths (scanner/io.ReadAll) are
left untouched.
2026-06-10 15:50:58 -07:00

237 lines
8.1 KiB
Go

/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
charmlog.Debugf("cors: request %s with origin %s: %v", targetURL, origin, err)
return CORSFinding{}, false
}
// headers are all we need; drain the body so the conn returns to the pool.
httpx.DrainClose(resp)
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)