Find Public SNS Topics and SQS Queues

A row of metal mailboxes mounted on a wall

Photo by Philippe Murray-Pietsch on Unsplash

To find public SNS topics and SQS queues, list them with ListTopics and ListQueues in each Region, read the Policy attribute with GetTopicAttributes or GetQueueAttributes, and look for Allow statements whose principal is "*" or {"AWS": "*"}. The statement is public unless a condition such as aws:SourceArn, aws:SourceAccount or aws:PrincipalOrgID narrows it.

Messaging resources don’t show up in most “is anything public?” checklists, which focus on buckets and databases. Yet a topic anyone can subscribe to leaks every message it carries, and a queue anyone can send to accepts forged work items. The usual cause is a copy-pasted policy with "Principal": "*" and the condition that was supposed to go with it left out. This example is for engineers who need to find public SNS topics and SQS queues across every Region of an account.

You’ll get a read-only TypeScript script for the AWS SDK for JavaScript v3 that reads each access policy and reports the statements that are open to everyone. It follows the pattern of the other AWS SDK v3 security audit examples and never changes a policy.

What makes an SNS topic or SQS queue public?

Both services use resource policies written in the IAM policy language. A topic or queue with no policy statement for other principals is private to its account. It becomes public when an Allow statement names every principal and nothing else limits who that is:

Statement Verdict
"Principal": "*", no condition Public (HIGH). Anyone with AWS credentials, in any account, can use the listed actions.
{"AWS": "*"} + AWS:SourceOwner = your account Not public. This is the shape of the default SNS topic policy.
"*" + aws:SourceArn of one topic or bucket Not public. The standard SNS-to-SQS subscription policy.
"*" + aws:PrincipalOrgID Not public. Limited to accounts in your organization.
{"Service": "s3.amazonaws.com"}, no source condition MEDIUM. Any account’s buckets could send to it through S3.

The last row is the service version of the confused deputy problem. S3, CloudWatch or EventBridge acting for another customer can reach your topic unless the policy checks aws:SourceArn or aws:SourceAccount. SNS still honors the older aws:SourceOwner key for services that set it, but AWS lists it as deprecated for new integrations. The general weakness, a resource whose permissions let unintended actors read or change it, is catalogued as CWE-732: Incorrect Permission Assignment for Critical Resource.

Which actions matter most?

  • sns:Subscribe: anyone can attach an HTTPS endpoint or queue and receive every message published to the topic.
  • sns:Publish and sqs:SendMessage: anyone can inject messages your consumers trust.
  • sqs:ReceiveMessage and sqs:DeleteMessage: anyone can read or drain the queue.
  • SNS:SetTopicAttributes, sqs:SetQueueAttributes or *: anyone can rewrite the policy itself.

How does the script find public SNS topics and queues?

  1. Lists RegionsDescribeRegions, or --regions=.
  2. Reads topic policiespaginateListTopics, then GetTopicAttributes for each topic’s Policy.
  3. Reads queue policiespaginateListQueues with MaxResults: 1000 (SQS only returns a NextToken when MaxResults is set), then GetQueueAttributes with AttributeNames: ["Policy"].
  4. Evaluates each Allow statementA wildcard AWS principal with no limiting condition is HIGH. A service principal without aws:SourceArn, aws:SourceAccount, aws:SourceOwner or a source organization key is MEDIUM. Conditions whose only value is "*" don’t count.
  5. ReportsA table per statement with its Sid and actions, and exit code 2 if anything is HIGH.

Prerequisites

Which IAM permissions does it need?

All read-only. Replace the account ID, or use "*" for the second statement if you audit several accounts with one policy.

sns-sqs-public-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListRegionsTopicsQueues",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeRegions",
        "sns:ListTopics",
        "sqs:ListQueues"
      ],
      "Resource": "*"
    },
    {
      "Sid": "ReadPolicies",
      "Effect": "Allow",
      "Action": [
        "sns:GetTopicAttributes",
        "sqs:GetQueueAttributes"
      ],
      "Resource": [
        "arn:aws:sns:*:111122223333:*",
        "arn:aws:sqs:*:111122223333:*"
      ]
    }
  ]
}

