openvibers/openvibe.community Commits

An erased paste's slug is never given to another paste (AGT-4) (#249)

OP OpenVibers committed 10 days ago · 9a2748efd5 · 7 files, +42 −5

* An erased paste's slug is never given to another paste (AGT-4)

Account erasure deletes a person's paste that no one else commented on, which took its slug out of
the table: a later paste could be given the same short public slug (adjective-noun-NN, ~180k), so a
link someone kept or a search engine indexed would open another person's paste, and a service
naming that slug could bring the erased paste back. The store's own rule says a deleted paste's
slug stays reserved.

Erasure now retires the slug first (migration 0014 retired_paste_slugs, a SHA-256 only, so nothing
readable stays), and both slug generation and a service-named slug check it.

* slugTaken is one query; the slug randomness test awaits its 2,000 slugs

The test built a Set of 2,000 pending promises (always size 2,000, so it proved nothing) and closed
the pool with their queries still running; on PostgreSQL that failed once a slug took two queries.

Changes

ddiff --git a/migrations/0014_retired_paste_slugs.sql b/migrations/0014_retired_paste_slugs.sql
nnew file mode 100644
iindex 0000000..7643d62
---- /dev/null
++++ b/migrations/0014_retired_paste_slugs.sql
@@@ -0,0 +1,9 @@
++-- phase: expand
++-- Paste slugs an account erasure took out of the table (ADR-033: a person's paste that no one else commented on is
++-- deleted, not tombstoned). Its slug stays retired: no later paste is ever given it, and no re-import can bring the
++-- paste back, so a link someone kept or a search engine indexed never opens another person's paste (AGT-4). Only a
++-- SHA-256 of the slug is kept, so nothing readable of the erased paste stays.
++CREATE TABLE retired_paste_slugs (
++ slug_hash text COLLATE "C" PRIMARY KEY CHECK (slug_hash ~ '^[0-9a-f]{64}$'),
++ retired_at text COLLATE "C" NOT NULL DEFAULT ov_now()
++);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 1376687..0cca04e 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -97,6 +97,8 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
ai_summary = NULL, ai_tags = NULL, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?`).run(TOMBSTONE, now, now, p.id);
add(retained, 'tombstones', 1);
} else {
++ // Its slug is retired first, so no later paste is given it and no re-import brings it back.
++ await require('../pastes/store').retireSlug(db, p.slug);
await db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);
add(erased, 'pastes', 1);
}
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 0e854ec..5319ec0 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -315,7 +315,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!isService(v) || body.slug == null || body.slug === '' || secret) return await store.generateSlug(db, { secret });
const slug = String(body.slug);
if (!SERVICE_SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug.toLowerCase())) fail(400, 'Invalid slug');
-- if (await db.prepare('SELECT 1 FROM pastes WHERE slug = ?').get(slug)) fail(409, 'Slug already taken');
++ if (await store.slugTaken(db, slug)) fail(409, 'Slug already taken'); // an erased paste's slug too: it never comes back
return slug;
}
ddiff --git a/server/pastes/store.js b/server/pastes/store.js
iindex ba79c46..b292785 100644
---- a/server/pastes/store.js
++++ b/server/pastes/store.js
@@@ -61,8 +61,21 @@ const SECRET_CHARS = 16; // 62^16 ≈ 2^95, on top of the words
* characters (adj-noun-XXXXXXXXXXXXXXXX): the ~1.5M word/number slugs could be walked, these
* cannot. Existing slugs never change.
*/
++// A slug is never given to a second paste, while its paste exists (soft-deleted ones included) or after an account
++// erasure deleted it (retired_paste_slugs keeps only a SHA-256 of it, migration 0014): a link someone kept never opens
++// another person's paste, and a re-import never brings an erased one back (AGT-4).
++const slugHash = (slug) => crypto.createHash('sha256').update(String(slug)).digest('hex');
++async function slugTaken(db, slug) {
++ return !!(await db.prepare('SELECT 1 FROM pastes WHERE slug = ? UNION ALL SELECT 1 FROM retired_paste_slugs WHERE slug_hash = ? LIMIT 1')
++ .get(String(slug), slugHash(slug)));
++}
++/** Retire an erased paste's slug before its row is deleted. */
++async function retireSlug(db, slug) {
++ await db.prepare('INSERT INTO retired_paste_slugs (slug_hash) VALUES (?) ON CONFLICT (slug_hash) DO NOTHING').run(slugHash(slug));
++}
++
async function generateSlug(db, { secret = false } = {}) {
-- const taken = db.prepare('SELECT 1 FROM pastes WHERE slug = ?');
++ const taken = { get: (slug) => slugTaken(db, 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++) {
@@@ -424,7 +437,7 @@ async function mapSet(db, system, type, id, targetType, targetId) {
}
module.exports = {
-- generateSlug, sanitizeTitle, detectLanguage,
++ generateSlug, slugTaken, retireSlug, sanitizeTitle, detectLanguage,
getBySlug, getById, insertPaste, updatePaste, listVersions, softDelete, listPastes,
countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,
setAi, setScreenshot, setVisibility, stats, statSeries, listForks, deleteAllForks,
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex c2ad232..61502c1 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -81,6 +81,12 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
const all = async (sql) => await db.prepare(sql).all();
assert.deepStrictEqual(await all('SELECT slug, owner_subject AS o, title, content FROM pastes ORDER BY id'), [
{ slug: 'p1', o: null, title: '[deleted]', content: '' }, { slug: 'p3', o: OTTO, title: "otto's", content: 'x' }], "answered paste a tombstone, the alone one gone, the alias's gone");
++ const store = require('../server/pastes/store');
++ const sha = (s) => require('crypto').createHash('sha256').update(s).digest('hex');
++ assert.deepStrictEqual((await all('SELECT slug_hash FROM retired_paste_slugs ORDER BY slug_hash')).map((r) => r.slug_hash), [sha('p2'), sha('p4')].sort(),
++ 'the erased pastes\' slugs are retired, as hashes only');
++ assert.deepStrictEqual([await store.slugTaken(db, 'p2'), await store.slugTaken(db, 'p4'), await store.slugTaken(db, 'p1'), await store.slugTaken(db, 'p9')], [true, true, true, false],
++ 'an erased slug is never given out again (AGT-4)');
assert.deepStrictEqual(await all('SELECT id, author_subject AS a, message FROM paste_comments ORDER BY id'), [
{ id: 1, a: OTTO, message: 'nice' }, { id: 2, a: null, message: '[deleted]' }, { id: 3, a: OTTO, message: 'hi dana' }], 'her answered comment a tombstone; the other gone');
assert.deepStrictEqual(await all('SELECT id, author_subject AS a, message, score, reply_count FROM comments ORDER BY id'), [
ddiff --git a/test/pastes-api.test.js b/test/pastes-api.test.js
iindex 9fae50d..d9d8dc4 100644
---- a/test/pastes-api.test.js
++++ b/test/pastes-api.test.js
@@@ -111,6 +111,10 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(p.ai_summary, 'chat exploded');
const dup = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'wild-moment-4242' } });
assert.strictEqual(dup.status, 409);
++ // An erased paste's slug (retired_paste_slugs) is never brought back by a service naming it (AGT-4).
++ await require('../server/pastes/store').retireSlug(t.db, 'erased-moment-77');
++ const back = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'erased-moment-77' } });
++ assert.strictEqual(back.status, 409, 'an erased slug is not given out again');
const bad = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'config' } });
assert.strictEqual(bad.status, 400);
const onlyAi = (await call('/api/pastes?origin=ai')).json();
ddiff --git a/test/security.test.js b/test/security.test.js
iindex 603ea86..1b50840 100644
---- a/test/security.test.js
++++ b/test/security.test.js
@@@ -174,8 +174,11 @@ const { boot, check, done } = require('./helpers/app');
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 }, async () => (await generateSlug(t.db, { secret: true })).split('-')[2]));
-- assert.strictEqual(many.size, 2000);
++ // 2,000 secret tails, all different. Awaited in batches: a Set of 2,000 pending promises (as this was) proved nothing
++ // and left queries running after the pool closed.
++ const tails = [];
++ for (let b = 0; b < 20; b++) tails.push(...await Promise.all(Array.from({ length: 100 }, async () => (await generateSlug(t.db, { secret: true })).split('-')[2])));
++ assert.strictEqual(new Set(tails).size, 2000);
});
await t.close();