Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion api/[domain]/v1/[rpc].ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export default async function handler(request: Request): Promise<Response> {
try {
corsHeaders = getCorsHeaders(request);
} catch {
corsHeaders = { 'Access-Control-Allow-Origin': '*' };
corsHeaders = { 'Access-Control-Allow-Origin': 'https://worldmonitor.app' };
}

// OPTIONS preflight
Expand Down
8 changes: 7 additions & 1 deletion api/download.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
});
Expand Down
2 changes: 1 addition & 1 deletion api/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 5 additions & 4 deletions api/youtube/embed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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$/,
Expand Down Expand Up @@ -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}
}
}
Expand All @@ -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){
Expand Down
8 changes: 8 additions & 0 deletions api/youtube/live.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
12 changes: 10 additions & 2 deletions middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ const SOCIAL_PREVIEW_UA =

const SOCIAL_PREVIEW_PATHS = new Set(['/api/story', '/api/og-story']);

const SECURITY_HEADERS: Record<string, string> = {
'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);
Expand All @@ -26,15 +34,15 @@ 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 },
});
}

// No user-agent or suspiciously short — likely a script
if (!ua || ua.length < 10) {
return new Response('{"error":"Forbidden"}', {
status: 403,
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS },
});
}
}
Expand Down
6 changes: 3 additions & 3 deletions server/worldmonitor/research/v1/list-arxiv-papers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ async function fetchArxivPapers(req: ListArxivPapersRequest): Promise<ArxivPaper

let searchQuery: string;
if (req.query) {
searchQuery = `all:${req.query}+AND+cat:${category}`;
searchQuery = `all:${encodeURIComponent(req.query)}+AND+cat:${encodeURIComponent(category)}`;
} else {
searchQuery = `cat:${category}`;
searchQuery = `cat:${encodeURIComponent(category)}`;
}

const url = `https://export.arxiv.org/api/query?search_query=${searchQuery}&start=0&max_results=${pageSize}`;
const url = `https://export.arxiv.org/api/query?search_query=${searchQuery}&start=0&max_results=${encodeURIComponent(String(pageSize))}`;

