openvibers/openvibe.news Commits
News: fixes from the pre-launch security review (#21)
Changes
@@@ -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) {@@@ -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 };@@@ -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) {@@@ -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 })),});}@@@ -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 = nconst 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,@@@ -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 })) };@@@ -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 }));@@@ -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>@@@ -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 };@@@ -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 } } });nnew file mode 100644@@@ -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');
@@ -13,6 +13,9 @@
@@ -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_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_auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=ovnews_write:10m rate=5r/s;
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 {
server {
listen 80;
listen 80;
@@ -94,7 +97,8 @@ server {
@@ -94,7 +97,8 @@ server {
}
}
# ── The Events webhook: OpenVibe.Events delivers over loopback, never through the public vhost ──
# ── 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 ─────────────────────────────────────────────────
# ── API ─────────────────────────────────────────────────
location /api/ {
location /api/ {
@@ -103,14 +107,19 @@ server {
@@ -103,14 +107,19 @@ server {
}
}
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4820/metrics) ──
# ── 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 ──
# ── 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 / {
location / {
+ limit_req zone=ovnews_pages burst=40 nodelay;
proxy_pass http://127.0.0.1:4820;
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) {
location ~ /\.(?!well-known) {
@@ -17,8 +17,10 @@ function csrfToken(config, viewer) {
@@ -17,8 +17,10 @@ function csrfToken(config, viewer) {
function checkCsrf(config, viewer, token) {
function checkCsrf(config, viewer, token) {
const expected = csrfToken(config, viewer);
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 };
module.exports = { csrfToken, checkCsrf };
@@ -53,6 +53,9 @@ function createViewerResolver({ auth, config, people }) {
@@ -53,6 +53,9 @@ function createViewerResolver({ auth, config, people }) {
}
}
const originHeader = req.get('x-ov-origin');
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"');
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');
const subjectHeader = req.get('x-ov-subject');
let subject = null;
let subject = null;
if (subjectHeader) {
if (subjectHeader) {
@@ -103,7 +103,7 @@ function createReading({ store, config, stories, publication, people, topics })
@@ -103,7 +103,7 @@ function createReading({ store, config, stories, publication, people, topics })
authors: m.authors.map((a) => ({ name: a.name })),
authors: m.authors.map((a) => ({ name: a.name })),
publisher: { name: 'OpenVibe.News', url: config.baseUrl },
publisher: { name: 'OpenVibe.News', url: config.baseUrl },
section: m.topic ? m.topic.name : undefined, inLanguage: 'en',
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 })),
});
});
}
}
@@ -31,6 +31,8 @@ const { ids } = require('openvibe-contracts');
@@ -31,6 +31,8 @@ const { ids } = require('openvibe-contracts');
const { createChangeCursor, pullChanges, normalize } = require('openvibe-publishing/ingest');
const { createChangeCursor, pullChanges, normalize } = require('openvibe-publishing/ingest');
const NEAR_TITLE = { anyOutlet: 0.9, sameOutlet: 0.75, windowMs: 48 * 3600 * 1000 };
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 newItemId = (now) => `nsi_${ids.ulid(now)}`;
const CURSOR = 'sources';
const CURSOR = 'sources';
@@ -84,8 +86,8 @@ function createIngest({ store, config, clusters, outbox, sources, discussion = n
@@ -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;
const published = item.published_at && Number.isFinite(Date.parse(item.published_at)) ? new Date(Date.parse(item.published_at)).toISOString() : null;
return {
return {
headline,
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),
outlet: await outletFor(item),
authors: JSON.stringify(authors),
authors: JSON.stringify(authors),
published_at: published,
published_at: published,
@@ -135,7 +135,7 @@ function createApi(ctx) {
@@ -135,7 +135,7 @@ function createApi(ctx) {
const topic = req.query.topic ? await topics.bySlug(req.query.topic) : null;
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');
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 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 { total, stories: rows } = await stories.listPublished({ topicId: topic ? topic.id : null, limit, offset });
const items = await reading.listItems(rows);
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 })) };
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 })) };
@@ -64,7 +64,8 @@ function createPublicRoutes(ctx) {
@@ -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 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 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 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 }));
const feedsFor = (base) => FEEDS.map((f) => ({ type: f.type, label: f.label, href: `${base === '/' ? '' : base}/${f.file}`, title: f.label }));
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* no-store and noindex.
* no-store and noindex.
*/
*/
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
-const { time } = require('./pages');
+const { time, httpHref } = require('./pages');
const { html: h, raw } = ssr;
const { html: h, raw } = ssr;
const csrfField = (csrf) => h`<input type="hidden" name="_csrf" value="${csrf}">`;
const csrfField = (csrf) => h`<input type="hidden" name="_csrf" value="${csrf}">`;
@@ -59,7 +59,7 @@ ${flash(message)}
@@ -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>
<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)}
<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>
<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>
</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>` : ''}
${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>
</form>
@@ -13,6 +13,8 @@ const { html: h, raw } = ssr;
@@ -13,6 +13,8 @@ const { html: h, raw } = ssr;
const dateLabel = (v) => (v ? new Date(v).toISOString().slice(0, 10) : null);
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 }));
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>`;
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) {
function storyListItem(it) {
@@ -66,7 +68,7 @@ function sourceRow(s) {
@@ -66,7 +68,7 @@ function sourceRow(s) {
}
}
return h`<tr id="source-${s.n}"${s.upstream_changed ? raw(' class="changed"') : ''}>
return h`<tr id="source-${s.n}"${s.upstream_changed ? raw(' class="changed"') : ''}>
<td>${s.n}</td>
<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.outlet}</td>
<td>${s.authors && s.authors.length ? s.authors.join(', ') : raw('<span class="meta">not stated</span>')}</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>
<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 }) {
@@ -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>`);
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 };
@@ -34,6 +34,16 @@ const { boot, check, done, launchReports } = require('./helpers/boot');
@@ -34,6 +34,16 @@ const { boot, check, done, launchReports } = require('./helpers/boot');
assert.strictEqual(r.status, 403, 'AI cannot publish');
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;
let rev;
await check('an AI draft is an AI-generated revision that cannot be published before a person reviews it', async () => {
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 } } });
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 } } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -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');