From 3555cbb26c8c4ac9da3f6cac2d9faf9f7578ba39 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:34:12 +0000 Subject: [PATCH] fix(security): add CALL procedure allowlist for Cypher readOnly, add OPTIONS to CORS - Cypher guard: restore positive allowlist for CALL targets (only db.* and apoc.meta.* allowed in readOnly mode). Extract cypherReadOnlyViolation() helper for clarity. Both copies updated. - CORS: add OPTIONS to allow_methods so preflight requests succeed. Co-Authored-By: Allaun Silverfox --- .../neo4j_obsidian_connector_router.js | 13 +++++++++++-- 5-Applications/cluster-dashboard/backend/main.py | 2 +- .../neo4j_obsidian_connector_router.js | 13 +++++++++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/4-Infrastructure/NoDupeLabs/connectors/topological-engine/neo4j_obsidian_connector_router.js b/4-Infrastructure/NoDupeLabs/connectors/topological-engine/neo4j_obsidian_connector_router.js index c6ff4c0b..e112ab95 100644 --- a/4-Infrastructure/NoDupeLabs/connectors/topological-engine/neo4j_obsidian_connector_router.js +++ b/4-Infrastructure/NoDupeLabs/connectors/topological-engine/neo4j_obsidian_connector_router.js @@ -198,6 +198,14 @@ router.post("/obsidian/search", async (req, res) => { }); const CYPHER_WRITE_RE = /\b(CREATE|MERGE|DELETE|DETACH|SET|REMOVE|DROP|CALL\s*\{)\b/i; +const CYPHER_CALL_RE = /\bCALL\b/i; +const CYPHER_CALL_ALLOWED_RE = /\bCALL\s+(db\.|apoc\.meta\.)/i; + +function cypherReadOnlyViolation(cypher) { + if (CYPHER_WRITE_RE.test(cypher)) return "readOnly mode forbids write clauses (CREATE/MERGE/DELETE/SET/REMOVE/DROP)"; + if (CYPHER_CALL_RE.test(cypher) && !CYPHER_CALL_ALLOWED_RE.test(cypher)) return "readOnly mode only allows CALL db.* and CALL apoc.meta.*"; + return null; +} router.post("/neo4j/cypher", async (req, res) => { const driver = getDriver(); @@ -207,8 +215,9 @@ router.post("/neo4j/cypher", async (req, res) => { const params = req.body?.params || {}; const readOnly = req.body?.readOnly !== false; if (!cypher) return res.status(400).json({ ok: false, error: "cypher is required" }); - if (readOnly && CYPHER_WRITE_RE.test(cypher)) { - return res.status(403).json({ ok: false, error: "readOnly mode forbids write clauses (CREATE/MERGE/DELETE/SET/REMOVE/DROP)" }); + const violation = readOnly ? cypherReadOnlyViolation(cypher) : null; + if (violation) { + return res.status(403).json({ ok: false, error: violation }); } const txFn = async (tx) => tx.run(cypher, params); const result = readOnly diff --git a/5-Applications/cluster-dashboard/backend/main.py b/5-Applications/cluster-dashboard/backend/main.py index 789942c4..fb3ae66c 100644 --- a/5-Applications/cluster-dashboard/backend/main.py +++ b/5-Applications/cluster-dashboard/backend/main.py @@ -37,7 +37,7 @@ _CORS_ORIGINS = [o.strip() for o in _CORS_ORIGINS if o.strip()] or [ app.add_middleware( CORSMiddleware, allow_origins=_CORS_ORIGINS, - allow_methods=["GET"], + allow_methods=["GET", "OPTIONS"], allow_headers=["Authorization", "Content-Type"], ) diff --git a/5-Applications/nodupe/connectors/topological-engine/neo4j_obsidian_connector_router.js b/5-Applications/nodupe/connectors/topological-engine/neo4j_obsidian_connector_router.js index f5d320b3..f30d4128 100644 --- a/5-Applications/nodupe/connectors/topological-engine/neo4j_obsidian_connector_router.js +++ b/5-Applications/nodupe/connectors/topological-engine/neo4j_obsidian_connector_router.js @@ -198,6 +198,14 @@ router.post("/obsidian/search", async (req, res) => { }); const CYPHER_WRITE_RE = /\b(CREATE|MERGE|DELETE|DETACH|SET|REMOVE|DROP|CALL\s*\{)\b/i; +const CYPHER_CALL_RE = /\bCALL\b/i; +const CYPHER_CALL_ALLOWED_RE = /\bCALL\s+(db\.|apoc\.meta\.)/i; + +function cypherReadOnlyViolation(cypher) { + if (CYPHER_WRITE_RE.test(cypher)) return "readOnly mode forbids write clauses (CREATE/MERGE/DELETE/SET/REMOVE/DROP)"; + if (CYPHER_CALL_RE.test(cypher) && !CYPHER_CALL_ALLOWED_RE.test(cypher)) return "readOnly mode only allows CALL db.* and CALL apoc.meta.*"; + return null; +} router.post("/neo4j/cypher", async (req, res) => { const driver = getDriver(); @@ -207,8 +215,9 @@ router.post("/neo4j/cypher", async (req, res) => { const params = req.body?.params || {}; const readOnly = req.body?.readOnly !== false; if (!cypher) return res.status(400).json({ ok: false, error: "cypher is required" }); - if (readOnly && CYPHER_WRITE_RE.test(cypher)) { - return res.status(403).json({ ok: false, error: "readOnly mode forbids write clauses (CREATE/MERGE/DELETE/SET/REMOVE/DROP)" }); + const violation = readOnly ? cypherReadOnlyViolation(cypher) : null; + if (violation) { + return res.status(403).json({ ok: false, error: violation }); } const txFn = async (tx) => tx.run(cypher, params); const result = readOnly