openvibers/openvibe.community Commits

Comments on a Pics picture, and pictures embedded in a paste (#316)

OP OpenVibers committed 4 days ago · 7bf453b5c7 · 7 files, +78 −4

* Comments on a Pics picture, and pictures embedded in a paste

A picture's own page opens its discussion, so Community asks Pics whether the picture is
public right now: listed, active and with public counts. Anything else fails closed with
a plain 404, so a private picture never even says that it exists.

- A thread may be opened on pics/picture from a browser, and the thread is only visible
  while the picture is public.
- A paste may embed a picture from the fixed Pics origin: raster formats only, no query
  strings, no other origins, no SVG.
- openvibe.pics is added to the API's allowed origins, so the picture page can read the
  thread from the browser.

* Merge remote-tracking branch 'origin/main' into pics-community-1109

* Pics picture check: classify the one new outbound call

Community asks the fixed OpenVibe.Pics origin whether a picture is public. The
security inventory now records that call site, with the fixed origin, no redirect
and the id shape as the reason it is not a person's URL.

Changes

ddiff --git a/public/css/community.css b/public/css/community.css
iindex b1f4e22..18fcebd 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -536,3 +536,5 @@ button.rx:hover { border-color: var(--accent); }
.paste-plain:focus-visible { outline: 2px solid rgba(var(--accent-rgb), .5); outline-offset: 2px; }
.locked-file { margin: 0 0 10px; }
.locked-file a { white-space: nowrap; }
++
++.md .pics-embed { display: block; max-width: 100%; height: auto; border-radius: 8px; margin: 1em 0; }
ddiff --git a/server/comments/service.js b/server/comments/service.js
iindex 3ccd426..bc10624 100644
---- a/server/comments/service.js
++++ b/server/comments/service.js
@@@ -58,6 +58,7 @@ const BROWSER_REF_TYPES = {
wiki: ['page'],
blog: ['post'],
reviews: ['entity'],
++ pics: ['picture'],
};
/** Threads that take comments from people only — no anonymous comments (Live's rule for VODs and clips). */
const SIGNED_IN_ONLY = {
@@@ -69,7 +70,7 @@ const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
const PAGE = 30;
const REPLY_PAGE = 20;
--function createCommentService({ db, network = null, limits = {}, canViewPaste = null } = {}) {
++function createCommentService({ db, network = null, limits = {}, canViewPaste = null, fetchImpl = globalThis.fetch } = {}) {
const authors = createAuthors({ db, network });
const commentLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 5, noun: 'comments', ...(limits.comments || {}) });
const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
@@@ -155,10 +156,21 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
return !(p.visibility === 'private' && !(v && (v.staff || (v.subject && p.owner_subject && v.subject === p.owner_subject))));
}
/** Whether the viewer may see the entity a thread belongs to (only Community's pastes can be checked here). */
-- const entityVisible = async (v, t) => !(t.ref_service === 'community' && t.ref_type === 'paste') || await pasteVisibleTo(v, t.ref_id);
++ async function publicPicture(id) {
++ if (!/^pic_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id))) return false;
++ try {
++ const response = await fetchImpl(`https://openvibe.pics/api/v1/pictures/${encodeURIComponent(id)}`, { redirect: 'error', signal: AbortSignal.timeout(3000), headers: { Accept: 'application/json' } });
++ if (!response.ok) return false;
++ const picture = await response.json();
++ return picture.id === id && picture.listed === true && picture.state === 'active' && typeof picture.views === 'number';
++ } catch { return false; }
++ }
++ const entityVisible = async (v, t) => t.ref_service === 'pics' && t.ref_type === 'picture' ? publicPicture(t.ref_id)
++ : !(t.ref_service === 'community' && t.ref_type === 'paste') || await pasteVisibleTo(v, t.ref_id);
/** Community's own entities must exist (and be visible to the viewer) before anyone opens a thread on them. */
async function checkCommunityRef(ref, v) {
++ if (ref.service === 'pics' && ref.type === 'picture' && !await publicPicture(ref.id)) fail(404, 'ref.not_found', 'No such public picture');
if (ref.service !== 'community') return;
if (ref.type === 'paste') {
if (!await pasteVisibleTo(v, ref.id)) fail(404, 'ref.not_found', 'No such paste');
ddiff --git a/server/config.js b/server/config.js
iindex e6e6821..9a88ea0 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -73,7 +73,7 @@ module.exports = {
// Browser origins allowed to call the embeddable APIs (/api/v1/comments, /api/v1/pulse)
// with a Bearer Network JWT. No cookies cross origins.
-- apiCorsOrigins: (process.env.API_CORS_ORIGINS || 'https://openvibe.live,https://openvibe.media,https://openvibe.network,https://openvibe.tools,https://openvibe.games')
++ apiCorsOrigins: (process.env.API_CORS_ORIGINS || 'https://openvibe.live,https://openvibe.media,https://openvibe.network,https://openvibe.tools,https://openvibe.games,https://openvibe.pics')
.split(',').map((s) => s.trim().replace(/\/$/, '')).filter(Boolean),
// OpenVibe.VIP — members-only spaces and threads (forum/service.js). Every answer comes from
ddiff --git a/server/render/markdown.js b/server/render/markdown.js
iindex 55c351e..11a19da 100644
---- a/server/render/markdown.js
++++ b/server/render/markdown.js
@@@ -123,6 +123,9 @@ function inline(raw) {
let s = codeSpans(String(raw), (code) => hold(`<code>${esc(code)}</code>`));
// 2. everything else is escaped before any tag is written
s = esc(s);
++ // Opt-in Pics embeds only: a fixed HTTPS origin and raster paths, no SVG or arbitrary trackers.
++ s = s.replace(/!\[([^\]\n]{0,300})\]\((https:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}\.(?:jpg|png|webp|gif|avif))\)/g,
++ (_m, alt, url) => hold(`<img src="${url}" alt="${alt}" loading="lazy" decoding="async" referrerpolicy="no-referrer" class="pics-embed">`));
// 3. [text](url) — only safe schemes; the rest stays as text
s = s.replace(/\[([^\]\n]{1,300})\]\(([^\s()]{1,2000})\)/g, (m, text, url) => {
if (!SAFE_URL.test(unescapeEntities(url))) return m;
ddiff --git a/test/markdown.test.js b/test/markdown.test.js
iindex 4c0ac0a..30243ed 100644
---- a/test/markdown.test.js
++++ b/test/markdown.test.js
@@@ -8,7 +8,12 @@ const { renderMarkdown: md, markdownToText } = require('../server/render/markdow
const { check, done } = require('./helpers/app');
(async () => {
-- await check('the supported subset renders', () => {
++ await check('Pics embeds accept only fixed raster URLs and escape alt text', () => {
++ assert.ok(md('![forest](https://openvibe.pics/i/Abcdef23.png)').includes('<img src="https://openvibe.pics/i/Abcdef23.png"'));
++ for (const url of ['https://evil.test/i/Abcdef23.png','https://openvibe.pics/i/Abcdef23.svg','https://openvibe.pics.evil.test/i/Abcdef23.png']) assert.ok(!md(`![x](${url})`).includes('<img'));
++ assert.ok(!md('![" onerror="alert(1)](https://openvibe.pics/i/Abcdef23.png)').includes('alt="" onerror'));
++ });
++ await check('the supported subset renders' , () => {
assert.strictEqual(md('Hello **bold**, *em*, _em_, ~~gone~~ and `x < y`'), '<p>Hello <strong>bold</strong>, <em>em</em>, <em>em</em>, <del>gone</del> and <code>x &lt; y</code></p>');
assert.strictEqual(md('line one\nline two'), '<p>line one<br>\nline two</p>');
assert.strictEqual(md('# Title\n## Sub'), '<h3>Title</h3>\n<h4>Sub</h4>', 'headings demoted so the page keeps one h1');
ddiff --git a/test/pics-comments.test.js b/test/pics-comments.test.js
nnew file mode 100644
iindex 0000000..07cbecb
---- /dev/null
++++ b/test/pics-comments.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++/**
++ * Comments on an OpenVibe.Pics picture, and a picture embedded in a paste.
++ *
++ * A picture's thread is opened by the picture's own page, so Community asks Pics whether the picture is public
++ * right now: listed, active, and with public counts. Anything else fails closed (404, never a hint that the
++ * picture exists). A paste may embed a picture from the fixed Pics origin, raster formats only.
++ */
++const assert = require('assert');
++const { createCommentService } = require('../server/comments/service');
++const { renderMarkdown } = require('../server/render/markdown');
++
++const PICTURE = 'pic_01ARZ3NDEKTSV4RRFFQ69G5FAV';
++const listed = (over = {}) => ({ id: PICTURE, listed: true, state: 'active', views: 3, ...over });
++
++(async () => {
++ const answers = [];
++ let requests = 0;
++ const service = createCommentService({ db: {}, fetchImpl: async () => { requests++; const body = answers.shift() || { ok: false }; return { ok: body.ok !== false, json: async () => body.body || body }; } });
++
++ await assert.rejects(service.resolve({ kind: 'anonymous' }, { ref: { service: 'pics', type: 'picture', id: 'not-a-picture' } }),
++ (e) => e.code === 'ref.not_found', 'an id that is not a picture is refused');
++ assert.equal(requests, 0, 'and nothing was asked of Pics');
++
++ answers.push({ body: listed({ listed: false }) });
++ await assert.rejects(service.resolve({ kind: 'anonymous' }, { ref: { service: 'pics', type: 'picture', id: PICTURE } }),
++ (e) => e.code === 'ref.not_found', 'an unlisted picture has no thread');
++
++ answers.push({ body: listed({ state: 'hidden' }) });
++ await assert.rejects(service.resolve({ kind: 'anonymous' }, { ref: { service: 'pics', type: 'picture', id: PICTURE } }),
++ (e) => e.code === 'ref.not_found', 'a hidden picture has no thread');
++
++ answers.push({ body: listed({ views: undefined }) });
++ await assert.rejects(service.resolve({ kind: 'anonymous' }, { ref: { service: 'pics', type: 'picture', id: PICTURE } }),
++ (e) => e.code === 'ref.not_found', 'a picture without public counts is not a public picture');
++
++ answers.push({ ok: false });
++ await assert.rejects(service.resolve({ kind: 'anonymous' }, { ref: { service: 'pics', type: 'picture', id: PICTURE } }),
++ (e) => e.code === 'ref.not_found', 'and Pics saying nothing fails closed');
++
++ // A paste may embed a picture: the fixed origin, raster formats only.
++ const embed = renderMarkdown('![forest](https://openvibe.pics/i/Abcdef23.png)');
++ assert.ok(embed.includes('<img'), 'a Pics picture embeds as an image');
++ assert.ok(embed.includes('https://openvibe.pics/i/Abcdef23.png'), 'and keeps its address');
++ assert.ok(!renderMarkdown('![x](https://openvibe.pics/i/Abcdef23.svg)').includes('<img'), 'no SVG');
++ assert.ok(!renderMarkdown('![x](https://openvibe.example/i/Abcdef23.png)').includes('<img'), 'no other origin');
++ assert.ok(!renderMarkdown('![x](https://openvibe.pics/i/Abcdef23.png?x=1)').includes('<img'), 'no query strings');
++ assert.ok(!renderMarkdown('![x](https://openvibe.pics/a/Abcdef23.png)').includes('<img'), 'no album paths');
++
++ process.stdout.write('Pics comments fail closed; fixed-origin raster embeds pass\n');
++})().catch((e) => { process.stderr.write(e.stack + '\n'); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex df2115a..5af736c 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -30,6 +30,7 @@ const INVENTORY = {
'server/render/pages.js': [1, 'not a call: the home page shows a fetch() sample as text (never executed)'],
'server/identity/account-data.js': [2, 'Network /oauth/token and /internal/account-exports and /internal/account-deletions (fixed paths) with a service token'],
'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],
++ 'server/comments/service.js': [1, 'OpenVibe.Pics GET /api/v1/pictures/<id> at the fixed https://openvibe.pics origin, no redirect, to ask whether a picture is public; the id must match the picture-ID shape and is encoded'],
'server/media/files.js': [2, 'OV_MEDIA_INTERNAL_URL v1 file store, service token'],
'server/media/objects.js': [2, 'OV_MEDIA_INTERNAL_URL Object API v2, service token'],
'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],