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