fix(web-app-seaweedfs): deny unsigned reads on non-public consumer buckets

SeaweedFS 4.49+ honours a public-read object or bucket ACL on an unsigned
GET/HEAD/list even in a bucket the platform treats as private
(seaweedfs/seaweedfs#11605). Since consumers hold WriteAcp, an app could
expose objects through the public S3 API domain just by sending
x-amz-acl: public-read.

Put a bucket policy on every non-public consumer bucket that denies
s3:Get* and s3:List* to principal "*" when aws:username is null. SeaweedFS
evaluates that deny before any AllUsers grant; signed and presigned
requests carry an IAM user ARN, so aws:username is set and they are not
affected. Public buckets keep their anonymous Read grant, and a bucket
that turns public gets our deny lifted again. Embedded sidecars publish
no port and are left as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-27 13:46:17 +01:00
1 parent 039505d757
commit 3b4ac40df5
5 files changed
+126

No files matched your search

+31
View File
@@ -105,9 +105,40 @@ def seaweedfs_sidecar_script(bucket, s3_port, access_key, secret_key):
)
def seaweedfs_anonymous_deny_policy(bucket):
"""Build the bucket policy that denies every unsigned read of *bucket*.
SeaweedFS honours a public-read object or bucket ACL on an unsigned read
unless a bucket policy explicitly denies it first. A bare
``Principal: "*"`` deny would also hit the consumer's own signed and
presigned requests; the ``Null`` condition on ``aws:username`` limits it to
unsigned ones, because SeaweedFS derives that key from a signed caller's
IAM user ARN and evaluates an unsigned request as principal ``*`` without
it.
Args:
bucket: bucket the policy is attached to; scopes both the bucket ARN
(listings) and its object ARNs (GET/HEAD).
"""
return {
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyAnonymousRead",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:Get*", "s3:List*"],
"Resource": [f"arn:aws:s3:::{bucket}", f"arn:aws:s3:::{bucket}/*"],
"Condition": {"Null": {"aws:username": "true"}},
}
],
}
class FilterModule:
def filters(self):
return {
"seaweedfs_anonymous_deny_policy": seaweedfs_anonymous_deny_policy,
"seaweedfs_command": seaweedfs_command,
"seaweedfs_sidecar_script": seaweedfs_sidecar_script,
}
+1
View File
@@ -18,6 +18,7 @@ The role serves three canonical domains:
Per-consumer S3 identities are rendered into `s3.json`: each consuming role receives an access key and bucket-scoped `Read`, `Write`, `List`, and `Tagging` actions, and consumers marked `public` additionally receive anonymous read on their bucket.
Consumer buckets are created through `weed shell` when a consumer role requests provisioning.
The bucket of every consumer not marked `public` carries a bucket policy that denies all unsigned reads, so a `public-read` ACL set by an app never exposes an object; signed and presigned requests are unaffected.
In embedded mode (`shared: false`) a consumer's compose stack receives a storage-only SeaweedFS container without UI or published ports.
The embedded S3 listener performs no authentication, so it MUST stay confined to the consumer's isolated compose network.
@@ -7,6 +7,10 @@
- name: "🔧 Provision the SeaweedFS bucket on the stack host"
when: IS_STACK_HOST | bool
vars:
_seaweedfs_bucket_public: "{{ lookup('objstore', objstore_consumer_id, 'public') }}"
_seaweedfs_bucket_policy_url: "http://127.0.0.1:{{ SEAWEEDFS_S3_PORT_INTERNAL }}/{{ lookup('objstore', objstore_consumer_id, 'bucket') }}?policy"
_seaweedfs_anonymous_deny_policy: "{{ lookup('objstore', objstore_consumer_id, 'bucket') | seaweedfs_anonymous_deny_policy }}"
block:
- name: "📋 List SeaweedFS buckets"
ansible.builtin.shell: |
@@ -34,3 +38,58 @@
delay: 10
until: _seaweedfs_create is succeeded
when: lookup('objstore', objstore_consumer_id, 'bucket') not in _seaweedfs_buckets.stdout
- name: "🔍 Read the bucket policy of '{{ objstore_consumer_id }}'"
ansible.builtin.shell: >-
{{ SEAWEEDFS_S3_ADMIN_CURL }} {{ _seaweedfs_bucket_policy_url | quote }}
args:
executable: /bin/bash
environment:
SEAWEEDFS_ADMIN_CREDENTIALS: "{{ SEAWEEDFS_ADMIN_CREDENTIALS }}"
register: _seaweedfs_bucket_policy
changed_when: false
failed_when: _seaweedfs_bucket_policy.rc not in [0, 22]
timeout: "{{ lookup('timeout', 60) | int }}"
retries: 3
delay: 10
until: _seaweedfs_bucket_policy is succeeded
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🔒 Deny unsigned reads on the private bucket of '{{ objstore_consumer_id }}'"
ansible.builtin.shell: >-
{{ SEAWEEDFS_S3_ADMIN_CURL }} -X PUT --data-binary @- {{ _seaweedfs_bucket_policy_url | quote }}
args:
executable: /bin/bash
stdin: "{{ _seaweedfs_anonymous_deny_policy | to_json }}"
environment:
SEAWEEDFS_ADMIN_CREDENTIALS: "{{ SEAWEEDFS_ADMIN_CREDENTIALS }}"
register: _seaweedfs_bucket_policy_put
changed_when: true
timeout: "{{ lookup('timeout', 60) | int }}"
retries: 3
delay: 10
until: _seaweedfs_bucket_policy_put is succeeded
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
when: >-
not (_seaweedfs_bucket_public | bool)
and (_seaweedfs_bucket_policy.rc != 0
or (_seaweedfs_bucket_policy.stdout | from_json) != _seaweedfs_anonymous_deny_policy)
- name: "🔓 Lift the unsigned-read deny from the public bucket of '{{ objstore_consumer_id }}'"
ansible.builtin.shell: >-
{{ SEAWEEDFS_S3_ADMIN_CURL }} -X DELETE {{ _seaweedfs_bucket_policy_url | quote }}
args:
executable: /bin/bash
environment:
SEAWEEDFS_ADMIN_CREDENTIALS: "{{ SEAWEEDFS_ADMIN_CREDENTIALS }}"
register: _seaweedfs_bucket_policy_delete
changed_when: true
timeout: "{{ lookup('timeout', 60) | int }}"
retries: 3
delay: 10
until: _seaweedfs_bucket_policy_delete is succeeded
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
when: >-
(_seaweedfs_bucket_public | bool)
and _seaweedfs_bucket_policy.rc == 0
and (_seaweedfs_bucket_policy.stdout | from_json) == _seaweedfs_anonymous_deny_policy
+6
View File
@@ -20,6 +20,12 @@ SEAWEEDFS_MASTER_DOMAIN: "{{ lookup('config', application_id, 'domains.canonical
SEAWEEDFS_ADMIN_ACCESS_KEY: "{{ lookup('users', 'administrator').username }}"
SEAWEEDFS_ADMIN_SECRET_KEY: "{{ lookup('users', 'administrator').password }}"
SEAWEEDFS_ADMIN_CREDENTIALS: "{{ SEAWEEDFS_ADMIN_ACCESS_KEY }}:{{ SEAWEEDFS_ADMIN_SECRET_KEY }}"
SEAWEEDFS_S3_REGION: "{{ lookup('config', application_id, 'services.seaweedfs.region') }}"
SEAWEEDFS_S3_ADMIN_CURL: >-
container exec -i -e SEAWEEDFS_ADMIN_CREDENTIALS {{ SEAWEEDFS_CONTAINER_ADDRESS }}
sh -c 'exec curl -fsS --aws-sigv4 "aws:amz:{{ SEAWEEDFS_S3_REGION }}:s3" --user "$SEAWEEDFS_ADMIN_CREDENTIALS" "$@"' curl
SEAWEEDFS_S3_CONFIG_PATH: "{{ [ lookup('container', application_id, 'directories.volumes'), 's3.json' ] | path_join }}"
@@ -5,6 +5,7 @@ from plugins.filter.seaweedfs import (
VOLUME_GROW_BATCH,
VOLUME_SIZE_LIMIT_MB,
min_free_space,
seaweedfs_anonymous_deny_policy,
seaweedfs_command,
seaweedfs_sidecar_script,
volume_slots,
@@ -92,5 +93,33 @@ class TestSeaweedfsSidecarScriptFilter(unittest.TestCase):
self.assertNotIn("$", seaweedfs_sidecar_script("app", 8333, "AK", "SK"))
class TestSeaweedfsAnonymousDenyPolicyFilter(unittest.TestCase):
def _statement(self, bucket="mastodon"):
(statement,) = seaweedfs_anonymous_deny_policy(bucket)["Statement"]
return statement
def test_uses_the_only_policy_version_seaweedfs_accepts(self):
self.assertEqual(seaweedfs_anonymous_deny_policy("b")["Version"], "2012-10-17")
def test_is_an_explicit_deny_for_every_principal(self):
statement = self._statement()
self.assertEqual(statement["Effect"], "Deny")
self.assertEqual(statement["Principal"], "*")
def test_spares_requests_that_carry_a_signed_username(self):
self.assertEqual(
self._statement()["Condition"], {"Null": {"aws:username": "true"}}
)
def test_covers_reads_and_listings_but_no_writes(self):
self.assertEqual(self._statement()["Action"], ["s3:Get*", "s3:List*"])
def test_scopes_the_bucket_and_its_objects(self):
self.assertEqual(
self._statement("pixelfed")["Resource"],
["arn:aws:s3:::pixelfed", "arn:aws:s3:::pixelfed/*"],
)
if __name__ == "__main__":
unittest.main()