DEV Community

Bala Paranj
Bala Paranj

Posted on

Tagged 'Internal', Readable by Everyone: Shopify's Production iOS Bucket and the Policy That Contradicted Its Own Tags

✓ Human-authored analysis; AI used for formatting and proofreading.

At some point, someone added a bucket policy to ping-api-production. A Shopify S3 bucket backing the iOS Ping API that granted s3:GetObject and s3:ListBucket to Principal: *. Everyone on the internet. Read and list.

The same bucket was tagged data-classification: internal and Environment: production.

A security researcher found it, listed the contents, and filed HackerOne #1021906.

The Tag Contradiction

The bucket configuration contained two statements that directly contradicted each other:

"tags": {
  "data-classification": "internal",
  "Environment": "production",
  "Owner": "mobile-engineering",
  "ManagedBy": "manual"
}
Enter fullscreen mode Exit fullscreen mode
"policy": {
  "Effect": "Allow",
  "Principal": "*",
  "Action": ["s3:GetObject", "s3:ListBucket"],
  "Resource": [
    "arn:aws:s3:::ping-api-production",
    "arn:aws:s3:::ping-api-production/*"
  ]
}
Enter fullscreen mode Exit fullscreen mode

The tag says internal. The policy says everyone.

One of these was written by the team that owns the data and understands its sensitivity. The other was written or inherited from a template at bucket creation time and never reviewed against the classification. Security controls are supposed to catch the gap between organizational intent and infrastructure reality.

Why This Keeps Happening

The bucket was ManagedBy: manual. No Terraform, CloudFormation or PR review when the policy was added. No drift detection when the tags were updated to reflect the correct data classification.

The sequence is common: a developer creates a bucket quickly, adds a public read policy to serve mobile assets, tags it correctly as internal production data, and moves on. The policy stays. The tags stay. Nobody reviews whether they are consistent.

In a CSPM dashboard, this surfaces as "public S3 bucket: ping-api-production" which is indistinguishable from a bucket intentionally hosting public static assets. There is no signal that the data-classification tag contradicts the access policy. There is no temporal evidence showing how long the contradiction has existed.

The Five Gaps on One Asset

Looking at the full bucket state, ping-api-production had five independent control failures:

Gap State
Public access public_read: true, public_list: true
Block Public Access All four settings disabled
Access logging Disabled — no record of who accessed what
Versioning Disabled — deleted objects unrecoverable
Transport encryption Not enforced — HTTP requests accepted

Any one of these would be a finding. Together on a production bucket tagged internal, they describe a bucket that was never hardened after initial creation.

The System Invariant

The invariant:

S3 buckets must not allow public read or list access.

Its mirror. What becomes possible when it is false:

Any unauthenticated user can browse and download the contents of a production iOS API bucket containing internal data.

The tag data-classification: internal is the owning team's statement of intent. The bucket policy with Principal: * is the actual infrastructure state. Stave evaluates the infrastructure state. The intent is preserved in the snapshot as context for the reviewer.

What Stave Detects

id: CTL.S3.PUBLIC.001
name: No Public S3 Buckets
predicate:
  any:
    - field: properties.storage.access.public_read
      op: eq
      value: true
    - field: properties.storage.access.public_list
      op: eq
      value: true
Enter fullscreen mode Exit fullscreen mode

The control fires on either public_read: true or public_list: true. The Shopify Ping bucket had both.

The E2E Test

This report is one of 28 end-to-end tests in Stave's test suite. The test reconstructs the exact bucket configuration from the report as a snapshot, runs stave apply, and compares the output byte-for-byte against a golden file.

./stave apply \
  --controls testdata/e2e/e2e-h1-shopify-1021906/controls \
  --observations testdata/e2e/e2e-h1-shopify-1021906/observations \
  --max-unsafe 168h \
  --eval-time 2026-01-11T00:00:00Z
Enter fullscreen mode Exit fullscreen mode

The test uses two snapshots. One at T1 (vulnerable state) and one at T2 (still vulnerable, 10 days later) to verify the temporal evidence. The expected output shows 240 hours of exposure against a 168-hour SLA, status OVERDUE, exit code 3.

The test is not claiming Stave would detect a public S3 bucket in general. It is proving that the specific control predicate, evaluated against a snapshot reconstructed from the specific Shopify Ping configuration, produces the specific expected output. The golden file is the evidence.

The Compound Finding

Running the full S3 control set against this bucket produces five findings, not one:

  • CTL.S3.PUBLIC.001 — public read and list access
  • CTL.S3.PUBLIC.ACCESS.BLOCK.001 — Block Public Access not enabled
  • CTL.S3.LOGGING.001 — access logging disabled
  • CTL.S3.VERSIONING.001 — versioning disabled
  • CTL.S3.TRANSPORT.001 — transport encryption not enforced

Each is a separate control gap. Each has its own SLA clock. The combination on a single production asset the compound chain model captures. They are five conditions that together describe a bucket with no detection, recovery or access control.

Remediation

# Remove the public policy
aws s3api delete-bucket-policy --bucket ping-api-production

# Enable all four Block Public Access settings
aws s3api put-public-access-block \
  --bucket ping-api-production \
  --public-access-block-configuration \
    BlockPublicAcls=true,IgnorePublicAcls=true,\
    BlockPublicPolicy=true,RestrictPublicBuckets=true

# Enable access logging
aws s3api put-bucket-logging \
  --bucket ping-api-production \
  --bucket-logging-status '{
    "LoggingEnabled": {
      "TargetBucket": "company-access-logs",
      "TargetPrefix": "ping-api-production/"
    }
  }'
Enter fullscreen mode Exit fullscreen mode

The longer-term fix is migrating to Terraform (ManagedBy: terraform). All policy changes go through pull request review. CTL.S3.PUBLIC.001 runs in CI where any bucket with public access blocks the merge.

Checklist

  • No S3 bucket has Principal: * in its bucket policy
  • Account-level Block Public Access enabled as a safety net
  • All S3 buckets have access logging enabled
  • Manually managed buckets (ManagedBy: manual) audited and migrated to IaC
  • CTL.S3.PUBLIC.001 runs in CI on every infrastructure change

The tag said internal. The policy said everyone. One of them was wrong.


HackerOne #1021906 Shopify ping-api-production bucket. Stave E2E test e2e-h1-shopify-1021906 reconstructs the vulnerable configuration and verifies detection against a golden file. Stave is an open-source infrastructure invariant verifier.

Top comments (0)