openvibers/openvibe.community Commits

Security: app tokens act only for on_behalf_of; burn-after-read cannot be read around; forks keep unlisted pastes unlisted

OP OpenVibers committed 23 Sept 2026 · cac1df9a12 · 5 files, +168 −6

- identity/viewer.js: developer-app (app:) and module (mod:) service tokens no longer choose the
  acting person with X-OV-Subject. They act for their on_behalf_of subject only (a different
  X-OV-Subject is 403 subject.not_delegated), and non-production (sandbox) app tokens are 401.
  Before, any app granted a public capability (community.paste.create / community.post.create)
  could name any usr_ id and read that person's private pastes, post as them, and edit or delete
  their forum posts. First-party svc: principals are unchanged.
- pastes: ?no_view=1 (and the download and fork-prefill pages that use it) no longer skip the burn
  of a burn-after-read paste; non-owners cannot fork one; public lists, search, the RSS feed and
  the home page leave burn-after-read pastes out (they carried content previews and made content
  searchable without burning it).
- pastes: a fork keeps the original's visibility (an unlisted paste's fork is unlisted, not
  public and listed/searchable/in Pulse).
- test/security.test.js covers all of the above.

Changes

ddiff --git a/server/identity/viewer.js b/server/identity/viewer.js
iindex 514fcb3..ac5a149 100644
---- a/server/identity/viewer.js
++++ b/server/identity/viewer.js
@@@ -15,6 +15,8 @@
* person: subject stays null). X-OV-Source-Ref may carry a JSON EntityRef (e.g. the stream).
* X-OV-Staff: 1 vouches that the acting person is staff; it needs community.paste.moderate
* (pastes) or community.comment.moderate (comments and the forum).
++ * Developer-app (app:…) and module (mod:…) tokens act only for the person in their
++ * on_behalf_of claim: X-OV-Subject naming anyone else is refused, and sandbox tokens are too.
*
* Identity never comes from a request body or query. A request that presents a service token is
* judged on that token alone: a bad one is refused, never downgraded to anonymous.
@@@ -50,6 +52,13 @@ function createViewerResolver({ auth, config, network }) {
const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });
if (!r.ok) throw new ViewerError(401, r.code, r.reason);
const claims = r.claims;
++ // Developer apps (app:…) and modules (mod:…) are third parties: they act only for the person
++ // who authorized them (the token's on_behalf_of), never for whoever X-OV-Subject names.
++ // Only first-party service principals (svc:…) are trusted to name the acting subject.
++ const firstParty = claims.actor_type === 'service' && String(claims.sub).startsWith('svc:');
++ if (!firstParty && claims.env !== undefined && claims.env !== 'production') {
++ throw new ViewerError(401, 'token.sandbox_refused', 'sandbox tokens are not accepted by openvibe.community');
++ }
const originHeader = req.get('x-ov-origin');
if (originHeader && originHeader !== 'ai' && originHeader !== 'user') throw new ViewerError(400, 'request.invalid_origin', 'X-OV-Origin must be "ai" or "user"');
@@@ -59,8 +68,13 @@ function createViewerResolver({ auth, config, network }) {
let subject = null;
if (subjectHeader) {
if (!isActingSubject(subjectHeader)) throw new ViewerError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… or gst_… subject id');
++ if (!firstParty && subjectHeader !== claims.on_behalf_of) {
++ throw new ViewerError(403, 'subject.not_delegated', 'an app acts only for the person who authorized it (on_behalf_of)');
++ }
// AI output is never attributed to a person, even if the caller also names one.
subject = origin === 'ai' ? null : subjectHeader;
++ } else if (!firstParty && ids.isSubjectId('user', claims.on_behalf_of)) {
++ subject = origin === 'ai' ? null : claims.on_behalf_of;
}
let sourceRef = null;
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex ed67f82..f8c6b9c 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -313,7 +313,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
/** GET /api/pastes/:slug — counts a page view unless noView; burns a spent burn-after-read paste. */
async get(v, slug, ctx = {}) {
const p = visible(v, slug);
-- if (!ctx.noView && !isOwner(v, p)) {
++ // ?no_view=1 skips view counting, never the burn: any read of a burn paste is the read.
++ if ((!ctx.noView || p.burn_after_read) && !isOwner(v, p)) {
if (p.burn_after_read) {
p.views = store.bumpViews(db, p.id);
if (p.views > 1) burn(p);
@@@ -463,6 +464,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
async fork(v, slug) {
const original = visible(v, slug);
if (original.type !== 'paste') fail(400, 'Only text pastes can be forked');
++ // 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);
const row = store.insertPaste(db, {
slug: store.generateSlug(db),
@@@ -472,7 +475,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
title: store.sanitizeTitle(`Fork of ${original.title}`),
content: original.content,
language: original.language,
-- visibility: 'public',
++ // A fork never widens who can find the content: unlisted stays unlisted.
++ visibility: visibilityOf(original.visibility, !!v.subject),
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 e4b0102..09a72f6 100644
---- a/server/pastes/store.js
++++ b/server/pastes/store.js
@@@ -161,7 +161,7 @@ function softDelete(db, id) {
/**
* List pastes.
* opts.ownerSubject only this owner's pastes
-- * opts.includeHidden also unlisted + private (only ever set for the owner or staff)
++ * opts.includeHidden also unlisted, private and burn-after-read (only ever set for the owner or staff)
* opts.type 'paste' | 'screenshot'
* opts.search substring of title or content
* opts.origin 'user' | 'ai' (default: all)
@@@ -174,7 +174,9 @@ function listPastes(db, opts = {}) {
const params = [];
// The AI work queue spans visibility (Media's rule) and takes rows nothing has annotated yet.
if (opts.needsAi) where.push("COALESCE(ai_summary, '') = '' AND ai_analyzed_at IS NULL");
-- else if (!opts.includeHidden) where.push("visibility = 'public'");
++ // Burn-after-read pastes are link-only: listing them (with a content preview, or as a search
++ // hit) would give their content away without the read that burns them.
++ else if (!opts.includeHidden) where.push("visibility = 'public' AND burn_after_read = 0");
if (opts.origin === 'user' || opts.origin === 'ai' || opts.origin === 'imported') { where.push('origin = ?'); params.push(opts.origin); }
if (opts.ownerSubject !== undefined) { where.push('owner_subject = ?'); params.push(opts.ownerSubject); }
if (opts.type === 'paste' || opts.type === 'screenshot') { where.push('type = ?'); params.push(opts.type); }
ddiff --git a/test/helpers/mock-network.js b/test/helpers/mock-network.js
iindex 86b4218..74dcaca 100644
---- a/test/helpers/mock-network.js
++++ b/test/helpers/mock-network.js
@@@ -90,9 +90,9 @@ function start() {
return jwt.sign({ sub: claims.id, ...claims }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h', ...opts });
}
/** A client-credentials service token (identity.service-token-claims@1). */
-- function signService({ sub = 'svc:live', aud = ['openvibe.community'], cap = [], iss = issuer, expSec = 300, key = privatePem } = {}) {
++ function signService({ sub = 'svc:live', actorType = 'service', aud = ['openvibe.community'], cap = [], iss = issuer, expSec = 300, key = privatePem, extra = {} } = {}) {
const now = Math.floor(Date.now() / 1000);
-- return serviceAuth.signServiceToken({ iss, sub, actor_type: 'service', aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex') }, key);
++ return serviceAuth.signServiceToken({ iss, sub, actor_type: actorType, aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex'), ...extra }, key);
}
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
const url = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/security.test.js b/test/security.test.js
nnew file mode 100644
iindex 0000000..d758b81
---- /dev/null
++++ b/test/security.test.js
@@@ -0,0 +1,142 @@
++'use strict';
++/**
++ * Security regressions:
++ * - developer-app / module tokens (sub app:… / mod:…) act only for the person who authorized
++ * them (on_behalf_of); X-OV-Subject can never make them someone else, and sandbox app tokens
++ * are refused.
++ * - 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.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot({
++ authority: 'community',
++ pasteLimits: { cooldownSeconds: 0, commentCooldownSeconds: 0 },
++ appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } },
++ });
++ const net = t.network;
++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
++ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
++ const CREATE = 'community.paste.create', WRITE = 'community.paste.write', POST = 'community.post.create';
++ const APP = 'app:app_01HZX3K5V7Q9M2N4P6R8T0W2Y4';
++ const appToken = ({ cap = [CREATE, POST], env = 'production', onBehalfOf = null, sub = APP, actorType = 'app' } = {}) => net.signService({
++ sub, actorType, cap, extra: { env, project_id: 'prj_test', ns: ['prj_test'], ...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}) },
++ });
++
++ const call = (path, { method = 'GET', token, cookie, headers = {}, json, ip } = {}) => {
++ const h = { ...headers };
++ if (token) h.authorization = `Bearer ${token}`;
++ if (ip) h['x-forwarded-for'] = ip;
++ let body;
++ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
++ return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
++ };
++
++ // ── developer apps cannot name someone else in X-OV-Subject ──
++ const priv = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'alex private notes', visibility: 'private' } });
++ assert.strictEqual(priv.status, 201, priv.text);
++ const privSlug = priv.json().slug;
++ const thread = await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Alex thread', body: 'original words' } });
++ assert.strictEqual(thread.status, 201, thread.text);
++ const alexPostId = thread.json().post.id;
++
++ await check('an app token naming a victim in X-OV-Subject cannot read their private paste', async () => {
++ const r = await call(`/api/pastes/${privSlug}`, { token: appToken(), headers: { 'x-ov-subject': alex.subject_id } });
++ assert.notStrictEqual(r.status, 200, `private paste leaked to a third-party app: ${r.text}`);
++ const list = await call('/api/pastes?username=alex&include_unlisted=1', { token: appToken(), headers: { 'x-ov-subject': alex.subject_id } });
++ assert.ok(list.status !== 200 || !list.json().pastes.some((p) => p.slug === privSlug), 'private paste listed to a third-party app');
++ });
++
++ await check('an app token naming a victim in X-OV-Subject cannot edit their forum post or post as them', async () => {
++ const r = await call(`/api/v1/posts/${alexPostId}`, { method: 'PUT', token: appToken(), headers: { 'x-ov-subject': alex.subject_id }, json: { body: 'defaced' } });
++ assert.notStrictEqual(r.status, 200, `post edited by a third-party app: ${r.text}`);
++ const post = t.db.prepare('SELECT body_markdown FROM posts WHERE id = ?').get(alexPostId);
++ assert.strictEqual(post.body_markdown, 'original words');
++ const p = await call('/api/pastes', { method: 'POST', token: appToken(), headers: { 'x-ov-subject': alex.subject_id }, json: { content: 'written as alex' } });
++ assert.strictEqual(p.status, 403, p.text);
++ });
++
++ await check('an app token acts for the person who authorized it (on_behalf_of), and only for them', async () => {
++ const own = await call('/api/pastes', { method: 'POST', token: appToken({ onBehalfOf: sam.subject_id }), json: { content: 'sam via an app' } });
++ assert.strictEqual(own.status, 201, own.text);
++ assert.strictEqual(own.json().paste.owner_subject, sam.subject_id);
++ const other = await call('/api/pastes', { method: 'POST', token: appToken({ onBehalfOf: sam.subject_id }), headers: { 'x-ov-subject': alex.subject_id }, json: { content: 'x' } });
++ assert.strictEqual(other.status, 403, other.text);
++ const modTok = appToken({ sub: 'mod:mod_01HZX3K5V7Q9M2N4P6R8T0W2Y4', actorType: 'mod' });
++ const mod = await call(`/api/pastes/${privSlug}`, { token: modTok, headers: { 'x-ov-subject': alex.subject_id } });
++ assert.notStrictEqual(mod.status, 200, 'module tokens are not trusted with X-OV-Subject either');
++ // First-party services keep acting for the subject they name.
++ const svc = await call(`/api/pastes/${privSlug}`, { token: net.signService({ cap: [WRITE] }), headers: { 'x-ov-subject': alex.subject_id } });
++ assert.strictEqual(svc.status, 200, svc.text);
++ });
++
++ await check('sandbox app tokens are refused', async () => {
++ const r = await call('/api/pastes', { method: 'POST', token: appToken({ env: 'sandbox' }), json: { content: 'sandbox' } });
++ assert.strictEqual(r.status, 401, r.text);
++ });
++
++ // ── burn after read ──────────────────────────────────────
++ const burnPaste = async (content) => {
++ const r = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { title: 'Burner', content, burn_after_read: true } });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.json().slug;
++ };
++
++ await check('burn-after-read pastes stay out of lists, search, the feed and the home page', async () => {
++ const slug = await burnPaste('BURNSECRET-one two three');
++ const list = await call('/api/pastes?limit=200');
++ assert.ok(!list.json().pastes.some((p) => p.slug === slug), 'listed publicly');
++ const search = await call('/api/pastes?search=BURNSECRET');
++ assert.strictEqual(search.json().pastes.length, 0, 'content searchable');
++ const feed = await call('/feed.xml');
++ assert.ok(!feed.text.includes('BURNSECRET') && !feed.text.includes(slug), 'in the RSS feed');
++ const home = await call('/');
++ assert.ok(!home.text.includes('BURNSECRET'), 'previewed on the home page');
++ const mine = await call('/api/pastes/by-user/alex', { cookie: alexJwt });
++ assert.ok(mine.json().pastes.some((p) => p.slug === slug), 'the owner still sees it');
++ });
++
++ await check('?no_view=1 does not let a reader skip the burn', async () => {
++ const slug = await burnPaste('burn me once');
++ const first = await call(`/api/pastes/${slug}?no_view=1`, { cookie: samJwt });
++ assert.strictEqual(first.status, 200, first.text);
++ const second = await call(`/api/pastes/${slug}?no_view=1`, { cookie: samJwt });
++ assert.strictEqual(second.status, 410, `read twice: ${second.text}`);
++ });
++
++ await check('downloading a burn-after-read paste is the read', async () => {
++ const slug = await burnPaste('download burns');
++ const dl = await call(`/p/${slug}/download`, { cookie: samJwt });
++ assert.strictEqual(dl.status, 200);
++ const again = await call(`/p/${slug}/download`, { cookie: samJwt });
++ assert.notStrictEqual(again.status, 200, 'downloaded twice');
++ });
++
++ await check('a burn-after-read paste cannot be forked into a lasting copy by someone else', async () => {
++ const slug = await burnPaste('fork me not');
++ const f = await call(`/api/pastes/${slug}/fork`, { method: 'POST', cookie: samJwt, json: {} });
++ assert.notStrictEqual(f.status, 201, `forked: ${f.text}`);
++ });
++
++ // ── forks keep an unlisted paste unlisted ────────────────
++ await check('forking an unlisted paste does not publish it', async () => {
++ const u = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'UNLISTEDSECRET body', visibility: 'unlisted' } });
++ assert.strictEqual(u.status, 201, u.text);
++ const f = await call(`/api/pastes/${u.json().slug}/fork`, { method: 'POST', cookie: samJwt, json: {} });
++ assert.strictEqual(f.status, 201, f.text);
++ assert.notStrictEqual(f.json().paste.visibility, 'public');
++ const search = await call('/api/pastes?search=UNLISTEDSECRET');
++ assert.strictEqual(search.json().pastes.length, 0, 'the fork is listed publicly');
++ const pub = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'public body' } });
++ const pf = await call(`/api/pastes/${pub.json().slug}/fork`, { method: 'POST', cookie: samJwt, json: {} });
++ assert.strictEqual(pf.json().paste.visibility, 'public', 'forks of public pastes stay public');
++ });
++
++ await t.close();
++ done();
++})();