Skip to content
Merged
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
49 changes: 49 additions & 0 deletions lib/csrf.js
Original file line number Diff line number Diff line change
@@ -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 };
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
42 changes: 42 additions & 0 deletions test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});