The script to find public SNS topics and SQS queues

find-public-sns-topics-and-sqs-queues.ts

// find-public-sns-topics-and-sqs-queues.ts
// Reads the access policy of every SNS topic and SQS queue in each Region and reports Allow statements that
// grant access to everyone (Principal "*" or {"AWS": "*"}) without a condition that narrows it to your
// account, organization, a source ARN or a VPC endpoint. Also flags service principals with no source check.
// Report-only: it never changes a policy.
// Usage: npx tsx find-public-sns-topics-and-sqs-queues.ts [--regions=us-east-1,eu-west-1]
import { EC2Client, DescribeRegionsCommand } from "@aws-sdk/client-ec2";
import { SNSClient, paginateListTopics, GetTopicAttributesCommand } from "@aws-sdk/client-sns";
import { SQSClient, paginateListQueues, GetQueueAttributesCommand } from "@aws-sdk/client-sqs";

const args = process.argv.slice(2);
const regionArg = args.find((a) => a.startsWith("--regions="))?.split("=")[1]?.split(",").map((s) => s.trim()).filter(Boolean);

type Severity = "HIGH" | "MEDIUM";
interface Finding {
  Region: string;
  Type: "SNS" | "SQS";
  Resource: string;
  Sid: string;
  Actions: string;
  Severity: Severity;
  Reason: string;
}
interface Statement {
  Sid?: string;
  Effect?: string;
  Action?: string | string[];
  Principal?: string | Record<string, string | string[]>;
  Condition?: Record<string, Record<string, string | string[]>>;
}

// Condition keys that tie a wildcard principal to specific callers or sources (compared lowercased).
const LIMITING = new Set([
  "aws:sourceowner", "aws:sourceaccount", "aws:sourcearn", "aws:sourceorgid", "aws:sourceorgpaths",
  "aws:principalaccount", "aws:principalarn", "aws:principalorgid", "aws:principalorgpaths",
  "aws:sourcevpce", "aws:sourcevpc", "aws:sourceip",
]);
const SOURCE_KEYS = ["aws:sourcearn", "aws:sourceaccount", "aws:sourceowner", "aws:sourceorgid", "aws:sourceorgpaths"];

const asArray = (v: string | string[] | undefined): string[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]);

// Limiting condition keys whose values are not just "*".
function limitingKeys(st: Statement): string[] {
  const keys: string[] = [];
  for (const block of Object.values(st.Condition ?? {})) {
    for (const [key, value] of Object.entries(block)) {
      const k = key.toLowerCase();
      if (LIMITING.has(k) && asArray(value).some((v) => v !== "*")) keys.push(k);
    }
  }
  return keys;
}

function analyze(policyJson: string | undefined): { sid: string; actions: string; severity: Severity; reason: string }[] {
  if (!policyJson) return [];
  const doc = JSON.parse(policyJson) as { Statement?: Statement | Statement[] };
  const statements = Array.isArray(doc.Statement) ? doc.Statement : doc.Statement ? [doc.Statement] : [];
  const out: { sid: string; actions: string; severity: Severity; reason: string }[] = [];

  statements.forEach((st, i) => {
    if (st.Effect !== "Allow") return;
    const sid = st.Sid ?? `#${i}`;
    const actions = asArray(st.Action).join(",") || "?";
    const limits = limitingKeys(st);
    const aws = st.Principal === "*" ? ["*"] : typeof st.Principal === "object" ? asArray(st.Principal.AWS) : [];
    const services = typeof st.Principal === "object" ? asArray(st.Principal.Service) : [];

    if (aws.includes("*") && limits.length === 0) {
      out.push({ sid, actions, severity: "HIGH", reason: "Principal * with no limiting condition" });
    } else if (services.length && !limits.some((k) => SOURCE_KEYS.includes(k))) {
      out.push({ sid, actions, severity: "MEDIUM", reason: `${services.join(",")} without aws:SourceArn/SourceAccount` });
    }
  });
  return out;
}

