diff --git a/lib/csrf.js b/lib/csrf.js new file mode 100644 index 0000000..7d02db2 --- /dev/null +++ b/lib/csrf.js @@ -0,0 +1,49 @@ +// Cross-site request forgery guard for the state-changing API. +// +// AgentXRay has no cookies and no auth, so a CSRF *token* would prove nothing — +// the actual attack is a page on another origin driving the visitor's browser +// to POST at http://127.0.0.1:3800 (rewrite prompts, install library items, +// trigger a backup). Browsers always attach `Origin` (and `Sec-Fetch-Site`) to +// such requests, so the boundary is: unsafe methods are accepted only when the +// request is same-origin or comes from a non-browser client (no Origin header). +// +// Same-origin is decided against the Host the request arrived on, not a fixed +// allow-list, so `HOST=0.0.0.0` LAN deployments keep working from any hostname +// the operator actually serves. + +const SAFE = new Set(['GET', 'HEAD', 'OPTIONS']); + +function originHost(value) { + try { + return new URL(value).host.toLowerCase(); + } catch { + return null; + } +} + +// Exported for unit tests: returns null when allowed, else a short reason. +function crossSiteReason(req) { + if (SAFE.has(req.method)) return null; + const fetchSite = req.get('sec-fetch-site'); + if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return `sec-fetch-site=${fetchSite}`; + const origin = req.get('origin'); + if (origin) { + const host = originHost(origin); + if (host !== (req.get('host') || '').toLowerCase()) return `origin ${origin} != host ${req.get('host')}`; + return null; + } + const referer = req.get('referer'); + if (referer) { + const host = originHost(referer); + if (host && host !== (req.get('host') || '').toLowerCase()) return `referer ${referer} != host ${req.get('host')}`; + } + return null; +} + +function csrfGuard(req, res, next) { + const reason = crossSiteReason(req); + if (reason) return res.status(403).json({ error: 'cross-site request rejected', reason }); + next(); +} + +module.exports = { csrfGuard, crossSiteReason }; diff --git a/package.json b/package.json index 3e8af6d..57a1d0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@alloevil/agent-xray", - "version": "1.17.0", + "version": "1.17.1", "description": "Web dashboard for viewing AI agent session logs — supports OpenClaw, Codex, Claude Code, Hermes, OMP, DeepSeek Harness, and Gemini CLI", "main": "server.js", "bin": { diff --git a/server.js b/server.js index 0e46ec7..a5a3d0f 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,7 @@ const mountLibraryRoutes = require('./lib/routes/library'); const mountBackupRoutes = require('./lib/routes/backup'); const mountWatchRoutes = require('./lib/routes/watch'); const mountLlmRoutes = require('./lib/routes/llm'); +const { csrfGuard } = require('./lib/csrf'); const app = express(); const PORT = process.env.PORT || 3800; @@ -35,6 +36,8 @@ if (HAS_DIST) { } else { app.use(express.static(PUBLIC_DIR, { maxAge: 0, etag: false, lastModified: false })); } +// Reject cross-site POST/PUT/DELETE before the body is parsed (lib/csrf.js). +app.use('/api', csrfGuard); app.use(express.json({ limit: '256kb' })); // Disable all caching diff --git a/test/api.test.js b/test/api.test.js index e7374f1..31f456d 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -449,3 +449,45 @@ describe('backup', () => { assert.ok(typeof status.lastBackup === 'string'); }); }); + +describe('csrf guard', () => { + let srv; + before(async () => { + srv = await startServer(); + }); + after(async () => { + await srv.stop(); + }); + + const send = (method, headers, pathname = '/api/prompts/hidden') => + fetch(srv.base + pathname, { + method, + headers: { 'Content-Type': 'application/json', ...headers }, + body: method === 'GET' ? undefined : JSON.stringify({ hash: 'deadbeef' }), + }); + + it('rejects a state-changing request whose Origin is another site', async () => { + const res = await send('POST', { Origin: 'https://evil.example' }); + assert.equal(res.status, 403); + const body = await res.json(); + assert.match(body.reason, /origin https:\/\/evil\.example/); + }); + + it('rejects when Sec-Fetch-Site says cross-site even without Origin', async () => { + const res = await send('DELETE', { 'Sec-Fetch-Site': 'cross-site' }, '/api/prompts/hidden/deadbeef'); + assert.equal(res.status, 403); + }); + + it('accepts same-origin browser requests and origin-less clients', async () => { + const host = new URL(srv.base).host; + const same = await send('POST', { Origin: `http://${host}`, 'Sec-Fetch-Site': 'same-origin' }); + assert.notEqual(same.status, 403); + const curl = await send('POST', {}); + assert.notEqual(curl.status, 403); + }); + + it('never blocks reads, whatever the origin', async () => { + const res = await send('GET', { Origin: 'https://evil.example', 'Sec-Fetch-Site': 'cross-site' }, '/api/version'); + assert.equal(res.status, 200); + }); +});