fix(security): remove hardcoded secrets, patch command injection, tighten CORS and Cypher guard (#76)

* 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 <bigdataiscoming+9i37y6j2@protonmail.com>

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

* 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 <bigdataiscoming+9i37y6j2@protonmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Allaun Silverfox <bigdataiscoming+9i37y6j2@protonmail.com>
This commit is contained in:
devin-ai-integration[bot] 2026-06-14 20:42:14 -05:00 committed by GitHub
parent 6db78dccf3
commit 43d5b2cdf6
7 changed files with 49 additions and 15 deletions

View file

@ -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) => { router.post("/neo4j/cypher", async (req, res) => {
const driver = getDriver(); const driver = getDriver();
const session = driver.session(); const session = driver.session();
@ -205,10 +214,14 @@ router.post("/neo4j/cypher", async (req, res) => {
const params = req.body?.params || {}; const params = req.body?.params || {};
const readOnly = req.body?.readOnly !== false; const readOnly = req.body?.readOnly !== false;
if (!cypher) return res.status(400).json({ ok: false, error: "cypher is required" }); 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)) { const violation = readOnly ? cypherReadOnlyViolation(cypher) : null;
return res.status(403).json({ ok: false, error: "readOnly mode allows MATCH/RETURN/WITH/db metadata only" }); 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)]))); 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 } }); res.json({ ok: true, records, summary: { queryType: result.summary.queryType } });
} catch (error) { } catch (error) {

View file

@ -5,8 +5,8 @@ global:
authentik: authentik:
log_level: info log_level: info
secret_key: "" # Will be auto-generated secret_key: "" # Will be auto-generated
bootstrap_password: "authentik" bootstrap_password: "" # REQUIRED: set via --set or sealed-secret before deploy
bootstrap_token: "authentik-bootstrap-token" bootstrap_token: "" # REQUIRED: set via --set or sealed-secret before deploy
email: email:
host: "" host: ""
port: 587 port: 587

View file

@ -2,7 +2,9 @@ import asyncio
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
import os import os
PASSWORD = "***REMOVED***" 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" CSV_PATH = "/home/allaun/.gemini/antigravity/scratch/affirm_loans_import.csv"
SCREENSHOT_DIR = "/home/allaun/.gemini/antigravity/scratch/screenshots" SCREENSHOT_DIR = "/home/allaun/.gemini/antigravity/scratch/screenshots"
os.makedirs(SCREENSHOT_DIR, exist_ok=True) os.makedirs(SCREENSHOT_DIR, exist_ok=True)

View file

@ -4,7 +4,9 @@ import os
import glob import glob
import re import re
PASSWORD = "***REMOVED***" 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" IMPORT_DIR = "/home/allaun/.gemini/antigravity/scratch/imports"
SCREENSHOT_DIR = "/home/allaun/.gemini/antigravity/scratch/screenshots/multi_import" SCREENSHOT_DIR = "/home/allaun/.gemini/antigravity/scratch/screenshots/multi_import"
os.makedirs(SCREENSHOT_DIR, exist_ok=True) os.makedirs(SCREENSHOT_DIR, exist_ok=True)

View file

@ -30,11 +30,15 @@ logging.basicConfig(level=logging.INFO)
log = logging.getLogger("dashboard") log = logging.getLogger("dashboard")
app = FastAPI(title="Research Stack Cluster 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=_CORS_ORIGINS,
allow_methods=["*"], allow_methods=["GET", "OPTIONS"],
allow_headers=["*"], allow_headers=["Authorization", "Content-Type"],
) )
KUBECONFIG = os.environ.get("KUBECONFIG", "/tmp/researchstack-kubeconfig.yaml") KUBECONFIG = os.environ.get("KUBECONFIG", "/tmp/researchstack-kubeconfig.yaml")

View file

@ -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) => { router.post("/neo4j/cypher", async (req, res) => {
const driver = getDriver(); const driver = getDriver();
const session = driver.session(); const session = driver.session();
@ -205,10 +214,14 @@ router.post("/neo4j/cypher", async (req, res) => {
const params = req.body?.params || {}; const params = req.body?.params || {};
const readOnly = req.body?.readOnly !== false; const readOnly = req.body?.readOnly !== false;
if (!cypher) return res.status(400).json({ ok: false, error: "cypher is required" }); 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)) { const violation = readOnly ? cypherReadOnlyViolation(cypher) : null;
return res.status(403).json({ ok: false, error: "readOnly mode allows MATCH/RETURN/WITH/db metadata only" }); 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)]))); 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 } }); res.json({ ok: true, records, summary: { queryType: result.summary.queryType } });
} catch (error) { } catch (error) {

View file

@ -1,6 +1,6 @@
import "dotenv/config"; import "dotenv/config";
import express from "express"; import express from "express";
import { exec, spawn } from "child_process"; import { exec, execFile, spawn } from "child_process";
import Database from "./sqlite.js"; import Database from "./sqlite.js";
import { join } from "path"; import { join } from "path";
import { notion, notionDatabaseId, validateNotionConfig } from "./notion.js"; import { notion, notionDatabaseId, validateNotionConfig } from "./notion.js";
@ -268,7 +268,7 @@ app.post("/ingest", async (req, res) => {
if (target === "forgejo" || target === "github") { if (target === "forgejo" || target === "github") {
const shim = target === "forgejo" ? "forgejo_shim.py" : "github_shim.py"; 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}`); if (error) console.error(`${target} Shim Error: ${error.message}`);
console.log(`${target} Shim Output: ${stdout}`); console.log(`${target} Shim Output: ${stdout}`);
}); });