async function listRegions(): Promise<string[]> {
  if (regionArg) return regionArg;
  const out = await new EC2Client({}).send(new DescribeRegionsCommand({}));
  return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}

async function scanRegion(region: string, findings: Finding[]): Promise<number> {
  let count = 0;
  const sns = new SNSClient({ region });
  for await (const page of paginateListTopics({ client: sns }, {})) {
    for (const t of page.Topics ?? []) {
      if (!t.TopicArn) continue;
      count++;
      try {
        const attrs = (await sns.send(new GetTopicAttributesCommand({ TopicArn: t.TopicArn }))).Attributes ?? {};
        for (const f of analyze(attrs.Policy)) {
          findings.push({ Region: region, Type: "SNS", Resource: t.TopicArn.split(":").pop() ?? t.TopicArn, Sid: f.sid, Actions: f.actions, Severity: f.severity, Reason: f.reason });
        }
      } catch (err) {
        console.error(`${region} SNS ${t.TopicArn}: ${err instanceof Error ? err.name : String(err)}`);
      }
    }
  }

  const sqs = new SQSClient({ region });
  for await (const page of paginateListQueues({ client: sqs }, { MaxResults: 1000 })) {
    for (const url of page.QueueUrls ?? []) {
      count++;
      try {
        const attrs = (await sqs.send(new GetQueueAttributesCommand({ QueueUrl: url, AttributeNames: ["Policy"] }))).Attributes ?? {};
        for (const f of analyze(attrs.Policy)) {
          findings.push({ Region: region, Type: "SQS", Resource: url.split("/").pop() ?? url, Sid: f.sid, Actions: f.actions, Severity: f.severity, Reason: f.reason });
        }
      } catch (err) {
        console.error(`${region} SQS ${url}: ${err instanceof Error ? err.name : String(err)}`);
      }
    }
  }
  return count;
}

async function main(): Promise<void> {
  const findings: Finding[] = [];
  let scanned = 0;
  for (const region of await listRegions()) {
    try {
      scanned += await scanRegion(region, findings);
    } catch (err) {
      console.error(`${region}: ${err instanceof Error ? err.name : String(err)}`);
    }
  }
  findings.sort((a, b) => a.Severity.localeCompare(b.Severity) || a.Region.localeCompare(b.Region));
  console.table(findings);
  const high = findings.filter((f) => f.Severity === "HIGH").length;
  console.log(`${scanned} topic(s) and queue(s) checked; ${findings.length} finding(s), ${high} HIGH.`);
  if (high) process.exitCode = 2;
}

