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.
Live verification performed
Section titled “Live verification performed”- MCP server booted against real Postgres/Redis/Qdrant:
/health+/health/readygreen; 24 tools listed;create_memory → recallround-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: upwith 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.
Fixed in this PR
Section titled “Fixed in this PR”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.tsrefuses to boot whenMCP_TRANSPORT=streamable-http+NODE_ENV=production+ auth off, unlessALLOW_UNAUTHENTICATED_HTTP=trueis set explicitly (deliberate trusted-network opt-in). - FIXED
docker-compose.prod.ymldefaultsAUTH_REQUIREDandRATE_LIMIT_ENABLEDon, requiresJWT_SECRET, binds the port to loopback by default, adds resource limits,cap_drop: ALL, andno-new-privileges.
Other security fixes
Section titled “Other security fixes”- FIXED PII/secret log leak:
main.tslogged the raw request body on session-less/mcprejects (memory content + any token in params), bypassing pino field redaction. Now logs only the JSON-RPC envelope. - FIXED
GET/DELETE /mcpbypassed auth + rate-limit pre-handlers — now metered likePOST. - FIXED No graceful shutdown — added
enableShutdownHooks()+ SIGTERM/SIGINT →app.close(). - FIXED CORS reflected any origin — added a
CORS_ALLOWED_ORIGINSallow-list. - FIXED
api-keys.controlleradmin-token compare was!==(not constant-time) while the memory controller was hardened — unified onconstantTimeStringEqual; raised the create-api-key admin token min length 1 → 16 to match the reindex/consolidate tools. - FIXED OAuth email verification:
google.providernow default-denies (verified_email !== trueinstead 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.modulenow reads an optionalQDRANT_API_KEY, and prod compose requires it. - FIXED
.strict()added tocreate_memory,list_memories,recall, and the reindex/job schemas (CLAUDE.md convention).
Pipelines
Section titled “Pipelines”- FIXED
ci.yml: least-privilegepermissions; Node matrix pinned to 22.x/24.x (waslatest); the e2e suites now run in CI against the existing service containers; a schema/migration drift gate; apnpm auditjob; and a trivy image scan after the Docker build.
Filed as issues (verified, deferred)
Section titled “Filed as issues (verified, deferred)”| # | 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). |
Strong points (do not regress)
Section titled “Strong points (do not regress)”- Both vector backends hard-require a
userIdfilter 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/memoryIdare 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
memoryIdas 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_TOKENthrows, 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.