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).
Changes
@@@ -94,6 +94,7 @@ function createRobots({ db, fetcher, agent, ttlMs, maxBytes, now = () => Date.noON 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.noreturn { 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 };}@@@ -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();
@@ -94,6 +94,7 @@ function createRobots({ db, fetcher, agent, ttlMs, maxBytes, now = () => Date.no
@@ -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,
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`);
outcome = excluded.outcome, rules = excluded.rules, crawl_delay_sec = excluded.crawl_delay_sec, detail = excluded.detail`);
const inflight = new Map();
const inflight = new Map();
+ const warned = new Set();
async function load(origin) {
async function load(origin) {
await beforeRequest(new URL(origin).host);
await beforeRequest(new URL(origin).host);
@@ -136,7 +137,18 @@ function createRobots({ db, fetcher, agent, ttlMs, maxBytes, now = () => Date.no
@@ -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 };
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 };
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);
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 };
return { allowed, reason: allowed ? null : `disallowed by ${url.origin}/robots.txt`, crawlDelaySec: row.crawl_delay_sec, source };
}
}
@@ -127,6 +127,29 @@ t('redirects to an internal address are refused', async () => {
@@ -127,6 +127,29 @@ t('redirects to an internal address are refused', async () => {
} finally { await s.close(); }
} 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('done', async () => { await web.close(); await svc.stop(); });
t.run();
t.run();