openvibers/openvibe.sources Commits

A corrupt stored robots.txt row no longer throws: it reads as no rules and warns once per origin (plan T0). Test: test/robots-ssrf.test.js stores unparseable rules and checks the fetch goes on. Built on a free model, reviewed independently (SHIP).

OP OpenVibers committed 29 Sept 2026 · d3f5e00600 · 2 files, +36 −1


Changes

ddiff --git a/server/robots.js b/server/robots.js
iindex 0d8155a..ba4bde4 100644
---- a/server/robots.js
++++ b/server/robots.js
@@@ -94,6 +94,7 @@ function createRobots({ db, fetcher, agent, ttlMs, maxBytes, now = () => Date.no
ON CONFLICT(origin) DO UPDATE SET fetched_at = excluded.fetched_at, expires_at = excluded.expires_at, status = excluded.status,
outcome = excluded.outcome, rules = excluded.rules, crawl_delay_sec = excluded.crawl_delay_sec, detail = excluded.detail`);
const inflight = new Map();
++ const warned = new Set();
async function load(origin) {
await beforeRequest(new URL(origin).host);
@@@ -136,7 +137,18 @@ function createRobots({ db, fetcher, agent, ttlMs, maxBytes, now = () => Date.no
return { allowed: false, reason: `robots.txt unreachable (${row.detail})`, crawlDelaySec: null, source, refusedCode: ['address_refused', 'port_refused', 'bad_url'].includes(code) ? code : null };
}
if (row.outcome === 'unavailable') return { allowed: true, reason: null, crawlDelaySec: null, source };
-- const parsed = { rules: JSON.parse(row.rules) };
++ let rules;
++ try {
++ rules = JSON.parse(row.rules);
++ } catch (err) {
++ // A corrupt stored row must not throw: treat it as no rules and warn once per origin.
++ if (!warned.has(row.origin)) {
++ warned.add(row.origin);
++ console.warn(`[robots] unreadable stored rules for ${row.origin}: ${err.message}`);
++ }
++ rules = [];
++ }
++ const parsed = { rules: Array.isArray(rules) ? rules : [] };
const allowed = isAllowed(parsed, url.pathname + url.search);
return { allowed, reason: allowed ? null : `disallowed by ${url.origin}/robots.txt`, crawlDelaySec: row.crawl_delay_sec, source };
}
ddiff --git a/test/robots-ssrf.test.js b/test/robots-ssrf.test.js
iindex 7b3a3f0..6a7f281 100644
---- a/test/robots-ssrf.test.js
++++ b/test/robots-ssrf.test.js
@@@ -127,6 +127,29 @@ t('redirects to an internal address are refused', async () => {
} finally { await s.close(); }
});
++t('a corrupt stored rules value is treated as no rules, not thrown on', async () => {
++ const s = await site({ '/feed.xml': () => ({ body: FEED }) });
++ try {
++ await svc.registry.create(sourceDef({ key: 'corrupt-robots', endpoints: [`${s.origin}/feed.xml`] }), 'test');
++ await svc.ingest.run('corrupt-robots', { trigger: 'manual' });
++ // Corrupt the cached row the way a bad migration or a truncated write would.
++ await svc.db.prepare(`UPDATE robots_cache SET outcome = 'parsed', rules = '{not json', expires_at = @e WHERE origin = @o`)
++ .run({ o: s.origin, e: Date.now() + 3600_000 });
++ assert.ok(await svc.db.prepare('SELECT 1 FROM robots_cache WHERE origin = ?').get(s.origin), 'row cached');
++ const warnings = [];
++ const realWarn = console.warn;
++ console.warn = (...a) => warnings.push(a.join(' '));
++ let states;
++ try {
++ const out = await svc.ingest.run('corrupt-robots', { trigger: 'manual' });
++ states = out.runs.map(r => r.state);
++ } finally { console.warn = realWarn; }
++ assert.deepStrictEqual(states, ['ok'], 'a corrupt row does not fail the run');
++ assert.ok(s.hits('/feed.xml').length > 0, 'the feed was still fetched');
++ assert.strictEqual(warnings.filter(w => w.includes(s.origin)).length, 1, `one warning naming the origin: ${warnings.join(' | ')}`);
++ } finally { await s.close(); }
++});
++
t('done', async () => { await web.close(); await svc.stop(); });
t.run();