openvibers/openvibe.host Commits

Refuse FedCM ID assertions as user tokens; cap archive unpacking at HOST_MAX_UNPACKED_BYTES

OP OpenVibers committed 23 Sept 2026 · 3d7375b09a · 10 files, +47 −9

Network signs FedCM assertions (typ fedcm, aud = any OpenVibe RP origin, tenant
subdomains of openvibe.host included) with the key and issuer of its access
tokens. Host accepted one as a Bearer user token, so a tenant page that obtained
its visitor's assertion could manage that visitor's projects. Tokens carrying
typ or nonce are no longer users.

A gzip archive could unpack to the project's storage quota (1 GiB by default)
in memory from a request of a few KiB, blocking the event loop that also serves
every tenant site. Archives now unpack to at most HOST_MAX_UNPACKED_BYTES
(default 256 MiB, or the quota if smaller), and entries are views into the tar
buffer instead of copies.

Changes

ddiff --git a/.env.example b/.env.example
iindex 7eb1267..18e3ef8 100644
---- a/.env.example
++++ b/.env.example
@@@ -41,6 +41,8 @@ HOST_FORM_SECRET=
# Largest request body for one deploy (bytes; nginx allows 110m on the upload routes)
#HOST_MAX_UPLOAD_BYTES=104857600
++# Largest size an uploaded archive may unpack to (default 256 MiB; the storage quota also applies).
++#HOST_MAX_UNPACKED_BYTES=268435456
# Per-project quota defaults (a staff override per project wins). Production projects:
#HOST_QUOTA_STORAGE_BYTES=1073741824
ddiff --git a/README.md b/README.md
iindex 57e91e2..cb00e39 100644
---- a/README.md
++++ b/README.md
@@@ -182,7 +182,7 @@ A Node service (`server/`, Express 4, better-sqlite3, port **4910**, service id
- a file is server-side code or an executable (`php`, `cgi`, `pl`, `py`, `sh`, `asp(x)`, `jsp`, `shtml`, `exe`, `jar`, …) or looks like a credential or database (`pem`, `key`, `p12`, `env`, `sqlite`, …);
- a file type is not on the allowlist (HTML, CSS, JavaScript, JSON, source maps, web manifests, text, Markdown, CSV, XML/RSS/Atom, WebVTT, SVG, PNG, JPEG, GIF, WebP, AVIF, ICO, BMP, fonts, MP4/WebM/Ogg video, MP3/Ogg/Opus/WAV/M4A/FLAC audio, PDF, WebAssembly, glTF, zip; extension-less files are plain text);
- two entries have the same path, a path is both a file and a directory, or there are no files;
--- a limit is exceeded: request size (`HOST_MAX_UPLOAD_BYTES`, the connection is closed rather than drained), files, bytes per file, total bytes (decompression stops at the limit, so a gzip bomb cannot fill memory), or storage.
++- a limit is exceeded: request size (`HOST_MAX_UPLOAD_BYTES`, the connection is closed rather than drained), files, bytes per file, total bytes (decompression stops at `HOST_MAX_UNPACKED_BYTES`, default 256 MiB, or the storage quota if smaller, so a gzip bomb cannot fill memory), or storage.
### Serving
@@@ -268,7 +268,7 @@ Nothing here has been done yet.
### Not done yet (Stage B)
--- Uploads are held in memory while they are validated (bounded by `HOST_MAX_UPLOAD_BYTES` and the decompression cap), so the service needs that much headroom per concurrent upload.
++- Uploads are held in memory while they are validated (bounded by `HOST_MAX_UPLOAD_BYTES` and `HOST_MAX_UNPACKED_BYTES`), so the service needs that much headroom per concurrent upload.
- Objects live on the service's local disk, not in OpenVibe.Media; there is no replication and no backup beyond `ovhost backup host` (the SQLite database only).
- Certificates for custom domains are issued by hand (step 8); there is no automatic ACME flow.
- Projects are created in Host. When Network has projects (ADR-014), Host should accept only Network project ids and read membership from Network.
ddiff --git a/server/artifacts/archive.js b/server/artifacts/archive.js
iindex a3eae28..2912506 100644
---- a/server/artifacts/archive.js
++++ b/server/artifacts/archive.js
@@@ -116,7 +116,7 @@ function readArchive(buf, limits) {
if (size > limits.maxFileBytes) throw new ArchiveError('quota.max_file_bytes', `"${name}" is ${size} bytes; the limit per file is ${limits.maxFileBytes}`, 413);
total += size;
if (total > limits.maxTotalBytes) throw new ArchiveError('deploy.too_large', `the files add up to more than ${limits.maxTotalBytes} bytes`, 413);
-- entries.push({ path: name, data: Buffer.from(data) });
++ entries.push({ path: name, data }); // a view into the tar buffer: no second copy
}
if (!sawEnd && off < tar.length) throw new ArchiveError('archive.corrupt', 'trailing garbage after the last entry');
return { entries, directories, compressed };
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
iindex f8fac1c..0b66f77 100644
---- a/server/auth/viewer.js
++++ b/server/auth/viewer.js
@@@ -54,6 +54,11 @@ function createViewerResolver({ auth, config }) {
async function fromUserToken(token) {
const claims = await auth.verify(token);
if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
++ // Network signs FedCM ID assertions (typ "fedcm", aud = any OpenVibe RP origin, including
++ // tenant subdomains of openvibe.host) with the same key and issuer as access tokens. An
++ // assertion is only ever swapped at /oauth/token, never presented as a session. Access
++ // tokens carry no typ.
++ if (claims.typ !== undefined || claims.nonce !== undefined) return null;
const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
if (!subject) return null;
return { kind: 'user', subject, staff: STAFF_ROLES.has(claims.role), user: claimsToUser(claims) };
ddiff --git a/server/config.js b/server/config.js
iindex a92172a..ca07231 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -70,6 +70,10 @@ function load(env = process.env) {
uploads: {
// Largest request body accepted for one deploy (the compressed archive or all multipart parts).
maxUploadBytes: int(env.HOST_MAX_UPLOAD_BYTES, 100 * MiB),
++ // Largest size an archive may unpack to (also capped by the project's storage quota). The
++ // unpacked files are held in memory while they are checked, so this bounds what a small
++ // gzip bomb can cost, whatever quota staff grant a project.
++ maxUnpackedBytes: int(env.HOST_MAX_UNPACKED_BYTES, 256 * MiB),
},
// Per-project quotas (a row in host_quotas overrides them per project; only staff set it).
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 56aabd5..908a608 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -125,7 +125,7 @@ function createApi(ctx) {
const pre = deploys.precheck(req.viewer, req.params.id);
let up;
try {
-- up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, limits: pre.limits });
++ up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });
} catch (err) {
if (!(err instanceof UploadError)) throw err;
if (err.closeConnection) res.set('Connection', 'close');
ddiff --git a/server/http/dashboard.js b/server/http/dashboard.js
iindex 1e31a66..c88fbf3 100644
---- a/server/http/dashboard.js
++++ b/server/http/dashboard.js
@@@ -164,7 +164,7 @@ function createDashboard(ctx) {
const pre = deploys.precheck(req.viewer, req.params.id);
let up;
try {
-- up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, limits: pre.limits });
++ up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });
} catch (err) {
if (!(err instanceof UploadError)) throw err;
if (err.closeConnection) res.set('Connection', 'close');
ddiff --git a/server/http/upload.js b/server/http/upload.js
iindex 6eed94c..75fb55d 100644
---- a/server/http/upload.js
++++ b/server/http/upload.js
@@@ -117,7 +117,7 @@ function readMultipart(req, { maxUploadBytes, limits }) {
* Throws UploadError for problems with the request itself; everything else (the archive's
* contents) is reported by the validator.
*/
--async function readUpload(req, { maxUploadBytes, limits }) {
++async function readUpload(req, { maxUploadBytes, maxUnpackedBytes = Infinity, limits }) {
const len = Number(req.headers['content-length']);
if (Number.isFinite(len) && len > maxUploadBytes) throw tooLarge(maxUploadBytes);
const type = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
@@@ -150,7 +150,7 @@ async function readUpload(req, { maxUploadBytes, limits }) {
if (rawArchive) {
source = 'archive';
let r;
-- try { r = readArchive(rawArchive, limits); } catch (err) {
++ try { r = readArchive(rawArchive, { ...limits, maxTotalBytes: Math.min(limits.maxTotalBytes, maxUnpackedBytes) }); } catch (err) {
if (err instanceof ArchiveError) { const e = new UploadError(err.status, err.code, err.message); e.fields = fields; e.source = 'archive'; throw e; }
throw err;
}
ddiff --git a/test/host-quota-auth.test.js b/test/host-quota-auth.test.js
iindex f13c0e5..2ed9a4f 100644
---- a/test/host-quota-auth.test.js
++++ b/test/host-quota-auth.test.js
@@@ -11,7 +11,7 @@ const { site: siteTar } = require('./stageb/tar');
const DAY = 24 * 3600 * 1000;
(async () => {
-- const t = await boot({ env: { HOST_MAX_UPLOAD_BYTES: String(64 * 1024) } });
++ const t = await boot({ env: { HOST_MAX_UPLOAD_BYTES: String(64 * 1024), HOST_MAX_UNPACKED_BYTES: String(1024 * 1024) } });
const alice = t.user('alice');
const staff = t.user('root', { role: 'admin' });
const project = await t.project(alice, 'Q');
@@@ -81,6 +81,17 @@ const DAY = 24 * 3600 * 1000;
assert.strictEqual(r.headers.connection, 'close');
});
++ await check('decompression: an archive unpacks to at most HOST_MAX_UNPACKED_BYTES, however large the storage quota', async () => {
++ // A few KiB of gzip that expands to 2 MiB (the default production quota would allow 1 GiB:
++ // a 1 MB request would otherwise inflate to 1 GiB in memory and block the event loop).
++ const zeros = Buffer.alloc(512 * 1024, 0);
++ const bomb = siteTar({ 'a.png': zeros, 'b.png': zeros, 'c.png': zeros, 'd.png': zeros });
++ assert.ok(bomb.length < 64 * 1024, 'well under the request limit');
++ const r = await t.api('POST', `/api/v1/sites/${site.id}/deploys`, { as: alice, body: bomb, headers: { 'content-type': 'application/gzip' } });
++ assert.strictEqual(r.status, 413, r.text);
++ assert.strictEqual(r.json().code, 'deploy.too_large');
++ });
++
await check('sites per project and custom domains per project', async () => {
await setQuota({ sites: 2, custom_domains: 1 });
await t.site(alice, project.id, 'second-site');
@@@ -175,6 +186,18 @@ const DAY = 24 * 3600 * 1000;
assert.strictEqual(sb.status, 201);
});
++ await check('a FedCM ID assertion is not a session: a tenant page on <site>.openvibe.host cannot act as its visitor', async () => {
++ // Network signs FedCM assertions with the key and issuer of its access tokens, for any RP
++ // origin under an OpenVibe zone, tenant subdomains of openvibe.host included.
++ const assertion = t.network.fedcmAssertion(alice, 'https://evil-tenant.openvibe.host');
++ const list = await t.api('GET', '/api/v1/projects', { as: assertion });
++ assert.strictEqual(list.status, 401, list.text);
++ const del = await t.api('DELETE', `/api/v1/projects/${project.id}`, { as: assertion });
++ assert.strictEqual(del.status, 401, del.text);
++ const dash = await t.request({ method: 'GET', host: 'openvibe.host', path: '/', headers: { cookie: `ov_host_session=${assertion}` } });
++ assert.ok(!/Your projects/.test(dash.text), 'the dashboard does not accept it as a session either');
++ });
++
await check('mods cannot manage hosted sites', async () => {
const { ids } = require('openvibe-contracts');
const mod = t.network.sign({ sub: `mod:${ids.newId('mod')}`, actor_type: 'mod', aud: ['openvibe.host'], cap: ALL });
ddiff --git a/test/stageb/mocks.js b/test/stageb/mocks.js
iindex 3b31851..2cccfed 100644
---- a/test/stageb/mocks.js
++++ b/test/stageb/mocks.js
@@@ -56,13 +56,17 @@ async function startNetwork() {
function userToken(u) {
return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role || 'user' }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
}
++ /** A FedCM ID assertion as Network's /fedcm/assertion signs it (same key and issuer, aud = the RP origin). */
++ function fedcmAssertion(u, origin, nonce = 'n0nce') {
++ return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), id: 1, subject_id: u.subject, username: u.username, display_name: u.display_name, nonce, typ: 'fedcm', jti: crypto.randomBytes(16).toString('hex') }, privatePem, { algorithm: 'RS256', issuer, audience: origin, expiresIn: 300 });
++ }
function serviceToken(client, cap, extra = {}) {
return sign({ sub: `svc:${client}`, aud: ['openvibe.host'], cap, ...extra });
}
function appToken(appId, cap, extra = {}) {
return sign({ sub: `app:${appId}`, actor_type: 'app', aud: ['openvibe.host'], cap, ...extra });
}
-- return { ...srv, publicPem, addUser, userToken, serviceToken, appToken, sign };
++ return { ...srv, publicPem, addUser, userToken, fedcmAssertion, serviceToken, appToken, sign };
}
module.exports = { startNetwork, listen };