openvibers/openvibe.media Commits

Media S3 (security): no S3 answer can be cached at the edge — private, no-store and CDN no-store always; the stored Cache-Control is never sent (#112)

OP OpenVibers committed 29 days ago · 6577cffba2 · 4 files, +28 −3

**Security fix: an S3 answer could be cached at Cloudflare and served to an unsigned request.**

**Found:** while proving Zone in production with a probe key. The steps:
1. Put an object with `CacheControl: public, max-age=300` and a cache-eligible extension (`.jpg`).
2. GET it signed twice: the first was a MISS, the second a HIT. Media sent the stored Cache-Control as the answer's header, and Cloudflare (which proxies `*.openvibe.network`) cached the signed response.
3. An **unsigned** GET of the same URL (`…?x-id=GetObject`, the SDK's query) answered **200 from Cloudflare's cache, with the bytes**.

**Exposure:** none beyond the probe. The S3 surface went live on 2026-10-14 and holds 5 objects in one zone (the probe's). The two with a stored Cache-Control are probe objects, now deleted, and their cache entries expire within 5 minutes.

**Mitigated in production first** (already live): the S3 vhost now hides the upstream Cache-Control and adds `Cache-Control: private, no-store`, `CDN-Cache-Control: no-store` and `Cloudflare-CDN-Cache-Control: no-store` on every answer. It was installed with `ov-vhost-install` (backup kept). The same probe now shows BYPASS for both signed GETs, and the unsigned GET gets 403 with no bytes.

**This PR:**
- `deploy/nginx/s3.openvibe.media.conf`: the same headers as the live vhost, in both server blocks, so the repo matches what is installed.
- `server/s3/routes.js`: `baseHeaders` sends `CDN-Cache-Control` and `Cloudflare-CDN-Cache-Control: no-store` beside `private, no-store`. The stored Cache-Control is kept in the object's metadata but never sent. A presigned URL's `response-cache-control` override is ignored.
- `test/s3-zones.test.js`: an object stored with `public, max-age=31536000`, fetched through a presigned URL asking for `public, max-age=600`, answers `private, no-store` with both CDN headers; HeadObject too.
- `docs/s3.md`: the rule and what was found.

Zones are private-only in v1, so nothing needs the stored value served. Publishing a zone through the CDN is a later ADR-031 amendment, and it will decide how to serve it.

Changes

ddiff --git a/deploy/nginx/s3.openvibe.media.conf b/deploy/nginx/s3.openvibe.media.conf
iindex c637d30..143db42 100644
---- a/deploy/nginx/s3.openvibe.media.conf
++++ b/deploy/nginx/s3.openvibe.media.conf
@@@ -45,6 +45,12 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_request_buffering off;
proxy_buffering off;
++ # Zones are private: no shared cache (Cloudflare's included) may keep an S3 answer, whatever Cache-Control an
++ # object was stored with. A signed GET kept at the edge would be served to an unsigned request for the same URL.
++ proxy_hide_header Cache-Control;
++ add_header Cache-Control "private, no-store" always;
++ add_header CDN-Cache-Control "no-store" always;
++ add_header Cloudflare-CDN-Cache-Control "no-store" always;
}
}
@@@ -78,5 +84,11 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_request_buffering off;
proxy_buffering off;
++ # Zones are private: no shared cache (Cloudflare's included) may keep an S3 answer, whatever Cache-Control an
++ # object was stored with. A signed GET kept at the edge would be served to an unsigned request for the same URL.
++ proxy_hide_header Cache-Control;
++ add_header Cache-Control "private, no-store" always;
++ add_header CDN-Cache-Control "no-store" always;
++ add_header Cloudflare-CDN-Cache-Control "no-store" always;
}
}
ddiff --git a/docs/s3.md b/docs/s3.md
iindex 4a12667..4977e1e 100644
---- a/docs/s3.md
++++ b/docs/s3.md
@@@ -38,6 +38,7 @@ a named object zone of one project environment. Zones are made and deleted only
- **Quotas.** A write is checked against the zone's `quota_bytes` and the environment's quota, before the bytes arrive and again after.
- **Zone deletion.** When Zone pushes state `deleting`, the zone answers `NoSuchBucket` at once. The minute sweep (`zones.sweepDeleting`) tombstones every object with no restore window and purges every copy. The status reports `objects_remaining` and `held_objects` until nothing is left. Then Zone marks the zone `deleted`: its name is retired, and keys scoped only to it are revoked.
- **HEAD through a proxy.** Cloudflare forwards a HEAD as a GET for paths it may cache (by extension: `.jpg`, `.bin`, `.zip` …), but SigV4 signs the method. A header-signed request with no body that arrives as GET and verifies only as HEAD is served as the GET, and the proxy returns just the headers. A presigned URL never gets this, because one may be shared for a HEAD alone; nor does any other method. Every S3 response is `Cache-Control: private, no-store`, so the edge never caches one (`cf-cache-status: BYPASS`). The fix at the edge itself is a DNS-only `s3.openvibe.media`.
++- **Never cached at the edge.** Every S3 answer is `Cache-Control: private, no-store`, with `CDN-Cache-Control` and `Cloudflare-CDN-Cache-Control: no-store`. This holds whatever Cache-Control an object was stored with (it is kept, not sent) and whatever a presigned URL's `response-cache-control` asks for. The S3 vhost enforces the same at nginx. On 2026-10-15, a probe showed Cloudflare serving a signed GET of an object stored with `public, max-age=…` to an unsigned request for the same URL. Only probe objects were exposed.
- **No browser execution.** Every response carries `Content-Security-Policy: sandbox; default-src 'none'` and `nosniff`, whatever type the object declares.
## Usage
ddiff --git a/server/s3/routes.js b/server/s3/routes.js
iindex fcc0477..ba3dd68 100644
---- a/server/s3/routes.js
++++ b/server/s3/routes.js
@@@ -70,6 +70,9 @@ function baseHeaders(res) {
res.set('X-Content-Type-Options', 'nosniff');
res.set('x-amz-request-id', res.locals.rid || (res.locals.rid = requestId()));
res.set('Cache-Control', 'private, no-store');
++ // Cloudflare reads these over Cache-Control: it never keeps an S3 answer, even one a client asked to be cacheable.
++ res.set('CDN-Cache-Control', 'no-store');
++ res.set('Cloudflare-CDN-Cache-Control', 'no-store');
}
/** Headers as given (Express's res.set would add a charset to a text Content-Type; S3 answers the stored type). */
@@@ -190,7 +193,8 @@ function objectHeaders(obj) {
for (const [k, v] of Object.entries(md.s3_meta || {})) h[`x-amz-meta-${k}`] = v;
if (md.content_disposition) h['Content-Disposition'] = md.content_disposition;
if (md.content_encoding) h['Content-Encoding'] = md.content_encoding;
-- if (md.cache_control) h['Cache-Control'] = md.cache_control;
++ // The stored Cache-Control is kept but never sent: zones are private, and a shared cache (Cloudflare in front of
++ // the S3 host) would serve a signed answer to anyone asking for the same URL (baseHeaders: private, no-store).
return h;
}
@@@ -198,7 +202,7 @@ function objectHeaders(obj) {
function responseOverrides(req) {
const o = {};
const map = { 'response-content-type': 'Content-Type', 'response-content-disposition': 'Content-Disposition',
-- 'response-cache-control': 'Cache-Control', 'response-content-encoding': 'Content-Encoding', 'response-content-language': 'Content-Language',
++ 'response-content-encoding': 'Content-Encoding', 'response-content-language': 'Content-Language',
'response-expires': 'Expires' };
for (const [q, h] of Object.entries(map)) if (typeof req.query[q] === 'string' && req.query[q].length <= 512) o[h] = req.query[q];
return o;
ddiff --git a/test/s3-zones.test.js b/test/s3-zones.test.js
iindex 0c3eb0d..b1e021f 100644
---- a/test/s3-zones.test.js
++++ b/test/s3-zones.test.js
@@@ -129,7 +129,15 @@ const { Readable } = require('stream');
assert.ok(Buffer.from(await out.Body.transformToByteArray()).equals(body), 'a streamed upload with a space and + in its key round-trips');
const raw = await fetch(`http://127.0.0.1:${port}/assets/site/index.html`);
assert.deepStrictEqual([raw.status, raw.headers.get('content-security-policy')], [403, "sandbox; default-src 'none'"], 'unsigned requests are refused, and nothing here runs in a browser');
-- console.log('✅ PutObject (plain and aws-chunked), GetObject with a range, HeadObject, metadata, unsigned refused');
++ // A stored Cache-Control is kept but never sent: a shared cache in front of the S3 host (Cloudflare) would hand a
++ // signed answer to an unsigned request for the same URL. So is a presigned URL's response-cache-control.
++ await s3.send(new S3.PutObjectCommand({ Bucket: 'assets', Key: 'cdn/logo.png', Body: 'png', ContentType: 'image/png', CacheControl: 'public, max-age=31536000' }));
++ const cached = await fetch(await getSignedUrl(s3, new S3.GetObjectCommand({ Bucket: 'assets', Key: 'cdn/logo.png', ResponseCacheControl: 'public, max-age=600' }), { expiresIn: 60 }));
++ assert.deepStrictEqual([cached.status, cached.headers.get('cache-control'), cached.headers.get('cdn-cache-control'), cached.headers.get('cloudflare-cdn-cache-control')],
++ [200, 'private, no-store', 'no-store', 'no-store'], 'every S3 answer is private, no-store, at the edge too');
++ assert.strictEqual((await s3.send(new S3.HeadObjectCommand({ Bucket: 'assets', Key: 'cdn/logo.png' }))).CacheControl, 'private, no-store');
++ await s3.send(new S3.DeleteObjectCommand({ Bucket: 'assets', Key: 'cdn/logo.png' }));
++ console.log('✅ PutObject (plain and aws-chunked), GetObject with a range, HeadObject, metadata, unsigned refused; nothing an edge may cache');
// ── Two doors: the object is an ordinary Media object ──
const row = await db.get("SELECT * FROM media_objects WHERE zone_id = ? AND object_key = 'site/index.html' AND lifecycle_status = 'ready'", [assets.id]);