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:
Keysat
2026-06-12 17:44:27 -05:00
parent 1959c22e19
commit aec2b7775b
8 changed files with 148 additions and 23 deletions
+3 -2
View File
@@ -38,8 +38,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
// * 0.1.0:70 (outreach voice upgrade — per-user voice from own emails + transparency; active-thread context)
// * 0.1.0:71 (voice by-purpose larger sample + Tier-B: create Gmail draft w/ in-thread reply)
// * 0.1.0:72 (stage v2.0 reserve-asset thesis spine as Workshop candidates)
// * Current: 0.1.0:73 (replace old settlement spine with v2.0 reserve-asset spine across Architect + outreach prompts, seed constants, and docs; promote v2.0 to the working approved spine + soft-retire old settlement nodes, reversibly, node-level only)
export const PACKAGE_VERSION = '0.1.0:73'
// * 0.1.0:73 (replace old settlement spine with v2.0 reserve-asset spine across Architect + outreach prompts, seed constants, and docs; promote v2.0 to the working approved spine + soft-retire old settlement nodes, reversibly, node-level only)
// * Current: 0.1.0:74 (security/privacy hardening — full-eval P0+2×P1: close /assets/ path traversal, add NER backstop to the outreach redaction boundary, filter deleted_at on get-by-id)
export const PACKAGE_VERSION = '0.1.0:74'
export const DATA_MOUNT_PATH = '/data'
export const WEB_PORT = 8080
+3 -2
View File
@@ -34,8 +34,9 @@ import { v_0_1_0_70 } from './v0.1.0.70'
import { v_0_1_0_71 } from './v0.1.0.71'
import { v_0_1_0_72 } from './v0.1.0.72'
import { v_0_1_0_73 } from './v0.1.0.73'
import { v_0_1_0_74 } from './v0.1.0.74'
export const versionGraph = VersionGraph.of({
current: v_0_1_0_73,
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72],
current: v_0_1_0_74,
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73],
})
+27
View File
@@ -0,0 +1,27 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Security/privacy hardening from the 2026-06-12 full-eval (P0 + two P1s). Code-only,
// no schema change (migrations are no-ops):
// * P0 — pre-auth path traversal in the /assets/ route (server.py): get_path()/urlparse
// does not normalize '..', so an unauthenticated GET /assets/../../data/crm.db (raw
// client) read any file the process could — the LP DB, the JWT signing secret (-> admin
// token forgery), the Gmail service-account key. Added a realpath containment check that
// 404s anything resolving outside FRONTEND_DIR.
// * P1 — the LP-outreach drafter (mcp/outreach_agent.py) built its redaction Boundary with
// no ner_fn, so unknown people/firms in raw email bodies reached Claude in the clear.
// Now passes the local-Qwen NER backstop (ner_fn=_ner_local) like architect_grounding;
// fails closed via the existing scrub_unavailable path if the local model is down.
// * P1 — get-by-ID handlers for contacts and organizations (server.py) omitted the
// deleted_at IS NULL filter, so soft-deleted records stayed readable by direct ID.
export const v_0_1_0_74 = VersionInfo.of({
version: '0.1.0:74',
releaseNotes: {
en_US: [
'Security hardening: close an unauthenticated file-read in static-asset serving (could expose',
'the database, the auth secret, and the Gmail key), tighten the LP-outreach privacy boundary so',
'unknown names in email bodies are de-identified before reaching Claude, and stop soft-deleted',
'contacts and organizations from being readable by direct link.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})