DEV Community

Bala Paranj
Bala Paranj

Posted on

Why `Resource: bucket/${tenant}/*` is Not Enough

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

A multi-tenant SaaS that stores customer files in S3 has a predictable architectural choice: one bucket shared across tenants with prefix-based isolation, or one bucket per tenant. Per-tenant buckets cap out around the AWS soft-limit (100 buckets per account); shared-bucket isolation scales further but only works if every layer of the stack respects the prefix invariant.

Two HackerOne reports describe what happens when a layer doesn't:

  • Shopify 94087 — signed S3 object keys allowed ../ path traversal. One tenant's signed URL reached another tenant's prefix.
  • Unikrn 254200 — the upload-URL signer didn't enforce per-tenant prefix at all. Tenant A could request a presigned PUT against tenant B's prefix; the signer minted it; S3 honoured it.

Both reports look like S3 misconfigurations. They aren't. The S3 bucket policy in both cases probably contained the right idea — Resource: arn:aws:s3:::<bucket>/${aws:userid}/*, or a StringEquals aws:PrincipalTag/tenant_id condition. The bucket was tenant-aware. The signer was not.

The Layer Boundary

The mental model:

client → API server → S3 bucket policy → object
Enter fullscreen mode Exit fullscreen mode

Bucket-level tenant isolation lives in the third box. Tag the principal, condition the policy on the tag, the policy rejects cross-tenant requests at S3's API layer.

The reality with presigned URLs:

client → API server → app-signer → presigned URL → object (S3 honors signature)
                       ↑
              the prefix-enforcement layer
Enter fullscreen mode Exit fullscreen mode

S3 sees a validly-signed request and serves the object. It does not re-evaluate the bucket policy against the requesting client's identity. The signature is the proof of authorisation. Whatever prefix the signer wrote into the URL S3 honours.

So the tenant-isolation invariant has to be enforced by the signer itself, at signing time, before the URL is minted. That means the signer needs:

  1. The requesting tenant's identity (from the session).
  2. A normalised target key (no .., no extra slashes).
  3. A check that the target key starts with the requesting tenant's prefix.

If any of those three is missing, the signer is the breach surface. The bucket policy could still pass a SOC 2 audit; the signer is the layer where one tenant becomes the next.

The System Invariant

Every app-signer that mints presigned URLs against a
shared bucket must enforce the tenant prefix and reject
path traversal.

Stave's observation schema has a top-level identities list (sibling to assets) that tracks long-lived signing identities. An app_signer identity carries a purpose field listing the security-relevant flags:

{
  "id": "appsigner:s3:acme-uploads",
  "type": "app_signer",
  "vendor": "aws",
  "properties": {
    "purpose": "signs_uploads;enforce_prefix=false;allow_traversal=true"
  }
}
Enter fullscreen mode Exit fullscreen mode

The enforce_prefix and allow_traversal flags are the operational state of the signer, captured by whatever collector observes the signing service. The bucket asset is separate, and tagged with the tenant scheme:

