From 1e61debad2abeced52fb32a6944ddfb7f5ca6175 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 15 Oct 2026 22:14:38 +0200 Subject: [PATCH] fix(objstore): sign funkwhale's media URLs against the host the browser dials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit funkwhale has two S3 surfaces sharing one private bucket. Audio goes out as X-Accel-Redirect and the front nginx fetches it server-side, but attachments (instance logo, avatars, covers) land in the API JSON as absolute URLs the BROWSER dials. AWS_S3_ENDPOINT_URL drives the boto3 client, so with the internal endpoint django-storages signed URLs for http://seaweedfs:8333, a host no browser can reach. Point the client at the new bucket-less public_endpoint key. Measured against the live gateway: a URL signed for seaweedfs:8333 and sent to the public host answers 403 SignatureDoesNotMatch, while one signed for the public host reaches bucket lookup — query-string SigV4 binds the Host, so the signing host and the fetch host must be the same string. AWS_S3_CUSTOM_DOMAIN cannot serve this: storages/backends/s3.py:689 returns UNSIGNED without a CloudFront signer, and the bucket also holds the music library that music/views.py exists to gate. AWS_CA_BUNDLE follows, because the public endpoint is TLS on the local CA and botocore reads its trust store only from that variable (configprovider.py:59) — sys-svc-compose-ca injects SSL_CERT_FILE, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE and NODE_EXTRA_CA_CERTS, never this one. It takes the bundle rather than the bare root cert, since AWS_CA_BUNDLE replaces the default store. AWS_QUERYSTRING_AUTH=True fixes a pre-existing break: upstream defaults it to `not PROXY_MEDIA`, which produced unsigned URLs against a private bucket. The new front nginx.conf.j2 serves /_protected/media, which the stock template resolves with `alias ${MEDIA_ROOT}/$1;` and therefore 404s for every S3-backed listen. web-svc-seaweedfs keeps no onion vhost. Its tor-contract reason now states the mechanical cause instead of the old claim that the role is never a Tor-browser surface, which decidim and funkwhale have made false: the node hidden service publishes http only while sys-front-tls keys listen/ssl on application_id, so an onion vhost would listen on 443 against a port-80 hidden service. Attachment fetches therefore still fail on an onion pick, and the spec is left to fail there rather than record the miss and pass. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/lookup/objstore.py | 3 + roles/web-app-decidim/templates/env.j2 | 2 +- roles/web-app-funkwhale/meta/services.yml | 2 +- roles/web-app-funkwhale/meta/volumes.yml | 10 ++ roles/web-app-funkwhale/tasks/main.yml | 10 ++ roles/web-app-funkwhale/templates/env.j2 | 6 +- .../web-app-funkwhale/templates/nginx.conf.j2 | 141 ++++++++++++++++++ roles/web-app-funkwhale/vars/main.yml | 3 + roles/web-svc-seaweedfs/meta/services.yml | 8 +- .../python/plugins/lookup/test_objstore.py | 24 +++ 10 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 roles/web-app-funkwhale/templates/nginx.conf.j2 diff --git a/plugins/lookup/objstore.py b/plugins/lookup/objstore.py index 8e47e743fb..297a53fa86 100644 --- a/plugins/lookup/objstore.py +++ b/plugins/lookup/objstore.py @@ -112,6 +112,7 @@ class LookupModule(LookupBase): "credentialed_url": "", "public_domain": "", "public_bucket": "", + "public_endpoint": "", "public_url": "", "reach_host": "127.0.0.1", } @@ -236,6 +237,7 @@ class LookupModule(LookupBase): if resolve_enabled(provider_app, bool(vars_.get("TLS_ENABLED", True))) else "http" ) + public_endpoint = f"{scheme}://{public_domain}" if public_domain else "" public_url = f"{scheme}://{public_bucket}" if public_bucket else "" resolved = { @@ -262,6 +264,7 @@ class LookupModule(LookupBase): "credentialed_url": credentialed_url, "public_domain": public_domain, "public_bucket": public_bucket, + "public_endpoint": public_endpoint, "public_url": public_url, "reach_host": "127.0.0.1", } diff --git a/roles/web-app-decidim/templates/env.j2 b/roles/web-app-decidim/templates/env.j2 index 50a388ec13..f7c5185d08 100644 --- a/roles/web-app-decidim/templates/env.j2 +++ b/roles/web-app-decidim/templates/env.j2 @@ -13,7 +13,7 @@ STORAGE_PROVIDER=s3 AWS_BUCKET={{ lookup('objstore', application_id, 'bucket') }} AWS_ACCESS_KEY_ID={{ lookup('objstore', application_id, 'access_key') }} AWS_SECRET_ACCESS_KEY={{ lookup('objstore', application_id, 'secret_key') }} -AWS_ENDPOINT={{ lookup('objstore', application_id, 'public_url') | regex_replace('/[^/]+/?$', '') }} +AWS_ENDPOINT={{ lookup('objstore', application_id, 'public_endpoint') }} AWS_REGION={{ lookup('objstore', application_id, 'region') }} AWS_PUBLIC={{ lookup('objstore', application_id, 'public') | string | lower }} {% endif %} diff --git a/roles/web-app-funkwhale/meta/services.yml b/roles/web-app-funkwhale/meta/services.yml index 08753541cd..476aef14c4 100644 --- a/roles/web-app-funkwhale/meta/services.yml +++ b/roles/web-app-funkwhale/meta/services.yml @@ -1,5 +1,5 @@ --- -# nocheck: seaweedfs-playwright — Funkwhale writes to the bucket only through the audio-library import flow, which needs a persona-owned library and a real track upload the role does not provision; PROXY_MEDIA=true serves every read back through the API, so the bucket has no persona-reachable surface +# nocheck: seaweedfs-playwright — the only objects this role writes are the branding images of tasks/01_manager.yml, and files/playwright/test-design.js fetches the sidebar logo from its bucket URL and compares it byte for byte with the configured asset; on an onion pick that fetch fails, because web-svc-seaweedfs publishes no onion vhost and the Tor-proxied browser cannot resolve its clearnet name; the document surface the shared scenario drives is the audio-library import flow, which needs a persona-owned library and a real track upload the role does not provision funkwhale: healthcheck: flavor: wget diff --git a/roles/web-app-funkwhale/meta/volumes.yml b/roles/web-app-funkwhale/meta/volumes.yml index 83701d2555..c41937e1ec 100644 --- a/roles/web-app-funkwhale/meta/volumes.yml +++ b/roles/web-app-funkwhale/meta/volumes.yml @@ -41,6 +41,16 @@ static: target: /usr/share/nginx/html/staticfiles read_only: true +front_nginx_conf: + type: config + source: "{{ [lookup('container', 'web-app-funkwhale', 'directories.config'), 'nginx.conf'] | path_join }}" + mode: "0444" + mounts: + - service: front + target: /etc/nginx/templates/default.conf.template + when: "{{ (lookup('config', 'web-app-funkwhale', 'services.seaweedfs.enabled', false) | bool) or + (lookup('config', 'web-app-funkwhale', 'services.minio.enabled', false) | bool) }}" + design_admin_template: type: config source: "{{ [lookup('container', 'web-app-funkwhale', 'directories.config'), 'design', 'admin_base_site.html'] | path_join }}" diff --git a/roles/web-app-funkwhale/tasks/main.yml b/roles/web-app-funkwhale/tasks/main.yml index 2e1413f963..f9b955f894 100644 --- a/roles/web-app-funkwhale/tasks/main.yml +++ b/roles/web-app-funkwhale/tasks/main.yml @@ -5,6 +5,16 @@ vars: webserver_extra_configuration: "client_max_body_size 512M;" +- name: "🧱 Render the front nginx override of {{ application_id }}" + ansible.builtin.include_tasks: + file: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/render_replicated_templates.yml') }}" + vars: + role_templates: + - src: "nginx.conf.j2" + dest: "{{ FUNKWHALE_FRONT_NGINX_HOST }}" + mode: "0444" + when: FUNKWHALE_OBJSTORE_ENABLED | bool + - name: "🎨 Render the Django admin template of {{ application_id }}" ansible.builtin.include_tasks: file: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/render_replicated_templates.yml') }}" diff --git a/roles/web-app-funkwhale/templates/env.j2 b/roles/web-app-funkwhale/templates/env.j2 index 763fc8c810..47fba917ff 100644 --- a/roles/web-app-funkwhale/templates/env.j2 +++ b/roles/web-app-funkwhale/templates/env.j2 @@ -29,9 +29,13 @@ MEDIA_ROOT={{ FUNKWHALE_MEDIA_DIR }} AWS_ACCESS_KEY_ID={{ lookup('objstore', application_id, 'access_key') }} AWS_SECRET_ACCESS_KEY={{ lookup('objstore', application_id, 'secret_key') }} AWS_STORAGE_BUCKET_NAME={{ lookup('objstore', application_id, 'bucket') }} -AWS_S3_ENDPOINT_URL={{ lookup('objstore', application_id, 'url') }} +AWS_S3_ENDPOINT_URL={{ lookup('objstore', application_id, 'public_endpoint') or undef(hint='objstore public_endpoint is empty for ' ~ application_id ~ ': the provider declares no domains.canonical.api, so no endpoint signs URLs the browser can reach') }} AWS_S3_REGION_NAME={{ lookup('objstore', application_id, 'region') }} +AWS_QUERYSTRING_AUTH=True PROXY_MEDIA=true +{% if lookup('ca_injected', application_id) %} +AWS_CA_BUNDLE={{ CA_TRUST.inject_bundle_container }} +{% endif %} {% endif %} STATIC_ROOT={{FUNKWHALE_STATIC_DIR}} diff --git a/roles/web-app-funkwhale/templates/nginx.conf.j2 b/roles/web-app-funkwhale/templates/nginx.conf.j2 new file mode 100644 index 0000000000..1f989d0b9f --- /dev/null +++ b/roles/web-app-funkwhale/templates/nginx.conf.j2 @@ -0,0 +1,141 @@ +upstream funkwhale-api { + server ${FUNKWHALE_API_HOST}:${FUNKWHALE_API_PORT}; +} + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + + +server { + listen 80; + listen [::]:80; + + server_name _; + + root /usr/share/nginx/html; + client_max_body_size ${NGINX_MAX_BODY_SIZE}; + charset utf-8; + + gzip on; + gzip_comp_level 5; + gzip_min_length 256; + gzip_proxied any; + gzip_vary on; + gzip_types + application/javascript + application/vnd.geo+json + application/vnd.ms-fontobject + application/x-font-ttf + application/x-web-app-manifest+json + font/opentype + image/bmp + image/svg+xml + image/x-icon + text/cache-manifest + text/css + text/plain + text/vcard + text/vnd.rim.location.xloc + text/vtt + text/x-component + text/x-cross-domain-policy; + + add_header Content-Security-Policy "default-src 'self'; connect-src https: wss: http: ws: 'self' 'unsafe-eval'; script-src 'self' 'wasm-unsafe-eval'; style-src https: http: 'self' 'unsafe-inline'; img-src https: http: 'self' data:; font-src https: http: 'self' data:; media-src https: http: 'self' data:; object-src 'none'"; + add_header Referrer-Policy "strict-origin-when-cross-origin"; + add_header X-Frame-Options "SAMEORIGIN" always; # nocheck: deprecated-http-header this is funkwhale's in-container front vhost, not the sys-svc-proxy vhost that owns the CSP SPOT + add_header Service-Worker-Allowed "/"; + + location /api/ { + include /etc/nginx/funkwhale_proxy.conf; + client_max_body_size ${NGINX_MAX_BODY_SIZE}; + proxy_pass http://funkwhale-api; + } + + location ~ ^/library/(albums|tracks|artists|playlists)/ { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api; + } + + location /channels/ { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api; + } + + location ~ ^/@(vite-plugin-pwa|vite|id)/ { + include /etc/nginx/funkwhale_proxy.conf; + alias /usr/share/nginx/html/; + try_files $uri $uri/ /index.html; + } + + location /@ { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api; + } + + location / { + include /etc/nginx/funkwhale_proxy.conf; + alias /usr/share/nginx/html/; + try_files $uri $uri/ /index.html; + } + + location ~ "/(front/)?embed.html" { + alias /usr/share/nginx/html/embed.html; + add_header Content-Security-Policy "connect-src https: http: 'self'; default-src 'self'; script-src 'self' unpkg.com 'unsafe-inline' 'unsafe-eval'; style-src https: http: 'self' 'unsafe-inline'; img-src https: http: 'self' data:; font-src https: http: 'self' data:; object-src 'none'; media-src https: http: 'self' data:"; + add_header Referrer-Policy "strict-origin-when-cross-origin"; + + expires 1d; + } + + location /federation/ { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api; + } + + location /rest/ { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api/api/subsonic/rest/; + } + + location /.well-known/ { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api; + } + + location /media/__sized__/ { + alias ${MEDIA_ROOT}/__sized__/; + add_header Access-Control-Allow-Origin '*'; + } + + location /media/attachments/ { + alias ${MEDIA_ROOT}/attachments/; + add_header Access-Control-Allow-Origin '*'; + } + + location /media/dynamic_preferences/ { + alias ${MEDIA_ROOT}/dynamic_preferences/; + add_header Access-Control-Allow-Origin '*'; + } + + location ~ /_protected/media/(.+) { + internal; + {{ NETWORK_DOCKER_DNS_NGINX_DIRECTIVE }} + proxy_set_header Authorization ""; + proxy_set_header Host $proxy_host; + proxy_ssl_server_name on; + proxy_pass $1; + add_header Access-Control-Allow-Origin '*'; + } + + location /_protected/music/ { + internal; + alias ${MUSIC_DIRECTORY_PATH}/; + add_header Access-Control-Allow-Origin '*'; + } + + location /manifest.json { + return 302 ${FUNKWHALE_PROTOCOL}://${FUNKWHALE_HOSTNAME}/api/v2/instance/spa-manifest.json; + } + +} diff --git a/roles/web-app-funkwhale/vars/main.yml b/roles/web-app-funkwhale/vars/main.yml index 780a64c15a..81ffb20d02 100644 --- a/roles/web-app-funkwhale/vars/main.yml +++ b/roles/web-app-funkwhale/vars/main.yml @@ -10,6 +10,9 @@ FUNKWHALE_DOCKER_API_PORT: "{{ lookup('config', application_id, 'services.api.po FUNKWHALE_TYPESENSE_API_KEY: "{{ lookup('config', application_id, 'secrets.credentials.api_key') }}" FUNKWHALE_API_CONTAINER: "{{ lookup('config', application_id, 'services.api.name') }}" +FUNKWHALE_OBJSTORE_ENABLED: "{{ lookup('objstore', application_id, 'enabled') | bool }}" +FUNKWHALE_FRONT_NGINX_HOST: "{{ [lookup('container', application_id, 'directories.config'), 'nginx.conf'] | path_join }}" + FUNKWHALE_DESIGN_ENABLED: "{{ lookup('config', application_id, 'services.design.enabled') | bool }}" FUNKWHALE_DESIGN_LOGO_ENABLED: "{{ FUNKWHALE_DESIGN_ENABLED | bool and lookup('design', application_id).logo is string }}" FUNKWHALE_DESIGN_PALETTE: "{{ lookup('design_palette', lookup('config', 'web-svc-design', 'services.design.colors.base')).light }}" diff --git a/roles/web-svc-seaweedfs/meta/services.yml b/roles/web-svc-seaweedfs/meta/services.yml index dba84f2795..6dedfb7d7e 100644 --- a/roles/web-svc-seaweedfs/meta/services.yml +++ b/roles/web-svc-seaweedfs/meta/services.yml @@ -1,7 +1,9 @@ --- -# nocheck: tor-contract — object-storage backend; consumed server-side over the -# internal network (matrix S3 media etc.), never a Tor-browser surface, so it -# rides no node onion. The browser consoles live in web-app-seaweedfs. +# nocheck: tor-contract — an onion vhost is not servable for this role: the +# node hidden service publishes http only (svc-prx-openresty +# ports.onion.https is false) while sys-front-tls/templates/nginx_ssl_header.j2 +# keys listen/ssl on application_id rather than on the rendered domain, so the +# onion vhost would listen on 443 while Tor forwards only 80. # nocheck: playwright-service-flag — central object-store provider entity seaweedfs: healthcheck: diff --git a/tests/unit/python/plugins/lookup/test_objstore.py b/tests/unit/python/plugins/lookup/test_objstore.py index 0135e5750f..08a63f4fc8 100644 --- a/tests/unit/python/plugins/lookup/test_objstore.py +++ b/tests/unit/python/plugins/lookup/test_objstore.py @@ -104,6 +104,7 @@ class ObjstoreLookupTests(unittest.TestCase): self.assertEqual(out["url"], "") self.assertEqual(out["public_domain"], "") self.assertEqual(out["public_bucket"], "") + self.assertEqual(out["public_endpoint"], "") self.assertEqual(out["public_url"], "") self.assertEqual(out["reach_host"], "127.0.0.1") @@ -139,6 +140,7 @@ class ObjstoreLookupTests(unittest.TestCase): self.assertEqual(out["url"], "http://seaweedfs-central:8334") self.assertEqual(out["public_domain"], "api.seaweedfs.s3.example.com") self.assertEqual(out["public_bucket"], "api.seaweedfs.s3.example.com/foo") + self.assertEqual(out["public_endpoint"], "https://api.seaweedfs.s3.example.com") self.assertEqual(out["public_url"], "https://api.seaweedfs.s3.example.com/foo") self.assertEqual( @@ -146,6 +148,28 @@ class ObjstoreLookupTests(unittest.TestCase): "http://seaweedfs-central:8334", ) + def test_named_canonical_siblings_do_not_win_over_the_api(self): + applications = { + "web-app-foo": { + "services": {"minio": {"enabled": True, "shared": True}}, + "secrets": {"credentials": {"objstore_secret_key": "sk"}}, + }, + "web-app-minio": { + "services": {"minio": {"name": "minio-central"}}, + "domains": { + "canonical": { + "console": "console.minio.s3.example.com", + "api": "api.minio.s3.example.com", + } + }, + }, + } + + out = self._run(["web-app-foo"], applications)[0] + + self.assertEqual(out["public_domain"], "api.minio.s3.example.com") + self.assertEqual(out["public_endpoint"], "https://api.minio.s3.example.com") + def test_credentialed_url_percent_encodes_both_halves(self): applications = { "web-app-foo": {