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 <bigdataiscoming+9i37y6j2@protonmail.com>
This commit is contained in:
Devin AI 2026-06-15 00:34:12 +00:00
parent 3347ebca7a
commit 3555cbb26c
3 changed files with 23 additions and 5 deletions

View file

@ -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

View file

@ -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"],
)

View file

@ -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