{
  "id": "acme-tenant-data",
  "type": "aws_s3_bucket",
  "properties": {
    "storage": {
      "kind": "bucket",
      "tags": {
        "tenant_mode": "shared",
        "tenant_prefix": "tenants/{tenant_id}/"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Two assets, one invariant: shared-tenant-mode bucket AND permissive signer = unsafe.

The Stave Control

id: CTL.S3.TENANT.ISOLATION.001
name: Shared-Bucket Tenant Isolation Must Enforce Prefix
severity: high
unsafe_predicate:
  all:
    - field: properties.storage.kind
      op: eq
      value: bucket
    - field: properties.storage.tags.tenant_mode
      op: eq
      value: shared
    - field: properties.storage.tags.tenant_prefix
      op: present
      value: true
    - field: identities
      op: any_match
      value:
        all:
          - field: type
            op: eq
            value: app_signer
          - field: id
            op: contains
            value: "appsigner:s3:"
          - any:
              - field: purpose
                op: contains
                value: "allow_traversal=true"
              - field: purpose
                op: contains
                value: "enforce_prefix=false"
Enter fullscreen mode Exit fullscreen mode

The predicate uses any_match. Stave's quantifier over the identities list to look for at least one app-signer with either flag in the unsafe state. CEL evaluates this predicate over the static configuration. The verdict: "the signer is permissive."

Why CEL is Not Enough

CEL detects the unsafe configuration. The natural follow-up question is reachability: given this signer, which tenant-to-tenant request can be made? That's a search across the (requesting_tenant, target_key) space, not a fold over property bags.

Customer-facing impact reports want concrete answers:

  • Tenant A can request a signed URL for tenants/B/photos/1.jpg.
  • Path traversal works: tenants/A/../B/secret.json resolves to tenant B's prefix when the signer doesn't normalise.

CEL says the configuration is unsafe. Z3 enumerates the specific requests it admits.

The Z3 Witness Model

The companion program at stave/examples/s3-tenant-prefix-isolation/z3prove/ encodes:

0 = (tenant=A, target="tenants/A/photo.png")        intended
1 = (tenant=A, target="tenants/B/photo.png")        cross-tenant
2 = (tenant=A, target="tenants/A/../B/secret.json") path traversal
Enter fullscreen mode Exit fullscreen mode

The admitted set is parameterised by the signer flags:

Signer state Admitted set
enforce_prefix=false {0, 1, 2} (everything)
enforce_prefix=true, allow_traversal=true {0, 2} (own + traversal)
enforce_prefix=true, allow_traversal=false {0} (own only)

The intended set is {0} — each tenant only legitimately needs their own prefix.

Solver discharges unsafe = admitted ∧ ¬intended against the configuration data the fixture provides:

=== before (signer permissive) ===
  signer purpose: signs_uploads;enforce_prefix=false;allow_traversal=true
  flags: enforce_prefix=false   allow_traversal=true
  admitted set: [tenant=A → tenants/A/photo.png
                 tenant=A → tenants/B/photo.png
                 tenant=A → tenants/A/../B/secret.json]
  intended set: ["tenant=A → tenants/A/photo.png"]
  verdict: SAT — witness request: tenant=A → tenants/B/photo.png
Enter fullscreen mode Exit fullscreen mode

SAT. The witness is concrete: tenant=A → tenants/B/photo.png. That's the request a penetration tester can replay; the audit trail will show tenant A signing the URL through normal API channels; nothing about the bucket itself looks misconfigured.

After the signer is fixed:

=== after  (signer enforced) ===
  signer purpose: signs_uploads;enforce_prefix=true;allow_traversal=false
  flags: enforce_prefix=true   allow_traversal=false
  admitted set: [tenant=A → tenants/A/photo.png]
  intended set: ["tenant=A → tenants/A/photo.png"]
  verdict: UNSAT — every admitted request is intended
Enter fullscreen mode Exit fullscreen mode

UNSAT. There is no cross-tenant request the signer admits.

Why This Pattern Hides

The configuration audits a SOC 2 reviewer runs against the bucket itself look fine. The bucket is private, has no public-read or public-list flag, the bucket policy references ${aws:PrincipalTag/tenant_id} in a Resource condition, the IAM roles for human users are all tenant-tagged. Every layer the auditor examines is tenant-aware.

The signer is application code. It runs in the API tier. It takes a session, takes a target key, returns a URL. The SOC 2 reviewer looks at it as a function call, not a security boundary. The function is correct. It produces URLs that work. Nobody re-runs the prefix-enforcement check because nobody filed it as a security control to begin with.

The Remediation

In application code:

 def sign_upload_url(session, target_key):
+    if "/.." in target_key or target_key.startswith("../"):
+        raise SecurityError("path traversal not allowed")
+    expected_prefix = f"tenants/{session.tenant_id}/"
+    if not target_key.startswith(expected_prefix):
+        raise SecurityError(f"key must start with {expected_prefix}")
     return s3.generate_presigned_url(
         "put_object",
         Params={"Bucket": "acme-tenant-data", "Key": target_key},
         ExpiresIn=900,
     )
Enter fullscreen mode Exit fullscreen mode

Two checks: traversal rejection, prefix enforcement. Both run before the signer mints anything. The application is now the layer that holds the tenant-isolation invariant.

For belt-and-braces enforcement at the bucket layer too:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "TenantScopedAccess",
    "Effect": "Allow",
    "Principal": {"AWS": "arn:aws:iam::111122223333:role/UploadProxy"},
    "Action": ["s3:GetObject", "s3:PutObject"],
    "Resource": "arn:aws:s3:::acme-tenant-data/tenants/${aws:PrincipalTag/tenant_id}/*",
    "Condition": {
      "StringEquals": {
        "aws:PrincipalTag/tenant_id": "${aws:PrincipalTag/tenant_id}"
      }
    }
  }]
}
Enter fullscreen mode Exit fullscreen mode

The condition is the keystone. A presigned URL minted with a session tagged tenant_id=A can never access tenants/B/..., regardless of what the signer does, because the URL's principal tag locks the resource ARN. This is the defense-in-depth layer; the signer is the primary enforcement layer.

The Prevention Lesson

The two reports happened because the signer wasn't audited as a security boundary. Three layers of prevention:

Library default. The presigned-URL helper API takes a session and a target key, and the helper itself enforces the tenant prefix. The application code can't construct an unsafe URL because the helper rejects them. This is the highest-leverage layer. Every existing and future upload feature inherits the invariant.

CI invariant check. stave apply runs against the pre-merge observation snapshot. A signer with enforce_prefix=false or allow_traversal=true produces exit 3 from CTL.S3.TENANT.ISOLATION.001. The fixture shipped with this article is the template with the same predicate, same exit code.

Resource-tag condition on the bucket policy. Even if the signer regresses, the bucket policy's Condition clause on aws:PrincipalTag/tenant_id rejects cross-tenant requests at S3's request-evaluation layer. This is the safety net, the layer that catches mistakes the primary missed.

Checklist

  • Every app-signer wrapping S3 presigned URLs enforces the tenant prefix and rejects traversal patterns
  • The presigned-URL helper API takes a session AND a target key (signer has access to both); calls passing arbitrary keys fail at helper boundary
  • Bucket policy on shared-tenant buckets carries a Condition clause on aws:PrincipalTag/tenant_id that locks the Resource ARN to the requester's tenant
  • stave apply runs in CI against snapshots that include app_signer identities; PRs introducing a permissive signer fail the gate
  • Code review for new tenant-aware features explicitly asks "what does the signer enforce?" not just "what does the bucket policy enforce?"

The two HackerOne reports differ in product, in industry, in whether the breach was read-only or write-capable. The configuration that exposed them was identical: a shared bucket with a tenant-aware policy, and a signer that didn't share the same awareness. The lesson is that the bucket policy is not the only layer. The application's signing helper is the layer that mints the URLs S3 will honour, and it has to enforce the same invariant the bucket policy enforces or the bucket policy is decorative.


The example at stave/examples/s3-tenant-prefix-isolation/ is two binaries side by side: a CEL evaluation via pkg/stave.Apply (asserts the unsafe state when the signer is permissive) and a Z3 SAT prover (extracts a concrete cross-tenant request the signer admits but the application never intended). The Z3 binary lives in a sibling Go module so its libz3 link stays out of Stave's main vendored tree. Stave detects this pattern and 31 other H1-grounded scenarios from local AWS configuration snapshots, with no cloud credentials.

Top comments (0)