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
Changes
@@@ -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;@@@ -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 = {}, limasync 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 = {}, limtitle: 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) });@@@ -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); }@@@ -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}`;nnew file mode 100644@@@ -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();++})();
@@ -15,6 +15,8 @@
@@ -15,6 +15,8 @@
* person: subject stays null). X-OV-Source-Ref may carry a JSON EntityRef (e.g. the stream).
* 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
* 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).
* (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
* 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.
* judged on that token alone: a bad one is refused, never downgraded to anonymous.
@@ -50,6 +52,13 @@ function createViewerResolver({ auth, config, network }) {
@@ -50,6 +52,13 @@ function createViewerResolver({ auth, config, network }) {
const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });
const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });
if (!r.ok) throw new ViewerError(401, r.code, r.reason);
if (!r.ok) throw new ViewerError(401, r.code, r.reason);
const claims = r.claims;
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');
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"');
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 }) {
@@ -59,8 +68,13 @@ function createViewerResolver({ auth, config, network }) {
let subject = null;
let subject = null;
if (subjectHeader) {
if (subjectHeader) {
if (!isActingSubject(subjectHeader)) throw new ViewerError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… or gst_… subject id');
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.
// AI output is never attributed to a person, even if the caller also names one.
subject = origin === 'ai' ? null : subjectHeader;
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;
let sourceRef = null;
@@ -313,7 +313,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -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. */
/** GET /api/pastes/:slug — counts a page view unless noView; burns a spent burn-after-read paste. */
async get(v, slug, ctx = {}) {
async get(v, slug, ctx = {}) {
const p = visible(v, slug);
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) {
if (p.burn_after_read) {
p.views = store.bumpViews(db, p.id);
p.views = store.bumpViews(db, p.id);
if (p.views > 1) burn(p);
if (p.views > 1) burn(p);
@@ -463,6 +464,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -463,6 +464,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
async fork(v, slug) {
async fork(v, slug) {
const original = visible(v, slug);
const original = visible(v, slug);
if (original.type !== 'paste') fail(400, 'Only text pastes can be forked');
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);
pasteRateCheck(v);
const row = store.insertPaste(db, {
const row = store.insertPaste(db, {
slug: store.generateSlug(db),
slug: store.generateSlug(db),
@@ -472,7 +475,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -472,7 +475,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
title: store.sanitizeTitle(`Fork of ${original.title}`),
title: store.sanitizeTitle(`Fork of ${original.title}`),
content: original.content,
content: original.content,
language: original.language,
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,
forked_from: original.id,
});
});
return created(row, { paste: await shapeOne(row, v) });
return created(row, { paste: await shapeOne(row, v) });
@@ -161,7 +161,7 @@ function softDelete(db, id) {
@@ -161,7 +161,7 @@ function softDelete(db, id) {
/**
/**
* List pastes.
* List pastes.
* opts.ownerSubject only this owner's 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.type 'paste' | 'screenshot'
* opts.search substring of title or content
* opts.search substring of title or content
* opts.origin 'user' | 'ai' (default: all)
* opts.origin 'user' | 'ai' (default: all)
@@ -174,7 +174,9 @@ function listPastes(db, opts = {}) {
@@ -174,7 +174,9 @@ function listPastes(db, opts = {}) {
const params = [];
const params = [];
// The AI work queue spans visibility (Media's rule) and takes rows nothing has annotated yet.
// 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");
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.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.ownerSubject !== undefined) { where.push('owner_subject = ?'); params.push(opts.ownerSubject); }
if (opts.type === 'paste' || opts.type === 'screenshot') { where.push('type = ?'); params.push(opts.type); }
if (opts.type === 'paste' || opts.type === 'screenshot') { where.push('type = ?'); params.push(opts.type); }
@@ -90,9 +90,9 @@ function start() {
@@ -90,9 +90,9 @@ function start() {
return jwt.sign({ sub: claims.id, ...claims }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h', ...opts });
return jwt.sign({ sub: claims.id, ...claims }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h', ...opts });
}
}
/** A client-credentials service token (identity.service-token-claims@1). */
/** 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);
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', () => {
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
const url = `http://127.0.0.1:${server.address().port}`;
const url = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,142 @@
@@ -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();
+})();