diff --git a/Makefile b/Makefile index 9de826b9..4c568bf3 100644 --- a/Makefile +++ b/Makefile @@ -8,9 +8,8 @@ GEN_SERVER_DIR := src/generated/server DOCS_API_DIR := docs/api # Go install settings -GO_PROXY := GOPROXY=direct GO_PRIVATE := GOPRIVATE=github.com/SebastienMelki -GO_INSTALL := $(GO_PROXY) $(GO_PRIVATE) go install +GO_INSTALL := $(GO_PRIVATE) go install # Required tool versions BUF_VERSION := v1.64.0 diff --git a/api/[domain]/v1/[rpc].ts b/api/[domain]/v1/[rpc].ts index 7ed18373..0a0a065b 100644 --- a/api/[domain]/v1/[rpc].ts +++ b/api/[domain]/v1/[rpc].ts @@ -86,7 +86,7 @@ export default async function handler(request: Request): Promise { try { corsHeaders = getCorsHeaders(request); } catch { - corsHeaders = { 'Access-Control-Allow-Origin': '*' }; + corsHeaders = { 'Access-Control-Allow-Origin': 'https://worldmonitor.app' }; } // OPTIONS preflight diff --git a/api/download.js b/api/download.js index 36db3df8..cea9c160 100644 --- a/api/download.js +++ b/api/download.js @@ -64,10 +64,16 @@ export default async function handler(req) { return Response.redirect(RELEASES_PAGE, 302); } + // Validate redirect URL is a GitHub download + const downloadUrl = String(asset.browser_download_url || ''); + if (!downloadUrl.startsWith('https://github.com/')) { + return Response.redirect(RELEASES_PAGE, 302); + } + return new Response(null, { status: 302, headers: { - 'Location': asset.browser_download_url, + 'Location': downloadUrl, 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60', }, }); diff --git a/api/version.js b/api/version.js index bfa4975f..4543e19c 100644 --- a/api/version.js +++ b/api/version.js @@ -33,7 +33,7 @@ export default async function handler() { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60', - 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Origin': 'https://worldmonitor.app', }, }); } catch { diff --git a/api/youtube/embed.js b/api/youtube/embed.js index fcfbd37d..68f90f1f 100644 --- a/api/youtube/embed.js +++ b/api/youtube/embed.js @@ -13,7 +13,6 @@ function sanitizeVideoId(value) { const ALLOWED_ORIGINS = [ /^https:\/\/(.*\.)?worldmonitor\.app$/, /^https:\/\/worldmonitor-[a-z0-9-]+-elie-habib-projects\.vercel\.app$/, - /^https:\/\/worldmonitor-[a-z0-9-]+\.vercel\.app$/, /^https?:\/\/localhost(:\d+)?$/, /^https?:\/\/127\.0\.0\.1(:\d+)?$/, /^tauri:\/\/localhost$/, @@ -88,12 +87,12 @@ export default async function handler(request) { playerVars:{autoplay:${autoplay},mute:${mute},playsinline:1,rel:0,controls:1,modestbranding:1,enablejsapi:1,origin:${JSON.stringify(origin)},widget_referrer:${JSON.stringify(origin)}}, events:{ onReady:function(){ - window.parent.postMessage({type:'yt-ready'},'*'); + window.parent.postMessage({type:'yt-ready'},${JSON.stringify(origin)}); if(${autoplay}===1){player.playVideo()} }, - onError:function(e){window.parent.postMessage({type:'yt-error',code:e.data},'*')}, + onError:function(e){window.parent.postMessage({type:'yt-error',code:e.data},${JSON.stringify(origin)})}, onStateChange:function(e){ - window.parent.postMessage({type:'yt-state',state:e.data},'*'); + window.parent.postMessage({type:'yt-state',state:e.data},${JSON.stringify(origin)}); if(e.data===1||e.data===3){hideOverlay();started=true} } } @@ -103,7 +102,9 @@ export default async function handler(request) { if(player&&player.playVideo){player.playVideo();player.unMute();hideOverlay()} }); setTimeout(function(){if(!started)overlay.classList.remove('hidden')},3000); + var allowedOrigin=${JSON.stringify(origin)}; window.addEventListener('message',function(e){ + if(e.origin!==allowedOrigin)return; if(!player||!player.getPlayerState)return; var m=e.data;if(!m||!m.type)return; switch(m.type){ diff --git a/api/youtube/live.js b/api/youtube/live.js index cc6afed4..7ce270f1 100644 --- a/api/youtube/live.js +++ b/api/youtube/live.js @@ -23,6 +23,14 @@ export default async function handler(request) { }); } + // Validate channel parameter to prevent path traversal + if (!/^@?[A-Za-z0-9_.\-]{1,100}$/.test(channel)) { + return new Response(JSON.stringify({ error: 'Invalid channel parameter' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + try { // Try to fetch the channel's live page const channelHandle = channel.startsWith('@') ? channel : `@${channel}`; diff --git a/middleware.ts b/middleware.ts index 7e4c5197..e4455270 100644 --- a/middleware.ts +++ b/middleware.ts @@ -12,6 +12,14 @@ const SOCIAL_PREVIEW_UA = const SOCIAL_PREVIEW_PATHS = new Set(['/api/story', '/api/og-story']); +const SECURITY_HEADERS: Record = { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', +}; + export default function middleware(request: Request) { const ua = request.headers.get('user-agent') ?? ''; const url = new URL(request.url); @@ -26,7 +34,7 @@ export default function middleware(request: Request) { if (BOT_UA.test(ua)) { return new Response('{"error":"Forbidden"}', { status: 403, - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS }, }); } @@ -34,7 +42,7 @@ export default function middleware(request: Request) { if (!ua || ua.length < 10) { return new Response('{"error":"Forbidden"}', { status: 403, - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS }, }); } } diff --git a/server/worldmonitor/research/v1/list-arxiv-papers.ts b/server/worldmonitor/research/v1/list-arxiv-papers.ts index bf69940b..11658a31 100644 --- a/server/worldmonitor/research/v1/list-arxiv-papers.ts +++ b/server/worldmonitor/research/v1/list-arxiv-papers.ts @@ -30,12 +30,12 @@ async function fetchArxivPapers(req: ListArxivPapersRequest): Promise) -> Result<(), String> { } fn generate_local_token() -> String { - use std::collections::hash_map::RandomState; - use std::hash::{BuildHasher, Hasher}; - let state = RandomState::new(); - let mut h1 = state.build_hasher(); - h1.write_u64(std::process::id() as u64); - let a = h1.finish(); - let mut h2 = state.build_hasher(); - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - h2.write_u128(nanos); - let b = h2.finish(); - format!("{a:016x}{b:016x}") + // Use OS CSPRNG for cryptographically secure token generation + let mut buf = [0u8; 32]; + let f = File::open("/dev/urandom").or_else(|_| File::open("/dev/random")); + if let Ok(mut rng) = f { + use std::io::Read; + let _ = rng.read_exact(&mut buf); + } else { + // Fallback for platforms without /dev/urandom (e.g., some Windows configs) + use std::collections::hash_map::RandomState; + use std::hash::{BuildHasher, Hasher}; + for chunk in buf.chunks_mut(8) { + let state = RandomState::new(); + let mut h = state.build_hasher(); + h.write_u128(SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0)); + let bytes = h.finish().to_le_bytes(); + chunk.copy_from_slice(&bytes[..chunk.len()]); + } + } + buf.iter().map(|b| format!("{b:02x}")).collect() } #[tauri::command] diff --git a/src/components/LiveNewsPanel.ts b/src/components/LiveNewsPanel.ts index db080ab6..31b35573 100644 --- a/src/components/LiveNewsPanel.ts +++ b/src/components/LiveNewsPanel.ts @@ -372,9 +372,13 @@ export class LiveNewsPanel extends Panel {
📺
${t('components.liveNews.notLive', { name: channel.name })}
- +
`; + const retryBtn = this.content.querySelector('.offline-retry'); + retryBtn?.addEventListener('click', () => { + (this.content.closest('.panel')?.querySelector('.live-channel-btn.active') as HTMLElement)?.click(); + }); } private showEmbedError(channel: LiveChannel, errorCode: number): void { diff --git a/src/settings-main.ts b/src/settings-main.ts index 3cb45c6e..a1e89e43 100644 --- a/src/settings-main.ts +++ b/src/settings-main.ts @@ -219,7 +219,7 @@ function initDiagnostics(): void { const rows = entries.slice().reverse().map((e) => { const ts = e.timestamp.split('T')[1]?.replace('Z', '') || e.timestamp; const cls = e.status < 300 ? 'ok' : e.status < 500 ? 'warn' : 'err'; - return `${escapeHtml(ts)}${e.method}${escapeHtml(e.path)}${e.status}${e.durationMs}ms`; + return `${escapeHtml(ts)}${escapeHtml(e.method)}${escapeHtml(e.path)}${e.status}${e.durationMs}ms`; }).join(''); trafficLogEl.innerHTML = `${rows}
${t('modals.settingsWindow.table.time')}${t('modals.settingsWindow.table.method')}${t('modals.settingsWindow.table.path')}${t('modals.settingsWindow.table.status')}${t('modals.settingsWindow.table.duration')}
`; diff --git a/src/utils/dom-utils.ts b/src/utils/dom-utils.ts index 9e9291ee..ad44e1c0 100644 --- a/src/utils/dom-utils.ts +++ b/src/utils/dom-utils.ts @@ -54,6 +54,7 @@ export function replaceChildren(el: Element, ...children: DomChild[]): void { el.appendChild(frag); } +/** @internal SECURITY: Only use with trusted, hardcoded HTML strings. Never pass user/external data. */ export function rawHtml(html: string): DocumentFragment { const tpl = document.createElement('template'); tpl.innerHTML = html; @@ -63,7 +64,7 @@ export function rawHtml(html: string): DocumentFragment { const SAFE_TAGS = new Set([ 'strong', 'em', 'b', 'i', 'br', 'p', 'ul', 'ol', 'li', 'span', 'div', 'a', ]); -const SAFE_ATTRS = new Set(['style', 'class', 'href', 'target', 'rel']); +const SAFE_ATTRS = new Set(['class', 'href', 'target', 'rel']); /** Like rawHtml() but strips tags and attributes not in the allowlist. */ export function safeHtml(html: string): DocumentFragment { diff --git a/vercel.json b/vercel.json index 684a62b1..aad46aad 100644 --- a/vercel.json +++ b/vercel.json @@ -5,6 +5,16 @@ { "source": "/ingest/:path*", "destination": "https://us.i.posthog.com/:path*" } ], "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "X-Frame-Options", "value": "DENY" }, + { "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" } + ] + }, { "source": "/", "headers": [