openvibers/openvibe.blog Commits

Security: draft and private series/category names stay off public pages; unpublishing hides the comment thread; /auth/me is no-store

OP OpenVibers committed 23 Sept 2026 · f8498f3e53 · 4 files, +83 −6

- The blog front page listed every series and category of the blog, including ones created by
  drafts and by private or members-only posts, so their names were public (and shared-cached)
  before any post was. It now lists only series and categories that hold a post the viewer may
  see in the list; series and category pages with no such post answer 404 without their names.
- effects.after treated an unpublished public post as still public, so its Community thread stayed
  readable after the post was unpublished. Public now means published and public.
- /auth/me answers with Cache-Control: private, no-store (it returns the signed-in profile).
- test/security.test.js covers all three.

Changes

ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex ffe1505..7e9f93e 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -375,6 +375,9 @@ function createAuthRoutes(config, auth) {
// ── GET /auth/me ─────────────────────────────────────────
router.get('/me', async (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ res.vary('Cookie');
++ res.vary('Authorization');
const token = extractToken(req);
if (!token) return res.status(401).json({ error: 'Not authenticated' });
const claims = await auth.verify(token);
ddiff --git a/server/domain/effects.js b/server/domain/effects.js
iindex cc5eef6..a09ad8a 100644
---- a/server/domain/effects.js
++++ b/server/domain/effects.js
@@@ -12,8 +12,9 @@ function createEffects({ outbox, community }) {
after(before, post, ctx) {
outbox.kick();
if (!post || !before) return;
-- const wasPublic = before.visibility === 'public' && before.state !== 'deleted';
-- const isPublic = post.visibility === 'public' && post.state !== 'deleted';
++ // Public = readable by everyone: published and public. Unpublishing hides the thread too.
++ const wasPublic = before.visibility === 'public' && before.state === 'published';
++ const isPublic = post.visibility === 'public' && post.state === 'published';
if (wasPublic && !isPublic) community.setThreadVisibility(post, 'hidden', ctx).catch(() => {});
else if (!wasPublic && isPublic) community.setThreadVisibility(post, 'public', ctx).catch(() => {});
},
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 1d7a51b..79a5549 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -87,6 +87,11 @@ function createPublicRoutes(ctx) {
const items = await reading.listItems(rows);
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
const feeds = reading.feedsOf(blog);
++ // Series and categories are named when a draft or restricted post uses them: list only those
++ // holding a post this viewer may see in the list, so their names never leak ahead of a post.
++ const shows = (filter) => posts.listPublished({ ...filter, blogId: blog.id, restricted, limit: 1 }).total > 0;
++ const prune = (nodes) => nodes.map((n) => ({ ...n, children: prune(n.children || []) }))
++ .filter((n) => n.children.length || shows({ termIds: [n.id] }));
send(req, res, 200, {
title: blog.title,
description: blog.description,
@@@ -102,7 +107,7 @@ function createPublicRoutes(ctx) {
: { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined }],
body: pages.blogIndex({
blog, blogUrl: `/@${blog.handle}`, items, pager, feeds,
-- series: blogs.series(blog), categories: reading.categoriesTree(blog),
++ series: blogs.series(blog).filter((sr) => shows({ seriesId: sr.id })), categories: prune(reading.categoriesTree(blog)),
canWrite: access.canWrite(store, req.viewer, blog, 'create'),
}),
}, { cacheable: !restricted });
@@@ -146,10 +151,12 @@ function createPublicRoutes(ctx) {
// ── Collections ─────────────────────────────────────────
-- async function collection(req, res, { blog, heading, intro, crumbs, path, filter, order, empty }) {
++ async function collection(req, res, { blog, heading, intro, crumbs, path, filter, order, empty, hideEmpty = false }) {
const page = pageNumber(req);
const restricted = blog ? isMember(blog, req.viewer) : false;
const { total, posts: rows } = posts.listPublished({ ...filter, blogId: blog ? blog.id : null, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE, order });
++ // A series or category named only by drafts or posts this viewer cannot see does not exist for them.
++ if (hideEmpty && !total) return notFound(req, res);
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
if (pager.outOfRange && total) return notFound(req, res);
const items = await reading.listItems(rows, { perBlogLinks: Boolean(blog) });
@@@ -189,7 +196,7 @@ function createPublicRoutes(ctx) {
const ids = [term.id, ...store.taxonomy.descendants(term.id).map((t) => t.id)];
return collection(req, res, { blog, heading: term.name, intro: `Posts in ${trail.map((t) => t.name).join(' › ')}.`, path: reading.urls.category(blog, term),
crumbs: [blogCrumb(blog), ...trail.map((t, i) => (i === trail.length - 1 ? { name: t.name } : { name: t.name, url: reading.urls.category(blog, t) }))],
-- filter: { termIds: ids }, empty: 'No public posts in this category yet.' });
++ filter: { termIds: ids }, empty: 'No public posts in this category yet.', hideEmpty: true });
}));
router.get('/@:handle/series/:series', wrap(async (req, res) => {
@@@ -197,7 +204,7 @@ function createPublicRoutes(ctx) {
const series = blog && blogs.seriesBySlug(blog, req.params.series);
if (!series) return notFound(req, res);
return collection(req, res, { blog, heading: series.title, intro: series.description || `A series on ${blog.title}.`, path: reading.urls.series(blog, series),
-- crumbs: [blogCrumb(blog), { name: series.title }], filter: { seriesId: series.id }, order: 'series', empty: 'No public parts of this series yet.' });
++ crumbs: [blogCrumb(blog), { name: series.title }], filter: { seriesId: series.id }, order: 'series', empty: 'No public parts of this series yet.', hideEmpty: true });
}));
async function personFor(who) {
ddiff --git a/test/security.test.js b/test/security.test.js
nnew file mode 100644
iindex 0000000..f3caab6
---- /dev/null
++++ b/test/security.test.js
@@@ -0,0 +1,66 @@
++'use strict';
++/**
++ * Security regressions: names of drafts' and private posts' series and categories never reach
++ * public pages; an unpublished post's Community thread is hidden; /auth/me is never cacheable.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
++
++(async () => {
++ const t = await boot();
++ const carol = t.network.addUser('carol');
++ await t.get('/api/v1/blogs', { as: carol, json: {} });
++
++ const make = async (title, { publish = true, ...extra } = {}) => {
++ const p = (await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title, body: `${title} ${LONG}`, ...extra } })).json().post;
++ if (publish) await t.get(`/api/v1/posts/${p.id}/publish`, { as: carol, json: {} });
++ return p;
++ };
++ const open = await make('Open post', { categories: 'Science', series: 'Public series' });
++ await make('Launch draft', { publish: false, categories: 'Launches > Project Nightingale', series: 'Nightingale countdown' });
++ await make('Minutes', { visibility: 'private', categories: 'Board minutes', series: 'Board series' });
++
++ await check('the blog front page lists only series and categories that hold posts the viewer may see', async () => {
++ const r = await t.get('/@carol');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /Public series/);
++ assert.match(r.text, /Science/);
++ for (const secret of ['Nightingale', 'Launches', 'Board minutes', 'Board series']) assert.doesNotMatch(r.text, new RegExp(secret), `${secret} leaked`);
++ const own = await t.get('/@carol', { as: carol });
++ assert.match(own.text, /Board minutes/, 'a member sees the private post’s category');
++ });
++
++ await check('series and category pages without a visible post answer 404 without their names', async () => {
++ for (const p of ['/@carol/series/nightingale-countdown', '/@carol/categories/project-nightingale', '/@carol/categories/launches', '/@carol/categories/board-minutes', '/@carol/series/board-series']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 404, p);
++ assert.doesNotMatch(r.text, /Nightingale|Launches|Board/, p);
++ }
++ assert.strictEqual((await t.get('/@carol/categories/board-minutes', { as: carol })).status, 200, 'members still see it');
++ assert.strictEqual((await t.get('/@carol/series/public-series')).status, 200);
++ });
++
++ await check('unpublishing a public post hides its Community thread; publishing again shows it', async () => {
++ await t.get('/@carol/open-post'); // the first view resolves the thread
++ const thread = () => [...t.community.threads.values()][0];
++ assert.ok(thread(), 'thread created');
++ assert.strictEqual(thread().visibility, 'public');
++ await t.get(`/api/v1/posts/${open.id}/unpublish`, { as: carol, json: {} });
++ await new Promise((r) => setTimeout(r, 100));
++ assert.strictEqual(thread().visibility, 'hidden');
++ await t.get(`/api/v1/posts/${open.id}/publish`, { as: carol, json: {} });
++ await new Promise((r) => setTimeout(r, 100));
++ assert.strictEqual(thread().visibility, 'public');
++ });
++
++ await check('/auth/me is private, no-store', async () => {
++ const r = await t.get('/auth/me', { as: carol });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });