openvibers/openvibe.community Commits
Community: screenshots typed by their bytes, secret burn-after-read slugs, same-site /new, cross-site logout subresources refused (#36)
Changes
@@@ -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;@@@ -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'),@@@ -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];@@@ -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;nnew file mode 100644@@@ -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 };@@@ -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 = {}, limconst 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 = {}, limasync 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 = {}, limuser_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 = {}, limif (!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');@@@ -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) });nnew file mode 100644@@@ -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');@@@ -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);@@@ -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 () => {
@@ -123,6 +123,13 @@ server {
@@ -123,6 +123,13 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
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 ──
# ── Static assets: Node marks hashed ?v= URLs immutable; let browsers keep them ──
location ~ ^/(?!api/|auth/).*\.(?:css|js|svg|png|ico|webmanifest|woff2?)$ {
location ~ ^/(?!api/|auth/).*\.(?:css|js|svg|png|ico|webmanifest|woff2?)$ {
proxy_pass http://127.0.0.1:4200;
proxy_pass http://127.0.0.1:4200;
@@ -134,10 +141,6 @@ server {
@@ -134,10 +141,6 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
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) ─────────────
# ── Everything else (server-rendered pages) ─────────────
location / {
location / {
proxy_pass http://127.0.0.1:4200;
proxy_pass http://127.0.0.1:4200;
@@ -413,6 +413,10 @@ async function createApp(opts = {}) {
@@ -413,6 +413,10 @@ async function createApp(opts = {}) {
// same anonymous-write budget.
// same anonymous-write budget.
app.post('/new', withUser, anonWriteLimiter, express.urlencoded({ extended: false, limit: '1mb' }), wrap(async (req, res) => {
app.post('/new', withUser, anonWriteLimiter, express.urlencoded({ extended: false, limit: '1mb' }), wrap(async (req, res) => {
const b = req.body || {};
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 = {
const values = {
title: String(b.title || '').slice(0, 200),
title: String(b.title || '').slice(0, 200),
language: String(b.language || 'auto'),
language: String(b.language || 'auto'),
@@ -364,7 +364,18 @@ function createAuthRoutes(config, auth) {
@@ -364,7 +364,18 @@ function createAuthRoutes(config, auth) {
});
});
// ── GET /auth/logout ─────────────────────────────────────
// ── 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) => {
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
// Best-effort refresh revocation. The Network's rotating refresh tokens
// self-invalidate, so a failure here is harmless.
// self-invalidate, so a failure here is harmless.
const refresh = req.cookies?.[REFRESH_COOKIE];
const refresh = req.cookies?.[REFRESH_COOKIE];
@@ -49,6 +49,7 @@ const { discussionModerator } = require('../identity/capabilities');
@@ -49,6 +49,7 @@ const { discussionModerator } = require('../identity/capabilities');
const { renderMarkdown, markdownToText } = require('../render/markdown');
const { renderMarkdown, markdownToText } = require('../render/markdown');
const { isUserSubject } = require('../vip');
const { isUserSubject } = require('../vip');
const { stripImageMetadata } = require('../media/strip-metadata');
const { stripImageMetadata } = require('../media/strip-metadata');
+const { sniffImage } = require('../media/sniff-image');
const reactions = require('./reactions');
const reactions = require('./reactions');
const blocks = require('../identity/blocks');
const blocks = require('../identity/blocks');
const STYLES = ['feed', 'forum'];
const STYLES = ['feed', 'forum'];
@@ -87,15 +88,6 @@ function parsePasteRefs(input) {
@@ -87,15 +88,6 @@ function parsePasteRefs(input) {
}
}
return out;
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 POSTS_PER_PAGE = 50;
const TITLE_MIN = 3, TITLE_MAX = 200;
const TITLE_MIN = 3, TITLE_MAX = 200;
const BODY_MAX = 40_000;
const BODY_MAX = 40_000;
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -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 };
@@ -21,6 +21,7 @@ const store = require('./store');
@@ -21,6 +21,7 @@ const store = require('./store');
const commentStore = require('../comments/store');
const commentStore = require('../comments/store');
const { discussionModerator } = require('../identity/capabilities');
const { discussionModerator } = require('../identity/capabilities');
const { stripImageMetadata } = require('../media/strip-metadata');
const { stripImageMetadata } = require('../media/strip-metadata');
+const { sniffImage } = require('../media/sniff-image');
const { capabilities } = require('openvibe-contracts');
const { capabilities } = require('openvibe-contracts');
const blocks = require('../identity/blocks');
const blocks = require('../identity/blocks');
@@ -36,7 +37,6 @@ const DEFAULT_LIMITS = {
@@ -36,7 +37,6 @@ const DEFAULT_LIMITS = {
viewCooldownSec: 6 * 3600,
viewCooldownSec: 6 * 3600,
viewEventsPerMinPerIp: 60,
viewEventsPerMinPerIp: 60,
};
};
-const IMAGE_MIME = /^image\/(png|jpeg|webp|gif)$/;
const SERVICE_SLUG_RE = /^[A-Za-z0-9_-]{3,80}$/;
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.
// 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']);
const RESERVED_SLUGS = new Set(['config', 'screenshot', 'bulk', 'admin', 'by-user']);
@@ -291,9 +291,12 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -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
* 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
* 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.
* 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);
const slug = String(body.slug);
if (!SERVICE_SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug.toLowerCase())) fail(400, 'Invalid 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 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
@@ -403,7 +406,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const metadata = serviceMetadata(v, body);
const metadata = serviceMetadata(v, body);
const visibility = visibilityOf(body.visibility, !!v.subject);
const visibility = visibilityOf(body.visibility, !!v.subject);
const row = await store.insertPaste(db, {
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,
owner_subject: v.subject || null,
origin: v.origin === 'ai' ? 'ai' : 'user',
origin: v.origin === 'ai' ? 'ai' : 'user',
type: 'paste',
type: 'paste',
@@ -424,16 +427,19 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -424,16 +427,19 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
async createScreenshot(v, body = {}, file, ctx = {}) {
async createScreenshot(v, body = {}, file, ctx = {}) {
await pasteRateCheck(v);
await pasteRateCheck(v);
if (!file || !file.buffer) fail(400, 'No screenshot uploaded');
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 (file.buffer.length > L.screenshotMaxSizeMb * 1024 * 1024) fail(400, `File too large (max ${L.screenshotMaxSizeMb} MB)`);
if (!media) fail(503, 'Media service unavailable');
if (!media) fail(503, 'Media service unavailable');
const visibility = visibilityOf(body.visibility, !!v.subject);
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 extra = serviceMetadata(v, body);
- const bytes = stripImageMetadata(file.buffer, file.mimetype);
+ const bytes = stripImageMetadata(file.buffer, mime);
let stored;
let stored;
try {
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) {
} catch (err) {
console.warn('[Pastes] screenshot upload failed:', err.message);
console.warn('[Pastes] screenshot upload failed:', err.message);
fail(502, 'Media service unavailable');
fail(502, 'Media service unavailable');
@@ -444,7 +450,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -444,7 +450,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
user_agent: body.user_agent || ctx.userAgent || null,
user_agent: body.user_agent || ctx.userAgent || null,
original_name: file.originalname || null,
original_name: file.originalname || null,
size_bytes: bytes.length,
size_bytes: bytes.length,
- mime_type: file.mimetype,
+ mime_type: mime,
};
};
const row = await store.insertPaste(db, {
const row = await store.insertPaste(db, {
slug,
slug,
@@ -681,11 +687,12 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -681,11 +687,12 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!p) fail(404, 'Paste not found');
if (!p) fail(404, 'Paste not found');
if (p.type !== 'screenshot' || !p.screenshot_url) fail(400, 'Not a screenshot paste');
if (p.type !== 'screenshot' || !p.screenshot_url) fail(400, 'Not a screenshot paste');
if (!file || !file.buffer) fail(400, 'Censored image is required');
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');
if (!media) fail(503, 'Media service unavailable');
let stored;
let stored;
try {
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) {
} catch (err) {
console.warn('[Pastes] censor upload failed:', err.message);
console.warn('[Pastes] censor upload failed:', err.message);
fail(502, 'Media service unavailable');
fail(502, 'Media service unavailable');
@@ -137,6 +137,22 @@ const { boot, check, done } = require('./helpers/app');
@@ -137,6 +137,22 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(evil.headers.get('location'), '/');
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 () => {
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');
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) });
const post = (body, headers = {}) => t.get('/auth/fedcm', { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body) });
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -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');
@@ -288,6 +288,29 @@ const { boot, check, done } = require('./helpers/app');
@@ -288,6 +288,29 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual(failed.json(), { error: 'Media service unavailable' });
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 () => {
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`);
const raw = await call(`/p/${alexSlug}/raw`);
assert.strictEqual(raw.status, 200);
assert.strictEqual(raw.status, 200);
@@ -216,6 +216,8 @@ const { boot, check, done } = require('./helpers/app');
@@ -216,6 +216,8 @@ const { boot, check, done } = require('./helpers/app');
has(empty.text, 'content is empty');
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' } });
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');
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 () => {
await check('404 page and API 404 JSON; health', async () => {