openvibers/openvibe.host Commits
Refuse FedCM ID assertions as user tokens; cap archive unpacking at HOST_MAX_UNPACKED_BYTES
Changes
@@@ -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@@@ -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.@@@ -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 };@@@ -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) };@@@ -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).@@@ -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');@@@ -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');@@@ -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;}@@@ -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 });@@@ -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 };
@@ -41,6 +41,8 @@ HOST_FORM_SECRET=
@@ -41,6 +41,8 @@ HOST_FORM_SECRET=
# Largest request body for one deploy (bytes; nginx allows 110m on the upload routes)
# Largest request body for one deploy (bytes; nginx allows 110m on the upload routes)
#HOST_MAX_UPLOAD_BYTES=104857600
#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:
# Per-project quota defaults (a staff override per project wins). Production projects:
#HOST_QUOTA_STORAGE_BYTES=1073741824
#HOST_QUOTA_STORAGE_BYTES=1073741824
@@ -182,7 +182,7 @@ A Node service (`server/`, Express 4, better-sqlite3, port **4910**, service id
@@ -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 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);
- 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;
- 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
### Serving
@@ -268,7 +268,7 @@ Nothing here has been done yet.
@@ -268,7 +268,7 @@ Nothing here has been done yet.
### Not done yet (Stage B)
### 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).
- 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.
- 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.
- Projects are created in Host. When Network has projects (ADR-014), Host should accept only Network project ids and read membership from Network.
@@ -116,7 +116,7 @@ function readArchive(buf, limits) {
@@ -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);
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;
total += size;
if (total > limits.maxTotalBytes) throw new ArchiveError('deploy.too_large', `the files add up to more than ${limits.maxTotalBytes} bytes`, 413);
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');
if (!sawEnd && off < tar.length) throw new ArchiveError('archive.corrupt', 'trailing garbage after the last entry');
return { entries, directories, compressed };
return { entries, directories, compressed };
@@ -54,6 +54,11 @@ function createViewerResolver({ auth, config }) {
@@ -54,6 +54,11 @@ function createViewerResolver({ auth, config }) {
async function fromUserToken(token) {
async function fromUserToken(token) {
const claims = await auth.verify(token);
const claims = await auth.verify(token);
if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
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;
const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
if (!subject) return null;
if (!subject) return null;
return { kind: 'user', subject, staff: STAFF_ROLES.has(claims.role), user: claimsToUser(claims) };
return { kind: 'user', subject, staff: STAFF_ROLES.has(claims.role), user: claimsToUser(claims) };
@@ -70,6 +70,10 @@ function load(env = process.env) {
@@ -70,6 +70,10 @@ function load(env = process.env) {
uploads: {
uploads: {
// Largest request body accepted for one deploy (the compressed archive or all multipart parts).
// Largest request body accepted for one deploy (the compressed archive or all multipart parts).
maxUploadBytes: int(env.HOST_MAX_UPLOAD_BYTES, 100 * MiB),
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).
// Per-project quotas (a row in host_quotas overrides them per project; only staff set it).
@@ -125,7 +125,7 @@ function createApi(ctx) {
@@ -125,7 +125,7 @@ function createApi(ctx) {
const pre = deploys.precheck(req.viewer, req.params.id);
const pre = deploys.precheck(req.viewer, req.params.id);
let up;
let up;
try {
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) {
} catch (err) {
if (!(err instanceof UploadError)) throw err;
if (!(err instanceof UploadError)) throw err;
if (err.closeConnection) res.set('Connection', 'close');
if (err.closeConnection) res.set('Connection', 'close');
@@ -164,7 +164,7 @@ function createDashboard(ctx) {
@@ -164,7 +164,7 @@ function createDashboard(ctx) {
const pre = deploys.precheck(req.viewer, req.params.id);
const pre = deploys.precheck(req.viewer, req.params.id);
let up;
let up;
try {
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) {
} catch (err) {
if (!(err instanceof UploadError)) throw err;
if (!(err instanceof UploadError)) throw err;
if (err.closeConnection) res.set('Connection', 'close');
if (err.closeConnection) res.set('Connection', 'close');
@@ -117,7 +117,7 @@ function readMultipart(req, { maxUploadBytes, limits }) {
@@ -117,7 +117,7 @@ function readMultipart(req, { maxUploadBytes, limits }) {
* Throws UploadError for problems with the request itself; everything else (the archive's
* Throws UploadError for problems with the request itself; everything else (the archive's
* contents) is reported by the validator.
* 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']);
const len = Number(req.headers['content-length']);
if (Number.isFinite(len) && len > maxUploadBytes) throw tooLarge(maxUploadBytes);
if (Number.isFinite(len) && len > maxUploadBytes) throw tooLarge(maxUploadBytes);
const type = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
const type = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
@@ -150,7 +150,7 @@ async function readUpload(req, { maxUploadBytes, limits }) {
@@ -150,7 +150,7 @@ async function readUpload(req, { maxUploadBytes, limits }) {
if (rawArchive) {
if (rawArchive) {
source = 'archive';
source = 'archive';
let r;
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; }
if (err instanceof ArchiveError) { const e = new UploadError(err.status, err.code, err.message); e.fields = fields; e.source = 'archive'; throw e; }
throw err;
throw err;
}
}
@@ -11,7 +11,7 @@ const { site: siteTar } = require('./stageb/tar');
@@ -11,7 +11,7 @@ const { site: siteTar } = require('./stageb/tar');
const DAY = 24 * 3600 * 1000;
const DAY = 24 * 3600 * 1000;
(async () => {
(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 alice = t.user('alice');
const staff = t.user('root', { role: 'admin' });
const staff = t.user('root', { role: 'admin' });
const project = await t.project(alice, 'Q');
const project = await t.project(alice, 'Q');
@@ -81,6 +81,17 @@ const DAY = 24 * 3600 * 1000;
@@ -81,6 +81,17 @@ const DAY = 24 * 3600 * 1000;
assert.strictEqual(r.headers.connection, 'close');
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 check('sites per project and custom domains per project', async () => {
await setQuota({ sites: 2, custom_domains: 1 });
await setQuota({ sites: 2, custom_domains: 1 });
await t.site(alice, project.id, 'second-site');
await t.site(alice, project.id, 'second-site');
@@ -175,6 +186,18 @@ const DAY = 24 * 3600 * 1000;
@@ -175,6 +186,18 @@ const DAY = 24 * 3600 * 1000;
assert.strictEqual(sb.status, 201);
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 () => {
await check('mods cannot manage hosted sites', async () => {
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const mod = t.network.sign({ sub: `mod:${ids.newId('mod')}`, actor_type: 'mod', aud: ['openvibe.host'], cap: ALL });
const mod = t.network.sign({ sub: `mod:${ids.newId('mod')}`, actor_type: 'mod', aud: ['openvibe.host'], cap: ALL });
@@ -56,13 +56,17 @@ async function startNetwork() {
@@ -56,13 +56,17 @@ async function startNetwork() {
function userToken(u) {
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' });
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 = {}) {
function serviceToken(client, cap, extra = {}) {
return sign({ sub: `svc:${client}`, aud: ['openvibe.host'], cap, ...extra });
return sign({ sub: `svc:${client}`, aud: ['openvibe.host'], cap, ...extra });
}
}
function appToken(appId, cap, extra = {}) {
function appToken(appId, cap, extra = {}) {
return sign({ sub: `app:${appId}`, actor_type: 'app', aud: ['openvibe.host'], 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 };
module.exports = { startNetwork, listen };