openvibers/openvibe.community Commits

Community: screenshots typed by their bytes, secret burn-after-read slugs, same-site /new, cross-site logout subresources refused (#36)

OP OpenVibers committed 10 Oct 2026 · 2b2b43657f · 10 files, +118 −24

Changes

ddiff --git a/deploy/nginx/openvibe.community.conf b/deploy/nginx/openvibe.community.conf
iindex fe6231f..b29044a 100644
---- a/deploy/nginx/openvibe.community.conf
++++ b/deploy/nginx/openvibe.community.conf
@@@ -123,6 +123,13 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
++ # ── Prometheus metrics and internal ingestion: host-local only. Node also refuses any request
++ # carrying X-Forwarded-For, which every proxied location sets; these blocks make nginx refuse
++ # them independently, in any letter case (Express routes /Metrics/ and /Internal/events too).
++ # Regex locations match in order, so these sit before the static-asset regex. ──
++ location ~* ^/metrics(/|$) { return 404; }
++ location ~* ^/internal(/|$) { return 404; }
++
# ── Static assets: Node marks hashed ?v= URLs immutable; let browsers keep them ──
location ~ ^/(?!api/|auth/).*\.(?:css|js|svg|png|ico|webmanifest|woff2?)$ {
proxy_pass http://127.0.0.1:4200;
@@@ -134,10 +141,6 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
-- # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4200/metrics). Node also
-- # refuses any request carrying X-Forwarded-For, which every location above sets. ──
-- location = /metrics { return 404; }
--
# ── Everything else (server-rendered pages) ─────────────
location / {
proxy_pass http://127.0.0.1:4200;
ddiff --git a/server/app.js b/server/app.js
iindex c510f75..47c23bb 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -413,6 +413,10 @@ async function createApp(opts = {}) {
// same anonymous-write budget.
app.post('/new', withUser, anonWriteLimiter, express.urlencoded({ extended: false, limit: '1mb' }), wrap(async (req, res) => {
const b = req.body || {};
++ // Same-site-only, like every other no-JS form post: SameSite=Lax already keeps the cookie off a
++ // cross-site POST, but a forged post would still create an anonymous paste (spam).
++ const origin = req.get('origin');
++ if (origin && origin !== 'null' && origin !== config.baseUrl) return html(res, pages.errorPage({ status: 403, title: 'Not allowed', message: 'That form was sent from another site.' }), 403);
const values = {
title: String(b.title || '').slice(0, 200),
language: String(b.language || 'auto'),
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 410178f..4a699be 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -364,7 +364,18 @@ function createAuthRoutes(config, auth) {
});
// ── GET /auth/logout ─────────────────────────────────────
++ // GET because the shared navbar signs out with a top-level navigation (render/layout.js sets
++ // logoutUrl '/auth/logout?next={path}'; navbar.js does `window.location.href = …`), and the
++ // Network's "sign out everywhere" is a chain of top-level redirects from openvibe.network
++ // (cross-site, but a navigation). What must not end the session is a cross-site subresource: an
++ // <img src="/auth/logout">, a frame or a fetch. Browsers label requests with fetch metadata, so
++ // those are refused; a client that sends none (an old browser, curl) is let through.
router.get('/logout', async (req, res) => {
++ const site = req.get('sec-fetch-site');
++ const navigation = req.get('sec-fetch-dest') === 'document'; // only a top-level navigation is a document
++ if (site && site !== 'same-origin' && site !== 'none' && !navigation) {
++ return res.status(403).type('text/plain').send('Sign-out must come from this site.');
++ }
// Best-effort refresh revocation. The Network's rotating refresh tokens
// self-invalidate, so a failure here is harmless.
const refresh = req.cookies?.[REFRESH_COOKIE];
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex e558689..921631f 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -49,6 +49,7 @@ const { discussionModerator } = require('../identity/capabilities');
const { renderMarkdown, markdownToText } = require('../render/markdown');
const { isUserSubject } = require('../vip');
const { stripImageMetadata } = require('../media/strip-metadata');
++const { sniffImage } = require('../media/sniff-image');
const reactions = require('./reactions');
const blocks = require('../identity/blocks');
const STYLES = ['feed', 'forum'];
@@@ -87,15 +88,6 @@ function parsePasteRefs(input) {
}
return out;
}
--/** The image type from its first bytes (never the name or the declared type), or null. */
--function sniffImage(b) {
-- if (!Buffer.isBuffer(b) || b.length < 12) return null;
-- if (b[0] === 0x89 && b.toString('latin1', 1, 4) === 'PNG') return 'image/png';
-- if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
-- if (b.toString('latin1', 0, 4) === 'GIF8') return 'image/gif';
-- if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
-- return null;
--}
const POSTS_PER_PAGE = 50;
const TITLE_MIN = 3, TITLE_MAX = 200;
const BODY_MAX = 40_000;
ddiff --git a/server/media/sniff-image.js b/server/media/sniff-image.js
nnew file mode 100644
iindex 0000000..ec5a5cc
---- /dev/null
++++ b/server/media/sniff-image.js
@@@ -0,0 +1,18 @@
++'use strict';
++
++/**
++ * The image type from a buffer's first bytes — never the file name or the client-declared
++ * Content-Type. Nothing that decides how to strip metadata or what Media stores may trust a
++ * caller's `mimetype`: a JPEG declaring image/png would otherwise skip PNG stripping entirely.
++ * Returns 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp', or null for anything else.
++ */
++function sniffImage(b) {
++ if (!Buffer.isBuffer(b) || b.length < 12) return null;
++ if (b[0] === 0x89 && b.toString('latin1', 1, 4) === 'PNG') return 'image/png';
++ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
++ if (b.toString('latin1', 0, 4) === 'GIF8') return 'image/gif';
++ if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
++ return null;
++}
++
++module.exports = { sniffImage };
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 4ea8153..75d6587 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -21,6 +21,7 @@ const store = require('./store');
const commentStore = require('../comments/store');
const { discussionModerator } = require('../identity/capabilities');
const { stripImageMetadata } = require('../media/strip-metadata');
++const { sniffImage } = require('../media/sniff-image');
const { capabilities } = require('openvibe-contracts');
const blocks = require('../identity/blocks');
@@@ -36,7 +37,6 @@ const DEFAULT_LIMITS = {
viewCooldownSec: 6 * 3600,
viewEventsPerMinPerIp: 60,
};
--const IMAGE_MIME = /^image\/(png|jpeg|webp|gif)$/;
const SERVICE_SLUG_RE = /^[A-Za-z0-9_-]{3,80}$/;
// Literal /api/pastes/<word> routes: a paste with one of these slugs would be unreachable there.
const RESERVED_SLUGS = new Set(['config', 'screenshot', 'bulk', 'admin', 'by-user']);
@@@ -291,9 +291,12 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
* A service may name the slug of a PUBLIC paste (Live's imports); otherwise it is generated. An
* unlisted or private paste always gets a secret slug: its slug is its only protection, and a
* service forwarding a person's request (Live's /api/pastes) passes their body through as-is.
++ * A burn-after-read paste is the same — it is never listed, so its short adj-noun-NN slug could be
++ * walked and the paste read (and burned) by a stranger before its intended reader.
*/
-- async function slugFor(v, body, visibility) {
-- if (!isService(v) || body.slug == null || body.slug === '' || visibility !== 'public') return await store.generateSlug(db, { secret: visibility !== 'public' });
++ async function slugFor(v, body, visibility, burnAfterRead = false) {
++ const secret = visibility !== 'public' || burnAfterRead;
++ 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');
@@@ -403,7 +406,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const metadata = serviceMetadata(v, body);
const visibility = visibilityOf(body.visibility, !!v.subject);
const row = await store.insertPaste(db, {
-- slug: await slugFor(v, body, visibility),
++ slug: await slugFor(v, body, visibility, truthy(body.burn_after_read)),
owner_subject: v.subject || null,
origin: v.origin === 'ai' ? 'ai' : 'user',
type: 'paste',
@@@ -424,16 +427,19 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
async createScreenshot(v, body = {}, file, ctx = {}) {
await pasteRateCheck(v);
if (!file || !file.buffer) fail(400, 'No screenshot uploaded');
-- if (!IMAGE_MIME.test(file.mimetype || '')) fail(400, 'Only PNG, JPEG, WebP, or GIF images allowed');
++ // The type comes from the bytes, never the client's declared Content-Type: a JPEG declaring
++ // image/png would otherwise keep its EXIF/GPS (stripPng bails on the signature mismatch).
++ const mime = sniffImage(file.buffer);
++ if (!mime) 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 visibility = visibilityOf(body.visibility, !!v.subject);
-- const slug = await slugFor(v, body, visibility);
++ const slug = await slugFor(v, body, visibility, truthy(body.burn_after_read));
const extra = serviceMetadata(v, body);
-- const bytes = stripImageMetadata(file.buffer, file.mimetype);
++ const bytes = stripImageMetadata(file.buffer, mime);
let stored;
try {
-- stored = await media.upload({ buffer: bytes, filename: file.originalname || 'screenshot.png', mime: file.mimetype, owner: v.subject || null });
++ stored = await media.upload({ buffer: bytes, filename: file.originalname || 'screenshot.png', mime, owner: v.subject || null });
} catch (err) {
console.warn('[Pastes] screenshot upload failed:', err.message);
fail(502, 'Media service unavailable');
@@@ -444,7 +450,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
user_agent: body.user_agent || ctx.userAgent || null,
original_name: file.originalname || null,
size_bytes: bytes.length,
-- mime_type: file.mimetype,
++ mime_type: mime,
};
const row = await store.insertPaste(db, {
slug,
@@@ -681,11 +687,12 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!p) fail(404, 'Paste not found');
if (p.type !== 'screenshot' || !p.screenshot_url) fail(400, 'Not a screenshot paste');
if (!file || !file.buffer) fail(400, 'Censored image is required');
-- if (!/^image\/(png|jpeg|webp)$/.test(file.mimetype || '')) fail(400, 'Only PNG, JPEG, or WebP images allowed');
++ const mime = sniffImage(file.buffer);
++ if (!mime || mime === 'image/gif') fail(400, 'Only PNG, JPEG, or WebP images allowed');
if (!media) fail(503, 'Media service unavailable');
let stored;
try {
-- stored = await media.upload({ buffer: stripImageMetadata(file.buffer, file.mimetype), filename: file.originalname || 'censored.png', mime: file.mimetype, owner: p.owner_subject || null });
++ stored = await media.upload({ buffer: stripImageMetadata(file.buffer, mime), filename: file.originalname || 'censored.png', mime, owner: p.owner_subject || null });
} catch (err) {
console.warn('[Pastes] censor upload failed:', err.message);
fail(502, 'Media service unavailable');
ddiff --git a/test/auth.test.js b/test/auth.test.js
iindex b15cc1b..19b2880 100644
---- a/test/auth.test.js
++++ b/test/auth.test.js
@@@ -137,6 +137,22 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(evil.headers.get('location'), '/');
});
++ await check('logout: a cross-site subresource GET is refused; the navbar and the Network sign-out-everywhere chain still sign out', async () => {
++ t.jar.set('ov_token', 'x'); t.jar.set('ov_refresh', 'refresh-3');
++ const img = await t.get('/auth/logout', { headers: { 'sec-fetch-site': 'cross-site', 'sec-fetch-dest': 'image' } });
++ assert.strictEqual(img.status, 403, 'an <img src=/auth/logout> must not end the session');
++ const frame = await t.get('/auth/logout', { headers: { 'sec-fetch-site': 'cross-site', 'sec-fetch-dest': 'iframe' } });
++ assert.strictEqual(frame.status, 403, 'nor a cross-site frame');
++ assert.ok(t.jar.has('ov_token') && t.jar.has('ov_refresh'), 'the session is untouched');
++ const fanout = await t.get('/auth/logout', { headers: { 'sec-fetch-site': 'cross-site', 'sec-fetch-dest': 'document' } });
++ assert.strictEqual(fanout.status, 302, 'openvibe.network\'s sign-out-everywhere hop is a top-level navigation');
++ assert.ok(!t.jar.has('ov_token') && !t.jar.has('ov_refresh'));
++ t.jar.set('ov_token', 'x'); t.jar.set('ov_refresh', 'refresh-4');
++ const navbar = await t.get('/auth/logout', { headers: { 'sec-fetch-site': 'same-origin', 'sec-fetch-dest': 'document' } });
++ assert.strictEqual(navbar.status, 302, 'the navbar navigates with window.location.href');
++ assert.ok(!t.jar.has('ov_token') && !t.jar.has('ov_refresh'));
++ });
++
await check('POST /auth/fedcm swaps a nonce-matching assertion for a session (jwt-bearer grant)', async () => {
assert.ok(!t.jar.has('ov_token'), 'precondition: signed out');
const post = (body, headers = {}) => t.get('/auth/fedcm', { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body) });
ddiff --git a/test/nginx-internal-paths.test.js b/test/nginx-internal-paths.test.js
nnew file mode 100644
iindex 0000000..c1941d8
---- /dev/null
++++ b/test/nginx-internal-paths.test.js
@@@ -0,0 +1,18 @@
++'use strict';
++// deploy/nginx/openvibe.community.conf: the vhost itself refuses /metrics (with or without a trailing
++// slash) and the whole /internal/ tree (Events ingestion), rather than leaning on the app's
++// forwarding-header check. The old exact-match `location = /metrics` left `/metrics/` (which Express
++// still routes to /metrics) and `/internal/events` falling through to the catch-all `location /`.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.community.conf'), 'utf8');
++assert.ok(!/location = \/metrics/.test(conf), 'the exact-match /metrics block is gone (it let /metrics/ through)');
++assert.match(conf, /location ~\* \^\/metrics\(\/\|\$\) \{ return 404; \}/, '/metrics in any case, with or without a slash');
++assert.match(conf, /location ~\* \^\/internal\(\/\|\$\) \{ return 404; \}/, '/internal in any case');
++// The HTTPS catch-all is the last `location / {` (the port-80 block has one earlier).
++const catchAll = conf.lastIndexOf('location / {');
++assert.ok(conf.indexOf('location ~* ^/metrics(/|$)') < conf.indexOf('location ~ ^/(?!api/|auth/)'), 'the metrics block precedes the static-asset regex');
++assert.ok(conf.indexOf('location ~* ^/internal(/|$)') < conf.indexOf('location ~ ^/(?!api/|auth/)'), 'the internal block precedes the static-asset regex');
++console.log('nginx internal paths: /metrics and /internal/ are blocked in the vhost');
ddiff --git a/test/pastes-api.test.js b/test/pastes-api.test.js
iindex c61a07e..439f5da 100644
---- a/test/pastes-api.test.js
++++ b/test/pastes-api.test.js
@@@ -288,6 +288,29 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual(failed.json(), { error: 'Media service unavailable' });
});
++ await check('screenshot upload: the type comes from the bytes, not the declared Content-Type', async () => {
++ // A JPEG whose bytes still carry EXIF, uploaded as image/png. Trusting the declared type let it
++ // pass the type check and skip stripping (stripPng bails on the signature mismatch), so the GPS
++ // reached Media intact. The sniffed type must drive both stripping and the stored mime.
++ const fd = new FormData();
++ fd.append('screenshot', new Blob([jpeg], { type: 'image/png' }), 'lie.png');
++ const r = await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: fd });
++ assert.strictEqual(r.status, 201, r.text);
++ const up = t.media.uploads[t.media.uploads.length - 1];
++ assert.strictEqual(up.mime, 'image/jpeg', 'Media is told the sniffed type, not the lie');
++ assert.ok(!up.bytes.includes(Buffer.from('GPS')), 'EXIF/GPS is stripped from a mislabelled JPEG');
++ assert.ok(up.bytes.includes(Buffer.from('pixels')) && up.bytes.includes(Buffer.from('JFIF')));
++ assert.strictEqual(JSON.parse(r.json().paste.metadata).mime_type, 'image/jpeg');
++ });
++
++ await check('a burn-after-read paste gets a secret slug, not a short walkable one', async () => {
++ const burn = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'one-time secret', burn_after_read: true } });
++ assert.strictEqual(burn.status, 201, burn.text);
++ assert.match(burn.json().slug, /^[a-z]+-[a-z]+-[A-Za-z0-9]{16}$/, 'the slug is the burn paste\'s only protection');
++ const plain = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'listed' } });
++ assert.match(plain.json().slug, /^[a-z]+-[a-z]+-\d{2,4}$/, 'a public non-burn paste keeps its short slug');
++ });
++
await check('/p/:slug/raw is served from the store (nosniff, private rules); /p/:slug/screenshot → stored URL', async () => {
const raw = await call(`/p/${alexSlug}/raw`);
assert.strictEqual(raw.status, 200);
ddiff --git a/test/ssr.test.js b/test/ssr.test.js
iindex d3b1964..4c234fd 100644
---- a/test/ssr.test.js
++++ b/test/ssr.test.js
@@@ -216,6 +216,8 @@ const { boot, check, done } = require('./helpers/app');
has(empty.text, 'content is empty');
const anonPost = await t.get('/new', { method: 'POST', body: new URLSearchParams({ title: 'from anon', content: 'x' }).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
assert.strictEqual(anonPost.status, 303, 'anonymous posts create in the store too');
++ const foreign = await t.get('/new', { method: 'POST', body: new URLSearchParams({ title: 'Cross site', content: 'x' }).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://evil.example' } });
++ assert.strictEqual(foreign.status, 403, 'a form sent from another site is refused');
});
await check('404 page and API 404 JSON; health', async () => {