Skip to content

security: fail-closed auth + drop wildcard CORS on HTTP server (v2.0.2)#10

Merged
NickCirv merged 1 commit into
mainfrom
security/v2.0.2
Apr 18, 2026
Merged

security: fail-closed auth + drop wildcard CORS on HTTP server (v2.0.2)#10
NickCirv merged 1 commit into
mainfrom
security/v2.0.2

Conversation

@NickCirv
Copy link
Copy Markdown
Owner

Summary

Fixes #7 — critical security issue where the local HTTP server shipped with Access-Control-Allow-Origin: * on every response and defaulted to no authentication. Any browser tab the developer visited could exfiltrate the local knowledge graph via GET /query and persistently poison mistake nodes via POST /learn (using text/plain, a CORS-safelisted content type). Poisoned nodes were then re-injected into the coding agent's context by Sentinel on SessionStart and every Edit/Write — yielding persistent indirect prompt injection against the user's AI agent.

Credit: @gabiudrescu — responsible disclosure via #7 with file:line citations, working PoC, and a suggested fix.

Four stacked defenses

  1. Fail-closed auth — every route except /health and /favicon.ico requires Authorization: Bearer <token> OR an HttpOnly engram_token cookie. A random 64-char hex token is auto-generated on first server start and persisted to ~/.engram/http-server.token (mode 0600). ENGRAM_API_TOKEN env still overrides, validated at startup then snapshot (no mid-session re-read — would bypass the 32-char minimum gate).
  2. No wildcard CORSACAO: * removed everywhere. Default is no CORS headers (dashboard is same-origin). Opt-in via ENGRAM_ALLOWED_ORIGINS=a.com,b.com.
  3. Host + Origin validation (DNS-rebinding defense) — Host must be 127.0.0.1|localhost|::1 on the bound port exactly. Origin if present must be same-origin or in allowlist, else 403.
  4. Content-Type: application/json enforced on mutations — POST/PUT/DELETE return 415 otherwise. Blocks the text/plain CSRF vector from the PoC.

Plus: /ui?token=<t> bootstrap with Sec-Fetch-Site gate (blocks cross-site oracle), safeEqual constant-time comparison with empty-string guard, authCookie() helper (HttpOnly; SameSite=Strict; Path=/), Referrer-Policy: no-referrer + Cache-Control: no-store on the 302.

Verification

  • All 4 original PoCs from Security: CORS wildcard + auth-off-by-default on engram server enables CSRF, graph exfil, and persistent prompt injection #7 blocked end-to-end on the rebuilt binary: 403 / 415 / 400 / 401
  • 29 new security tests in tests/server/security.test.ts covering fail-closed auth, empty-Bearer guard, env-downgrade rejection, cookie auth, no wildcard CORS anywhere, Host case-insensitive + no-port rejection, text/plain CSRF block, /ui?token cross-site oracle defense, and the end-to-end exploit chain
  • 670/670 tests pass (up from 641 baseline; +29 new)
  • TypeScript strict clean
  • In-session reviews: security-reviewer (1 HIGH, 4 MED, 7 LOW — all HIGH/MED ship-blockers addressed) + code-reviewer (2 CRITICAL, 3 HIGH, 6 MED/LOW — all CRITICAL/HIGH addressed). Low-priority follow-ups (WWW-Authenticate header, CSP tightening, error-detail stripping) tracked for v2.0.3 / v2.1.0.

Breaking

External callers (curl, scripts, CI probes) must now send the token:

curl -H "Authorization: Bearer $(cat ~/.engram/http-server.token)" \
     http://127.0.0.1:7337/stats

See CHANGELOG.md v2.0.2 entry for full list.

Test plan

🤖 Generated with Claude Code

Fixes #7 — CORS wildcard + auth-off-by-default let any browser tab the
developer visits exfiltrate the graph via GET /query and persistently
poison mistake/decision nodes via POST /learn (text/plain, a CORS-
safelisted content type). Poisoned nodes were then re-injected into the
agent's context by Sentinel on SessionStart and Edit/Write, yielding
persistent indirect prompt injection.

Four stacked defenses:

1. Fail-closed auth. Every route except /health and /favicon.ico requires
   Authorization: Bearer <token> or an HttpOnly engram_token cookie. A
   random 64-char hex token is auto-generated on first server start and
   persisted to ~/.engram/http-server.token (0600). ENGRAM_API_TOKEN env
   still overrides, validated once at startup then snapshot — no mid-
   session re-read (would bypass the length gate).

2. No wildcard CORS. Access-Control-Allow-Origin: * removed everywhere.
   Default is no CORS headers. Opt-in allowlist via
   ENGRAM_ALLOWED_ORIGINS=a.com,b.com.

3. Host + Origin validation (DNS-rebinding defense). Host must match
   127.0.0.1|localhost|::1 on the bound port exactly (case-insensitive
   hostname, strict port). Origin if present must be same-origin or in
   ENGRAM_ALLOWED_ORIGINS, else 403.

4. Content-Type: application/json enforced on mutations (POST/PUT/DELETE
   return 415 otherwise). Blocks the text/plain CSRF vector from the PoC.

Plus:

- /ui?token=<t> bootstrap exchange: the CLI-launched browser navigates
  to this URL; server validates via constant-time safeEqual, swaps for an
  HttpOnly SameSite=Strict cookie, 302s to clean /ui. Defense-in-depth
  via Sec-Fetch-Site gate (accepts none/same-origin only) prevents
  cross-origin <img>/<iframe> probe oracles. Response carries
  Referrer-Policy: no-referrer and Cache-Control: no-store.
- safeEqual: length-first, constant-time, now also rejects empty
  strings (defense against corrupt-state == corrupt-state matches).
- All 4 PoCs from issue #7 verified blocked end-to-end on the rebuilt
  binary: 403 / 415 / 400 / 401.
- 29 new security tests (cross-origin exfil, text/plain CSRF,
  DNS rebinding, no-auth, empty Bearer, env-downgrade rejection, CORS
  wildcard absence, Host case-insensitive + no-port rejection,
  /ui?token cross-site oracle block, /ui?token address-bar allow).
- 670/670 total tests pass (up from 641 baseline; +29 new).

Breaking: external curl/CI probes must now send the token. See
CHANGELOG.md v2.0.2 entry for one-liner.

Credit: @gabiudrescu for responsible disclosure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@NickCirv NickCirv merged commit a2739cf into main Apr 18, 2026
4 checks passed
@NickCirv NickCirv deleted the security/v2.0.2 branch April 18, 2026 05:12
NickCirv added a commit that referenced this pull request Apr 24, 2026
…rena reference plugin

ITEM #2 — Plugin contract v2

Extends ContextProviderPlugin so plugin authors can declare an MCP server
via 'mcpConfig' and skip writing resolve()/isAvailable() by hand. The
loader auto-wraps via createMcpProvider() from item #1. Classic plugins
(custom resolve()) continue to work unchanged — if both fields are
present, the author's resolve() wins (they opted into custom logic).

Type changes (src/providers/types.ts):
  - ContextProviderPlugin stays strict (extends ContextProvider fully) —
    this is the POST-VALIDATION shape the resolver consumes
  - NEW: RawPluginShape — the pre-validation shape a plugin-file author
    writes in .mjs. tier/tokenBudget/timeoutMs/resolve/isAvailable all
    optional (loader fills from factory when mcpConfig present)

Loader changes (src/providers/plugin-loader.ts):
  - validatePlugin() branches on 'has mcpConfig vs. has resolve()'
  - name/label/version always required
  - Classic path: tier/tokenBudget/timeoutMs/isAvailable required
  - mcpConfig path: config validated via validateProviderConfig(),
    merged with plugin fields (author overrides win over factory defaults)
  - One clear error per rejection — 'invalid mcpConfig: <reason>' tells
    you exactly which sub-field on which plugin is broken

Tests (+7 cases in tests/providers/plugin-loader.test.ts):
  - mcpConfig-only plugin auto-wraps resolve + isAvailable
  - Plugin with neither resolve nor mcpConfig rejected (clear message)
  - Invalid mcpConfig rejected (bad command, bad http url)
  - Custom resolve wins over mcpConfig when both present
  - Plugin tokenBudget override wins over factory default
  - Missing version rejected even for mcpConfig plugins

ITEM #6 — Serena plugin reference

docs/plugins/examples/serena-plugin.mjs (~60 lines incl. docs) — the
full Serena (oraios/serena) wrapper as an mcpConfig-only plugin. Install
is cp + enable. Thanks to item #2, NO custom transport code needed.

docs/plugins/examples/static-context-plugin.mjs — the classic-path
reference showing a tier 1 plugin with hand-rolled resolve() for users
who just want to inject a fixed string on every Read.

docs/plugins/README.md — author-facing guide. Shape 1 (MCP-backed),
Shape 2 (classic), template tokens, safety guarantees, debugging
checklist, publishing notes.

FULL SUITE

808 -> 815 tests (+7), all passing. TypeScript clean, lint clean.

V3.0 PROGRESS

Done: #1 foundation, #2, #6, #7, #9, #10, #11 = 7 of 12 scope items.
Next: #3 budget-weighted resolver + mistakes-boost (~2-3d).
NickCirv added a commit that referenced this pull request Apr 24, 2026
Two orthogonal improvements to the resolver's assembly pipeline. Both
exported from resolver.ts so they're testable in isolation, and both
run in the main resolveRichPacket() flow before the final priority sort.

1. PER-PROVIDER BUDGET ENFORCEMENT (enforcePerProviderBudget)

Providers are SUPPOSED to self-truncate their content to 'tokenBudget',
but a bad plugin or a non-conforming MCP server shouldn't be able to
spend our entire total budget on one section. New helper truncates
each result to the provider's declared budget BEFORE assembly.

- Under-budget content passes through unchanged (zero-cost)
- Over-budget content is line-truncated (never cut mid-word)
- Edge: first line alone > budget -> hard-cap characters with marker

