Harden privacy boundary and asset serving (v0.1.0:74)
Fixes from the 2026-06-12 full-eval (P0 + two P1s); code-only, no schema change. Without these the "private CRM" premise was breachable on the LAN: - P0: the /assets/ route joined the request path onto FRONTEND_DIR without normalizing '..' (get_path/urlparse pass it through), so an unauthenticated GET /assets/../../data/crm.db read any file the process could — the LP DB, the JWT signing secret (-> admin-token forgery), the Gmail key. Add a realpath containment check that 404s anything resolving outside FRONTEND_ROOT. - P1: the LP-outreach drafter built its redaction Boundary with no ner_fn, so unknown people/firms in raw email bodies reached Claude in the clear. Pass the local-Qwen NER backstop (ner_fn=_ner_local), matching architect_grounding; fails closed via the existing scrub_unavailable path if the local model is down. - P1: get-by-id handlers leaked soft-deleted records by direct ID. Add deleted_at IS NULL to every get-by-id path — contacts, organizations, opportunities, lp_profiles — and to the nested related-data sub-selects in the contact/opportunity detail payloads, matching the list-handler convention. Bumps the package to v0.1.0:74 (utils.ts + versions/v0.1.0.74.ts + graph). Full report in EVALUATION.md; remaining P2/P3 triaged in AGENTS.md Current state.
This commit is contained in:
+19
-10
@@ -73,6 +73,7 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_DIR = os.path.dirname(BASE_DIR)
|
||||
DATA_DIR = os.environ.get("CRM_DATA_DIR", os.path.join(PROJECT_DIR, "data"))
|
||||
FRONTEND_DIR = os.environ.get("CRM_FRONTEND_DIR", os.path.join(PROJECT_DIR, "frontend"))
|
||||
FRONTEND_ROOT = os.path.realpath(FRONTEND_DIR) # resolved once; the /assets/ containment boundary
|
||||
DB_PATH = os.environ.get("CRM_DB_PATH", os.path.join(DATA_DIR, "crm.db"))
|
||||
SECRET_KEY = os.environ.get("CRM_SECRET_KEY", "venture-crm-secret-change-in-production-" + str(uuid.uuid4()))
|
||||
TOKEN_EXPIRY_HOURS = 24
|
||||
@@ -1716,6 +1717,14 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.send_file(os.path.join(FRONTEND_DIR, 'index.html'))
|
||||
if path.startswith('/assets/'):
|
||||
filepath = os.path.join(FRONTEND_DIR, path.lstrip('/'))
|
||||
# Containment check: get_path()/urlparse does NOT normalize '..', so without
|
||||
# this an unauthenticated GET /assets/../../data/crm.db (raw client) would read
|
||||
# any file the process can — the LP DB, the JWT secret, the Gmail key. Resolve
|
||||
# and require the target stay under FRONTEND_ROOT; 404 (not 403) so it looks like
|
||||
# any other miss and still trips the scanner abuse counter.
|
||||
_real = os.path.realpath(filepath)
|
||||
if _real != FRONTEND_ROOT and not _real.startswith(FRONTEND_ROOT + os.sep):
|
||||
return self.send_error_json("File not found", 404)
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
content_types = {
|
||||
'.css': 'text/css',
|
||||
@@ -2185,7 +2194,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
SELECT c.*, o.name as organization_name
|
||||
FROM contacts c
|
||||
LEFT JOIN organizations o ON c.organization_id = o.id
|
||||
WHERE c.id = ?
|
||||
WHERE c.id = ? AND c.deleted_at IS NULL
|
||||
""", (contact_id,)).fetchone()
|
||||
|
||||
if not contact:
|
||||
@@ -2198,16 +2207,16 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
result['communications'] = rows_to_list(conn.execute(
|
||||
"""SELECT cm.*, u.full_name as created_by_name
|
||||
FROM communications cm LEFT JOIN users u ON cm.created_by = u.id
|
||||
WHERE cm.contact_id = ? ORDER BY cm.communication_date DESC LIMIT 20""",
|
||||
WHERE cm.contact_id = ? AND cm.deleted_at IS NULL ORDER BY cm.communication_date DESC LIMIT 20""",
|
||||
(contact_id,)
|
||||
).fetchall())
|
||||
|
||||
result['opportunities'] = rows_to_list(conn.execute(
|
||||
"SELECT * FROM opportunities WHERE contact_id = ? ORDER BY updated_at DESC",
|
||||
"SELECT * FROM opportunities WHERE contact_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC",
|
||||
(contact_id,)
|
||||
).fetchall())
|
||||
|
||||
lp = conn.execute("SELECT * FROM lp_profiles WHERE contact_id = ?", (contact_id,)).fetchone()
|
||||
lp = conn.execute("SELECT * FROM lp_profiles WHERE contact_id = ? AND deleted_at IS NULL", (contact_id,)).fetchone()
|
||||
result['lp_profile'] = row_to_dict(lp) if lp else None
|
||||
|
||||
conn.close()
|
||||
@@ -2362,17 +2371,17 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def handle_get_organization(self, user, org_id):
|
||||
conn = get_db()
|
||||
org = conn.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)).fetchone()
|
||||
org = conn.execute("SELECT * FROM organizations WHERE id = ? AND deleted_at IS NULL", (org_id,)).fetchone()
|
||||
if not org:
|
||||
conn.close()
|
||||
return self.send_error_json("Organization not found", 404)
|
||||
|
||||
result = row_to_dict(org)
|
||||
result['contacts'] = rows_to_list(conn.execute(
|
||||
"SELECT * FROM contacts WHERE organization_id = ? ORDER BY last_name", (org_id,)
|
||||
"SELECT * FROM contacts WHERE organization_id = ? AND deleted_at IS NULL ORDER BY last_name", (org_id,)
|
||||
).fetchall())
|
||||
result['opportunities'] = rows_to_list(conn.execute(
|
||||
"SELECT * FROM opportunities WHERE organization_id = ? ORDER BY updated_at DESC", (org_id,)
|
||||
"SELECT * FROM opportunities WHERE organization_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC", (org_id,)
|
||||
).fetchall())
|
||||
conn.close()
|
||||
return self.send_json({"data": result})
|
||||
@@ -2498,7 +2507,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
LEFT JOIN contacts c ON op.contact_id = c.id
|
||||
LEFT JOIN organizations o ON op.organization_id = o.id
|
||||
LEFT JOIN users u ON op.owner_id = u.id
|
||||
WHERE op.id = ?
|
||||
WHERE op.id = ? AND op.deleted_at IS NULL
|
||||
""", (opp_id,)).fetchone()
|
||||
|
||||
if not opp:
|
||||
@@ -2509,7 +2518,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
result['communications'] = rows_to_list(conn.execute(
|
||||
"""SELECT cm.*, u.full_name as created_by_name
|
||||
FROM communications cm LEFT JOIN users u ON cm.created_by = u.id
|
||||
WHERE cm.opportunity_id = ? ORDER BY cm.communication_date DESC""",
|
||||
WHERE cm.opportunity_id = ? AND cm.deleted_at IS NULL ORDER BY cm.communication_date DESC""",
|
||||
(opp_id,)
|
||||
).fetchall())
|
||||
|
||||
@@ -2975,7 +2984,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
FROM lp_profiles lp
|
||||
LEFT JOIN contacts c ON lp.contact_id = c.id
|
||||
LEFT JOIN organizations o ON c.organization_id = o.id
|
||||
WHERE lp.id = ?
|
||||
WHERE lp.id = ? AND lp.deleted_at IS NULL
|
||||
""", (lp_id,)).fetchone()
|
||||
if not lp:
|
||||
conn.close()
|
||||
|
||||
Reference in New Issue
Block a user