const response = await fetch(url, {
headers: { Accept: 'application/xml' },
Expand Down
1 change: 0 additions & 1 deletion src-tauri/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/target
/.cargo/config.local.toml
/Cargo.lock
/gen
48 changes: 47 additions & 1 deletion src-tauri/sidecar/local-api-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -775,7 +775,6 @@ async function dispatch(requestUrl, req, routes, context) {
success: true,
mode: context.mode,
port: context.port,
apiDir: context.apiDir,
remoteBase: context.remoteBase,
cloudFallback: context.cloudFallback,
routes: routes.length,
Expand Down Expand Up @@ -842,6 +841,53 @@ async function dispatch(requestUrl, req, routes, context) {
if (!feedUrl) return json({ error: 'Missing url parameter' }, 400);
try {
const parsed = new URL(feedUrl);
// Security: only allow known RSS feed domains (mirrors api/rss-proxy.js allowlist)
const RSS_ALLOWED_DOMAINS = new Set([
'feeds.bbci.co.uk','www.theguardian.com','feeds.npr.org','news.google.com',
'www.aljazeera.com','rss.cnn.com','hnrss.org','feeds.arstechnica.com',
'www.theverge.com','www.cnbc.com','feeds.marketwatch.com','www.defenseone.com',
'breakingdefense.com','www.bellingcat.com','techcrunch.com','huggingface.co',
'www.technologyreview.com','rss.arxiv.org','export.arxiv.org',
'www.federalreserve.gov','www.sec.gov','www.whitehouse.gov','www.state.gov',
'www.defense.gov','home.treasury.gov','www.justice.gov','tools.cdc.gov',
'www.fema.gov','www.dhs.gov','www.thedrive.com','krebsonsecurity.com',
'finance.yahoo.com','thediplomat.com','venturebeat.com','foreignpolicy.com',
'www.ft.com','openai.com','www.reutersagency.com','feeds.reuters.com',
'rsshub.app','asia.nikkei.com','www.cfr.org','www.csis.org','www.politico.com',
'www.brookings.edu','layoffs.fyi','www.defensenews.com','www.militarytimes.com',
'taskandpurpose.com','news.usni.org','www.oryxspioenkop.com','www.gov.uk',
'www.foreignaffairs.com','www.atlanticcouncil.org','www.zdnet.com',
'www.techmeme.com','www.darkreading.com','www.schneier.com','rss.politico.com',
'www.anandtech.com','www.tomshardware.com','www.semianalysis.com',
'feed.infoq.com','thenewstack.io','devops.com','dev.to','lobste.rs',
'changelog.com','seekingalpha.com','news.crunchbase.com','www.saastr.com',
'feeds.feedburner.com','www.producthunt.com','www.axios.com','github.blog',
'githubnext.com','mshibanami.github.io','www.engadget.com','news.mit.edu',
'dev.events','www.ycombinator.com','a16z.com','review.firstround.com',
'www.sequoiacap.com','www.nfx.com','www.aaronsw.com','bothsidesofthetable.com',
'www.lennysnewsletter.com','stratechery.com','www.eu-startups.com','tech.eu',
'sifted.eu','www.techinasia.com','kr-asia.com','techcabal.com',
'disrupt-africa.com','lavca.org','contxto.com','inc42.com','yourstory.com',
'pitchbook.com','www.cbinsights.com','www.techstars.com',
'english.alarabiya.net','www.arabnews.com','www.timesofisrael.com',
'www.haaretz.com','www.scmp.com','kyivindependent.com','www.themoscowtimes.com',
'feeds.24.com','feeds.capi24.com','www.france24.com','www.euronews.com',
'www.lemonde.fr','rss.dw.com','www.africanews.com','www.lasillavacia.com',
'www.channelnewsasia.com','www.thehindu.com','news.un.org','www.iaea.org',
'www.who.int','www.cisa.gov','www.crisisgroup.org','rusi.org',
'warontherocks.com','www.aei.org','responsiblestatecraft.org','www.fpri.org',
'jamestown.org','www.chathamhouse.org','ecfr.eu','www.gmfus.org',
'www.wilsoncenter.org','www.lowyinstitute.org','www.mei.edu','www.stimson.org',
'www.cnas.org','carnegieendowment.org','www.rand.org','fas.org',
'www.armscontrol.org','www.nti.org','thebulletin.org','www.iss.europa.eu',
'www.fao.org','worldbank.org','www.imf.org','www.hurriyet.com.tr','tvn24.pl',
'www.polsatnews.pl','www.rp.pl','meduza.io','novayagazeta.eu',
'www.bangkokpost.com','vnexpress.net','www.abc.net.au','news.ycombinator.com',
'www.coindesk.com','cointelegraph.com',
]);
if (!RSS_ALLOWED_DOMAINS.has(parsed.hostname)) {
return json({ error: 'Domain not allowed' }, 403);
}
const response = await fetchWithTimeout(feedUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Expand Down
36 changes: 22 additions & 14 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,20 +129,28 @@ fn save_vault(cache: &HashMap<String, String>) -> 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]
Expand Down
6 changes: 5 additions & 1 deletion src/components/LiveNewsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,9 +372,13 @@ export class LiveNewsPanel extends Panel {
<div class="live-offline">
<div class="offline-icon">📺</div>
<div class="offline-text">${t('components.liveNews.notLive', { name: channel.name })}</div>
<button class="offline-retry" onclick="this.closest('.panel').querySelector('.live-channel-btn.active')?.click()">${t('common.retry')}</button>
<button class="offline-retry">${t('common.retry')}</button>
</div>
`;
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 {
Expand Down
2 changes: 1 addition & 1 deletion src/settings-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tr class="diag-${cls}"><td>${escapeHtml(ts)}</td><td>${e.method}</td><td title="${escapeHtml(e.path)}">${escapeHtml(e.path)}</td><td>${e.status}</td><td>${e.durationMs}ms</td></tr>`;
return `<tr class="diag-${cls}"><td>${escapeHtml(ts)}</td><td>${escapeHtml(e.method)}</td><td title="${escapeHtml(e.path)}">${escapeHtml(e.path)}</td><td>${e.status}</td><td>${e.durationMs}ms</td></tr>`;
}).join('');

trafficLogEl.innerHTML = `<table class="diag-table"><thead><tr><th>${t('modals.settingsWindow.table.time')}</th><th>${t('modals.settingsWindow.table.method')}</th><th>${t('modals.settingsWindow.table.path')}</th><th>${t('modals.settingsWindow.table.status')}</th><th>${t('modals.settingsWindow.table.duration')}</th></tr></thead><tbody>${rows}</tbody></table>`;
Expand Down
3 changes: 2 additions & 1 deletion src/utils/dom-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down