openvibers/openvibe.host Commits

Stage B abuse limits from the threat review: deploys stop before the shared disk fills, one person owns at most 10 projects, a takedown clears what the site left in visitors' browsers

OP OpenVibers committed 23 Sept 2026 · 0ff29d5956 · 10 files, +74 −9

- HOST_MIN_FREE_BYTES (default 5 GiB): deploys are refused with 507 storage.host_full, before the body is read, while the object store's filesystem has less free space; serving is unaffected. The disk is shared with Live, Media and every database, and 50 projects x 1 GiB per person could fill it. /api/ready reports it as the optional check disk_headroom.
- HOST_MAX_PROJECTS_PER_OWNER (default 10, was a fixed 50).
- The takedown 451 carries Clear-Site-Data: "cache", "storage", so browsers drop caches, storage and service workers a taken-down (phishing) site installed.
- Tests in host-abuse.test.js; the Stage B test boot sets HOST_MIN_FREE_BYTES=0 so results never depend on the machine's free space.

Changes

ddiff --git a/.env.example b/.env.example
iindex 3cf9646..fcfd7c3 100644
---- a/.env.example
++++ b/.env.example
@@@ -47,6 +47,11 @@ HOST_FORM_SECRET=
# Uploads read and validated at the same time, service-wide; each is held in memory (up to the two
# limits above), so this bounds what concurrent uploads can cost. Others get 503 + Retry-After.
#HOST_MAX_CONCURRENT_UPLOADS=2
++# Deploys are refused (507) while the object store's filesystem has less than this free: the disk
++# is shared with every other service on the host. Serving is never affected. Default 5 GiB.
++#HOST_MIN_FREE_BYTES=5368709120
++# Projects one person may own; each gets the per-project quotas below. Default 10.
++#HOST_MAX_PROJECTS_PER_OWNER=10
# Per-project quota defaults (a staff override per project wins). Production projects:
#HOST_QUOTA_STORAGE_BYTES=1073741824
ddiff --git a/server/app.js b/server/app.js
iindex ace9fd3..d0992fd 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -76,7 +76,7 @@ function createApp(opts = {}) {
const takedowns = createTakedowns({ store });
const projects = createProjects({ store, config, access, blobs, takedowns, log });
const sites = createSites({ store, config, access, projects, takedowns });
-- const deploys = createDeploys({ store, access, projects, sites, blobs, outbox, takedowns, log });
++ const deploys = createDeploys({ store, config, access, projects, sites, blobs, outbox, takedowns, log });
const domains = createDomains({ store, config, access, projects, sites, outbox, resolver: opts.resolver, log });
const auth = opts.auth || createAuthClient(config);
const viewers = createViewerResolver({ auth, config });
@@@ -129,7 +129,7 @@ function createApp(opts = {}) {
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-host', version: VERSION }));
app.get('/release.json', release.handler);
-- const readiness = createHostReadiness({ store, blobs, auth, outbox, release: release.release });
++ const readiness = createHostReadiness({ store, blobs, auth, outbox, release: release.release, minFreeBytes: () => config.uploads.minFreeBytes });
app.get('/api/ready', readiness.handler);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
// The dashboard host: only the public front page and the legal pages are crawlable. Tenant sites
ddiff --git a/server/config.js b/server/config.js
iindex 4f6bd02..30883d5 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -76,6 +76,14 @@ function load(env = process.env) {
maxUnpackedBytes: int(env.HOST_MAX_UNPACKED_BYTES, 256 * MiB),
// Uploads read and validated at the same time, service-wide (each is held in memory).
maxConcurrent: int(env.HOST_MAX_CONCURRENT_UPLOADS, 2),
++ // Uploads are refused while the object store's filesystem has less than this free. The
++ // disk is shared with every other service on the host (Live, Media, databases), so a
++ // tenant must never be able to fill it.
++ minFreeBytes: int(env.HOST_MIN_FREE_BYTES, 5 * 1024 * MiB),
++ },
++ // Projects one person may own (each gets the per-project quotas below).
++ projects: {
++ maxPerOwner: int(env.HOST_MAX_PROJECTS_PER_OWNER, 10),
},
// Per-project quotas (a row in host_quotas overrides them per project; only staff set it).
ddiff --git a/server/domain/deploys.js b/server/domain/deploys.js
iindex fa39508..204234a 100644
---- a/server/domain/deploys.js
++++ b/server/domain/deploys.js
@@@ -23,7 +23,7 @@ const { principalOf, actorRef } = require('./access');
const DAY = 24 * 3600 * 1000;
const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`);
--function createDeploys({ store, access, projects, sites, blobs, outbox, takedowns, log = console }) {
++function createDeploys({ store, config = null, access, projects, sites, blobs, outbox, takedowns, log = console }) {
const { db } = store;
const q = {
byId: db.prepare('SELECT * FROM host_deploys WHERE id = ?'),
@@@ -75,6 +75,15 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, takedown
function precheck(viewer, siteId) {
const { site, project } = sites.load(viewer, siteId, 'deploy');
takedowns.assertOpen(site);
++ const minFree = config ? config.uploads.minFreeBytes : 0;
++ if (minFree > 0) {
++ let free = Infinity;
++ try { free = blobs.freeBytes(); } catch (err) { log.warn('[Host] could not read free disk space:', err.message); }
++ if (free < minFree) {
++ log.warn(`[Host] uploads refused: ${free} bytes free, HOST_MIN_FREE_BYTES is ${minFree}`);
++ throw new ApiError(507, 'storage.host_full', 'Host is short of disk space and is not accepting deploys right now; try again later');
++ }
++ }
const limits = limitsFor(project);
const recent = q.deploysSince.get(project.id, store.now() - DAY).n;
if (recent >= limits.quota.deploysPerDay) {
ddiff --git a/server/domain/projects.js b/server/domain/projects.js
iindex 18c91b6..ece0548 100644
---- a/server/domain/projects.js
++++ b/server/domain/projects.js
@@@ -81,7 +81,8 @@ function createProjects({ store, config, access, blobs, takedowns, log = console
networkProjectId = String(input.network_project_id);
if (!NETWORK_PROJECT_RE.test(networkProjectId)) throw new ApiError(422, 'project.invalid_network_project_id', 'network_project_id is not a project id');
}
-- if (q.ownedCount.get(owner).n >= 50) throw new ApiError(429, 'quota.projects', 'you already own 50 projects');
++ const maxOwned = (config.projects && config.projects.maxPerOwner) || 10;
++ if (q.ownedCount.get(owner).n >= maxOwned) throw new ApiError(429, 'quota.projects', `you already own ${maxOwned} projects`);
const id = newId('project', store.now());
const now = store.now();
try {
ddiff --git a/server/http/tenant.js b/server/http/tenant.js
iindex e5542d5..49a24d7 100644
---- a/server/http/tenant.js
++++ b/server/http/tenant.js
@@@ -181,7 +181,9 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
}
// Taken down by staff: nothing of the tenant's is served, on any of its hosts, until lifted.
if (takedowns && takedowns.ofSite(site)) {
-- return plain(res, 451, 'This site is unavailable: OpenVibe.Host staff took it down after a report.');
++ // Clear-Site-Data asks the browser to drop what the site left behind for this visitor
++ // (caches, storage and service workers a phishing page may have installed).
++ return plain(res, 451, 'This site is unavailable: OpenVibe.Host staff took it down after a report.', { 'Clear-Site-Data': '"cache", "storage"' });
}
// Absolute-form request targets must agree with the Host header (or they would pick the site).
let target = req.url || '/';
ddiff --git a/server/observability.js b/server/observability.js
iindex e1235b4..a6c1fd5 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -12,7 +12,7 @@
const { createReadiness } = require('openvibe-shared/ready');
const { CHARTER_TABLES } = require('./db');
--function createHostReadiness({ store, blobs, auth, outbox, release = null }) {
++function createHostReadiness({ store, blobs, auth, outbox, release = null, minFreeBytes = () => 0 }) {
const { db } = store;
return createReadiness({
service: 'host',
@@@ -27,6 +27,15 @@ function createHostReadiness({ store, blobs, auth, outbox, release = null }) {
},
},
{ name: 'storage', required: true, check: () => blobs.writable() },
++ {
++ // Serving goes on; only new deploys are refused below the floor.
++ name: 'disk_headroom', required: false,
++ check: () => {
++ const free = blobs.freeBytes();
++ const floor = minFreeBytes();
++ return free >= floor ? { ok: true, detail: { free_bytes: free } } : `${free} bytes free, below HOST_MIN_FREE_BYTES (${floor}): new deploys are refused`;
++ },
++ },
{
name: 'network_jwks', required: false,
check: () => {
ddiff --git a/server/storage.js b/server/storage.js
iindex b0884cd..4246bf3 100644
---- a/server/storage.js
++++ b/server/storage.js
@@@ -95,13 +95,19 @@ function createBlobStore(root) {
return n;
}
++ /** Free bytes on the filesystem that holds the objects (shared with every other service on the host). */
++ function freeBytes() {
++ const s = fs.statfsSync(base);
++ return Number(s.bavail) * Number(s.bsize);
++ }
++
function writable() {
fs.accessSync(tmpDir, fs.constants.W_OK);
fs.accessSync(projectsDir, fs.constants.W_OK);
return true;
}
-- return { root: base, pathFor, put, exists, remove, removeProject, list, sweepTmp, writable };
++ return { root: base, pathFor, put, exists, remove, removeProject, list, sweepTmp, writable, freeBytes };
}
module.exports = { createBlobStore, SHA_RE };
ddiff --git a/test/host-abuse.test.js b/test/host-abuse.test.js
iindex 63fab1d..5d4d655 100644
---- a/test/host-abuse.test.js
++++ b/test/host-abuse.test.js
@@@ -13,7 +13,7 @@ const { boot, check, done } = require('./stageb/boot');
const { site: siteTar } = require('./stageb/tar');
(async () => {
-- const t = await boot({ env: { HOST_MAX_CONCURRENT_UPLOADS: '1' } });
++ const t = await boot({ env: { HOST_MAX_CONCURRENT_UPLOADS: '1', HOST_MAX_PROJECTS_PER_OWNER: '4' } });
const alice = t.user('alice');
const staff = t.user('root', { role: 'admin' });
const pa = await t.project(alice, 'A');
@@@ -61,6 +61,7 @@ const { site: siteTar } = require('./stageb/tar');
assert.ok(!/ALPHA|js\(\)/.test(g.text), 'no tenant content');
assert.strictEqual(g.headers['cache-control'], 'no-store');
assert.strictEqual(g.headers.etag, undefined);
++ assert.strictEqual(g.headers['clear-site-data'], '"cache", "storage"', 'the visitor\'s browser drops what the site left behind');
}
}
assert.strictEqual((await t.get('other.openvibe.host', '/')).text, 'OTHER', 'a site-level takedown leaves the project\'s other sites up');
@@@ -165,6 +166,30 @@ const { site: siteTar } = require('./stageb/tar');
assert.strictEqual(t.ctx.uploadGate.inFlight, 0);
});
++ await check('the shared disk: below HOST_MIN_FREE_BYTES deploys are refused (507) before the body is read; serving goes on; readiness says so', async () => {
++ const pd = await t.project(alice, 'D');
++ const delta = await t.site(alice, pd.id, 'delta');
++ await t.deploy(alice, delta.id, { 'index.html': 'DELTA' });
++ const saved = t.ctx.config.uploads.minFreeBytes;
++ t.ctx.config.uploads.minFreeBytes = Number.MAX_SAFE_INTEGER;
++ try {
++ const r = await t.upload(alice, delta.id, { 'index.html': 'MORE' });
++ assert.strictEqual(r.status, 507, r.text);
++ assert.strictEqual(r.json().code, 'storage.host_full');
++ assert.strictEqual((await t.get('delta.openvibe.host', '/')).text, 'DELTA');
++ const ready = (await t.api('GET', '/api/ready')).json();
++ const check = Array.isArray(ready.checks) ? ready.checks.find((c) => c.name === 'disk_headroom') : ready.checks.disk_headroom;
++ assert.ok(check && !check.ok && /new deploys are refused/.test(JSON.stringify(check)), JSON.stringify(ready));
++ } finally { t.ctx.config.uploads.minFreeBytes = saved; }
++ assert.strictEqual((await t.upload(alice, delta.id, { 'index.html': 'MORE' })).status, 201);
++ });
++
++ await check('one person owns at most HOST_MAX_PROJECTS_PER_OWNER projects (each gets its own quotas)', async () => {
++ const r = await t.api('POST', '/api/v1/projects', { as: alice, json: { name: 'fifth' } });
++ assert.strictEqual(r.status, 429, r.text);
++ assert.match(r.json().detail, /you already own 4 projects/);
++ });
++
await t.close();
done();
})();
ddiff --git a/test/stageb/boot.js b/test/stageb/boot.js
iindex 764d0b6..ed4e5e1 100644
---- a/test/stageb/boot.js
++++ b/test/stageb/boot.js
@@@ -44,7 +44,7 @@ async function boot(opts = {}) {
HOST_SITES_DOMAIN: 'openvibe.host',
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
OV_OAUTH_CLIENT_ID: 'host', OV_OAUTH_CLIENT_SECRET: SECRET, COOKIE_SECURE: 'false',
-- HOST_FORM_SECRET: 'test-form-secret', HOST_WORKER: 'off',
++ HOST_FORM_SECRET: 'test-form-secret', HOST_WORKER: 'off', HOST_MIN_FREE_BYTES: '0',
...(opts.env || {}),
};
const configLib = require('../../server/config');