mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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:
1 parent
039505d757
commit
3b4ac40df5
5 files changed
+126
No files matched your search
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user