diff --git a/deploy/Caddyfile.example b/deploy/Caddyfile.example
index 0bb9357..f25a23a 100644
--- a/deploy/Caddyfile.example
+++ b/deploy/Caddyfile.example
@@ -70,18 +70,57 @@
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
{$EKKLESIA_STORAGE_HOSTNAME} {
- reverse_proxy https://object-storage:8443 {
- # An S3 signature covers the Host header: pass the original through unchanged, or every signed
- # URL fails verification.
- header_up Host {host}
- header_up -CF-Connecting-IP
- header_up -X-Real-IP
- header_up -Forwarded
- header_up -True-Client-IP
- transport http {
- # object-storage presents a certificate from the installation-private CA.
- tls_trust_pool file /etc/caddy/ekklesia-ca.crt
- tls_server_name object-storage
+ # Delivery-layer CORS belongs to the deployment, not to the object store: this block decides it,
+ # so it stays the same with any S3 backend behind it. Only the origins whose pages read objects
+ # with fetch() get CORS: the Ekklesia origin, which serves the Dashboard and the displays. An
needs none. Every object
+ # URL is still a signed, expiring capability; CORS only decides which pages may read the bytes.
+ @cors_origin header Origin https://{$EKKLESIA_HOSTNAME}
+
+ # A preflight is answered here and never reaches storage. Only GET and HEAD are ever allowed, and
+ # no request header beyond the CORS-safelisted ones.
+ @preflight method OPTIONS
+ handle @preflight {
+ header @cors_origin {
+ Access-Control-Allow-Origin {http.request.header.Origin}
+ Access-Control-Allow-Methods "GET, HEAD"
+ Access-Control-Max-Age 600
+ }
+ header Vary Origin
+ respond 204
+ }
+
+ handle {
+ # Set when the response is written, after the upstream's own CORS headers were removed below.
+ header @cors_origin >Access-Control-Allow-Origin {http.request.header.Origin}
+ header >Vary Origin
+
+ reverse_proxy https://object-storage:8443 {
+ # An S3 signature covers the Host header: pass the original through unchanged, or every
+ # signed URL fails verification.
+ header_up Host {host}
+ header_up -CF-Connecting-IP
+ header_up -X-Real-IP
+ header_up -Forwarded
+ header_up -True-Client-IP
+ # The object store's own CORS answer (SeaweedFS reflects any origin, with every method)
+ # is not the deployment's policy.
+ header_down -Access-Control-Allow-Origin
+ header_down -Access-Control-Allow-Methods
+ header_down -Access-Control-Allow-Headers
+ header_down -Access-Control-Expose-Headers
+ header_down -Access-Control-Allow-Credentials
+ header_down -Access-Control-Max-Age
+ header_down -Vary
+ transport http {
+ # object-storage presents a certificate from the installation-private CA.
+ tls_trust_pool file /etc/caddy/ekklesia-ca.crt
+ tls_server_name object-storage
+ # HTTP/1.1 only. Over HTTP/2, SeaweedFS answers a conditional request with a 304 that
+ # declares a Content-Length it never sends; Caddy then aborts the browser's stream,
+ # which a browser reports as ERR_HTTP2_PROTOCOL_ERROR on every revalidation. Its
+ # HTTP/1.1 304 is clean. Browsers still reach this proxy over HTTP/2 and HTTP/3.
+ versions 1.1
+ }
}
}
}
diff --git a/deploy/Caddyfile.split.example b/deploy/Caddyfile.split.example
index ae0ffe4..19196a3 100644
--- a/deploy/Caddyfile.split.example
+++ b/deploy/Caddyfile.split.example
@@ -92,18 +92,61 @@
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
{$EKKLESIA_STORAGE_HOSTNAME} {
- reverse_proxy https://object-storage:8443 {
- # An S3 signature covers the Host header: pass the original through unchanged, or every signed
- # URL fails verification.
- header_up Host {host}
- header_up -CF-Connecting-IP
- header_up -X-Real-IP
- header_up -Forwarded
- header_up -True-Client-IP
- transport http {
- # object-storage presents a certificate from the installation-private CA.
- tls_trust_pool file /etc/caddy/ekklesia-ca.crt
- tls_server_name object-storage
+ # Delivery-layer CORS belongs to the deployment, not to the object store: this block decides it,
+ # so it stays the same with any S3 backend behind it. Only the origins whose pages read objects
+ # with fetch() get CORS: the API origin, where displays run, and the Dashboard origin,
+ # where a module's preview may cache images too. An
needs none. Every object
+ # URL is still a signed, expiring capability; CORS only decides which pages may read the bytes.
+ @cors_origin {
+ header Origin https://{$EKKLESIA_HOSTNAME}
+ header Origin https://{$EKKLESIA_DASHBOARD_HOSTNAME}
+ }
+
+ # A preflight is answered here and never reaches storage. Only GET and HEAD are ever allowed, and
+ # no request header beyond the CORS-safelisted ones.
+ @preflight method OPTIONS
+ handle @preflight {
+ header @cors_origin {
+ Access-Control-Allow-Origin {http.request.header.Origin}
+ Access-Control-Allow-Methods "GET, HEAD"
+ Access-Control-Max-Age 600
+ }
+ header Vary Origin
+ respond 204
+ }
+
+ handle {
+ # Set when the response is written, after the upstream's own CORS headers were removed below.
+ header @cors_origin >Access-Control-Allow-Origin {http.request.header.Origin}
+ header >Vary Origin
+
+ reverse_proxy https://object-storage:8443 {
+ # An S3 signature covers the Host header: pass the original through unchanged, or every
+ # signed URL fails verification.
+ header_up Host {host}
+ header_up -CF-Connecting-IP
+ header_up -X-Real-IP
+ header_up -Forwarded
+ header_up -True-Client-IP
+ # The object store's own CORS answer (SeaweedFS reflects any origin, with every method)
+ # is not the deployment's policy.
+ header_down -Access-Control-Allow-Origin
+ header_down -Access-Control-Allow-Methods
+ header_down -Access-Control-Allow-Headers
+ header_down -Access-Control-Expose-Headers
+ header_down -Access-Control-Allow-Credentials
+ header_down -Access-Control-Max-Age
+ header_down -Vary
+ transport http {
+ # object-storage presents a certificate from the installation-private CA.
+ tls_trust_pool file /etc/caddy/ekklesia-ca.crt
+ tls_server_name object-storage
+ # HTTP/1.1 only. Over HTTP/2, SeaweedFS answers a conditional request with a 304 that
+ # declares a Content-Length it never sends; Caddy then aborts the browser's stream,
+ # which a browser reports as ERR_HTTP2_PROTOCOL_ERROR on every revalidation. Its
+ # HTTP/1.1 304 is clean. Browsers still reach this proxy over HTTP/2 and HTTP/3.
+ versions 1.1
+ }
}
}
}
diff --git a/deploy/README.md b/deploy/README.md
index 779f9be..a70e100 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -178,6 +178,29 @@ the topology and warns when the two hostnames are not siblings.
The Dashboard learns the API's origin at run time: the API writes its identity base URL into the page
it serves (``). Nothing about the topology is built into the image.
+### Storage delivery: CORS is the deployment's
+
+Browsers fetch objects directly from the storage hostname through signed, expiring URLs (ADR 0031).
+**Which pages may read those bytes with `fetch()` is decided by the delivery layer, the proxy, and
+never by the object store.** That policy therefore stays the same with SeaweedFS, R2, or any other
+S3 backend behind it, and an installation that changes backends changes nothing about CORS.
+
+The reference proxy's storage block:
+
+- grants CORS only to the origins whose pages read objects with `fetch()`: the Ekklesia origin in the
+ same-origin topology; the API origin (displays) and the Dashboard origin (a module's preview) in
+ the split topology. An `
` needs no CORS and is unaffected;
+- allows `GET` and `HEAD` only, and no request header beyond the CORS-safelisted ones, always with
+ `Vary: Origin`;
+- answers preflight `OPTIONS` requests itself, so they never reach storage;
+- removes the object store's own CORS headers. SeaweedFS's default reflects any origin and
+ advertises `PUT`, `POST`, and `DELETE`.
+
+The proxy reaches storage over **HTTP/1.1 only**. Over HTTP/2, SeaweedFS answers a conditional
+request with a `304` that declares a `Content-Length` it never sends. The proxy then resets the
+browser's stream, and every reload of a cached image fails with `ERR_HTTP2_PROTOCOL_ERROR`.
+Browsers still reach the proxy itself over HTTP/2 and HTTP/3.
+
### Behind an existing reverse proxy
Run the base alone and attach `api` and `object-storage` to your proxy's network with an overlay of
@@ -213,6 +236,8 @@ Then make your proxy do what `Caddyfile.example` does:
- forward the storage hostname to `https://object-storage:8443`, verifying it against
`/etc/ekklesia/tls/ca.crt` with server name `object-storage`, and **pass the original `Host`
through unchanged**, or every signed URL fails (ADR 0031).
+- reach `object-storage` over **HTTP/1.1** only, and own the storage hostname's CORS as described in
+ [Storage delivery](#storage-delivery-cors-is-the-deployments).
The proxy's address must be fixed and must never be handed to anything else. With Docker, give its
network an explicit subnet, limit `ip_range` to part of it, and fix the proxy's address outside that
@@ -288,7 +313,10 @@ deployment:
platform-only image and a disposable PostgreSQL and SeaweedFS that exist only for that run. It
follows the first boot above and checks the following through the proxy:
- readiness and the Dashboard;
- - a signed object URL fetched through the storage hostname;
+ - a signed object URL fetched through the storage hostname, and the same image loaded twice over
+ HTTP/2, the second time as a conditional request that must be a clean `304`;
+ - storage CORS: exactly the allowed origins, `GET` and `HEAD` only, preflights answered by the
+ proxy, and none of the object store's own CORS headers;
- the client-address chain, both from an ordinary peer and from the tunnel connector's address;
- initial-owner sign-in and sign-out;
- that the API's database connection is TLS 1.3, and that it is refused by a foreign CA and by a