2026-10-13 16:58:15 +02:00
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 );
});
}
2026-10-13 23:19:22 +02:00
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
2026-10-13 16:58:15 +02:00
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 ;}
});
}