Default budget for unknown/missing providers is 200 tokens (matches
the MCP-config default from item #1).

2. MISTAKES-BOOST RERANKING (boostByMistakes)

If the engram:mistakes provider fires for this file, scan OTHER
providers' content for substring matches against mistake labels
(extracted from the '  ! <label> (flagged <age>)' format). Matching
results get confidence * 1.5 (capped at 1.0).

Runs BEFORE the priority sort, but the secondary sort is now
(priority asc, confidence desc) — so boost breaks ties WITHIN a
priority tier without overriding priority across tiers.

- Case-insensitive matching (labels normalized to lowercase)
- Does NOT boost the mistakes provider itself
- No-op if no mistakes are reported for this file (common case)

Examples of the intended effect:
- An engram:git commit message mentioning a known-broken function
  sorts UP within the git tier
- A mempalace decision that references a mistaken architectural
  choice bubbles ahead of unrelated decisions

TESTS (+10 cases in tests/providers/resolver.test.ts)

enforcePerProviderBudget:
  - Under-budget untouched
  - Over-budget truncated by line with marker
  - Hard-cap when first line alone exceeds budget
  - Default 200 tokens when provider not found

boostByMistakes:
  - No-op when no mistakes provider in set
  - Matching substring boosts confidence 0.6 -> 0.9
  - Cap enforced (0.8 * 1.5 = 1.2 -> 1.0)
  - Non-matching results left alone
  - Mistakes provider itself is never self-boosted
  - Case-insensitive matching across upper/lower case variations

Full suite: 815 -> 825 tests (+10), all passing. TypeScript clean.

V3.0 PROGRESS: 8 of 12 scope items done.
  ✅ #1 foundation ✅ #2#3#6#7#9#10#11
  Remaining: #4 Auto-Memory (blocked on MEMORY.md fixture), #5 SSE
  streaming, #8 pre-mortem warnings, #12 MCP Registry submit, and
  #1 completion (HTTP transport + real-server integration tests).
NickCirv added a commit that referenced this pull request Apr 24, 2026
Opt-in warnings that fire BEFORE Claude Code runs an Edit/Write/Bash
tool call against code previously flagged as a mistake. Fully gated
via ENGRAM_MISTAKE_GUARD env var — zero overhead when unset.

MODES

  unset / '0' → off (default — no database read, no overhead)
  '1'         → permissive: tool proceeds, a warning is prepended
                to any additionalContext the primary handler emits
  '2'         → strict:     tool is denied with the warning as reason

Hooks Edit/Write/Bash only. Read already surfaces mistakes via the
engram:mistakes context provider — duplicating at tool-call time would
be noise.

MATCHING

Edit/Write:
  - Normalize tool_input.file_path to relative POSIX vs projectRoot
  - Indexed lookup via store.getNodesByFile() (uses idx_nodes_source_file)
  - Dedupe by node id when both relative + raw shapes are stored

Bash:
  - Substring match on mistake.metadata.commandPattern (length >2)
  - Fallback: substring match on mistake.sourceFile (length >3 to avoid
    accidentally matching single-char paths like 'a')
  - Full-table scan of mistakes (unavoidable — no file axis to index on).
    Bounded by project size; only runs when the guard is explicitly on.

BI-TEMPORAL FILTER (item #7 interop)

Mistakes with validUntil <= now are suppressed — they refer to code
that has since been refactored away. Prevents stale-warning fatigue.

INTEGRATION

New file: src/intercept/handlers/mistake-guard.ts
  - currentGuardMode() — reads env var at call time, not module load,
    so tests can flip between cases cleanly
  - findMatchingMistakesAsync(target, projectRoot) — the matcher
  - formatWarning(matches) — human-readable warning block
  - applyMistakeGuard(rawResult, payload, kind) — wrapping fn that
    augments additionalContext (permissive) or overrides to deny (strict)

src/intercept/dispatch.ts wiring: after runHandler() returns for Edit/
Write/Bash, pass result through applyMistakeGuard() before returning.
Two-line diff. Doesn't touch the existing handlers.

SAFETY

Every code path in mistake-guard is wrapped in try/catch with a null
return. A guard failure MUST NEVER break the primary handler. If the
store open fails, the env var is wrong, the payload is malformed —
guard silently returns the raw result unchanged.

TESTS (+21 cases in tests/intercept/handlers/mistake-guard.test.ts)

  - currentGuardMode: off/permissive/strict recognition, bogus values
    coerced to off
  - formatWarning: empty-match string, single-match header, >5-match
    collapse with '… and N more'
  - findMatchingMistakesAsync (file): rel path, abs path normalization,
    no-match, validUntil filter
  - findMatchingMistakesAsync (bash): commandPattern substring match,
    sourceFile-in-command match, case-insensitive, too-short pattern
    guard, validUntil filter
  - applyMistakeGuard: mode=off no-op, permissive augments additional
    context, permissive no-match no-op, strict denies with reason,
    permissive from passthrough emits fresh allow-with-warning

Full suite: 825 -> 846 tests (+21), all passing. TypeScript clean.

V3.0 PROGRESS — 9 of 12 scope items

  ✅ #1 foundation  ✅ #2#3#6#7#8#9#10#11

Remaining:
  - #1 completion (HTTP transport + real-server integration tests)
  - #4 Anthropic Auto-Memory bridge (blocked: needs MEMORY.md fixture)
  - #5 SSE streaming for rich packet assembly
  - #12 Official MCP Registry submission (post-ship)
NickCirv added a commit that referenced this pull request Apr 24, 2026
Adds progressive delivery for rich packet assembly. Instead of blocking
on Promise.allSettled (which waits for the slowest provider — Serena
cold-start, mempalace ChromaDB warmup), clients can stream results
as they arrive and render each section immediately.

NEW — resolveRichPacketStreaming generator (src/providers/resolver.ts)

AsyncGenerator<StreamEvent> that yields:
  { type: 'provider', result: ProviderResult }  — as each resolves
  { type: 'done', providerCount, durationMs }  — final totals

Order = ARRIVAL order (fast providers first). Consumers who want
priority order use the non-streaming resolveRichPacket() which applies
full priority + mistakes-boost + budget logic.

Implementation: fan-out all providers, funnel outcomes into a FIFO
queue + wake-on-arrival pattern. No extra deps. Per-provider timeouts
preserved (same resolveWithTimeout path as non-streaming).

NEW — /context/stream SSE endpoint (src/server/http.ts)

GET /context/stream?file=<relative-path> (auth required).
Emits one SSE frame per StreamEvent. Frame shape matches MCP SEP-1699
(SSE resumption):

  id: 0
  event: provider
  data: {"provider":"engram:ast", …}

  id: 1
  event: provider
  data: {"provider":"engram:mistakes", …}

  id: N
  event: done
  data: {"providerCount":N,"durationMs":347}

Supports Last-Event-ID header — clients reconnecting via
'Last-Event-ID: 3' skip events 0-3 and pick up from 4. Useful for
long-running sessions that drop WiFi mid-stream without losing context.

Client-disconnect aborts the stream cleanly (req.close handler short-
circuits the generator loop).

TESTS (+6 new)

resolver.test.ts (+2):
  - Smoke: streaming generator terminates with a 'done' event for any
    project (no hang, no runaway)
  - Arrival-order invariant: toy generator mirrors production shape,
    verifies fast results yield before slow ones

server/http.test.ts (+4):
  - Missing 'file' param returns 400
  - Valid request returns 200 + text/event-stream + ends with 'done'
  - Every frame carries an 'id:' header (SEP-1699 resumption)
  - Auth required — unauthenticated returns 401

Full suite: 870 -> 876 tests (+6), all passing. TypeScript clean.

V3.0 PROGRESS — 11 of 12 scope items done

  ✅ #1 foundation  ✅ #2#3#4#5#6#7#8#9#10#11

Only remaining in-scope work:
  - #12 MCP Registry submission (~2h, post-ship only)

Plus item #1 completion (HTTP transport + minimal MCP server fixture
for integration tests) — technically part of #1 which shipped its
foundation as c719591; the HTTP transport path was explicitly deferred
until this SSE work landed. Now it can.
shahe-dev pushed a commit to shahe-dev/engram that referenced this pull request Apr 25, 2026
…aming-collision disarm

Items NickCirv#10 + NickCirv#11 from v3.0 Spine implementation plan
(docs/superpowers/specs/2026-04-24-v3.0-spine-implementation.md).

ITEM NickCirv#10 — engram gen emits CLAUDE.md AND AGENTS.md by default

When no --target flag is passed, autogen() now writes BOTH CLAUDE.md
AND AGENTS.md (and updates legacy .cursorrules if present). AGENTS.md
is the Linux Foundation universal agent-instructions standard adopted
by Codex CLI, Cursor, Windsurf, GitHub Copilot, JetBrains Junie, and
Antigravity (donated to AAIF Dec 2025).

Single-source-of-truth: same generated summary writes to both files.

Explicit --target=claude / cursor / agents preserves single-file behavior.

API change: autogen() return type goes from { file: string; ... } to
{ files: string[]; ... }. Only one caller (cli.ts) — updated. All
tests pass (26/26, +5 new dual-emit cases).

ITEM NickCirv#11 — README naming-collision disarm section

Adds 'What engramx is not' section between hero and Dashboard.
Disarms collision with Go-Engram (Gentleman-Programming/engram),
DeepSeek's Engram paper (Jan 2026), and MemPalace in the first 30
seconds of any new visitor read. Per decision: 'engramx' stays
canonical brand (decision logged in strategy folder).

PLANNING SPEC

Lands the full v3.0 implementation plan at
docs/superpowers/specs/2026-04-24-v3.0-spine-implementation.md
(605 lines, sibling to the elevation-trilogy spec). Grounded in actual
file paths + line numbers from the existing codebase — provider system
already production-grade, Pillar 1 is EXTENDING not rewriting. 12 scope
items mapped with dependencies, branch strategy, schema migrations,
test strategy, release checklist.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: CORS wildcard + auth-off-by-default on engram server enables CSRF, graph exfil, and persistent prompt injection

1 participant