openvibers/openvibe.community Commits

Unlisted and private pastes always get a secret slug: a service-named slug only applies to public pastes (Live forwards people's /api/pastes bodies as-is, so a person could pick a guessable slug)

OP OpenVibers committed 23 Sept 2026 · 922f834131 · 2 files, +13 −3


Changes

ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 99fd036..51d2cbe 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -262,9 +262,13 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return { ai_summary: String(body.ai_summary).slice(0, 2000), ai_tags: tags, ai_analyzed_at: new Date().toISOString().replace('T', ' ').slice(0, 19) };
}
-- /** A service may name the slug (Live's imports); otherwise it is generated — secret unless the paste is public. */
++ /**
++ * A service may name the slug of a PUBLIC paste (Live's imports); otherwise it is generated. An
++ * unlisted or private paste always gets a secret slug: its slug is its only protection, and a
++ * service forwarding a person's request (Live's /api/pastes) passes their body through as-is.
++ */
function slugFor(v, body, visibility) {
-- if (!isService(v) || body.slug == null || body.slug === '') return store.generateSlug(db, { secret: visibility !== 'public' });
++ if (!isService(v) || body.slug == null || body.slug === '' || visibility !== 'public') return store.generateSlug(db, { secret: visibility !== 'public' });
const slug = String(body.slug);
if (!SERVICE_SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug.toLowerCase())) fail(400, 'Invalid slug');
if (db.prepare('SELECT 1 FROM pastes WHERE slug = ?').get(slug)) fail(409, 'Slug already taken');
ddiff --git a/test/security.test.js b/test/security.test.js
iindex 77d8019..5dab1c3 100644
---- a/test/security.test.js
++++ b/test/security.test.js
@@@ -154,7 +154,13 @@ const { boot, check, done } = require('./helpers/app');
assert.match(fork.json().slug, SECRET, 'a fork of an unlisted paste is unlisted, with a secret slug');
const service = await call('/api/pastes', { method: 'POST', token: net.signService({ cap: [CREATE] }), headers: { 'x-ov-subject': alex.subject_id }, json: { content: 'svc hidden', visibility: 'unlisted' } });
assert.strictEqual(service.status, 201, service.text);
-- assert.match(service.json().slug, SECRET, 'service-created unlisted pastes too, unless the service names the slug');
++ assert.match(service.json().slug, SECRET, 'service-created unlisted pastes too');
++ // A service forwarding a person's body (Live's /api/pastes) cannot give an unlisted paste a chosen slug.
++ const named = await call('/api/pastes', { method: 'POST', token: net.signService({ cap: [CREATE] }), json: { content: 'weak', visibility: 'unlisted', slug: 'aaa' } });
++ assert.strictEqual(named.status, 201, named.text);
++ assert.match(named.json().slug, SECRET, 'a named slug is ignored for an unlisted paste');
++ const namedPub = await call('/api/pastes', { method: 'POST', token: net.signService({ cap: [CREATE] }), json: { content: 'imported', visibility: 'public', slug: 'imported-public-1' } });
++ assert.strictEqual(namedPub.json().slug, 'imported-public-1', 'a service still names a public paste\'s slug');
const pub = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'public words' } });
assert.match(pub.json().slug, SHORT, 'public pastes are listed anyway: short slug');
// A paste made before this change (short slug, unlisted) still opens by its slug.