Skip to content

Security review 2026-07-02

ENGRAM codebase review — security, functionality and pipelines.

Date: 2026-07-02 Scope: Full monorepo — MCP server, auth/multi-tenancy, MCP tools/core, memory tiers + vector store + embeddings, web dashboard (tRPC/NextAuth), and CI/CD + deployment. Six parallel review passes, each with a security + functionality lens. Base: main @ a0b1f2d.

Every finding below was verified against code (not speculation). Items marked FIXED are addressed in the accompanying PR; FILED items are tracked as GitHub issues because they need a larger change or a product decision.

  • MCP server booted against real Postgres/Redis/Qdrant: /health + /health/ready green; 24 tools listed; create_memory → recall round-trip returned the seeded memory at cosine 0.81 (Qdrant) and 0.78 (pgvector); admin-token gating rejects a wrong token and accepts the right one; .strict() schemas reject unknown keys.
  • pgvector backend booted standalone — health shows pgvector: up with no Qdrant probe (confirms #193), and a full create→recall round-trip works.
  • Web dashboard booted: serves /signin (200), redirects unauthenticated dashboard routes to sign-in (307), and dev-auth is correctly disabled under a production build. Backend integration test runs live against Postgres (6/6).
  • e2e suites (4 files, 14 tests incl. the ungated http-transport test) pass against live infra. Full unit sweep 491/493 (the 2 failures are the psql-shelling backup test, which needs the postgres client binary installed — environmental, present in CI). pgvector integration 77/77. Retrieval eval green.

Auth posture (consensus HIGH — flagged independently by 4 of 6 reviewers)

Section titled “Auth posture (consensus HIGH — flagged independently by 4 of 6 reviewers)”

The streamable-http transport served all tenants unauthenticated under the default AUTH_REQUIRED=false, taking userId from tool input rather than a credential. The shipped docker-compose.prod.yml published the port with this posture.

  • FIXED main.ts refuses to boot when MCP_TRANSPORT=streamable-http + NODE_ENV=production + auth off, unless ALLOW_UNAUTHENTICATED_HTTP=true is set explicitly (deliberate trusted-network opt-in).
  • FIXED docker-compose.prod.yml defaults AUTH_REQUIRED and RATE_LIMIT_ENABLED on, requires JWT_SECRET, binds the port to loopback by default, adds resource limits, cap_drop: ALL, and no-new-privileges.
  • FIXED PII/secret log leak: main.ts logged the raw request body on session-less /mcp rejects (memory content + any token in params), bypassing pino field redaction. Now logs only the JSON-RPC envelope.
  • FIXED GET/DELETE /mcp bypassed auth + rate-limit pre-handlers — now metered like POST.
  • FIXED No graceful shutdown — added enableShutdownHooks() + SIGTERM/SIGINT → app.close().
  • FIXED CORS reflected any origin — added a CORS_ALLOWED_ORIGINS allow-list.
  • FIXED api-keys.controller admin-token compare was !== (not constant-time) while the memory controller was hardened — unified on constantTimeStringEqual; raised the create-api-key admin token min length 1 → 16 to match the reindex/consolidate tools.
  • FIXED OAuth email verification: google.provider now default-denies (verified_email !== true instead of === false); the web dashboard rejects unverified provider emails before the operator allow-list.
  • FIXED Web dev-auth gate is now an allow-list (NODE_ENV === 'development') instead of a !== 'production' deny-list that would enable passwordless impersonation in staging/preview/unset-NODE_ENV environments.
  • FIXED Qdrant had no API key in prod — qdrant.module now reads an optional QDRANT_API_KEY, and prod compose requires it.
  • FIXED .strict() added to create_memory, list_memories, recall, and the reindex/job schemas (CLAUDE.md convention).
  • FIXED ci.yml: least-privilege permissions; Node matrix pinned to 22.x/24.x (was latest); the e2e suites now run in CI against the existing service containers; a schema/migration drift gate; a pnpm audit job; and a trivy image scan after the Docker build.
# Severity Area Summary
1 HIGH web Dashboard cross-tenant writes/search silently broken when authenticated to the MCP server (identity tools rewrite userId to the key’s tenant). Needs a delegated/admin tool mode.
2 HIGH release No publish pipeline; docker-compose.prod.yml + docs/deploy.md reference a GHCR image nothing pushes.
3 MED auth JWT sessions are not revocable; logout doesn’t invalidate the Bearer token (no jti denylist).
4 MED data Quota TOCTOU: concurrent create/promote can exceed maxMemoriesPerUser.
5 MED tools ingest_conversation amplifies to hundreds of embed/DB ops per single rate-limited request.
6 MED web/ops Web app uses the full-privilege DATABASE_URL; wants a read-only role. Backup pipeline has no scheduled run / offsite / Redis+Qdrant restore test.
7 LOW correctness tools/list emits malformed JSON Schemas (enums/arrays/objects dropped; defaulted fields marked required).
8 LOW hardening Handler errors leak internal messages to clients; /health/metrics unauthenticated; Helmet CSP disabled; assembled prompt-context not delimited as untrusted; recall userId injection depends on Zod v4 .refine() staying a ZodObject (latent trap).
  • Both vector backends hard-require a userId filter before searching (fail-closed tenant isolation).
  • JWT verification is algorithm-confusion-immune (never selects alg from the token; HMAC-SHA256 + constant-time compare; enforces iss/exp/iat; min 32-char secret).
  • userId/memoryId are CUID/CUID2-validated, closing Redis key-injection.
  • API keys stored as SHA-256 of a 24-byte random secret; revocation is an atomic updateMany (TOCTOU-safe); rate limiting uses an atomic Redis Lua INCR+EXPIRE and meters every tool in a JSON-RPC batch.
  • profile-lite uses AES-256-GCM with memoryId as AAD (blocks ciphertext swap).
  • No SQL injection: every pgvector raw query uses bound parameters.
  • Env validation fails fast; admin tools cannot silently open (missing MCP_ADMIN_TOKEN throws, never defaults to allow).
  • Triple-layered web route protection (proxy default-deny + server auth() re-check + tRPC session check); allow-list re-validated on every request.