Find S3 Buckets With Wildcard CORS Allowed Origins

An open metal farm gate in a wire fence across a green field

Photo by Nick Tiemeyer on Unsplash

To find S3 CORS allowed origins with a wildcard, list buckets with ListBuckets, call GetBucketCors on each (a NoSuchCORSConfiguration error means the bucket has no CORS rules), and flag rules whose AllowedOrigins contain * or a pattern like https://*.example.com. Rules that combine a wildcard with PUT, POST or DELETE matter most.

CORS rules get written once, usually while someone is fighting a browser error at the end of a sprint, and the fastest fix is "AllowedOrigins": ["*"]. The upload works, the ticket closes, and the wildcard stays. A year later nobody remembers which buckets have it or why.

This example is for engineers auditing S3 before a security review. The script reads every bucket’s CORS configuration, finds S3 CORS allowed origins with a wildcard, rates each rule by the methods it opens, and can replace the wildcards with exact origins on buckets you name. It’s report-only by default and uses the AWS SDK for JavaScript v3.

What does a wildcard in S3 CORS allowed origins actually allow?

Less than people fear, and more than people think. CORS is a browser rule: it tells a browser whether JavaScript on one site may read responses from, or send non-simple requests to, another origin. The protocol is defined in the Fetch Standard’s CORS protocol section. Three consequences for S3:

  • CORS grants no access. AWS states that ACLs and policies continue to apply when you enable CORS. A private bucket with AllowedOrigins: ["*"] is still private; requests still need a valid signature or a policy that allows them.
  • Non-browser clients ignore it. curl, the AWS CLI and SDKs never check CORS, so removing a wildcard doesn’t stop anyone who holds credentials or a presigned URL.
  • It removes a browser-side guard. With * plus PUT, JavaScript on any website can send uploads to the bucket from a visitor’s browser, if it has something that authorizes them: a presigned URL your app issued, or a bucket policy that allows public writes. PortSwigger’s guide to CORS misconfigurations makes the same point for any server: CORS isn’t a defense against cross-origin attacks, and loose origins widen what a malicious page can do.

So a wildcard on a GET-only rule for public fonts or images is normal. A wildcard on a rule that allows writes is worth fixing, and so are wildcard subdomains (https://*.example.com trusts every subdomain, including a forgotten one someone else can take over) and plain http:// origins.

How does S3 evaluate CORS rules?

A bucket holds up to 100 rules. For each browser request, S3 uses the first rule whose AllowedOrigins, AllowedMethods and AllowedHeaders all match. Each origin string can contain at most one *. Allowed methods are limited to GET, PUT, POST, DELETE and HEAD. When nothing matches, the browser gets a 403 Forbidden with “CORS Response: This CORS request is not allowed.”

ExposeHeaders is a different setting: it lists response headers that browser JavaScript may read. ETag isn’t one of the headers browsers expose by default, so a browser that uploads in parts can’t read each part’s ETag, which CompleteMultipartUpload needs, unless the rule exposes it. Exposing ETag is harmless; the script reports it so you know which rules serve browser uploads. The example to upload large files and streams to S3 with AWS SDK v3 shows the multipart flow from Node.js.

What does the script do?

  1. Lists buckets with their RegionspaginateListBuckets returns each bucket’s BucketRegion, so the script calls GetBucketCors through a client in the right Region.
  2. Reads CORS rulesGetBucketCors per bucket. NoSuchCORSConfiguration means no rules, and the bucket is skipped.
  3. Rates every ruleHIGH: * with a write method. MEDIUM: wildcard subdomain or http:// origin with a write method. LOW: wildcards on read-only rules. OK: exact origins.
  4. Optionally tightensWith --apply --buckets a --origins https://app.example.com, it replaces wildcard origins in every rule of the named buckets with your exact HTTPS origins and writes the full configuration back with PutBucketCors.

Prerequisites

  • Node.js 18 or later, tsx, and @aws-sdk/client-s3.
  • A list of the origins that legitimately call each bucket: your app’s domains, preview environments, local development if you really need it.
  • Know which buckets are public. The example to find public and private S3 buckets with the AWS SDK gives you that list; a public bucket with a write wildcard is the combination to fix first.

Which IAM permissions does it need?

s3-cors-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListBuckets",
      "Effect": "Allow",
      "Action": "s3:ListAllMyBuckets",
      "Resource": "*"
    },
    {
      "Sid": "ReadCors",
      "Effect": "Allow",
      "Action": "s3:GetBucketCORS",
      "Resource": "arn:aws:s3:::*"
    },
    {
      "Sid": "ApplyOnlyToNamedBuckets",
      "Effect": "Allow",
      "Action": ["s3:GetBucketCORS", "s3:PutBucketCORS"],
      "Resource": "arn:aws:s3:::uploads-prod"
    }
  ]
}

