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
Changes
@@@ -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);@@@ -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(() => {});},@@@ -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) {nnew file mode 100644@@@ -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); });
@@ -375,6 +375,9 @@ function createAuthRoutes(config, auth) {
@@ -375,6 +375,9 @@ function createAuthRoutes(config, auth) {
// ── GET /auth/me ─────────────────────────────────────────
// ── GET /auth/me ─────────────────────────────────────────
router.get('/me', async (req, res) => {
router.get('/me', async (req, res) => {
+ res.set('Cache-Control', 'private, no-store');
+ res.vary('Cookie');
+ res.vary('Authorization');
const token = extractToken(req);
const token = extractToken(req);
if (!token) return res.status(401).json({ error: 'Not authenticated' });
if (!token) return res.status(401).json({ error: 'Not authenticated' });
const claims = await auth.verify(token);
const claims = await auth.verify(token);
@@ -12,8 +12,9 @@ function createEffects({ outbox, community }) {
@@ -12,8 +12,9 @@ function createEffects({ outbox, community }) {
after(before, post, ctx) {
after(before, post, ctx) {
outbox.kick();
outbox.kick();
if (!post || !before) return;
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(() => {});
if (wasPublic && !isPublic) community.setThreadVisibility(post, 'hidden', ctx).catch(() => {});
else if (!wasPublic && isPublic) community.setThreadVisibility(post, 'public', ctx).catch(() => {});
else if (!wasPublic && isPublic) community.setThreadVisibility(post, 'public', ctx).catch(() => {});
},
},
@@ -87,6 +87,11 @@ function createPublicRoutes(ctx) {
@@ -87,6 +87,11 @@ function createPublicRoutes(ctx) {
const items = await reading.listItems(rows);
const items = await reading.listItems(rows);
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
const feeds = reading.feedsOf(blog);
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, {
send(req, res, 200, {
title: blog.title,
title: blog.title,
description: blog.description,
description: blog.description,
@@ -102,7 +107,7 @@ function createPublicRoutes(ctx) {
@@ -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 }],
: { '@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({
body: pages.blogIndex({
blog, blogUrl: `/@${blog.handle}`, items, pager, feeds,
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'),
canWrite: access.canWrite(store, req.viewer, blog, 'create'),
}),
}),
}, { cacheable: !restricted });
}, { cacheable: !restricted });
@@ -146,10 +151,12 @@ function createPublicRoutes(ctx) {
@@ -146,10 +151,12 @@ function createPublicRoutes(ctx) {
// ── Collections ─────────────────────────────────────────
// ── 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 page = pageNumber(req);
const restricted = blog ? isMember(blog, req.viewer) : false;
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 });
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}`) });
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);
if (pager.outOfRange && total) return notFound(req, res);
const items = await reading.listItems(rows, { perBlogLinks: Boolean(blog) });
const items = await reading.listItems(rows, { perBlogLinks: Boolean(blog) });
@@ -189,7 +196,7 @@ function createPublicRoutes(ctx) {
@@ -189,7 +196,7 @@ function createPublicRoutes(ctx) {
const ids = [term.id, ...store.taxonomy.descendants(term.id).map((t) => t.id)];
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),
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) }))],
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) => {
router.get('/@:handle/series/:series', wrap(async (req, res) => {
@@ -197,7 +204,7 @@ function createPublicRoutes(ctx) {
@@ -197,7 +204,7 @@ function createPublicRoutes(ctx) {
const series = blog && blogs.seriesBySlug(blog, req.params.series);
const series = blog && blogs.seriesBySlug(blog, req.params.series);
if (!series) return notFound(req, res);
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),
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) {
async function personFor(who) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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); });