openvibers/openvibe.community Commits

Unlisted pastes get slugs nobody can guess: a new unlisted or private paste (and a fork of one) gets adj-noun- plus 16 random base62 characters (~2^95) instead of the ~1.5M adj-noun-NN space that could be walked; public pastes keep the short form, service-named slugs are unchanged, and existing slugs keep working

OP OpenVibers committed 23 Sept 2026 · 2e6f43b86e · 4 files, +70 −11


Changes

ddiff --git a/README.md b/README.md
iindex c6e21ab..9a71ebc 100644
---- a/README.md
++++ b/README.md
@@@ -64,6 +64,9 @@ redirects its old paste URLs here after cutover).
(`POST ${OV_MEDIA_INTERNAL_URL}/api/v1/community/files`, service token with
`media.object.upload`); the paste keeps the public URL and `media_ref`
`legacy:community:file:<key>`. Imported pastes keep their existing Media screenshot URLs.
++- **Slugs**: public pastes get a short `adj-noun-NN` slug; a new unlisted or private paste (and
++ a fork of one) gets `adj-noun-` + 16 random base62 characters, because its slug is the only
++ thing keeping it unlisted. Existing slugs never change and keep working.
- **Deletes are soft** (content scrubbed, slug kept reserved); edits append `paste_versions`.
Moving the data: `node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex f8c6b9c..99fd036 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -262,8 +262,9 @@ 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) };
}
-- function slugFor(v, body) {
-- if (!isService(v) || body.slug == null || body.slug === '') return store.generateSlug(db);
++ /** A service may name the slug (Live's imports); otherwise it is generated — secret unless the paste is public. */
++ function slugFor(v, body, visibility) {
++ if (!isService(v) || body.slug == null || body.slug === '') 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');
@@@ -356,15 +357,16 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!content || typeof content !== 'string' || content.trim().length === 0) fail(400, 'Content is required');
if (content.length > L.maxSizeKb * 1024) fail(400, `Paste too large (max ${L.maxSizeKb} KB)`);
const metadata = serviceMetadata(v, body);
++ const visibility = visibilityOf(body.visibility, !!v.subject);
const row = store.insertPaste(db, {
-- slug: slugFor(v, body),
++ slug: slugFor(v, body, visibility),
owner_subject: v.subject || null,
origin: v.origin === 'ai' ? 'ai' : 'user',
type: 'paste',
title: store.sanitizeTitle(body.title),
content: content.trim(),
language: store.detectLanguage(content, body.language),
-- visibility: visibilityOf(body.visibility, !!v.subject),
++ visibility,
stream_ref: streamRefFor(v, body),
metadata: metadata ? JSON.stringify(metadata) : null,
burn_after_read: truthy(body.burn_after_read) ? 1 : 0,
@@@ -381,7 +383,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!IMAGE_MIME.test(file.mimetype || '')) fail(400, 'Only PNG, JPEG, WebP, or GIF images allowed');
if (file.buffer.length > L.screenshotMaxSizeMb * 1024 * 1024) fail(400, `File too large (max ${L.screenshotMaxSizeMb} MB)`);
if (!media) fail(503, 'Media service unavailable');
-- const slug = slugFor(v, body);
++ const visibility = visibilityOf(body.visibility, !!v.subject);
++ const slug = slugFor(v, body, visibility);
const extra = serviceMetadata(v, body);
const bytes = stripImageMetadata(file.buffer, file.mimetype);
let stored;
@@@ -407,7 +410,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
title: store.sanitizeTitle(body.title || 'Screenshot'),
content: String(body.description || body.content || ''),
language: 'text',
-- visibility: visibilityOf(body.visibility, !!v.subject),
++ visibility,
screenshot_url: stored.url,
media_ref: stored.media_ref,
stream_ref: streamRefFor(v, body),
@@@ -467,16 +470,17 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
// A lasting copy would outlive the burn; only the owner (or staff) may fork one.
if (original.burn_after_read && !canSeeHidden(v, original)) fail(403, 'Burn-after-read pastes cannot be forked');
pasteRateCheck(v);
++ // A fork never widens who can find the content: unlisted stays unlisted (and gets a secret slug).
++ const visibility = visibilityOf(original.visibility, !!v.subject);
const row = store.insertPaste(db, {
-- slug: store.generateSlug(db),
++ slug: store.generateSlug(db, { secret: visibility !== 'public' }),
owner_subject: v.subject || null,
origin: v.origin === 'ai' ? 'ai' : 'user',
type: 'paste',
title: store.sanitizeTitle(`Fork of ${original.title}`),
content: original.content,
language: original.language,
-- // A fork never widens who can find the content: unlisted stays unlisted.
-- visibility: visibilityOf(original.visibility, !!v.subject),
++ visibility,
forked_from: original.id,
});
return created(row, { paste: await shapeOne(row, v) });
ddiff --git a/server/pastes/store.js b/server/pastes/store.js
iindex 09a72f6..ec669e5 100644
---- a/server/pastes/store.js
++++ b/server/pastes/store.js
@@@ -50,9 +50,29 @@ const SLUG_NOUNS = [
'trout', 'tulip', 'vale', 'vine', 'viper', 'wave', 'wren', 'wolf',
];
--/** A free adj-noun-NN slug. Deleted pastes keep theirs, so an old link never points somewhere new. */
--function generateSlug(db) {
++const SECRET_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
++const SECRET_CHARS = 16; // 62^16 ≈ 2^95, on top of the words
++
++/**
++ * A free slug. Deleted pastes keep theirs, so an old link never points somewhere new.
++ *
++ * Public pastes get a short adj-noun-NN slug (listed anyway, so nothing to guess). An unlisted or
++ * private paste's slug is the only thing keeping it unlisted, so `secret` adds 16 random base62
++ * characters (adj-noun-XXXXXXXXXXXXXXXX): the ~1.5M word/number slugs could be walked, these
++ * cannot. Existing slugs never change.
++ */
++function generateSlug(db, { secret = false } = {}) {
const taken = db.prepare('SELECT 1 FROM pastes WHERE slug = ?');
++ const words = () => `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}`;
++ if (secret) {
++ for (let i = 0; i < 10; i++) {
++ let tail = '';
++ for (let j = 0; j < SECRET_CHARS; j++) tail += SECRET_ALPHABET[crypto.randomInt(SECRET_ALPHABET.length)];
++ const slug = `${words()}-${tail}`;
++ if (!taken.get(slug)) return slug;
++ }
++ throw new Error('Could not generate a unique paste slug');
++ }
for (let i = 0; i < 10; i++) {
const slug = `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}-${crypto.randomInt(10, 100)}`;
if (!taken.get(slug)) return slug;
ddiff --git a/test/security.test.js b/test/security.test.js
iindex d758b81..77d8019 100644
---- a/test/security.test.js
++++ b/test/security.test.js
@@@ -7,6 +7,7 @@
* - burn-after-read pastes: every way of reading the content counts as the read (?no_view=1,
* download, fork), and they never appear in public lists, search, feeds or the home page.
* - a fork of an unlisted paste is not published as a public paste.
++ * - unlisted (and private) pastes get slugs nobody can guess; existing slugs keep working.
*/
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
@@@ -137,6 +138,37 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(pf.json().paste.visibility, 'public', 'forks of public pastes stay public');
});
++ // ── unlisted slugs cannot be guessed ─────────────────────
++ await check('new unlisted/private pastes get a high-entropy slug; public ones keep the short form; old slugs still open', async () => {
++ const SECRET = /^[a-z]+-[a-z]+-[A-Za-z0-9]{16}$/;
++ const SHORT = /^[a-z]+-[a-z]+-\d{2,4}$/;
++ const slugs = new Set();
++ for (const [who, visibility] of [[{ cookie: alexJwt }, 'unlisted'], [{ cookie: alexJwt }, 'private'], [{}, 'unlisted'], [{}, 'private']]) {
++ const r = await call('/api/pastes', { method: 'POST', ...who, json: { content: `hidden ${visibility}`, visibility }, ip: '192.0.2.61' });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.match(r.json().slug, SECRET, `${visibility} (${who.cookie ? 'signed in' : 'anonymous'})`);
++ slugs.add(r.json().slug);
++ }
++ const fork = await call(`/api/pastes/${[...slugs][0]}/fork`, { method: 'POST', cookie: samJwt, json: {} });
++ assert.strictEqual(fork.status, 201, fork.text);
++ 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');
++ 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.
++ t.db.prepare("INSERT INTO pastes (slug, type, title, content, language, visibility) VALUES ('calm-otter-42', 'paste', 'old', 'old unlisted words', 'text', 'unlisted')").run();
++ const old = await call('/api/pastes/calm-otter-42');
++ assert.strictEqual(old.status, 200, old.text);
++ assert.strictEqual(old.json().paste.content, 'old unlisted words');
++ assert.strictEqual((await call('/p/calm-otter-42/raw')).status, 200);
++ // The secret part is really random.
++ const { generateSlug } = require('../server/pastes/store');
++ const many = new Set(Array.from({ length: 2000 }, () => generateSlug(t.db, { secret: true }).split('-')[2]));
++ assert.strictEqual(many.size, 2000);
++ });
++
await t.close();
done();
})();