The IAM actions are spelled s3:GetBucketCORS and s3:PutBucketCORS, although the API operations are GetBucketCors and PutBucketCors. Remove the last statement for report-only runs. To draft a policy for your own variant, paste the script into the free IAM policy generator for TypeScript, then check it against the guide to review a generated IAM policy for least privilege.

The script to find S3 CORS allowed origins with a wildcard

find-s3-buckets-with-open-cors.ts

// find-s3-buckets-with-open-cors.ts
// Reads the CORS configuration of every S3 bucket and flags AllowedOrigins "*", wildcard subdomains and
// plain-http origins, rated by the methods they allow. Report only, unless you pass --apply with explicit
// bucket names and the exact origins that should replace the wildcards.
// Usage: npx tsx find-s3-buckets-with-open-cors.ts [--region us-east-1]
//        npx tsx find-s3-buckets-with-open-cors.ts --apply --buckets assets-prod --origins https://app.example.com
import {
  GetBucketCorsCommand,
  PutBucketCorsCommand,
  S3Client,
  paginateListBuckets,
  type CORSRule,
} from "@aws-sdk/client-s3";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const list = (name: string): string[] => (flag(name) ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const homeRegion = flag("--region") ?? process.env.AWS_REGION ?? "us-east-1";
const apply = args.includes("--apply");
const applyBuckets = list("--buckets");
const newOrigins = list("--origins");
if (apply && (applyBuckets.length === 0 || newOrigins.length === 0)) {
  console.error("--apply needs --buckets a,b and --origins https://app.example.com,...");
  process.exit(1);
}
if (newOrigins.some((o) => !o.startsWith("https://") || o.includes("*"))) {
  console.error("--origins must be exact https:// origins without wildcards");
  process.exit(1);
}

const clients = new Map<string, S3Client>();
const s3For = (region: string): S3Client => {
  let c = clients.get(region);
  if (!c) {
    c = new S3Client({ region });
    clients.set(region, c);
  }
  return c;
};
const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
const WRITE = new Set(["PUT", "POST", "DELETE"]);
const RANK: Record<string, number> = { HIGH: 3, MEDIUM: 2, LOW: 1, OK: 0 };

async function getRules(bucket: string, region: string): Promise<CORSRule[] | null> {
  try {
    const out = await s3For(region).send(new GetBucketCorsCommand({ Bucket: bucket }));
    return out.CORSRules ?? [];
  } catch (err) {
    if (err instanceof Error && err.name === "NoSuchCORSConfiguration") return null; // no CORS at all
    throw err;
  }
}

// Rate one rule: which origins it opens, and whether it allows methods that change data
function rate(rule: CORSRule): { level: string; note: string } {
  const origins = rule.AllowedOrigins ?? [];
  const methods = rule.AllowedMethods ?? [];
  const writes = methods.filter((m) => WRITE.has(m.toUpperCase()));
  const any = origins.includes("*");
  const wildSub = origins.filter((o) => o !== "*" && o.includes("*"));
  const plain = origins.filter((o) => o.startsWith("http://"));
  const m = methods.join("/");
  if (any && writes.length > 0) return { level: "HIGH", note: `* allows ${m}` };
  if (wildSub.length > 0 && writes.length > 0) return { level: "MEDIUM", note: `${wildSub.join(" ")} allows ${m}` };
  if (plain.length > 0 && writes.length > 0) return { level: "MEDIUM", note: `plain-http ${plain.join(" ")} allows ${m}` };
  if (any) return { level: "LOW", note: `* allows ${m} (fine for public assets)` };
  if (wildSub.length > 0 || plain.length > 0) return { level: "LOW", note: `${[...wildSub, ...plain].join(" ")} allows ${m}` };
  return { level: "OK", note: `${origins.length} exact origin(s), ${m}` };
}

// Replace "*" and wildcard origins with the exact origins you passed; keep exact origins and everything else
function tighten(rules: CORSRule[]): CORSRule[] {
  return rules.map((r) => {
    const origins = r.AllowedOrigins ?? [];
    if (!origins.some((o) => o.includes("*"))) return r;
    const kept = origins.filter((o) => !o.includes("*"));
    return { ...r, AllowedOrigins: [...new Set([...kept, ...newOrigins])] };
  });
}

async function applyChanges(regions: Map<string, string>): Promise<void> {
  for (const bucket of applyBuckets) {
    const region = regions.get(bucket) ?? homeRegion;
    try {
      const rules = await getRules(bucket, region);
      if (!rules || rules.length === 0) {
        console.log(`${bucket}: no CORS configuration, nothing to tighten`);
        continue;
      }
      const updated = tighten(rules);
      // PutBucketCors replaces the whole configuration, so send every rule, changed or not
      await s3For(region).send(new PutBucketCorsCommand({ Bucket: bucket, CORSConfiguration: { CORSRules: updated } }));
      console.log(`${bucket}: wildcard origins replaced with ${newOrigins.join(", ")} in ${updated.length} rule(s)`);
    } catch (err) {
      console.error(`${bucket}: ${errText(err)}`);
      process.exitCode = 1;
    }
  }
}

async function main(): Promise<void> {
  const regions = new Map<string, string>();
  for await (const page of paginateListBuckets({ client: s3For(homeRegion) }, {})) {
    for (const b of page.Buckets ?? []) if (b.Name) regions.set(b.Name, b.BucketRegion ?? homeRegion);
  }
  if (apply) return applyChanges(regions);

  const rows: Record<string, string | number>[] = [];
  for (const [bucket, region] of regions) {
    try {
      const rules = await getRules(bucket, region);
      if (rules === null) continue; // most buckets: no CORS configuration
      const rated = rules.map(rate).sort((a, b) => RANK[b.level] - RANK[a.level]);
      rows.push({
        Bucket: bucket,
        Region: region,
        Rules: rules.length,
        Risk: rated[0]?.level ?? "OK",
        Worst: rated[0]?.note ?? "empty configuration",
        ExposeETag: rules.some((r) => (r.ExposeHeaders ?? []).some((h) => h.toLowerCase() === "etag")) ? "yes" : "no",
      });
    } catch (err) {
      rows.push({ Bucket: bucket, Region: region, Rules: 0, Risk: "?", Worst: errText(err), ExposeETag: "-" });
    }
  }
  rows.sort((a, b) => (RANK[String(b.Risk)] ?? 0) - (RANK[String(a.Risk)] ?? 0));
  console.table(rows);
  const high = rows.filter((r) => r.Risk === "HIGH").length;
  console.log(`${regions.size} buckets, ${rows.length} with a CORS configuration, ${high} allowing writes from any origin.`);
  console.log("Report only: nothing was changed.");
}

main().catch((err) => {
  console.error(errText(err));
  process.exit(1);
});

PutBucketCors replaces the whole configuration, so the script always sends every rule back, changed or not. It never adds or removes methods and headers; if a rule should stop allowing DELETE, edit that by hand.

How do you run it?

Terminal

npm install @aws-sdk/client-s3
npm install --save-dev tsx typescript @types/node

# Report on every bucket in the account
AWS_PROFILE=readonly npx tsx find-s3-buckets-with-open-cors.ts

# Replace wildcard origins on one bucket with two exact origins
AWS_PROFILE=storage-admin npx tsx find-s3-buckets-with-open-cors.ts --apply \
  --buckets uploads-prod --origins https://app.example.com,https://admin.example.com

Sample output

Output

┌─────────┬──────────────────┬─────────────┬───────┬──────────┬──────────────────────────────────────────────┬────────────┐
│ (index) │ Bucket           │ Region      │ Rules │ Risk     │ Worst                                        │ ExposeETag │
├─────────┼──────────────────┼─────────────┼───────┼──────────┼──────────────────────────────────────────────┼────────────┤
│ 0       │ 'uploads-prod'   │ 'us-east-1' │ 1     │ 'HIGH'   │ '* allows GET/PUT/POST'                      │ 'yes'      │
│ 1       │ 'partner-portal' │ 'us-east-1' │ 1     │ 'MEDIUM' │ 'https://*.example.com allows PUT'           │ 'no'       │
│ 2       │ 'web-fonts'      │ 'eu-west-1' │ 1     │ 'LOW'    │ '* allows GET/HEAD (fine for public assets)' │ 'no'       │
│ 3       │ 'app-assets'     │ 'us-west-2' │ 1     │ 'OK'     │ '1 exact origin(s), GET'                     │ 'no'       │
└─────────┴──────────────────┴─────────────┴───────┴──────────┴──────────────────────────────────────────────┴────────────┘
5 buckets, 4 with a CORS configuration, 1 allowing writes from any origin.
Report only: nothing was changed.

Bucket names are illustrative; the output came from a run against mocked SDK clients. uploads-prod takes browser uploads through presigned URLs and allows them from any site: replace * with your app’s origin. partner-portal trusts every subdomain of example.com. web-fonts is a normal public-asset rule. The fifth bucket has no CORS rules and doesn’t appear.

What should you check before tightening CORS?

  • Find every caller. Search your frontends for the bucket name and endpoint, including preview deployments. Server access logs record each request’s Referer and User-Agent, which point to the sites and browsers calling the bucket; the example to find S3 buckets without server access logging turns logging on first if you have none.
  • Mind the presigned upload flow. Browser uploads with presigned URLs are the main reason buckets get write CORS rules. The example to create a presigned S3 upload URL with AWS SDK v3 shows the server side; the rule only needs your app’s origin. Browser forms that use an S3 presigned POST from AWS SDK v3 need POST in the rule instead of PUT.
  • Watch CDN caches. If CloudFront sits in front of the bucket, it must forward the Origin header and include it in the cache key, or it will serve cached responses without the right CORS headers after the change.
  • Fix access, not just CORS. CORS is the last layer. Check the bucket’s real permissions with the examples to find S3 buckets that still use ACLs and find S3 buckets whose policy doesn’t require HTTPS.

Troubleshooting

  • AccessDenied on GetBucketCors. The profile lacks s3:GetBucketCORS, or the bucket policy denies it. The script records the error for that bucket and continues.
  • Directory buckets are missing. GetBucketCors isn’t supported for directory buckets, and ListBuckets doesn’t return them.
  • The browser still fails after tightening. The origin must match exactly: scheme, host and port, no trailing slash. The browser may also be using a cached preflight response until MaxAgeSeconds runs out.

Ask ChatWithCloud instead

You can ask ChatWithCloud “Which S3 buckets have a CORS rule that allows PUT from any origin?” and it writes AWS SDK for JavaScript v2 code, runs it locally with your AWS profile and summarizes the result. The walkthrough to analyze your AWS security posture with an AI CLI shows related questions. It can be wrong and doesn’t ask before making changes, so keep it on a read-only profile, as the ChatWithCloud security page recommends, and tighten CORS with the script.

Frequently asked questions

Is an S3 CORS allowed origins wildcard a security risk?

On a read-only rule for public assets, usually not. On a rule that allows PUT, POST or DELETE it lets any website’s JavaScript send writes from a visitor’s browser, if it has a presigned URL or the bucket allows public writes. Replace it with exact origins.

Can I use a wildcard subdomain in S3 CORS?

Yes. Each origin can contain one *, such as https://*.example.com. It trusts every subdomain, so list exact origins where you can.

Does S3 CORS replace a bucket policy?

No. ACLs and bucket policies still decide who can read or write; CORS only controls what browsers let cross-origin scripts do.

How do I remove all CORS rules from a bucket?

Call DeleteBucketCors, or aws s3api delete-bucket-cors --bucket name. Cross-origin requests from browsers will then fail; server-side callers are unaffected.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud