Files
safetynet/apps/server/company-news.mjs
T

73 lines
5.4 KiB
JavaScript

import https from 'node:https';
import { lookup as dnsLookup } from 'node:dns/promises';
import { isIP, BlockList } from 'node:net';
import sax from 'sax';
import { traceOperation } from './telemetry.mjs';
const MAX_BYTES=512*1024, TIMEOUT=5000;
function safeUrl(value){
const url=new URL(value);
if(url.protocol!=='https:'||url.username||url.password||url.hash||url.port&&url.port!=='443'||isIP(url.hostname.replace(/^\[|\]$/g,'')))throw Error('Invalid news URL');
return url;
}
const forbidden=new BlockList();forbidden.addSubnet('127.0.0.0',8,'ipv4');forbidden.addAddress('0.0.0.0','ipv4');forbidden.addAddress('::1','ipv6');forbidden.addAddress('::','ipv6');const mapped=new BlockList();mapped.addSubnet('::ffff:0:0',96,'ipv6');
function loopback(address){const family=isIP(address);return !family||forbidden.check(address,family===6?'ipv6':'ipv4')||family===6&&mapped.check(address,'ipv6');}
export function publicNewsAddress(address){
if(loopback(address))return false;
if(isIP(address)===6)return /^[23][a-f0-9]{3}:/i.test(address)&&!/^2001:(db8|0):/i.test(address);
if(isIP(address)!==4)return false;
const [a,b]=address.split('.').map(Number);
return !(a===0||a===10||a===127||a>=224||a===169&&b===254||a===172&&b>=16&&b<=31||a===192&&[0,168].includes(b)||a===198&&[18,19,51].includes(b)||a===203&&b===0||a===100&&b>=64&&b<=127);
}
function download(url,{address,family,signal}){
return new Promise((resolve,reject)=>{
const req=https.get(url,{signal,autoSelectFamily:false,lookup:(_host,_options,callback)=>callback(null,address,family),timeout:TIMEOUT,headers:{Accept:'application/rss+xml, application/atom+xml, application/xml','User-Agent':'SafetyNet Company News/1'}},res=>{
if(res.statusCode!==200||res.headers['content-encoding']&&res.headers['content-encoding']!=='identity'){res.destroy();reject(Error('News response rejected'));return;}
let size=0;const chunks=[];
res.on('data',chunk=>{size+=chunk.length;if(size>MAX_BYTES){res.destroy(Error('News feed too large'));return;}chunks.push(chunk);});
res.on('end',()=>resolve(Buffer.concat(chunks)));res.on('error',reject);
});req.on('timeout',()=>req.destroy(Error('News request timeout')));req.on('error',reject);
});
}
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
const plain=value=>value.replace(/<[^>]*>/g,' ').replace(/[\u0000-\u001f\u007f]/g,' ').replace(/\s+/g,' ').trim();
export function parseCompanyNews(bytes){
if(bytes.length>MAX_BYTES)throw Error('News feed too large');
const xml=new TextDecoder('utf-8',{fatal:true}).decode(bytes);
if(/<!\s*(DOCTYPE|ENTITY)/i.test(xml))throw Error('DTD is forbidden');
const parser=sax.parser(true,{trim:false,xmlns:false}),items=[];let entry=null,field=null,depth=0,entryDepth=0,root=null;
parser.onopentag=node=>{
depth++;if(depth>32)throw Error('Feed nesting exceeded');const name=node.name.toLowerCase().split(':').at(-1);
if(depth===1){root=name;if(!['rss','feed','rdf'].includes(root))throw Error('Unsupported feed');}
if(['item','entry'].includes(name)&&!entry){entry={title:'',url:'',date:''};entryDepth=depth;}
else if(entry){
if(name==='title')field='title';else if(name==='link'){field='url';const href=node.attributes.href;if(href&&(!node.attributes.rel||node.attributes.rel==='alternate'))entry.url=String(href).slice(0,2048);}
else if(['pubdate','published','updated','date'].includes(name))field='date';
}
};
const append=text=>{if(entry&&field)entry[field]=(entry[field]+text).slice(0,field==='url'?2048:1024);};parser.ontext=append;parser.oncdata=append;
parser.onclosetag=()=>{
if(entry&&depth===entryDepth){let url;try{url=safeUrl(entry.url.trim()).href;}catch{}const title=plain(entry.title).slice(0,200),date=Date.parse(entry.date);if(title&&url&&items.length<30)items.push({title,url,...(Number.isFinite(date)?{publishedAt:new Date(date).toISOString()}: {})});entry=null;}
field=null;depth--;
};
parser.write(xml).close();if(!root)throw Error('Empty feed');return items;
}
// Fetch once centrally. Never expose feed markup or fetch article contents.
export function createCompanyNewsReader({allowedHosts=process.env.COMPANY_NEWS_ALLOWED_HOSTS||'',lookup=dnsLookup,request=download,ttlMs=60000}={}){
const allowed=new Set((Array.isArray(allowedHosts)?allowedHosts:allowedHosts.split(',')).map(x=>x.trim().toLowerCase()).filter(Boolean)),cache=new Map();
return async policy=>traceOperation('startpage.news',{policyVersion:policy.version},async()=>{
if(!policy.startPage?.newsEnabled||!policy.startPage.feedUrl)return [];
const url=safeUrl(policy.startPage.feedUrl),key=url.href,now=Date.now(),existing=cache.get(key);
if(existing&&existing.expires>now)return await existing.promise;
const promise=(async()=>{
const signal=AbortSignal.timeout(TIMEOUT);
const addresses=await Promise.race([lookup(url.hostname,{all:true,verbatim:true}),new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(Error('News DNS timeout')),{once:true}))]);
if(!addresses.length||addresses.some(x=>loopback(x.address)||!isIP(x.address)||!publicNewsAddress(x.address)&&!allowed.has(url.hostname)))throw Error('News destination rejected');
const first=addresses[0];const bytes=await request(url,{address:first.address,family:first.family,signal});return parseCompanyNews(bytes);
})();
cache.set(key,{expires:now+Math.min(Math.max(ttlMs,1000),300000),promise});if(cache.size>64)cache.delete(cache.keys().next().value);
try{return await promise;}catch(error){cache.delete(key);throw error;}
});
}