Skip to content

Commit c73febd

Browse files
committed
Merge branch 'fix/s3-region-full-url'
2 parents 8aee549 + 24f15a0 commit c73febd

4 files changed

Lines changed: 239 additions & 20 deletions

File tree

internal/api/scan_handlers.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,16 @@ func scanS3(c *gin.Context) {
706706
if req.Region != nil {
707707
opts.Region = *req.Region
708708
}
709+
// Accept a full S3 URL (e.g. bucket.s3.ap-south-1.amazonaws.com) in the bucket
710+
// field: use the clean bucket name for the scan record/title and adopt an embedded
711+
// region when none was supplied. handleScan re-parses + auto-detects too.
712+
if b, r := s3mod.ParseBucketInput(bucket); b != "" {
713+
bucket = b
714+
opts.Bucket = b
715+
if opts.Region == "" && r != "" {
716+
opts.Region = r
717+
}
718+
}
709719
scanID := generateScanID()
710720
go RunScanInProcess(scanID, "s3", bucket, func() error {
711721
return s3mod.Run(opts)

internal/api/ui/pages/launcher.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
dns_dangling: { path: 'dns', modes: ['domain', 'domain_list'], extra: { dns_type: 'dangling-ip' }, placeholders: { domain: 'example.com', domain_list: 'one domain per line' } },
2121
dns_takeover: { path: 'dns-takeover', modes: ['domain', 'domain_list'], placeholders: { domain: 'example.com', domain_list: 'one domain per line' } },
2222
dns_cf1016: { path: 'dns-cf1016', modes: ['domain', 'subdomain', 'domain_list', 'subdomain_list'], placeholders: { domain: 'example.com', subdomain: 'api.example.com', domain_list: 'one domain per line', subdomain_list: 'one subdomain per line' } },
23-
s3: { path: 's3', modes: ['bucket', 'bucket_list', 'domain', 'domain_list'], placeholders: { bucket: 'bucket-name', bucket_list: 'one bucket per line', domain: 'example.com', domain_list: 'one domain per line' } },
23+
s3: { path: 's3', modes: ['bucket', 'bucket_list', 'domain', 'domain_list'], placeholders: { bucket: 'bucket-name or bucket.s3.ap-south-1.amazonaws.com', bucket_list: 'one bucket name or s3 URL per line', domain: 'example.com', domain_list: 'one domain per line' } },
2424
github: { path: 'github', modes: ['repo', 'repo_list'], placeholders: { repo: 'owner/repository or github.com/owner/repo', repo_list: 'one owner/repo per line' } },
2525
github_org: { path: 'github_org', modes: ['domain', 'domain_list'], placeholders: { domain: 'org-name or github.com/org', domain_list: 'one org per line' } },
2626
zerodays: { path: 'zerodays', modes: ['domain', 'domain_list'], placeholders: { domain: 'example.com', domain_list: 'one domain per line' } },
@@ -60,7 +60,7 @@
6060
dns: [{ key: 'dns_type', label: 'DNS type', type: 'select', options: ['takeover', 'dangling-ip'], default: 'takeover', advanced: false, help: 'takeover = CNAME/NS takeover checks; dangling-ip = dangling A-record checks.' }],
6161
dns_dangling: [{ key: 'dns_type', label: 'DNS type', type: 'select', options: ['dangling-ip', 'takeover'], default: 'dangling-ip', advanced: false, help: 'dangling-ip = dangling A-record checks.' }],
6262
s3: [
63-
{ key: 'region', label: 'Region (optional)', type: 'text', advanced: false, help: 'AWS region for the bucket, e.g. us-east-1. Leave empty to auto-detect.' },
63+
{ key: 'region', label: 'Region (optional)', type: 'text', advanced: false, help: 'AWS region, e.g. ap-south-1. Leave empty to auto-detect (parsed from the URL if you paste a full s3 URL, else read from the bucket itself).' },
6464
{ key: 'threads', label: 'Threads (reserved)', type: 'number', min: 1, advanced: true, help: 'Reserved for future concurrent bucket probing.' },
6565
],
6666
ffuf: [

internal/scanner/s3/s3.go

Lines changed: 179 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -192,18 +192,13 @@ func handleDomainEnumAndScan(opts Options, resultsDir string) error {
192192
if region == "" {
193193
region = opts.Region
194194
}
195-
regions := []string{"us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"}
195+
regions := []string{"us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1", "ap-south-1", "eu-central-1"}
196196
if region != "" {
197197
regions = []string{region}
198198
}
199199
out:
200200
for _, r := range regions {
201-
urls := []string{
202-
fmt.Sprintf("https://%s.s3.amazonaws.com/?list-type=2", fb.name),
203-
fmt.Sprintf("https://%s.s3-%s.amazonaws.com/?list-type=2", fb.name, r),
204-
fmt.Sprintf("https://s3.amazonaws.com/%s/?list-type=2", fb.name),
205-
fmt.Sprintf("https://s3-%s.amazonaws.com/%s/?list-type=2", r, fb.name),
206-
}
201+
urls := bucketListURLs(fb.name, r)
207202
for _, u := range urls {
208203
bucketDir := filepath.Join(outputDir, fb.name)
209204
_ = os.MkdirAll(bucketDir, 0755)
@@ -455,6 +450,26 @@ func handleScan(opts Options, resultsDir string) error {
455450
return fmt.Errorf("bucket name is required for scan action")
456451
}
457452

453+
// Accept a full S3 URL (bucket.s3.region.amazonaws.com, path-style, with or without
454+
// scheme) as well as a plain bucket name — extract the bucket and any embedded region.
455+
if b, r := ParseBucketInput(opts.Bucket); b != "" {
456+
opts.Bucket = b
457+
if opts.Region == "" {
458+
opts.Region = r
459+
}
460+
}
461+
// Auto-detect the region when still unknown. Required for buckets outside us-east-1
462+
// (e.g. ap-south-1) whose virtual-hosted endpoints differ from the global host; also
463+
// fulfils the launcher's "leave empty to auto-detect" hint.
464+
if opts.Region == "" {
465+
if r := detectBucketRegion(opts.Bucket); r != "" {
466+
opts.Region = r
467+
logger.GetLogger().Infof("[INFO] S3 scan: auto-detected region %s for bucket %s", r, opts.Bucket)
468+
} else {
469+
logger.GetLogger().Infof("[INFO] S3 scan: could not auto-detect region for %s (bucket may not exist); falling back to multi-region probing", opts.Bucket)
470+
}
471+
}
472+
458473
outputDir := filepath.Join(resultsDir, "s3", utils.SanitizeTargetSegment(opts.Bucket))
459474
if err := os.MkdirAll(outputDir, 0755); err != nil {
460475
return fmt.Errorf("failed to create output directory: %v", err)
@@ -552,22 +567,17 @@ func handleScan(opts Options, resultsDir string) error {
552567
}
553568
fmt.Printf("[INFO] Testing bucket %s without authentication (public access check)...\n", opts.Bucket)
554569

555-
regions := []string{"us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"}
570+
regions := []string{"us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1", "ap-south-1", "eu-central-1"}
556571
if opts.Region != "" {
557572
regions = []string{opts.Region}
558573
}
559574
logger.GetLogger().Infof("[INFO] S3 scan: Testing %d region(s) for public access", len(regions))
560-
575+
561576
var foundURL string
562577
totalURLsTested := 0
563578
for _, region := range regions {
564-
urls := []string{
565-
fmt.Sprintf("https://%s.s3.amazonaws.com/?list-type=2", opts.Bucket),
566-
fmt.Sprintf("https://%s.s3-%s.amazonaws.com/?list-type=2", opts.Bucket, region),
567-
fmt.Sprintf("https://s3.amazonaws.com/%s/?list-type=2", opts.Bucket),
568-
fmt.Sprintf("https://s3-%s.amazonaws.com/%s/?list-type=2", region, opts.Bucket),
569-
}
570-
579+
urls := bucketListURLs(opts.Bucket, region)
580+
571581
logger.GetLogger().Infof("[INFO] S3 scan: Testing region %s (%d URL format(s))", region, len(urls))
572582
for _, testURL := range urls {
573583
totalURLsTested++
@@ -674,13 +684,164 @@ func handleScan(opts Options, resultsDir string) error {
674684
return nil
675685
}
676686

687+
// ParseBucketInput accepts a plain bucket name OR any S3 URL form and returns the
688+
// bucket name and (when present) the AWS region. Recognised forms:
689+
//
690+
// bucket-name
691+
// bucket.s3.amazonaws.com (virtual-hosted, global/us-east-1)
692+
// bucket.s3.ap-south-1.amazonaws.com (virtual-hosted, modern dot)
693+
// bucket.s3-ap-south-1.amazonaws.com (virtual-hosted, legacy dash)
694+
// bucket.s3.dualstack.ap-south-1.amazonaws.com (virtual-hosted, dualstack)
695+
// s3.amazonaws.com/bucket (path-style, global)
696+
// s3.ap-south-1.amazonaws.com/bucket (path-style, regional)
697+
// https://…/… (scheme, port and trailing path are stripped)
698+
func ParseBucketInput(input string) (bucket, region string) {
699+
s := strings.TrimSpace(input)
700+
if s == "" {
701+
return "", ""
702+
}
703+
if i := strings.Index(s, "://"); i != -1 {
704+
s = s[i+3:]
705+
}
706+
host := s
707+
path := ""
708+
if i := strings.IndexByte(s, '/'); i != -1 {
709+
host = s[:i]
710+
path = strings.Trim(s[i+1:], "/")
711+
}
712+
if i := strings.IndexByte(host, ':'); i != -1 { // strip port
713+
host = host[:i]
714+
}
715+
host = strings.ToLower(strings.TrimSuffix(host, "."))
716+
717+
const sfx = ".amazonaws.com"
718+
if !strings.HasSuffix(host, sfx) {
719+
// Plain bucket name (no AWS host) — return as-is.
720+
return host, ""
721+
}
722+
723+
core := strings.TrimSuffix(host, sfx) // e.g. "bucket.s3.ap-south-1", "bucket.s3", "s3.ap-south-1", "s3"
724+
725+
// Path-style: host is "s3" / "s3.<region>" / "s3-<region>", bucket is first path segment.
726+
switch {
727+
case core == "s3":
728+
return firstSegment(path), ""
729+
case strings.HasPrefix(core, "s3."):
730+
return firstSegment(path), cleanRegion(strings.TrimPrefix(core, "s3."))
731+
case strings.HasPrefix(core, "s3-"):
732+
return firstSegment(path), cleanRegion(strings.TrimPrefix(core, "s3-"))
733+
}
734+
735+
// Virtual-hosted: "<bucket>.s3" / "<bucket>.s3.<region>" / "<bucket>.s3-<region>".
736+
// LastIndex so bucket names that themselves contain ".s3" still parse correctly.
737+
if strings.HasSuffix(core, ".s3") {
738+
return strings.TrimSuffix(core, ".s3"), ""
739+
}
740+
if i := strings.LastIndex(core, ".s3."); i != -1 {
741+
return core[:i], cleanRegion(core[i+len(".s3."):])
742+
}
743+
if i := strings.LastIndex(core, ".s3-"); i != -1 {
744+
return core[:i], cleanRegion(core[i+len(".s3-"):])
745+
}
746+
// Unknown amazonaws host shape — fall back to the first label.
747+
return firstSegment(core), ""
748+
}
749+
750+
// firstSegment returns everything before the first '/' (a bucket name, which may
751+
// itself contain dots, so we deliberately do not split on '.').
752+
func firstSegment(s string) string {
753+
if i := strings.IndexByte(s, '/'); i != -1 {
754+
return s[:i]
755+
}
756+
return s
757+
}
758+
759+
// cleanRegion strips dualstack/website prefixes and trailing labels, leaving the bare
760+
// region (e.g. "dualstack.ap-south-1" -> "ap-south-1"). Returns "" for non-regions.
761+
func cleanRegion(r string) string {
762+
r = strings.TrimPrefix(r, "dualstack.")
763+
r = strings.TrimPrefix(r, "dualstack-")
764+
r = strings.TrimPrefix(r, "website-")
765+
r = strings.TrimPrefix(r, "website.")
766+
if i := strings.IndexByte(r, '.'); i != -1 { // region is a single label
767+
r = r[:i]
768+
}
769+
switch r {
770+
case "", "external-1", "website", "dualstack", "accelerate":
771+
// Non-region S3 host keywords (transfer-acceleration, website, FIPS dualstack).
772+
return ""
773+
}
774+
return r
775+
}
776+
777+
// detectBucketRegion returns the bucket's AWS region from the x-amz-bucket-region
778+
// response header, which S3 sets on any request to the bucket — even unauthenticated
779+
// 301/403/400 responses. Returns "" if it can't be determined.
780+
func detectBucketRegion(bucketName string) string {
781+
if bucketName == "" {
782+
return ""
783+
}
784+
client := &http.Client{Timeout: 8 * time.Second}
785+
for _, u := range []string{
786+
"https://" + bucketName + ".s3.amazonaws.com",
787+
"https://s3.amazonaws.com/" + bucketName,
788+
} {
789+
req, err := http.NewRequest("HEAD", u, nil)
790+
if err != nil {
791+
continue
792+
}
793+
resp, err := client.Do(req)
794+
if err != nil {
795+
continue
796+
}
797+
region := resp.Header.Get("x-amz-bucket-region")
798+
resp.Body.Close()
799+
if region != "" {
800+
return region
801+
}
802+
}
803+
return ""
804+
}
805+
806+
// bucketListURLs returns the candidate list-objects endpoints for a bucket, covering
807+
// the modern dot-style regional host (required for buckets outside us-east-1, e.g.
808+
// ap-south-1), the legacy dash-style host, and both virtual-hosted and path-style forms.
809+
func bucketListURLs(bucketName, region string) []string {
810+
const q = "/?list-type=2"
811+
urls := []string{
812+
fmt.Sprintf("https://%s.s3.amazonaws.com%s", bucketName, q),
813+
fmt.Sprintf("https://s3.amazonaws.com/%s%s", bucketName, q),
814+
}
815+
if region != "" && region != "us-east-1" {
816+
urls = append(urls,
817+
fmt.Sprintf("https://%s.s3.%s.amazonaws.com%s", bucketName, region, q), // modern dot (virtual-hosted)
818+
fmt.Sprintf("https://s3.%s.amazonaws.com/%s%s", region, bucketName, q), // modern dot (path-style)
819+
fmt.Sprintf("https://%s.s3-%s.amazonaws.com%s", bucketName, region, q), // legacy dash (virtual-hosted)
820+
fmt.Sprintf("https://s3-%s.amazonaws.com/%s%s", region, bucketName, q), // legacy dash (path-style)
821+
)
822+
}
823+
return urls
824+
}
825+
677826
func probeBucketPermissions(bucketName, region string, logFile io.Writer) (canList, canRead, canWrite, canDelete bool) {
827+
// Resolve the region if the caller didn't supply one, so write/read/delete probes
828+
// hit the bucket's real regional endpoint instead of only the global host (which
829+
// 301-redirects for buckets outside us-east-1, making every probe read false).
830+
if region == "" {
831+
region = detectBucketRegion(bucketName)
832+
}
678833
client := &http.Client{Timeout: 8 * time.Second}
679834
urls := []string{
680835
fmt.Sprintf("https://%s.s3.amazonaws.com", bucketName),
681836
}
682-
if region != "" {
683-
urls = append(urls, fmt.Sprintf("https://%s.s3-%s.amazonaws.com", bucketName, region))
837+
if region != "" && region != "us-east-1" {
838+
// Modern dot-style host first — required for buckets outside us-east-1 (e.g.
839+
// ap-south-1); the global host above returns a 301 redirect for those, so
840+
// without this the permission probe always reads false. Legacy dash kept as fallback.
841+
urls = append(urls,
842+
fmt.Sprintf("https://%s.s3.%s.amazonaws.com", bucketName, region),
843+
fmt.Sprintf("https://%s.s3-%s.amazonaws.com", bucketName, region),
844+
)
684845
}
685846
testKey := fmt.Sprintf("autoar-perm-test-%d.txt", time.Now().UnixNano())
686847
testBody := []byte("autoar permission test")

internal/scanner/s3/s3_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,54 @@ import (
44
"testing"
55
)
66

7+
func TestParseBucketInput(t *testing.T) {
8+
cases := []struct {
9+
in string
10+
wantBucket string
11+
wantRegion string
12+
}{
13+
// Plain bucket name.
14+
{"my-bucket", "my-bucket", ""},
15+
{"unifyapps-sonydev-application-uploads-cloudstorage-2", "unifyapps-sonydev-application-uploads-cloudstorage-2", ""},
16+
// Virtual-hosted, modern dot.
17+
{"unifyapps-sonydev-application-uploads-cloudstorage-2.s3.ap-south-1.amazonaws.com", "unifyapps-sonydev-application-uploads-cloudstorage-2", "ap-south-1"},
18+
{"my-bucket.s3.ap-south-1.amazonaws.com", "my-bucket", "ap-south-1"},
19+
{"https://my-bucket.s3.eu-west-1.amazonaws.com/", "my-bucket", "eu-west-1"},
20+
{"http://my-bucket.s3.us-west-2.amazonaws.com/some/key.txt", "my-bucket", "us-west-2"},
21+
// Virtual-hosted, global (no region).
22+
{"my-bucket.s3.amazonaws.com", "my-bucket", ""},
23+
{"https://my-bucket.s3.amazonaws.com", "my-bucket", ""},
24+
// Virtual-hosted, legacy dash.
25+
{"my-bucket.s3-ap-south-1.amazonaws.com", "my-bucket", "ap-south-1"},
26+
// Virtual-hosted, dualstack.
27+
{"my-bucket.s3.dualstack.ap-south-1.amazonaws.com", "my-bucket", "ap-south-1"},
28+
// Transfer-acceleration endpoint — not a region.
29+
{"my-bucket.s3-accelerate.amazonaws.com", "my-bucket", ""},
30+
{"my-bucket.s3-accelerate.dualstack.amazonaws.com", "my-bucket", ""},
31+
// Website endpoint — region is the real region, "website" prefix stripped.
32+
{"my-bucket.s3-website-us-east-1.amazonaws.com", "my-bucket", "us-east-1"},
33+
// GovCloud region passes through unchanged.
34+
{"my-bucket.s3.us-gov-west-1.amazonaws.com", "my-bucket", "us-gov-west-1"},
35+
// Path-style.
36+
{"s3.amazonaws.com/my-bucket", "my-bucket", ""},
37+
{"s3.ap-south-1.amazonaws.com/my-bucket", "my-bucket", "ap-south-1"},
38+
{"https://s3.ap-south-1.amazonaws.com/my-bucket/key", "my-bucket", "ap-south-1"},
39+
{"s3-eu-west-1.amazonaws.com/my-bucket", "my-bucket", "eu-west-1"},
40+
// Dotted bucket name in virtual-hosted form (LastIndex handling).
41+
{"assets.media.s3.eu-central-1.amazonaws.com", "assets.media", "eu-central-1"},
42+
// Whitespace + trailing dot/port.
43+
{" my-bucket.s3.ap-south-1.amazonaws.com ", "my-bucket", "ap-south-1"},
44+
// Empty.
45+
{"", "", ""},
46+
}
47+
for _, c := range cases {
48+
gotB, gotR := ParseBucketInput(c.in)
49+
if gotB != c.wantBucket || gotR != c.wantRegion {
50+
t.Errorf("ParseBucketInput(%q) = (%q, %q), want (%q, %q)", c.in, gotB, gotR, c.wantBucket, c.wantRegion)
51+
}
52+
}
53+
}
54+
755
func TestGenerateBucketNamesSimple(t *testing.T) {
856
names := generateBucketNames("example.com")
957
if len(names) != 22 {

0 commit comments

Comments
 (0)