openvibers/openvibe.news Commits

News: fixes from the pre-launch security review (#21)

OP OpenVibers committed 9 Oct 2026 · 731c9b0382 · 11 files, +59 −16

- Only OpenVibe.AI (svc:ai) may deliver as AI: an AI delivery writes a revision without an editor, and any service or
  app token with news.story.revise could claim X-OV-Origin: ai. Others now get 403 request.origin_not_ai.
- A source's link becomes a link only when it is http(s): ingest keeps no other canonical URL, and the story and
  cluster pages and JSON-LD check it again (a publisher's javascript: URL would have been clickable).
- Listing pages stop at page 1000 and the API offset at 100000 (far OFFSET scans on public routes).
- The form CSRF check compares byte lengths, so a non-ASCII token is a 403, not a 500.
- nginx: /internal/ and /metrics are refused case-insensitively (Express routes /METRICS too); pages get a per-address
  limit and the sitemaps and llms-full.txt a slower one; location / no longer drops the server's Host and
  X-Forwarded-Proto headers by setting its own.
- test/links.test.js; test/ai.test.js covers the origin rule.

Changes

ddiff --git a/deploy/nginx/openvibe.news.conf b/deploy/nginx/openvibe.news.conf
iindex 679900f..14eca93 100644
---- a/deploy/nginx/openvibe.news.conf
++++ b/deploy/nginx/openvibe.news.conf
@@@ -13,6 +13,9 @@
limit_req_zone $binary_remote_addr zone=ovnews_api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=ovnews_auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=ovnews_write:10m rate=5r/s;
++# Pages and feeds, and the sitemaps and llms-full.txt (they read every published story) at a slower rate.
++limit_req_zone $binary_remote_addr zone=ovnews_pages:10m rate=10r/s;
++limit_req_zone $binary_remote_addr zone=ovnews_index:10m rate=30r/m;
server {
listen 80;
@@@ -94,7 +97,8 @@ server {
}
# ── The Events webhook: OpenVibe.Events delivers over loopback, never through the public vhost ──
-- location /internal/ { return 404; }
++ # Case-insensitive: Express routes /Internal/ and /METRICS like /internal/ and /metrics.
++ location ~* ^/internal/ { return 404; }
# ── API ─────────────────────────────────────────────────
location /api/ {
@@@ -103,14 +107,19 @@ server {
}
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4820/metrics) ──
-- location = /metrics { return 404; }
++ location ~* ^/metrics$ { return 404; }
++
++ # ── Sitemaps and llms-full.txt: built from every published story on each request ──
++ location ~* ^/(sitemap\.xml|sitemaps/|llms-full\.txt) {
++ limit_req zone=ovnews_index burst=10 nodelay;
++ proxy_pass http://127.0.0.1:4820;
++ }
# ── Pages, feeds, sitemaps, robots.txt, llms.txt, static assets ──
++ # (No proxy_set_header here: one would drop every server-level header, Host and X-Forwarded-Proto included.)
location / {
++ limit_req zone=ovnews_pages burst=40 nodelay;
proxy_pass http://127.0.0.1:4820;
-- proxy_set_header X-Real-IP $remote_addr;
-- proxy_set_header X-Forwarded-For $remote_addr;
-- proxy_set_header CF-Connecting-IP $remote_addr;
}
location ~ /\.(?!well-known) {
ddiff --git a/server/auth/forms.js b/server/auth/forms.js
iindex 5b43fa5..c6c1c7c 100644
---- a/server/auth/forms.js
++++ b/server/auth/forms.js
@@@ -17,8 +17,10 @@ function csrfToken(config, viewer) {
function checkCsrf(config, viewer, token) {
const expected = csrfToken(config, viewer);
-- if (!expected || typeof token !== 'string' || token.length !== expected.length) return false;
-- return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
++ if (!expected || typeof token !== 'string') return false;
++ const a = Buffer.from(token); const b = Buffer.from(expected);
++ // Compare byte lengths, not string lengths: a non-ASCII token of the right length would make timingSafeEqual throw.
++ return a.length === b.length && crypto.timingSafeEqual(a, b);
}
module.exports = { csrfToken, checkCsrf };
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
iindex b9f4124..b150956 100644
---- a/server/auth/viewer.js
++++ b/server/auth/viewer.js
@@@ -53,6 +53,9 @@ function createViewerResolver({ auth, config, people }) {
}
const originHeader = req.get('x-ov-origin');
if (originHeader && originHeader !== 'ai' && originHeader !== 'user') throw new ViewerError(400, 'request.invalid_origin', 'X-OV-Origin must be "ai" or "user"');
++ // An AI delivery writes a revision without an editor (a person reviews it before it is published), so only
++ // OpenVibe.AI itself may claim it; any other service or app saying X-OV-Origin: ai is refused.
++ if (originHeader === 'ai' && claims.sub !== 'svc:ai') throw new ViewerError(403, 'request.origin_not_ai', 'only OpenVibe.AI (svc:ai) delivers AI output');
const subjectHeader = req.get('x-ov-subject');
let subject = null;
if (subjectHeader) {
ddiff --git a/server/domain/reading.js b/server/domain/reading.js
iindex c25b1f2..dd3cb24 100644
---- a/server/domain/reading.js
++++ b/server/domain/reading.js
@@@ -103,7 +103,7 @@ function createReading({ store, config, stories, publication, people, topics })
authors: m.authors.map((a) => ({ name: a.name })),
publisher: { name: 'OpenVibe.News', url: config.baseUrl },
section: m.topic ? m.topic.name : undefined, inLanguage: 'en',
-- citations: m.sources.filter((s) => s.status !== 'removed' && s.url).map((s) => ({ url: s.url, title: s.headline })),
++ citations: m.sources.filter((s) => s.status !== 'removed' && /^https?:\/\//i.test(String(s.url || ''))).map((s) => ({ url: s.url, title: s.headline })),
});
}
ddiff --git a/server/domain/source-items.js b/server/domain/source-items.js
iindex 24f41bd..351f4bb 100644
---- a/server/domain/source-items.js
++++ b/server/domain/source-items.js
@@@ -31,6 +31,8 @@ const { ids } = require('openvibe-contracts');
const { createChangeCursor, pullChanges, normalize } = require('openvibe-publishing/ingest');
const NEAR_TITLE = { anyOutlet: 0.9, sameOutlet: 0.75, windowMs: 48 * 3600 * 1000 };
++// Only an http(s) URL is kept as an item's link: a publisher's javascript: or data: URL would become a link on a page.
++const httpUrl = (u) => { try { const x = new URL(String(u || '')); return x.protocol === 'https:' || x.protocol === 'http:' ? x.href : null; } catch { return null; } };
const newItemId = (now) => `nsi_${ids.ulid(now)}`;
const CURSOR = 'sources';
@@@ -84,8 +86,8 @@ function createIngest({ store, config, clusters, outbox, sources, discussion = n
const published = item.published_at && Number.isFinite(Date.parse(item.published_at)) ? new Date(Date.parse(item.published_at)).toISOString() : null;
return {
headline,
-- canonical_url: item.canonical_url || null,
-- url_key: normalize.urlKey(item.canonical_url),
++ canonical_url: httpUrl(item.canonical_url),
++ url_key: httpUrl(item.canonical_url) ? normalize.urlKey(item.canonical_url) : null,
outlet: await outletFor(item),
authors: JSON.stringify(authors),
published_at: published,
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 38f9430..6fa136f 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -135,7 +135,7 @@ function createApi(ctx) {
const topic = req.query.topic ? await topics.bySlug(req.query.topic) : null;
if (req.query.topic && !topic) throw new ApiError(404, 'topic.not_found', 'No such topic');
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
-- const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
++ const offset = Math.min(Math.max(parseInt(req.query.offset, 10) || 0, 0), 100000);
const { total, stories: rows } = await stories.listPublished({ topicId: topic ? topic.id : null, limit, offset });
const items = await reading.listItems(rows);
return { total, stories: items.map((i) => ({ id: i.story.id, url: publication.abs(i.url), headline: i.headline, state: i.story.state, topic: i.topic ? i.topic.name : null, sources: i.sourceCount, first_published_at: iso(i.published_at), updated_at: i.updated_at })) };
ddiff --git a/server/http/public.js b/server/http/public.js
iindex d3e9c73..dcadcef 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -64,7 +64,8 @@ function createPublicRoutes(ctx) {
}
const notFound = (req, res) => messagePage(req, res, 404, 'Not found', 'There is nothing at this address.', { href: '/', label: 'OpenVibe.News' });
-- const pageNumber = (req) => { const n = parseInt(req.query.page, 10); return Number.isInteger(n) && n > 0 ? n : 1; };
++ // At most page 1000: a far page is a far OFFSET scan on a public route (past the last page answers 404 anyway).
++ const pageNumber = (req) => { const n = parseInt(req.query.page, 10); return Number.isInteger(n) && n > 0 ? Math.min(n, 1000) : 1; };
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const FEEDS = [{ type: 'rss', label: 'RSS', file: 'feed.xml' }, { type: 'atom', label: 'Atom', file: 'atom.xml' }, { type: 'json', label: 'JSON Feed', file: 'feed.json' }];
const feedsFor = (base) => FEEDS.map((f) => ({ type: f.type, label: f.label, href: `${base === '/' ? '' : base}/${f.file}`, title: f.label }));
ddiff --git a/server/render/editor.js b/server/render/editor.js
iindex f22b39e..508154a 100644
---- a/server/render/editor.js
++++ b/server/render/editor.js
@@@ -6,7 +6,7 @@
* no-store and noindex.
*/
const ssr = require('openvibe-publishing/ssr');
--const { time } = require('./pages');
++const { time, httpHref } = require('./pages');
const { html: h, raw } = ssr;
const csrfField = (csrf) => h`<input type="hidden" name="_csrf" value="${csrf}">`;
@@@ -59,7 +59,7 @@ ${flash(message)}
<p class="meta">${cluster.id} · ${cluster.status}${cluster.merged_into ? h` into <a href="/clusters/${cluster.merged_into}">${cluster.merged_into}</a>` : ''}${cluster.split_from ? h` · split from <a href="/clusters/${cluster.split_from}">${cluster.split_from}</a>` : ''} · items grouped by shared named entities and terms within a time window; every membership says why.</p>
<form method="post" action="/clusters/${cluster.id}/split">${csrfField(csrf)}
<table><thead><tr><th>Split</th><th>Headline</th><th>Outlet</th><th>Published</th><th>Status</th><th>Why here</th></tr></thead><tbody>
--${items.map((it) => h`<tr><td>${open ? h`<input type="checkbox" name="items" value="${it.id}" aria-label="split ${it.headline}">` : ''}</td><td>${it.canonical_url ? h`<a href="${it.canonical_url}" rel="noopener nofollow">${it.headline}</a>` : it.headline}<br><span class="meta">${it.id} · Sources ${it.sources_item_id} r${it.sources_revision}</span></td><td>${it.outlet}</td><td>${it.published_at ? it.published_at.slice(0, 10) : 'not stated'}</td><td>${it.status}${it.dedupe ? h`<br><span class="meta">${parse(it.dedupe, {}).rule}: ${parse(it.dedupe, {}).detail}</span>` : ''}${it.removed_reason ? h`<br><span class="meta">${it.removed_reason}</span>` : ''}</td><td class="meta">${why(it.cluster_reason)}</td></tr>`)}
++${items.map((it) => h`<tr><td>${open ? h`<input type="checkbox" name="items" value="${it.id}" aria-label="split ${it.headline}">` : ''}</td><td>${httpHref(it.canonical_url) ? h`<a href="${httpHref(it.canonical_url)}" rel="noopener nofollow">${it.headline}</a>` : it.headline}<br><span class="meta">${it.id} · Sources ${it.sources_item_id} r${it.sources_revision}</span></td><td>${it.outlet}</td><td>${it.published_at ? it.published_at.slice(0, 10) : 'not stated'}</td><td>${it.status}${it.dedupe ? h`<br><span class="meta">${parse(it.dedupe, {}).rule}: ${parse(it.dedupe, {}).detail}</span>` : ''}${it.removed_reason ? h`<br><span class="meta">${it.removed_reason}</span>` : ''}</td><td class="meta">${why(it.cluster_reason)}</td></tr>`)}
</tbody></table>
${open ? h`<label for="split-reason">Reason for the split</label><input id="split-reason" name="reason" maxlength="500"><button type="submit">Split the ticked items into a new cluster</button>` : ''}
</form>
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex c52bb17..85e591e 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -13,6 +13,8 @@ const { html: h, raw } = ssr;
const dateLabel = (v) => (v ? new Date(v).toISOString().slice(0, 10) : null);
const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString(), { label: dateLabel(v) || undefined }));
++// A source's link comes from a publisher through OpenVibe.Sources: only http(s) becomes a link (never javascript: or data:).
++const httpHref = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' || x.protocol === 'http:' ? x.href : null; } catch { return null; } };
const cites = (ns) => h`<sup class="cites">${ns.map((n, i) => h`${i ? ',' : ''}<a href="#source-${n}" aria-label="source ${n}">${n}</a>`)}</sup>`;
function storyListItem(it) {
@@@ -66,7 +68,7 @@ function sourceRow(s) {
}
return h`<tr id="source-${s.n}"${s.upstream_changed ? raw(' class="changed"') : ''}>
<td>${s.n}</td>
--<td>${s.url ? h`<a href="${s.url}" rel="noopener nofollow">${s.headline}</a>` : s.headline}${s.summary ? h`<p class="source-summary">${s.summary}</p>` : ''}${s.upstream_changed ? h`<p class="notice small">The source changed after this revision; editors are checking it.</p>` : ''}${s.status === 'removed' ? h`<p class="notice small">Removed upstream${s.removed_reason ? h`: ${s.removed_reason}` : ''}.</p>` : ''}</td>
++<td>${httpHref(s.url) ? h`<a href="${httpHref(s.url)}" rel="noopener nofollow">${s.headline}</a>` : s.headline}${s.summary ? h`<p class="source-summary">${s.summary}</p>` : ''}${s.upstream_changed ? h`<p class="notice small">The source changed after this revision; editors are checking it.</p>` : ''}${s.status === 'removed' ? h`<p class="notice small">Removed upstream${s.removed_reason ? h`: ${s.removed_reason}` : ''}.</p>` : ''}</td>
<td>${s.outlet}</td>
<td>${s.authors && s.authors.length ? s.authors.join(', ') : raw('<span class="meta">not stated</span>')}</td>
<td>${s.published_at ? time(Date.parse(s.published_at)) : raw('<span class="meta">not stated</span>')}</td>
@@@ -157,4 +159,4 @@ function message({ heading, text, action }) {
return String(h`<section class="message"><h1>${heading}</h1><p>${text}</p>${action ? h`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}</section>`);
}
--module.exports = { home, topicsIndex, topicPage, storyPage, commentsSection, message, storyList, time, dateLabel, cites };
++module.exports = { home, topicsIndex, topicPage, storyPage, commentsSection, message, storyList, time, dateLabel, cites, httpHref };
ddiff --git a/test/ai.test.js b/test/ai.test.js
iindex 1bb5dce..ca3afca 100644
---- a/test/ai.test.js
++++ b/test/ai.test.js
@@@ -34,6 +34,16 @@ const { boot, check, done, launchReports } = require('./helpers/boot');
assert.strictEqual(r.status, 403, 'AI cannot publish');
});
++ await check('only OpenVibe.AI may deliver as AI: another service or an app saying X-OV-Origin: ai is refused', async () => {
++ const body = { headline: 'x', paragraphs, expected_revision: 0, authorship: { workflow: wf } };
++ let r = await t.get(`/api/v1/stories/${story.id}/revisions`, { as: t.network.serviceToken('live', ['news.story.revise']), headers: aiHeaders, json: body });
++ assert.strictEqual(r.status, 403, r.text);
++ assert.strictEqual(r.json().code, 'request.origin_not_ai');
++ const app = t.network.signService({ sub: 'app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', aud: ['openvibe.news'], cap: ['news.story.revise'], actorType: 'app' });
++ r = await t.get(`/api/v1/stories/${story.id}/revisions`, { as: app, headers: aiHeaders, json: body });
++ assert.strictEqual(r.status, 403, r.text);
++ });
++
let rev;
await check('an AI draft is an AI-generated revision that cannot be published before a person reviews it', async () => {
const r = await t.get(`/api/v1/stories/${story.id}/revisions`, { as: aiToken, headers: aiHeaders, json: { headline: 'Europa Clipper is on its way to Jupiter', paragraphs, expected_revision: 0, authorship: { workflow: wf } } });
ddiff --git a/test/links.test.js b/test/links.test.js
nnew file mode 100644
iindex 0000000..3f24309
---- /dev/null
++++ b/test/links.test.js
@@@ -0,0 +1,14 @@
++'use strict';
++/**
++ * A source's link reaches a public page only when it is http(s): a publisher's javascript: or data: URL (it arrives
++ * through OpenVibe.Sources) is never a clickable link. Pre-launch review, 2026-10-09.
++ */
++const assert = require('assert');
++const { httpHref } = require('../server/render/pages');
++
++assert.strictEqual(httpHref('https://example.com/a?b=1'), 'https://example.com/a?b=1');
++assert.strictEqual(httpHref('http://example.com/'), 'http://example.com/');
++for (const bad of ['javascript:alert(document.domain)', 'JavaScript:alert(1)', ' javascript:alert(1)', 'data:text/html,<script>alert(1)</script>', 'vbscript:x', '/relative', '', null, undefined]) {
++ assert.strictEqual(httpHref(bad), null, `${JSON.stringify(bad)} is not a link`);
++}
++console.log('links: only http(s) source URLs become links');