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)
Changes
@@@ -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;}}@@@ -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@@@ -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;@@@ -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]);
@@ -45,6 +45,12 @@ server {
@@ -45,6 +45,12 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_request_buffering off;
proxy_request_buffering off;
proxy_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 {
@@ -78,5 +84,11 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_request_buffering off;
proxy_request_buffering off;
proxy_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;
}
}
}
}
@@ -38,6 +38,7 @@ a named object zone of one project environment. Zones are made and deleted only
@@ -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.
- **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.
- **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`.
- **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.
- **No browser execution.** Every response carries `Content-Security-Policy: sandbox; default-src 'none'` and `nosniff`, whatever type the object declares.
## Usage
## Usage
@@ -70,6 +70,9 @@ function baseHeaders(res) {
@@ -70,6 +70,9 @@ function baseHeaders(res) {
res.set('X-Content-Type-Options', 'nosniff');
res.set('X-Content-Type-Options', 'nosniff');
res.set('x-amz-request-id', res.locals.rid || (res.locals.rid = requestId()));
res.set('x-amz-request-id', res.locals.rid || (res.locals.rid = requestId()));
res.set('Cache-Control', 'private, no-store');
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). */
/** 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) {
@@ -190,7 +193,8 @@ function objectHeaders(obj) {
for (const [k, v] of Object.entries(md.s3_meta || {})) h[`x-amz-meta-${k}`] = v;
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_disposition) h['Content-Disposition'] = md.content_disposition;
if (md.content_encoding) h['Content-Encoding'] = md.content_encoding;
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;
return h;
}
}
@@ -198,7 +202,7 @@ function objectHeaders(obj) {
@@ -198,7 +202,7 @@ function objectHeaders(obj) {
function responseOverrides(req) {
function responseOverrides(req) {
const o = {};
const o = {};
const map = { 'response-content-type': 'Content-Type', 'response-content-disposition': 'Content-Disposition',
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' };
'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];
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;
return o;
@@ -129,7 +129,15 @@ const { Readable } = require('stream');
@@ -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');
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`);
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');
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 ──
// ── 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]);
const row = await db.get("SELECT * FROM media_objects WHERE zone_id = ? AND object_key = 'site/index.html' AND lifecycle_status = 'ready'", [assets.id]);