main().catch((err) => {
  console.error(err instanceof Error ? `${err.name}: ${err.message}` : err);
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-sns @aws-sdk/client-sqs @aws-sdk/client-ec2
npm install --save-dev tsx typescript

# Every enabled Region
AWS_PROFILE=security-audit npx tsx find-public-sns-topics-and-sqs-queues.ts

# Two Regions only
AWS_PROFILE=security-audit npx tsx find-public-sns-topics-and-sqs-queues.ts --regions=us-east-1,eu-west-1

Sample output

Output

┌─────────┬─────────────┬───────┬──────────────────┬──────────────────┬─────────────────────────────┬──────────┬────────────────────────────────────────────────────────┐
│ (index) │ Region      │ Type  │ Resource         │ Sid              │ Actions                     │ Severity │ Reason                                                 │
├─────────┼─────────────┼───────┼──────────────────┼──────────────────┼─────────────────────────────┼──────────┼────────────────────────────────────────────────────────┤
│ 0       │ 'eu-west-1' │ 'SQS' │ 'legacy-import'  │ '#0'             │ 'sqs:*'                     │ 'HIGH'   │ 'Principal * with no limiting condition'               │
│ 1       │ 'us-east-1' │ 'SNS' │ 'order-events'   │ 'AllowSubscribe' │ 'SNS:Subscribe,SNS:Receive' │ 'HIGH'   │ 'Principal * with no limiting condition'               │
│ 2       │ 'us-east-1' │ 'SQS' │ 'webhook-intake' │ 'PublicSend'     │ 'sqs:SendMessage'           │ 'HIGH'   │ 'Principal * with no limiting condition'               │
│ 3       │ 'us-east-1' │ 'SNS' │ 'bucket-uploads' │ 'S3Publish'      │ 'SNS:Publish'               │ 'MEDIUM' │ 's3.amazonaws.com without aws:SourceArn/SourceAccount' │
└─────────┴─────────────┴───────┴──────────────────┴──────────────────┴─────────────────────────────┴──────────┴────────────────────────────────────────────────────────┘
87 topic(s) and queue(s) checked; 4 finding(s), 3 HIGH.

The names are illustrative. order-events lets anyone subscribe, so any AWS account can receive order data. webhook-intake accepts messages from anyone. bucket-uploads lets S3 publish on behalf of any bucket owner. legacy-import is the worst: sqs:* for everyone includes reading, deleting and changing the policy.

How do you fix a public topic or queue?

  • Name the caller. Replace "*" with the account ID or role ARN that needs access, if there is one. Roles have the same problem in their trust policies; the script to find IAM roles trusted by external AWS accounts audits those.
  • Add the missing condition. For S3, EventBridge or another topic, add ArnEquals on aws:SourceArn (or StringEquals on aws:SourceAccount). For your own accounts, use aws:PrincipalOrgID.
  • Remove unneeded actions. A publisher needs sns:Publish, not SNS:*; a sender needs sqs:SendMessage, not sqs:*.
  • Write it back. SetTopicAttributes or SetQueueAttributes with AttributeName Policy. Statements added with AddPermission can be removed by label with RemovePermission.

Test the integration afterwards: publish a message as shown in the guide to publish an SNS message with AWS SDK v3 in TypeScript, and confirm the queue still receives it with the code from send and receive SQS messages with AWS SDK v3.

What the script can’t tell you

  • Whether a condition value is yours. aws:SourceAccount set to a stranger’s account still counts as limited. Read the values on HIGH-risk topics by hand.
  • Deny statements. An explicit Deny can close a gap the script reports; it evaluates Allow statements only.
  • Encryption and delivery. KMS encryption, subscription endpoints and dead-letter queues are separate checks. The script to find SQS queues without a dead-letter queue covers one of them.

Topics and queues are rarely the only public resource. The scripts to find public and private S3 buckets with the AWS SDK and to find public Lambda function URLs with AuthType NONE cover the two most common others. Machine images are another: the script to find public AMIs shared with every AWS account checks them in each Region.

Ask ChatWithCloud instead

ChatWithCloud turns a plain-English question into AWS SDK for JavaScript v2 code, runs it on your machine with your profile and sends the JSON result to the AI model to write the answer. Try “Which SNS topics in us-east-1 have a policy with Principal * and no conditions?” or “Show me the access policy of the order-events topic.” Use a read-only AWS profile for ChatWithCloud, since generated code runs without a confirmation step, and read the ChatWithCloud security model for what’s sent.

Frequently asked questions

Is the default SNS topic policy public?

No. Its statement uses {"AWS": "*"}, but the AWS:SourceOwner condition limits it to your own account. The script treats it as private.

How do I check if an SQS queue is public?

Read its Policy attribute with GetQueueAttributes. An Allow statement with "Principal": "*" and no condition such as aws:SourceArn or aws:PrincipalOrgID makes it public.

Is Principal * in an SQS policy for SNS safe?

Only with an aws:SourceArn condition that names your topic. AWS’s own example grants the sns.amazonaws.com service principal with that condition, which is the tighter form.

Can someone subscribe to my SNS topic from another account?

Only if the topic policy allows sns:Subscribe for their account or for everyone. A public sns:Subscribe lets any account receive your messages.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud