fix(objstore): serve browser assets from the consumer's own origin

web-svc-seaweedfs publishes no onion vhost by the tor-contract in its meta/services.yml, so a consumer served only on onion domains was handed an asset origin on the provider's clearnet host. A Tor-proxied browser resolves that name through nothing: BookWyrm logged 19 CSP violations for api.seaweedfs.s3.<domain>/bookwyrm/static, which also took down its theme link and logo specs because bookwyrm.js never loaded.

The lookup now rebases public_domain, public_bucket, public_endpoint and public_url onto the consumer's own origin plus /objstore whenever it is onion-only and the provider is not, and exposes proxy_required, proxy_prefix, upstream_domain and upstream_scheme. A self-guarding nginx fragment routes that prefix over the loopback to the provider's own vhost, so no vhost joins the object-store network and the proxy never becomes a consumer with a bucket it does not use. The location carries the bucket and proxy_pass repeats it; a prefix-only route would serve every bucket of the store under each consumer's origin.

BookWyrm additionally pins AWS_S3_URL_PROTOCOL, because upstream derives PROTOCOL from DOMAIN and calls everything but localhost https, so its asset URLs stayed https where the page is http.

The vhost cross-cutting lint gains an APPLIES predicate: the route is required of an override only when the role declares an object-store engine, which takes the finding from 15 overrides down to the 5 that consume one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-10-22 17:04:07 +02:00
1 parent 4c4b00a0e9
commit 40b178d9a4
12 files changed
+180 -2

No files matched your search

+42 -2
View File
@@ -10,7 +10,11 @@ from ansible.plugins.lookup import LookupBase
from utils.manager.credential_key import OVERRIDE_SECTION
from utils.roles.applications.config import get
from utils.roles.entity.name import entity_name
from utils.tls_common import resolve_enabled
from utils.tls_common import (
collect_domains_for_app,
is_onion_domain,
resolve_enabled,
)
OBJSTORE_ENGINES = ("seaweedfs", "minio")
OBJSTORE_PROVIDER_ROLE = {
@@ -23,6 +27,7 @@ OBJSTORE_DEFAULT_IMAGE = {
"minio": "quay.io/minio/minio",
}
OBJSTORE_DEFAULT_REGION = "us-east-1"
OBJSTORE_PROXY_PREFIX = "/objstore"
def _is_enabled(applications: dict[str, Any], consumer_id: str, engine: str) -> bool:
@@ -114,6 +119,10 @@ class LookupModule(LookupBase):
"public_bucket": "",
"public_endpoint": "",
"public_url": "",
"proxy_required": False,
"proxy_prefix": "",
"upstream_domain": "",
"upstream_scheme": "",
"reach_host": "127.0.0.1",
}
return [resolved if want == "all" else resolved.get(want, "")]
@@ -228,7 +237,6 @@ class LookupModule(LookupBase):
public_domain = (
str(public_domain) if public_domain is not None else ""
).strip()
public_bucket = f"{public_domain}/{bucket}" if public_domain else ""
provider_app = applications.get(provider_role) or {}
if not isinstance(provider_app, dict):
provider_app = {}
@@ -237,6 +245,34 @@ class LookupModule(LookupBase):
if resolve_enabled(provider_app, bool(vars_.get("TLS_ENABLED", True)))
else "http"
)
def _lookup(name: str, args: list[Any]) -> Any:
return lookup_loader.get(
name, loader=self._loader, templar=getattr(self, "_templar", None)
).run(args, variables=vars_)[0]
def _consumer_is_onion_only() -> bool:
domains_map = _lookup("domains", [])
if consumer_id not in domains_map:
return False
served_on = collect_domains_for_app(
domains_map, consumer_id, err_prefix="objstore"
)
return bool(served_on) and all(is_onion_domain(d) for d in served_on)
proxy_required = bool(
public_domain
and not is_onion_domain(public_domain)
and _consumer_is_onion_only()
)
upstream_domain = public_domain
upstream_scheme = scheme
if proxy_required:
consumer_tls = _lookup("tls", [consumer_id])
public_domain = f"{consumer_tls['domain']}{OBJSTORE_PROXY_PREFIX}"
scheme = consumer_tls["protocols"]["web"]
public_bucket = f"{public_domain}/{bucket}" if public_domain else ""
public_endpoint = f"{scheme}://{public_domain}" if public_domain else ""
public_url = f"{scheme}://{public_bucket}" if public_bucket else ""
@@ -266,6 +302,10 @@ class LookupModule(LookupBase):
"public_bucket": public_bucket,
"public_endpoint": public_endpoint,
"public_url": public_url,
"proxy_required": proxy_required,
"proxy_prefix": OBJSTORE_PROXY_PREFIX,
"upstream_domain": upstream_domain,
"upstream_scheme": upstream_scheme,
"reach_host": "127.0.0.1",
}
return [resolved if want == "all" else resolved.get(want, "")]
@@ -56,6 +56,8 @@ server
{% include 'roles/web-app-prometheus/templates/nginx/locations.conf.j2' %}
{% include 'roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2' %}
{% include 'roles/sys-svc-proxy/templates/mcp/vhost.conf.j2' %}
}
@@ -26,5 +26,7 @@ server {
error_page 500 501 502 503 504 /500.html;
{% include 'roles/web-app-prometheus/templates/nginx/locations.conf.j2' %}
{% include 'roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2' %}
{% include 'roles/sys-svc-proxy/templates/mcp/vhost.conf.j2' %}
}
@@ -39,5 +39,7 @@ server
{% include 'roles/web-app-prometheus/templates/nginx/locations.conf.j2' %}
{% include 'roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2' %}
{% include 'roles/sys-svc-proxy/templates/mcp/vhost.conf.j2' %}
}
+1
View File
@@ -20,6 +20,7 @@ AWS_STORAGE_BUCKET_NAME={{ lookup('objstore', application_id, 'bucket') }}
AWS_S3_ENDPOINT_URL={{ lookup('objstore', application_id, 'url') }}
AWS_S3_REGION_NAME={{ lookup('objstore', application_id, 'region') }}
AWS_S3_CUSTOM_DOMAIN={{ lookup('objstore', application_id, 'public_bucket') }}
AWS_S3_URL_PROTOCOL={{ lookup('tls', application_id, 'protocols.web') }}:
USE_S3_FOR_EXPORTS=false
{% else %}
USE_S3=false
@@ -44,5 +44,7 @@ server
{% include 'roles/web-app-prometheus/templates/nginx/locations.conf.j2' %}
{% include 'roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2' %}
{% include 'roles/sys-svc-proxy/templates/mcp/vhost.conf.j2' %}
}
@@ -29,4 +29,6 @@ server
{% endif %}
{% include 'roles/web-app-prometheus/templates/nginx/locations.conf.j2' %}
{% include 'roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2' %}
}
@@ -52,4 +52,6 @@ server {
}
{% include 'roles/web-app-prometheus/templates/nginx/locations.conf.j2' %}
{% include 'roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2' %}
}
@@ -18,6 +18,8 @@ server
{% include 'roles/web-app-prometheus/templates/nginx/locations.conf.j2' %}
{% include 'roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2' %}
{% else %}
{% include 'roles/sys-front-tls/templates/nginx_ssl_header.j2' %}
@@ -0,0 +1,12 @@
{% if lookup('objstore', application_id, 'proxy_required') | bool %}
{% set objstore = lookup('objstore', application_id) %}
location {{ objstore.proxy_prefix }}/{{ objstore.bucket }}/ {
proxy_pass {{ objstore.upstream_scheme }}://127.0.0.1/{{ objstore.bucket }}/;
proxy_ssl_server_name on;
proxy_ssl_name {{ objstore.upstream_domain }};
proxy_set_header Host {{ objstore.upstream_domain }};
proxy_http_version 1.1;
proxy_set_header Connection "";
{% include 'roles/sys-svc-proxy/templates/headers/upstream_strips.conf.j2' %}
}
{% endif %}
@@ -108,9 +108,29 @@ def scraped(role: str) -> bool:
)
def consumes_objstore(role: str) -> bool:
"""Return whether the role's own vhost has to route an object store.
Args:
role: the role directory name.
"""
meta = PROJECT_ROOT / "roles" / role / ROLE_FILE_META_SERVICES
if not meta.is_file():
return False
block = load_yaml_any(str(meta), default_if_missing={})
if not isinstance(block, Mapping):
return False
for engine in ("seaweedfs", "minio"):
entry = block.get(engine)
if isinstance(entry, Mapping) and entry.get("enabled") is not False:
return True
return False
APPLIES = {
"roles/sys-svc-proxy/templates/mcp/vhost.conf.j2": serves_mcp,
"roles/web-app-prometheus/templates/nginx/locations.conf.j2": scraped,
"roles/web-svc-seaweedfs/templates/nginx/locations.conf.j2": consumes_objstore,
}
SATISFIED_BY = {
@@ -8,6 +8,7 @@ consumer has enabled and yields a uniform S3 connection payload.
import importlib.util
import unittest
import unittest.mock as mock
from typing import ClassVar
from unittest.mock import patch
from ansible.errors import AnsibleError
@@ -296,5 +297,95 @@ class ObjstoreLookupTests(unittest.TestCase):
)
class ObjstoreSameOriginProxyTests(unittest.TestCase):
"""Pins the rebase of the browser-facing values onto the consumer's own
origin. web-svc-seaweedfs publishes no onion vhost (the tor-contract in its
meta/services.yml), so a page served only on an onion reaches the provider's
clearnet host through no route at all.
"""
@classmethod
def setUpClass(cls):
cls.mod = _load_module("plugins/lookup/objstore.py", "lookup_objstore")
APPLICATIONS: ClassVar[dict] = {
"web-app-foo": {
"services": {"seaweedfs": {"enabled": True, "shared": True}},
"secrets": {"credentials": {"objstore_secret_key": "sk"}},
},
"web-svc-seaweedfs": {
"services": {"seaweedfs": {"name": "seaweedfs-central"}},
"domains": {"canonical": {"api": "api.seaweedfs.s3.example.com"}},
},
}
def _resolve(self, *, consumer_domains, consumer_web="http"):
vars_ = {"DIR_COMPOSITIONS": "/opt/compose/", "TLS_ENABLED": True}
lm = self.mod.LookupModule()
lm._templar = _DummyTemplar(vars_)
lm._loader = mock.MagicMock()
payloads = {
"applications": self.APPLICATIONS,
"domains": {"web-app-foo": consumer_domains},
"tls": {
"domain": consumer_domains[0],
"protocols": {"web": consumer_web},
},
}
def fake_get(name, **_kwargs):
return mock.MagicMock(run=lambda *_a, **_k: [payloads[name]])
with (
mock.patch.object(self.mod, "lookup_loader") as loader_mock,
patch.object(
self.mod,
"entity_name",
side_effect=lambda r: r.split("-", 2)[-1],
),
):
loader_mock.get.side_effect = fake_get
return lm.run(["web-app-foo"], variables=vars_)[0]
def test_an_onion_only_consumer_gets_the_bucket_on_its_own_origin(self):
out = self._resolve(consumer_domains=["foo.abcdefghij.onion"])
self.assertTrue(out["proxy_required"])
self.assertEqual(out["proxy_prefix"], "/objstore")
self.assertEqual(out["public_domain"], "foo.abcdefghij.onion/objstore")
self.assertEqual(
out["public_url"],
"http://foo.abcdefghij.onion/objstore/foo",
"the browser must dial the page's own origin, never the provider's "
"clearnet host it cannot resolve through Tor",
)
self.assertEqual(
(out["upstream_domain"], out["upstream_scheme"]),
("api.seaweedfs.s3.example.com", "https"),
"the nginx fragment still has to reach the provider's own vhost, so "
"the rebase must not overwrite the upstream it proxies to",
)
def test_a_clearnet_consumer_keeps_the_provider_host(self):
out = self._resolve(consumer_domains=["foo.example.com"], consumer_web="https")
self.assertFalse(
out["proxy_required"],
"a clearnet page reaches the provider directly, so no deployment "
"pays for a proxy hop it does not need",
)
self.assertEqual(out["public_domain"], "api.seaweedfs.s3.example.com")
self.assertEqual(out["public_url"], "https://api.seaweedfs.s3.example.com/foo")
def test_a_consumer_on_both_families_keeps_the_provider_host(self):
out = self._resolve(
consumer_domains=["foo.example.com", "foo.abcdefghij.onion"],
)
self.assertFalse(out["proxy_required"])
self.assertEqual(out["public_domain"], "api.seaweedfs.s3.example.com")
if __name__ == "__main__":
unittest.main()