From 85506530f2562e8fe8fe994576a0f7b30dbe98da Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 14 Jun 2026 20:42:14 -0500 Subject: [PATCH] fix(security): remove hardcoded secrets, patch command injection, tighten CORS and Cypher guard (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): remove hardcoded secrets, patch command injection, tighten CORS and Cypher guard - run_import_workflow.py, run_multi_import.py: replace hardcoded budget password with BUDGET_PASSWORD env-var (fail-fast if unset) - server.js /ingest: replace shell-interpolated exec() with execFile() so user-controlled title/body cannot inject shell commands - authentik-values.yaml: blank out bootstrap_password and bootstrap_token so they must be supplied at deploy time via --set or sealed-secret - cluster-dashboard main.py: restrict CORS from allow_origins=["*"] to env-configurable whitelist (default: dashboard.researchstack.info), methods to GET, headers to Authorization+Content-Type - neo4j_obsidian_connector_router.js (both copies): replace permissive prefix-only readOnly regex with a deny-list that blocks CREATE/MERGE/DELETE/DETACH/SET/REMOVE/DROP anywhere in the query, and route readOnly queries through session.readTransaction() Co-Authored-By: Allaun Silverfox * 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 * fix(security): use negative lookahead for CALL allowlist, remove dead CALL\s*\{ branch - Replace two-regex CALL check with single negative-lookahead CYPHER_CALL_DISALLOWED_RE = /\bCALL\s+(?!db\.|apoc\.meta\.)/i This correctly blocks queries containing ANY disallowed CALL target, even when bundled alongside an allowed CALL db.* or CALL apoc.meta.*. - Remove dead CALL\s*\{ alternative from CYPHER_WRITE_RE — the trailing \b never matched because { is a non-word character. - Both copies updated identically. Co-Authored-By: Allaun Silverfox --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Allaun Silverfox --- .../neo4j_obsidian_connector_router.js | 19 ++++++++++++++++--- .../kube/manifests/authentik-values.yaml | 4 ++-- .../scripts/run_import_workflow.py | 4 +++- 4-Infrastructure/scripts/run_multi_import.py | 4 +++- .../cluster-dashboard/backend/main.py | 10 +++++++--- .../neo4j_obsidian_connector_router.js | 19 ++++++++++++++++--- 5-Applications/scripts/server.js | 4 ++-- 7 files changed, 49 insertions(+), 15 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 0134caea..2e53e45b 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 @@ -197,6 +197,15 @@ router.post("/obsidian/search", async (req, res) => { } }); +const CYPHER_WRITE_RE = /\b(CREATE|MERGE|DELETE|DETACH|SET|REMOVE|DROP)\b/i; +const CYPHER_CALL_DISALLOWED_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_DISALLOWED_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(); const session = driver.session(); @@ -205,10 +214,14 @@ 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 && !/^\s*(MATCH|RETURN|WITH|CALL\s+db\.|CALL\s+apoc\.meta\.)/i.test(cypher)) { - return res.status(403).json({ ok: false, error: "readOnly mode allows MATCH/RETURN/WITH/db metadata only" }); + const violation = readOnly ? cypherReadOnlyViolation(cypher) : null; + if (violation) { + return res.status(403).json({ ok: false, error: violation }); } - const result = await session.run(cypher, params); + const txFn = async (tx) => tx.run(cypher, params); + const result = readOnly + ? await session.readTransaction(txFn) + : await session.writeTransaction(txFn); const records = result.records.map(r => Object.fromEntries(r.keys.map(k => [k, r.get(k)]))); res.json({ ok: true, records, summary: { queryType: result.summary.queryType } }); } catch (error) { diff --git a/4-Infrastructure/kube/manifests/authentik-values.yaml b/4-Infrastructure/kube/manifests/authentik-values.yaml index 4fa53d6c..713f8c56 100644 --- a/4-Infrastructure/kube/manifests/authentik-values.yaml +++ b/4-Infrastructure/kube/manifests/authentik-values.yaml @@ -5,8 +5,8 @@ global: authentik: log_level: info secret_key: "" # Will be auto-generated - bootstrap_password: "authentik" - bootstrap_token: "authentik-bootstrap-token" + bootstrap_password: "" # REQUIRED: set via --set or sealed-secret before deploy + bootstrap_token: "" # REQUIRED: set via --set or sealed-secret before deploy email: host: "" port: 587 diff --git a/4-Infrastructure/scripts/run_import_workflow.py b/4-Infrastructure/scripts/run_import_workflow.py index 0a54e6ff..52c70648 100644 --- a/4-Infrastructure/scripts/run_import_workflow.py +++ b/4-Infrastructure/scripts/run_import_workflow.py @@ -2,7 +2,9 @@ import asyncio from playwright.async_api import async_playwright import os -PASSWORD = "v1D7TtupOMq8pK" +PASSWORD = os.environ.get("BUDGET_PASSWORD") +if not PASSWORD: + raise SystemExit("Error: BUDGET_PASSWORD environment variable is not set.") CSV_PATH = "/home/allaun/.gemini/antigravity/scratch/affirm_loans_import.csv" SCREENSHOT_DIR = "/home/allaun/.gemini/antigravity/scratch/screenshots" os.makedirs(SCREENSHOT_DIR, exist_ok=True) diff --git a/4-Infrastructure/scripts/run_multi_import.py b/4-Infrastructure/scripts/run_multi_import.py index 87c4b4c4..2b4f924d 100644 --- a/4-Infrastructure/scripts/run_multi_import.py +++ b/4-Infrastructure/scripts/run_multi_import.py @@ -4,7 +4,9 @@ import os import glob import re -PASSWORD = "v1D7TtupOMq8pK" +PASSWORD = os.environ.get("BUDGET_PASSWORD") +if not PASSWORD: + raise SystemExit("Error: BUDGET_PASSWORD environment variable is not set.") IMPORT_DIR = "/home/allaun/.gemini/antigravity/scratch/imports" SCREENSHOT_DIR = "/home/allaun/.gemini/antigravity/scratch/screenshots/multi_import" os.makedirs(SCREENSHOT_DIR, exist_ok=True) diff --git a/5-Applications/cluster-dashboard/backend/main.py b/5-Applications/cluster-dashboard/backend/main.py index ae28af49..fb3ae66c 100644 --- a/5-Applications/cluster-dashboard/backend/main.py +++ b/5-Applications/cluster-dashboard/backend/main.py @@ -30,11 +30,15 @@ logging.basicConfig(level=logging.INFO) log = logging.getLogger("dashboard") app = FastAPI(title="Research Stack Cluster Dashboard") +_CORS_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "").split(",") +_CORS_ORIGINS = [o.strip() for o in _CORS_ORIGINS if o.strip()] or [ + "https://dashboard.researchstack.info", +] app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], + allow_origins=_CORS_ORIGINS, + allow_methods=["GET", "OPTIONS"], + allow_headers=["Authorization", "Content-Type"], ) KUBECONFIG = os.environ.get("KUBECONFIG", "/tmp/researchstack-kubeconfig.yaml") 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 0628863f..e0267c65 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 @@ -197,6 +197,15 @@ router.post("/obsidian/search", async (req, res) => { } }); +const CYPHER_WRITE_RE = /\b(CREATE|MERGE|DELETE|DETACH|SET|REMOVE|DROP)\b/i; +const CYPHER_CALL_DISALLOWED_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_DISALLOWED_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(); const session = driver.session(); @@ -205,10 +214,14 @@ 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 && !/^\s*(MATCH|RETURN|WITH|CALL\s+db\.|CALL\s+apoc\.meta\.)/i.test(cypher)) { - return res.status(403).json({ ok: false, error: "readOnly mode allows MATCH/RETURN/WITH/db metadata only" }); + const violation = readOnly ? cypherReadOnlyViolation(cypher) : null; + if (violation) { + return res.status(403).json({ ok: false, error: violation }); } - const result = await session.run(cypher, params); + const txFn = async (tx) => tx.run(cypher, params); + const result = readOnly + ? await session.readTransaction(txFn) + : await session.writeTransaction(txFn); const records = result.records.map(r => Object.fromEntries(r.keys.map(k => [k, r.get(k)]))); res.json({ ok: true, records, summary: { queryType: result.summary.queryType } }); } catch (error) { diff --git a/5-Applications/scripts/server.js b/5-Applications/scripts/server.js index b2e1f97f..205b6d1a 100644 --- a/5-Applications/scripts/server.js +++ b/5-Applications/scripts/server.js @@ -1,6 +1,6 @@ import "dotenv/config"; import express from "express"; -import { exec, spawn } from "child_process"; +import { exec, execFile, spawn } from "child_process"; import Database from "./sqlite.js"; import { join } from "path"; import { notion, notionDatabaseId, validateNotionConfig } from "./notion.js"; @@ -268,7 +268,7 @@ app.post("/ingest", async (req, res) => { if (target === "forgejo" || target === "github") { const shim = target === "forgejo" ? "forgejo_shim.py" : "github_shim.py"; - exec(`python3 tools/scripts/${shim} "${title}" "${body}" "${humanCost}" "${lean.witness.trace_hash}"`, (error, stdout) => { + execFile("python3", [`tools/scripts/${shim}`, title, body, humanCost, lean.witness.trace_hash], (error, stdout) => { if (error) console.error(`${target} Shim Error: ${error.message}`); console.log(`${target} Shim Output: ${stdout}`); });