VYPR
High severityGHSA Advisory· Published May 29, 2026· Updated May 29, 2026

AgenticMail API/storage and outbound relay hardening fixes

CVE-2026-47255

Description

The current upstream main branch at commit 7e0206d was reviewed, and the fix-first patch set was rebased on 2026-05-18. The patches cover: validated and bound inactive-agent hour filtering; storage SQL identifier validation; metadata-backed ownership checks for raw storage SQL; blocking direct storage metadata access through raw SQL; fail-closed outbound worker secret handling; SMTP envelope/header control-character validation before command construction; and TLS certificate verification as the default for MailSender with an explicit opt-out for local development. Validation completed locally with targeted API/Core security tests plus API/Core builds. The security patch branch was not published publicly because te repository's SECURITY.md asks reporters not to open public vulnerability issues.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Multiple API, storage, and relay vulnerabilities in agenticmail before 0.9.46 allow SQL injection, cross-agent data access, and secret disclosure.

Vulnerability

agenticmail versions prior to 0.9.46 contain multiple security flaws in the API, storage, and outbound relay components [3]. The storage routes interpolate raw SQL identifiers (where keys, groupBy, orderBy, index/column/conflict identifiers) without validation, enabling SQL injection (CWE-89) [3]. The having clause, though length-bounded and checked for stacked statements and DDL/DML keywords after 0.9.46, was previously unvalidated [4]. The raw /storage/sql endpoint does not resolve tables against metadata ownership records, allowing cross-agent access to storage tables owned by other agents (CWE-284) [3]. The outbound Cloudflare worker shipped with a hardcoded OUTBOUND_SECRET in its metadata and fallback code (CWE-798) [3]. MailSender unconditionally set rejectUnauthorized: false, accepting any TLS certificate for SMTP (CWE-319) [3]. Additionally, the fix for TLS verification broke local mail delivery until 0.9.49, where loopback hosts opt out by default [1].

Exploitation

An attacker with network access to the agenticmail API can exploit SQL injection by crafting requests with malicious identifier values, such as in where, groupBy, orderBy, or having parameters of storage queries [3][4]. No authentication is required if the endpoint is exposed; authenticated agents can also escalate to data belonging to other agents using the raw SQL endpoint by referencing tables not owned by them [3]. The hardcoded outbound relay secret can be obtained by reading the public repository, enabling an attacker to impersonate the relay or send arbitrary messages [3]. For TLS, since verification was off by default, a man-in-the-middle attacker could intercept SMTP traffic without detection [3].

Impact

Successful exploitation leads to: SQL injection allowing arbitrary read/write access to storage data, including cross-agent data exfiltration or modification [3]; privilege escalation via accessing other agents' tables [3]; disclosure and misuse of the hardcoded outbound relay secret, potentially leading to credential theft or unauthorized relay use [3]; and plaintext interception of outbound email due to lack of TLS verification [3]. The combination of flaws could allow full compromise of the agenticmail storage and relay infrastructure.

Mitigation

All deployments must upgrade to agenticmail version 0.9.46 (released 2026-05-18) which includes the security patch GHSA-wjjv-3mj2-39hf [3]. Version 0.9.49 (released 2026-05-19) further adjusts TLS handling to allow local loopback hosts to opt out of verification by default, while still enforcing verification for remote hosts [1]. Operators who deployed the Cloudflare worker must rotate OUTBOUND_SECRET after upgrading [3]. There is no workaround for the pre-patch vulnerabilities — upgrading is required. No CVE has been assigned to this issue; it is tracked as GHSA-wjjv-3mj2-39hf [3]. The vulnerability is not listed on CISA's Known Exploited Vulnerabilities (KEV) catalog as of this writing.

AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

4
6c70c8254c90

Release 0.9.49: loopback hosts opt out of TLS verification by default

https://github.com/agenticmail/agenticmailOpe OlatunjiMay 19, 2026via ghsa
7 files changed · +59 10
  • agenticmail/package.json+3 3 modified
    @@ -1,6 +1,6 @@
     {
       "name": "@agenticmail/cli",
    -  "version": "0.9.48",
    +  "version": "0.9.49",
       "description": "Email and SMS infrastructure for AI agents — the first platform to give agents real email addresses and phone numbers",
       "type": "module",
       "main": "dist/index.js",
    @@ -31,8 +31,8 @@
         "prepublishOnly": "npm run build"
       },
       "dependencies": {
    -    "@agenticmail/api": "^0.9.33",
    -    "@agenticmail/core": "^0.9.12",
    +    "@agenticmail/api": "^0.9.34",
    +    "@agenticmail/core": "^0.9.13",
         "json5": "^2.2.3"
       },
       "optionalDependencies": {
    
  • CHANGELOG.md+19 0 modified
    @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
     The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
     and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
     
    +## [0.9.49] - 2026-05-19
    +
    +### Fixed
    +
    +- **Local mail sending broke under the v0.9.46 TLS hardening.**
    +  GHSA-wjjv-3mj2-39hf made `MailSender` TLS certificate verification
    +  the default. Correct for remote servers — but the bundled local
    +  mail server (Stalwart on `127.0.0.1`) presents a self-signed
    +  certificate, so verification always failed and local agent-to-agent
    +  mail stopped working out of the box on self-hosted deployments
    +  (`API error 500: self-signed certificate`). `MailSender` now
    +  resolves `rejectUnauthorized` per host: loopback hosts
    +  (`localhost`, `127.0.0.0/8`, `::1`, `*.localhost`) default to
    +  verification OFF — a self-signed cert on a loopback address is not a
    +  meaningful MITM surface — while remote hosts still verify by
    +  default. An explicit `tlsRejectUnauthorized` option overrides either
    +  way. Exposed as `resolveTlsRejectUnauthorized` / `isLoopbackMailHost`
    +  for reuse.
    +
     ## [0.9.48] - 2026-05-18
     
     ### Added
    
  • packages/api/package.json+2 2 modified
    @@ -1,6 +1,6 @@
     {
       "name": "@agenticmail/api",
    -  "version": "0.9.33",
    +  "version": "0.9.34",
       "description": "REST API server for AgenticMail — email and SMS endpoints for AI agents",
       "type": "module",
       "main": "dist/index.js",
    @@ -28,7 +28,7 @@
         "prepublishOnly": "npm run build"
       },
       "dependencies": {
    -    "@agenticmail/core": "^0.9.12",
    +    "@agenticmail/core": "^0.9.13",
         "cors": "^2.8.5",
         "dotenv": "^16.4.7",
         "express": "^4.21.0",
    
  • packages/core/package.json+1 1 modified
    @@ -1,6 +1,6 @@
     {
       "name": "@agenticmail/core",
    -  "version": "0.9.12",
    +  "version": "0.9.13",
       "description": "Core SDK for AgenticMail — email, SMS, and phone number access for AI agents",
       "type": "module",
       "main": "dist/index.js",
    
  • packages/core/src/index.ts+1 1 modified
    @@ -18,7 +18,7 @@ export { AgentDeletionService } from './accounts/deletion.js';
     export type { DeletionReport, DeletionSummary, ArchivedEmail, ArchiveAndDeleteOptions } from './accounts/deletion.js';
     
     // Mail Operations
    -export { MailSender, type MailSenderOptions, type SendResultWithRaw } from './mail/sender.js';
    +export { MailSender, isLoopbackMailHost, resolveTlsRejectUnauthorized, type MailSenderOptions, type SendResultWithRaw } from './mail/sender.js';
     export { MailReceiver, type MailReceiverOptions, type FolderInfo } from './mail/receiver.js';
     export { parseEmail } from './mail/parser.js';
     export type {
    
  • packages/core/src/mail/sender.ts+31 1 modified
    @@ -18,6 +18,36 @@ export interface SendResultWithRaw extends SendResult {
       raw: Buffer;
     }
     
    +/** True for loopback hosts — the bundled local mail server lives here. */
    +export function isLoopbackMailHost(host: string | undefined): boolean {
    +  const h = (host ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '');
    +  return h === 'localhost'
    +    || h === '::1'
    +    || h.endsWith('.localhost')
    +    || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
    +}
    +
    +/**
    + * Resolve the effective TLS `rejectUnauthorized` for a mail connection.
    + *
    + * GHSA-wjjv-3mj2-39hf made certificate verification the default — but
    + * the bundled local mail server (Stalwart on 127.0.0.1) presents a
    + * self-signed certificate, so verifying it always fails and breaks
    + * local agent-to-agent mail out of the box. A self-signed cert on a
    + * loopback address is not a meaningful MITM surface, so for loopback
    + * hosts verification defaults OFF. Remote hosts still verify by
    + * default. An explicit `tlsRejectUnauthorized` option always wins
    + * either way, so a deployment can still force-verify localhost or
    + * opt a remote host out if it really needs to.
    + */
    +export function resolveTlsRejectUnauthorized(
    +  host: string | undefined,
    +  explicit: boolean | undefined,
    +): boolean {
    +  if (explicit !== undefined) return explicit;
    +  return !isLoopbackMailHost(host);
    +}
    +
     export class MailSender {
       private transporter: Transporter;
       private email: string;
    @@ -33,7 +63,7 @@ export class MailSender {
             pass: options.password,
           },
           tls: {
    -        rejectUnauthorized: options.tlsRejectUnauthorized ?? true,
    +        rejectUnauthorized: resolveTlsRejectUnauthorized(options.host, options.tlsRejectUnauthorized),
           },
           connectionTimeout: 10_000, // 10s to establish TCP connection
           greetingTimeout: 10_000,   // 10s for SMTP greeting
    
  • packages/mcp/package.json+2 2 modified
    @@ -1,6 +1,6 @@
     {
       "name": "@agenticmail/mcp",
    -  "version": "0.9.11",
    +  "version": "0.9.13",
       "mcpName": "io.github.agenticmail/mcp",
       "description": "MCP server for AgenticMail — give any AI client real email and SMS capabilities",
       "type": "module",
    @@ -30,7 +30,7 @@
       "dependencies": {
         "@modelcontextprotocol/sdk": "^1.12.0",
         "zod": "^3.24.0",
    -    "@agenticmail/core": "^0.9.12"
    +    "@agenticmail/core": "^0.9.13"
       },
       "devDependencies": {
         "tsup": "^8.4.0",
    
8cb053f2307d

Release 0.9.46: security patch GHSA-wjjv-3mj2-39hf + host session/bridge changes

https://github.com/agenticmail/agenticmailOpe OlatunjiMay 18, 2026via ghsa
6 files changed · +61 10
  • agenticmail/package.json+3 3 modified
    @@ -1,6 +1,6 @@
     {
       "name": "@agenticmail/cli",
    -  "version": "0.9.45",
    +  "version": "0.9.46",
       "description": "Email and SMS infrastructure for AI agents — the first platform to give agents real email addresses and phone numbers",
       "type": "module",
       "main": "dist/index.js",
    @@ -31,8 +31,8 @@
         "prepublishOnly": "npm run build"
       },
       "dependencies": {
    -    "@agenticmail/api": "^0.9.31",
    -    "@agenticmail/core": "^0.9.9",
    +    "@agenticmail/api": "^0.9.32",
    +    "@agenticmail/core": "^0.9.10",
         "json5": "^2.2.3"
       },
       "optionalDependencies": {
    
  • CHANGELOG.md+51 0 modified
    @@ -5,6 +5,57 @@ All notable changes to this project will be documented in this file.
     The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
     and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
     
    +## [0.9.46] - 2026-05-18
    +
    +### Security — GHSA-wjjv-3mj2-39hf (HIGH)
    +
    +API/storage and outbound-relay hardening. Reported and patched by
    +@benediktkraus via private vulnerability disclosure; maintainer
    +hardening folded in on merge. **All deployments should upgrade.**
    +
    +- **Hardcoded outbound-relay secret removed** (CWE-798). The Cloudflare
    +  outbound worker shipped `OUTBOUND_SECRET = "outbound_2sabi_secret_key"`
    +  in `metadata.json`, and `outbound.js` used the same literal as a
    +  fallback — so anyone who read the public repo held the relay secret.
    +  The binding and the fallback are gone; the worker now refuses to
    +  start (HTTP 500) unless `OUTBOUND_SECRET` is configured. **Operators
    +  who deployed the worker must rotate `OUTBOUND_SECRET`.**
    +- **SQL injection in the storage routes closed** (CWE-89). Raw
    +  identifier interpolation in `where` keys, `groupBy`, `orderBy`, and
    +  index/column/conflict identifiers is now strictly validated against
    +  `^[A-Za-z_][A-Za-z0-9_]{0,63}$`. The `having` clause — the one query
    +  clause still interpolated raw — is now length-bounded and rejects
    +  stacked statements, comment markers, and DDL/DML keywords.
    +- **Cross-agent storage access closed** (CWE-284). Raw `/storage/sql`
    +  now resolves every referenced table against the storage-metadata
    +  ownership records (comma-joins included) and denies tables owned by
    +  other agents; direct access to the metadata table itself is blocked.
    +- **TLS certificate verification on by default** (CWE-319).
    +  `MailSender` previously set `rejectUnauthorized: false`
    +  unconditionally — every SMTP connection accepted any certificate.
    +  Verification is now the default, with an explicit
    +  `tlsRejectUnauthorized: false` opt-out for local development.
    +- **SMTP envelope/header injection blocked** (CWE-20). The outbound
    +  worker now rejects CR/LF and control characters in envelope
    +  addresses and headers before building SMTP commands.
    +- **Inactive-agent hour filtering validated** (CWE-20). `/accounts/inactive`
    +  and `/accounts/cleanup` now validate the `hours` parameter as a
    +  positive integer and bind it as a query parameter.
    +
    +Patched versions: `@agenticmail/api@0.9.32`, `@agenticmail/core@0.9.10`
    +(and the packages that depend on them).
    +
    +### Changed
    +
    +- **Host session registry widened** (#36, @benediktkraus) — `HostName`
    +  now covers `openclaw`/`gemini`/`hermes`; `HostSession` gains optional
    +  `resumeMode` and `hostMetadata` fields.
    +- **Bridge wake primitives shared** (#37, @benediktkraus) — the
    +  duplicated bridge-wake logic in the Claude Code and Codex adapters
    +  (`composeBridgeWakePrompt`, error classification, the live-operator
    +  window) is consolidated into a shared `@agenticmail/core` module.
    +  Pure refactor, no behavior change.
    +
     ## [0.9.45] - 2026-05-18
     
     ### Added — EU SMS provider registry + 46elks
    
  • packages/api/package.json+2 2 modified
    @@ -1,6 +1,6 @@
     {
       "name": "@agenticmail/api",
    -  "version": "0.9.31",
    +  "version": "0.9.32",
       "description": "REST API server for AgenticMail — email and SMS endpoints for AI agents",
       "type": "module",
       "main": "dist/index.js",
    @@ -28,7 +28,7 @@
         "prepublishOnly": "npm run build"
       },
       "dependencies": {
    -    "@agenticmail/core": "^0.9.9",
    +    "@agenticmail/core": "^0.9.10",
         "cors": "^2.8.5",
         "dotenv": "^16.4.7",
         "express": "^4.21.0",
    
  • packages/claudecode/package.json+2 2 modified
    @@ -1,6 +1,6 @@
     {
       "name": "@agenticmail/claudecode",
    -  "version": "0.2.22",
    +  "version": "0.2.23",
       "description": "Claude Code integration for AgenticMail — surfaces every AgenticMail agent as a native Claude Code subagent so any Claude Code session can delegate to them with the Agent tool",
       "type": "module",
       "main": "dist/index.js",
    @@ -47,7 +47,7 @@
         "prepublishOnly": "npm run build"
       },
       "dependencies": {
    -    "@agenticmail/core": "^0.9.7",
    +    "@agenticmail/core": "^0.9.10",
         "@agenticmail/mcp": "^0.9.8",
         "@anthropic-ai/claude-agent-sdk": "^0.2.140"
       },
    
  • packages/codex/package.json+2 2 modified
    @@ -1,6 +1,6 @@
     {
       "name": "@agenticmail/codex",
    -  "version": "0.1.18",
    +  "version": "0.1.19",
       "description": "OpenAI Codex CLI integration for AgenticMail — surfaces every AgenticMail agent as a native Codex subagent and wires the dispatcher daemon to the Codex SDK",
       "type": "module",
       "main": "dist/index.js",
    @@ -45,7 +45,7 @@
         "prepublishOnly": "npm run build"
       },
       "dependencies": {
    -    "@agenticmail/core": "^0.9.7",
    +    "@agenticmail/core": "^0.9.10",
         "@agenticmail/mcp": "^0.9.8",
         "@iarna/toml": "^2.2.5"
       },
    
  • packages/core/package.json+1 1 modified
    @@ -1,6 +1,6 @@
     {
       "name": "@agenticmail/core",
    -  "version": "0.9.9",
    +  "version": "0.9.10",
       "description": "Core SDK for AgenticMail — email, SMS, and phone number access for AI agents",
       "type": "module",
       "main": "dist/index.js",
    
234b811e426a

Merge security-fix: GHSA-wjjv-3mj2-39hf API/storage/relay hardening

https://github.com/agenticmail/agenticmailOpe OlatunjiMay 18, 2026via ghsa
9 files changed · +513 91
  • packages/api/src/routes/accounts.ts+25 6 modified
    @@ -17,6 +17,17 @@ function sanitizeAgent(agent: any): any {
       return agent;
     }
     
    +function parsePositiveIntegerHours(value: unknown, fallback: number): number | null {
    +  if (value === undefined || value === null || value === '') return fallback;
    +  const raw = Array.isArray(value) ? value[0] : value;
    +  if (typeof raw !== 'string' && typeof raw !== 'number') return null;
    +  const text = String(raw).trim();
    +  if (!/^\d+$/.test(text)) return null;
    +  const parsed = Number.parseInt(text, 10);
    +  if (!Number.isSafeInteger(parsed) || parsed < 1) return null;
    +  return parsed;
    +}
    +
     export function createAccountRoutes(accountManager: AccountManager, db: Database, config: AgenticMailConfig): Router {
       const router = Router();
       const deletionService = new AgentDeletionService(db, accountManager, config);
    @@ -204,29 +215,37 @@ export function createAccountRoutes(accountManager: AccountManager, db: Database
       // IMPORTANT: Must be before /accounts/:id to avoid "inactive" matching as :id
       router.get('/accounts/inactive', requireMaster, async (_req, res, next) => {
         try {
    -      const hours = Math.max(parseInt(_req.query.hours as string) || 24, 1);
    +      const hours = parsePositiveIntegerHours(_req.query.hours, 24);
    +      if (hours === null) {
    +        res.status(400).json({ error: 'hours must be a positive integer' });
    +        return;
    +      }
           // Use COALESCE so agents with NULL last_activity_at fall back to created_at
           // (prevents brand-new agents from being flagged as inactive)
           const rows = db.prepare(
             `SELECT id, name, email, role, last_activity_at, persistent, created_at FROM agents
    -         WHERE persistent = 0 AND COALESCE(last_activity_at, created_at) < datetime('now', '-${hours} hours')
    +         WHERE persistent = 0 AND COALESCE(last_activity_at, created_at) < datetime('now', ?)
              ORDER BY COALESCE(last_activity_at, created_at) ASC`
    -      ).all() as any[];
    +      ).all(`-${hours} hours`) as any[];
           res.json({ agents: rows, count: rows.length });
         } catch (err) { next(err); }
       });
     
       // Cleanup inactive non-persistent agents — requires master key
       router.post('/accounts/cleanup', requireMaster, async (req, res, next) => {
         try {
    -      const hours = Math.max(parseInt(req.body?.hours as string) || 24, 1);
    +      const hours = parsePositiveIntegerHours(req.body?.hours, 24);
    +      if (hours === null) {
    +        res.status(400).json({ error: 'hours must be a positive integer' });
    +        return;
    +      }
           const dryRun = req.body?.dryRun === true;
           // Use COALESCE so agents with NULL last_activity_at fall back to created_at
           // (prevents brand-new agents from being swept up in cleanup)
           const rows = db.prepare(
             `SELECT id, name, email FROM agents
    -         WHERE persistent = 0 AND COALESCE(last_activity_at, created_at) < datetime('now', '-${hours} hours')`
    -      ).all() as any[];
    +         WHERE persistent = 0 AND COALESCE(last_activity_at, created_at) < datetime('now', ?)`
    +      ).all(`-${hours} hours`) as any[];
     
           if (dryRun) {
             res.json({ wouldDelete: rows, count: rows.length, dryRun: true });
    
  • packages/api/src/routes/storage.ts+186 81 modified
    @@ -39,6 +39,34 @@ interface IndexDef {
     
     // ─── Helpers ────────────────────────────────────────────
     
    +const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
    +
    +class StorageRouteError extends Error {
    +  constructor(message: string, public statusCode = 400) {
    +    super(message);
    +  }
    +}
    +
    +function sendStorageError(res: Response, err: any): void {
    +  const statusCode = Number.isInteger(err?.statusCode) ? err.statusCode : 500;
    +  res.status(statusCode).json({ error: err?.message || 'Storage operation failed' });
    +}
    +
    +function requireIdentifier(value: unknown, label = 'identifier'): string {
    +  if (typeof value !== 'string' || !IDENTIFIER_RE.test(value)) {
    +    throw new StorageRouteError(`${label} contains invalid characters`);
    +  }
    +  return value;
    +}
    +
    +function requireIdentifierList(values: string[], label: string): string[] {
    +  return values.map((value) => requireIdentifier(value, label));
    +}
    +
    +function escapeSqlString(value: string): string {
    +  return value.replace(/'/g, "''");
    +}
    +
     function mapColumnType(col: ColumnDef, dialect: string): string {
       const typeMap: Record<string, Record<string, string>> = {
         sqlite: { text: 'TEXT', integer: 'INTEGER', real: 'REAL', boolean: 'INTEGER', json: 'JSON', blob: 'BLOB', timestamp: 'TEXT' },
    @@ -50,7 +78,8 @@ function mapColumnType(col: ColumnDef, dialect: string): string {
     }
     
     function buildColumnDDL(col: ColumnDef, dialect: string): string {
    -  let ddl = `${col.name} ${mapColumnType(col, dialect)}`;
    +  const columnName = requireIdentifier(col.name, 'column name');
    +  let ddl = `${columnName} ${mapColumnType(col, dialect)}`;
       if (col.primaryKey) ddl += ' PRIMARY KEY';
       if (col.required && !col.primaryKey) ddl += ' NOT NULL';
       if (col.unique && !col.primaryKey) ddl += ' UNIQUE';
    @@ -81,15 +110,15 @@ function buildColumnDDL(col: ColumnDef, dialect: string): string {
           const trimmed = col.default.slice(0, 500).trim();
           const isSqlExpr = /\(.*\)/.test(trimmed)
             || /^CURRENT_(?:TIMESTAMP|DATE|TIME)$/i.test(trimmed);
    -      val = isSqlExpr ? `(${trimmed})` : `'${col.default.replace(/'/g, "''")}'`;
    +      val = isSqlExpr ? `(${trimmed})` : `'${escapeSqlString(col.default)}'`;
         } else {
           val = col.default;
         }
         ddl += ` DEFAULT ${val}`;
       }
       if (col.check) ddl += ` CHECK (${col.check})`;
       if (col.references) {
    -    ddl += ` REFERENCES ${col.references.table}(${col.references.column})`;
    +    ddl += ` REFERENCES ${requireIdentifier(col.references.table, 'reference table')}(${requireIdentifier(col.references.column, 'reference column')})`;
         if (col.references.onDelete) ddl += ` ON DELETE ${col.references.onDelete}`;
       }
       return ddl;
    @@ -108,56 +137,114 @@ function resolveTable(agentId: string, name: string): string {
     }
     
     function isSafeTable(tableName: string): boolean {
    -  return tableName.startsWith('agt_') || tableName.startsWith('shared_');
    +  return IDENTIFIER_RE.test(tableName) && (tableName.startsWith('agt_') || tableName.startsWith('shared_'));
     }
     
     function buildWhereClause(where: Record<string, any>): { sql: string; params: any[] } {
       const params: any[] = [];
       const conditions = Object.entries(where).map(([k, v]) => {
    -    if (v === null) return `${k} IS NULL`;
    +    const column = requireIdentifier(k, 'where key');
    +    if (v === null) return `${column} IS NULL`;
         if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
           // Operator objects: { $gt: 5, $lt: 10, $like: '%foo%', $ne: 'bar', $is_null: true, $in: [1,2,3] }
           const ops = Object.entries(v).map(([op, val]) => {
             switch (op) {
    -          case '$gt': params.push(val); return `${k} > ?`;
    -          case '$gte': params.push(val); return `${k} >= ?`;
    -          case '$lt': params.push(val); return `${k} < ?`;
    -          case '$lte': params.push(val); return `${k} <= ?`;
    -          case '$ne': params.push(val); return `${k} != ?`;
    -          case '$like': params.push(val); return `${k} LIKE ?`;
    -          case '$ilike': params.push(val); return `LOWER(${k}) LIKE LOWER(?)`;
    -          case '$not_like': params.push(val); return `${k} NOT LIKE ?`;
    +          case '$gt': params.push(val); return `${column} > ?`;
    +          case '$gte': params.push(val); return `${column} >= ?`;
    +          case '$lt': params.push(val); return `${column} < ?`;
    +          case '$lte': params.push(val); return `${column} <= ?`;
    +          case '$ne': params.push(val); return `${column} != ?`;
    +          case '$like': params.push(val); return `${column} LIKE ?`;
    +          case '$ilike': params.push(val); return `LOWER(${column}) LIKE LOWER(?)`;
    +          case '$not_like': params.push(val); return `${column} NOT LIKE ?`;
               case '$in': {
    +            if (!Array.isArray(val)) throw new StorageRouteError('$in requires an array value');
                 const arr = val as any[];
                 params.push(...arr);
    -            return `${k} IN (${arr.map(() => '?').join(', ')})`;
    +            return `${column} IN (${arr.map(() => '?').join(', ')})`;
               }
               case '$not_in': {
    +            if (!Array.isArray(val)) throw new StorageRouteError('$not_in requires an array value');
                 const arr = val as any[];
                 params.push(...arr);
    -            return `${k} NOT IN (${arr.map(() => '?').join(', ')})`;
    +            return `${column} NOT IN (${arr.map(() => '?').join(', ')})`;
               }
    -          case '$is_null': return val ? `${k} IS NULL` : `${k} IS NOT NULL`;
    +          case '$is_null': return val ? `${column} IS NULL` : `${column} IS NOT NULL`;
               case '$between': {
    +            if (!Array.isArray(val) || val.length !== 2) throw new StorageRouteError('$between requires a two-item array');
                 const [lo, hi] = val as [any, any];
                 params.push(lo, hi);
    -            return `${k} BETWEEN ? AND ?`;
    +            return `${column} BETWEEN ? AND ?`;
               }
    -          default: params.push(val); return `${k} = ?`;
    +          default:
    +            throw new StorageRouteError(`Unsupported where operator: ${op}`);
             }
           });
           return ops.join(' AND ');
         }
         if (Array.isArray(v)) {
           params.push(...v);
    -      return `${k} IN (${v.map(() => '?').join(', ')})`;
    +      return `${column} IN (${v.map(() => '?').join(', ')})`;
         }
         params.push(typeof v === 'object' ? JSON.stringify(v) : v);
    -    return `${k} = ?`;
    +    return `${column} = ?`;
       });
       return { sql: conditions.join(' AND '), params };
     }
     
    +function buildOrderBy(orderBy: string): string {
    +  const parts = orderBy.split(',').map((part) => {
    +    const match = part.trim().match(/^([A-Za-z_][A-Za-z0-9_]{0,63})(?:\s+(ASC|DESC))?$/i);
    +    if (!match) throw new StorageRouteError('orderBy contains invalid characters');
    +    return `${match[1]}${match[2] ? ` ${match[2].toUpperCase()}` : ''}`;
    +  });
    +  return parts.join(', ');
    +}
    +
    +function buildGroupBy(groupBy: string): string {
    +  return requireIdentifierList(groupBy.split(',').map((part) => part.trim()), 'groupBy column').join(', ');
    +}
    +
    +function extractTableRefs(sql: string): string[] {
    +  const refs = new Set<string>();
    +  const refPattern = /\b(?:FROM|INTO|UPDATE|TABLE|JOIN)\s+["`[]?([A-Za-z_][A-Za-z0-9_]{0,63})["`\]]?/gi;
    +  for (const match of sql.matchAll(refPattern)) refs.add(match[1]);
    +  // Hardening — the FROM/JOIN-anchored scan above misses tables in a
    +  // comma-join list (`FROM agt_mine, agt_victim` only captures the
    +  // first). Catch EVERY storage-table-shaped token anywhere in the
    +  // query as a backstop: any agt_*/shared_*/metadata token must be
    +  // ownership-checked by verifySqlAccess, comma-join or not.
    +  const storageTokenPattern = /\b(agt_[A-Za-z0-9_]+|shared_[A-Za-z0-9_]+|agenticmail_storage_meta)\b/gi;
    +  for (const match of sql.matchAll(storageTokenPattern)) refs.add(match[1]);
    +  return [...refs];
    +}
    +
    +/**
    + * Hardening — `having` was the one query clause still interpolated raw
    + * into SQL after the #GHSA storage patch validated `groupBy`/`orderBy`.
    + * HAVING legitimately needs aggregate expressions (`COUNT(*) > 5`), so
    + * it can't be identifier-validated like the others. Instead: bound the
    + * length and reject statement-breaking constructs (stacked statements,
    + * comment markers) and any DDL/DML keyword. The caller already owns the
    + * table and is authenticated, so a HAVING expression that can't escape
    + * the SELECT or run DDL/DML has no meaningful blast radius.
    + */
    +function sanitizeHavingClause(having: string): string {
    +  if (typeof having !== 'string') {
    +    throw new StorageRouteError('having must be a string');
    +  }
    +  if (having.length > 200) {
    +    throw new StorageRouteError('having clause is too long (max 200 chars)');
    +  }
    +  if (/;|--|\/\*|\*\//.test(having)) {
    +    throw new StorageRouteError('having clause contains a forbidden token');
    +  }
    +  if (/\b(DROP|DELETE|INSERT|UPDATE|UNION|ATTACH|DETACH|PRAGMA|CREATE|ALTER|REPLACE|EXEC|VACUUM)\b/i.test(having)) {
    +    throw new StorageRouteError('having clause contains a forbidden keyword');
    +  }
    +  return having;
    +}
    +
     function nowExpr(dialect: string): string {
       return dialect === 'postgres' ? 'NOW()' : "datetime('now')";
     }
    @@ -253,6 +340,25 @@ export function createStorageRoutes(
         return meta;
       }
     
    +  async function verifySqlAccess(agent: { id: string }, sql: string): Promise<void> {
    +    const tableRefs = extractTableRefs(sql);
    +    for (const tbl of tableRefs) {
    +      if (tbl === 'agenticmail_storage_meta') {
    +        throw new StorageRouteError('Direct SQL access to storage metadata is not allowed', 403);
    +      }
    +      if (!isSafeTable(tbl)) {
    +        throw new StorageRouteError(`Cannot operate on table "${tbl}". Only agt_* and shared_* tables are allowed.`, 403);
    +      }
    +      const meta = await db.get('SELECT * FROM agenticmail_storage_meta WHERE table_name = ?', [tbl]);
    +      if (!meta) {
    +        throw new StorageRouteError(`Table "${tbl}" is not registered in storage metadata`, 404);
    +      }
    +      if (meta.agent_id !== agent.id && !meta.shared) {
    +        throw new StorageRouteError(`Access denied for table "${tbl}"`, 403);
    +      }
    +    }
    +  }
    +
       // ─── Metadata tracking table ────────────────────────
       const ensureMetaTable = (() => {
         let done = false;
    @@ -327,14 +433,15 @@ export function createStorageRoutes(
           if (indexes?.length) {
             for (let i = 0; i < indexes.length; i++) {
               const idx = indexes[i];
    -          const idxName = idx.name || `idx_${tableName}_${idx.columns.join('_')}`;
    +          const idxColumns = requireIdentifierList(idx.columns, 'index column');
    +          const idxName = idx.name ? requireIdentifier(idx.name, 'index name') : `idx_${tableName}_${idxColumns.join('_')}`;
               const unique = idx.unique ? 'UNIQUE ' : '';
    -          let idxSql = `CREATE ${unique}INDEX IF NOT EXISTS ${idxName} ON ${tableName}(${idx.columns.join(', ')})`;
    +          let idxSql = `CREATE ${unique}INDEX IF NOT EXISTS ${idxName} ON ${tableName}(${idxColumns.join(', ')})`;
               if (idx.where && (dialect === 'sqlite' || dialect === 'postgres' || dialect === 'turso')) {
                 idxSql += ` WHERE ${idx.where}`;
               }
               await db.run(idxSql);
    -          idxMeta.push({ name: idxName, columns: idx.columns, unique: !!idx.unique, where: idx.where });
    +          idxMeta.push({ name: idxName, columns: idxColumns, unique: !!idx.unique, where: idx.where });
             }
           }
     
    @@ -344,7 +451,7 @@ export function createStorageRoutes(
           );
     
           res.json({ ok: true, table: tableName, columns: allCols, indexes: idxMeta });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── GET /storage/tables — List tables ──────────────
    @@ -379,7 +486,7 @@ export function createStorageRoutes(
               updatedAt: t.updated_at,
             })),
           });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── GET /storage/tables/:name/describe — Full schema ─
    @@ -436,7 +543,7 @@ export function createStorageRoutes(
             dbIndexes: indexInfo,
             createdAt: meta.created_at,
           });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── POST /storage/tables/:name/columns — Add column ─
    @@ -461,7 +568,7 @@ export function createStorageRoutes(
           await db.run(`UPDATE agenticmail_storage_meta SET columns = ?, updated_at = ${nowExpr(dialect)} WHERE table_name = ?`, [JSON.stringify(cols), tableName]);
     
           res.json({ ok: true, column: column.name });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── DELETE /storage/tables/:name/columns/:col — Drop column ─
    @@ -489,7 +596,7 @@ export function createStorageRoutes(
           await db.run(`UPDATE agenticmail_storage_meta SET columns = ?, updated_at = ${nowExpr(dialect)} WHERE table_name = ?`, [JSON.stringify(cols), tableName]);
     
           res.json({ ok: true, dropped: colName });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── POST /storage/tables/:name/rename — Rename table ─
    @@ -513,7 +620,7 @@ export function createStorageRoutes(
             [newTableName, newName, tableName]);
     
           res.json({ ok: true, oldTable: tableName, newTable: newTableName });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── POST /storage/tables/:name/rename-column — Rename column ─
    @@ -539,7 +646,7 @@ export function createStorageRoutes(
           await db.run(`UPDATE agenticmail_storage_meta SET columns = ?, updated_at = ${nowExpr(dialect)} WHERE table_name = ?`, [JSON.stringify(cols), tableName]);
     
           res.json({ ok: true, oldName, newName });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── DELETE /storage/tables/:name — Drop table ──────
    @@ -558,7 +665,7 @@ export function createStorageRoutes(
           await db.run('DELETE FROM agenticmail_storage_meta WHERE table_name = ?', [tableName]);
     
           res.json({ ok: true, dropped: tableName });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── POST /storage/tables/:name/clone — Clone table ─
    @@ -593,7 +700,7 @@ export function createStorageRoutes(
           );
     
           res.json({ ok: true, table: newTableName, rows: countResult?.cnt || 0 });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
     
    @@ -629,7 +736,7 @@ export function createStorageRoutes(
           await db.run(`UPDATE agenticmail_storage_meta SET indexes = ?, updated_at = ${nowExpr(dialect)} WHERE table_name = ?`, [JSON.stringify(indexes), tableName]);
     
           res.json({ ok: true, index: finalName });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── GET /storage/tables/:name/indexes — List indexes ─
    @@ -658,7 +765,7 @@ export function createStorageRoutes(
           }
     
           res.json({ indexes: dbIndexes });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── DELETE /storage/tables/:name/indexes/:idx — Drop index ─
    @@ -685,7 +792,7 @@ export function createStorageRoutes(
           await db.run(`UPDATE agenticmail_storage_meta SET indexes = ?, updated_at = ${nowExpr(dialect)} WHERE table_name = ?`, [JSON.stringify(indexes), tableName]);
     
           res.json({ ok: true, dropped: idxName });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── POST /storage/tables/:name/reindex — Rebuild indexes ─
    @@ -709,7 +816,7 @@ export function createStorageRoutes(
           }
     
           res.json({ ok: true });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
     
    @@ -736,7 +843,7 @@ export function createStorageRoutes(
     
           let inserted = 0;
           for (const row of rows) {
    -        const keys = Object.keys(row);
    +        const keys = requireIdentifierList(Object.keys(row), 'row key');
             const vals = Object.values(row).map(v => typeof v === 'object' && v !== null ? JSON.stringify(v) : v);
             const placeholders = keys.map(() => '?').join(', ');
             await db.run(`INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders})`, vals);
    @@ -748,7 +855,7 @@ export function createStorageRoutes(
           await db.run('UPDATE agenticmail_storage_meta SET row_count = ?, updated_at = ' + nowExpr(dialect) + ' WHERE table_name = ?', [countResult?.cnt || 0, tableName]);
     
           res.json({ ok: true, inserted });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── POST /storage/upsert — Insert or update on conflict ─
    @@ -768,23 +875,24 @@ export function createStorageRoutes(
           if (!meta) return;
     
           let upserted = 0;
    +      const safeConflictColumn = requireIdentifier(conflictColumn, 'conflict column');
           for (const row of rows) {
    -        const keys = Object.keys(row);
    +        const keys = requireIdentifierList(Object.keys(row), 'row key');
             const vals = Object.values(row).map(v => typeof v === 'object' && v !== null ? JSON.stringify(v) : v);
             const placeholders = keys.map(() => '?').join(', ');
    -        const updateCols = keys.filter(k => k !== conflictColumn).map(k => `${k} = excluded.${k}`).join(', ');
    +        const updateCols = keys.filter(k => k !== safeConflictColumn).map(k => `${k} = excluded.${k}`).join(', ');
     
             if (dialect === 'mysql') {
    -          const dupUpdate = keys.filter(k => k !== conflictColumn).map(k => `${k} = VALUES(${k})`).join(', ');
    +          const dupUpdate = keys.filter(k => k !== safeConflictColumn).map(k => `${k} = VALUES(${k})`).join(', ');
               await db.run(`INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders}) ON DUPLICATE KEY UPDATE ${dupUpdate}`, vals);
             } else {
    -          await db.run(`INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders}) ON CONFLICT(${conflictColumn}) DO UPDATE SET ${updateCols}`, vals);
    +          await db.run(`INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders}) ON CONFLICT(${safeConflictColumn}) DO UPDATE SET ${updateCols}`, vals);
             }
             upserted++;
           }
     
           res.json({ ok: true, upserted });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── POST /storage/query — Query rows ───────────────
    @@ -814,7 +922,7 @@ export function createStorageRoutes(
           const meta = await verifyAccess(agent, tableName, res);
           if (!meta) return;
     
    -      const selectCols = columns?.length ? columns.join(', ') : '*';
    +      const selectCols = columns?.length ? requireIdentifierList(columns, 'selected column').join(', ') : '*';
           let sql = `SELECT ${distinct ? 'DISTINCT ' : ''}${selectCols} FROM ${tableName}`;
           let params: any[] = [];
     
    @@ -824,15 +932,15 @@ export function createStorageRoutes(
             params = w.params;
           }
     
    -      if (groupBy) sql += ` GROUP BY ${groupBy.replace(/[^a-zA-Z0-9_, ()]/g, '')}`;
    -      if (having) sql += ` HAVING ${having}`;
    -      if (orderBy) sql += ` ORDER BY ${orderBy.replace(/[^a-zA-Z0-9_, ]/g, '')}`;
    +      if (groupBy) sql += ` GROUP BY ${buildGroupBy(groupBy)}`;
    +      if (having) sql += ` HAVING ${sanitizeHavingClause(having)}`;
    +      if (orderBy) sql += ` ORDER BY ${buildOrderBy(orderBy)}`;
           if (limit) { sql += ' LIMIT ?'; params.push(limit); }
           if (offset) { sql += ' OFFSET ?'; params.push(offset); }
     
           const rows = await db.all(sql, params);
           res.json({ rows, count: rows.length });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── POST /storage/aggregate — Aggregate queries ────
    @@ -858,8 +966,8 @@ export function createStorageRoutes(
           if (!meta) return;
     
           const selects = operations.map((op, i) => {
    -        const alias = op.alias || `${op.fn}_${op.column || 'all'}`;
    -        const col = op.column || '*';
    +        const alias = requireIdentifier(op.alias || `${op.fn}_${op.column || 'all'}`, 'operation alias');
    +        const col = op.column ? requireIdentifier(op.column, 'operation column') : '*';
             switch (op.fn) {
               case 'count': return `COUNT(${col}) as ${alias}`;
               case 'count_distinct': return `COUNT(DISTINCT ${col}) as ${alias}`;
    @@ -871,7 +979,8 @@ export function createStorageRoutes(
             }
           });
     
    -      let sql = `SELECT ${groupBy ? groupBy + ', ' : ''}${selects.join(', ')} FROM ${tableName}`;
    +      const groupBySql = groupBy ? buildGroupBy(groupBy) : '';
    +      let sql = `SELECT ${groupBySql ? groupBySql + ', ' : ''}${selects.join(', ')} FROM ${tableName}`;
           let params: any[] = [];
     
           if (where && Object.keys(where).length) {
    @@ -880,11 +989,11 @@ export function createStorageRoutes(
             params = w.params;
           }
     
    -      if (groupBy) sql += ` GROUP BY ${groupBy.replace(/[^a-zA-Z0-9_, ]/g, '')}`;
    +      if (groupBySql) sql += ` GROUP BY ${groupBySql}`;
     
           const rows = await db.all(sql, params);
           res.json({ result: rows });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── POST /storage/update — Update rows ─────────────
    @@ -903,13 +1012,14 @@ export function createStorageRoutes(
           const meta = await verifyAccess(agent, tableName, res);
           if (!meta) return;
     
    -      const setClauses = Object.keys(set).map(k => `${k} = ?`);
    +      const setKeys = requireIdentifierList(Object.keys(set), 'set key');
    +      const setClauses = setKeys.map(k => `${k} = ?`);
           const setVals = Object.values(set).map(v => typeof v === 'object' && v !== null ? JSON.stringify(v) : v);
           const w = buildWhereClause(where);
     
           await db.run(`UPDATE ${tableName} SET ${setClauses.join(', ')} WHERE ${w.sql}`, [...setVals, ...w.params]);
           res.json({ ok: true });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── POST /storage/delete-rows — Delete rows ───────
    @@ -935,7 +1045,7 @@ export function createStorageRoutes(
           await db.run('UPDATE agenticmail_storage_meta SET row_count = ?, updated_at = ' + nowExpr(dialect) + ' WHERE table_name = ?', [countResult?.cnt || 0, tableName]);
     
           res.json({ ok: true });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── POST /storage/truncate — Delete all rows ──────
    @@ -958,7 +1068,7 @@ export function createStorageRoutes(
     
           await db.run('UPDATE agenticmail_storage_meta SET row_count = 0, updated_at = ' + nowExpr(dialect) + ' WHERE table_name = ?', [tableName]);
           res.json({ ok: true });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
     
    @@ -981,7 +1091,7 @@ export function createStorageRoutes(
           await db.run(`UPDATE agenticmail_storage_meta SET table_name = ?, archived_at = ${nowExpr(dialect)} WHERE table_name = ?`, [archivedName, tableName]);
     
           res.json({ ok: true, archived: archivedName });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       router.post('/storage/tables/:name/unarchive', async (req: Request, res: Response) => {
    @@ -1000,7 +1110,7 @@ export function createStorageRoutes(
           await db.run('UPDATE agenticmail_storage_meta SET table_name = ?, archived_at = NULL WHERE table_name = ?', [restoredName, archivedName]);
     
           res.json({ ok: true, restored: restoredName });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
     
    @@ -1048,7 +1158,7 @@ export function createStorageRoutes(
           }
     
           res.json({ rows, rowCount: rows.length });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── POST /storage/tables/:name/import — Bulk import ─
    @@ -1075,24 +1185,26 @@ export function createStorageRoutes(
           let skipped = 0;
     
           for (const row of rows) {
    -        const keys = Object.keys(row);
    +        const keys = requireIdentifierList(Object.keys(row), 'row key');
             const vals = Object.values(row).map(v => typeof v === 'object' && v !== null ? JSON.stringify(v) : v);
             const placeholders = keys.map(() => '?').join(', ');
     
             try {
               if (onConflict === 'replace' && conflictColumn) {
    -            const updateCols = keys.filter(k => k !== conflictColumn).map(k => `${k} = excluded.${k}`).join(', ');
    +            const safeConflictColumn = requireIdentifier(conflictColumn, 'conflict column');
    +            const updateCols = keys.filter(k => k !== safeConflictColumn).map(k => `${k} = excluded.${k}`).join(', ');
                 if (dialect === 'mysql') {
    -              const dupUpdate = keys.filter(k => k !== conflictColumn).map(k => `${k} = VALUES(${k})`).join(', ');
    +              const dupUpdate = keys.filter(k => k !== safeConflictColumn).map(k => `${k} = VALUES(${k})`).join(', ');
                   await db.run(`INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders}) ON DUPLICATE KEY UPDATE ${dupUpdate}`, vals);
                 } else {
    -              await db.run(`INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders}) ON CONFLICT(${conflictColumn}) DO UPDATE SET ${updateCols}`, vals);
    +              await db.run(`INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders}) ON CONFLICT(${safeConflictColumn}) DO UPDATE SET ${updateCols}`, vals);
                 }
               } else if (onConflict === 'skip' && conflictColumn) {
    +            const safeConflictColumn = requireIdentifier(conflictColumn, 'conflict column');
                 if (dialect === 'mysql') {
                   await db.run(`INSERT IGNORE INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders})`, vals);
                 } else {
    -              await db.run(`INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders}) ON CONFLICT(${conflictColumn}) DO NOTHING`, vals);
    +              await db.run(`INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders}) ON CONFLICT(${safeConflictColumn}) DO NOTHING`, vals);
                 }
               } else {
                 await db.run(`INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders})`, vals);
    @@ -1108,7 +1220,7 @@ export function createStorageRoutes(
           await db.run('UPDATE agenticmail_storage_meta SET row_count = ?, updated_at = ' + nowExpr(dialect) + ' WHERE table_name = ?', [countResult?.cnt || 0, tableName]);
     
           res.json({ ok: true, imported, skipped, totalRows: countResult?.cnt || 0 });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
     
    @@ -1132,16 +1244,7 @@ export function createStorageRoutes(
             if (upper.includes(p)) return res.status(403).json({ error: `Operation not allowed: ${p}` });
           }
     
    -      // Ensure the query references only safe tables
    -      const tableRefs = sql.match(/(?:FROM|INTO|UPDATE|TABLE|JOIN)\s+(\w+)/gi);
    -      if (tableRefs) {
    -        for (const ref of tableRefs) {
    -          const tbl = ref.split(/\s+/).pop()!;
    -          if (!isSafeTable(tbl) && tbl !== 'agenticmail_storage_meta') {
    -            return res.status(403).json({ error: `Cannot operate on table "${tbl}". Only agt_* and shared_* tables are allowed.` });
    -          }
    -        }
    -      }
    +      await verifySqlAccess(agent, sql);
     
           if (upper.startsWith('SELECT') || upper.startsWith('WITH') || upper.startsWith('EXPLAIN') || upper.startsWith('PRAGMA')) {
             const rows = await db.all(sql, params);
    @@ -1150,7 +1253,7 @@ export function createStorageRoutes(
             await db.run(sql, params);
             return res.json({ ok: true });
           }
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
     
    @@ -1191,7 +1294,7 @@ export function createStorageRoutes(
             dbSize,
             dialect,
           });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── POST /storage/vacuum — Optimize/compact database ─
    @@ -1212,7 +1315,7 @@ export function createStorageRoutes(
             for (const t of tables) await db.run(`OPTIMIZE TABLE ${t.table_name}`);
           }
           res.json({ ok: true });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── POST /storage/analyze — Update query planner stats ─
    @@ -1236,18 +1339,20 @@ export function createStorageRoutes(
           }
     
           res.json({ ok: true });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       // ─── POST /storage/explain — Query execution plan ───
     
       router.post('/storage/explain', async (req: Request, res: Response) => {
         const agent = getAgent(req, res);
         if (!agent) return;
    +    await ensureMetaTable();
     
         try {
           const { sql, params } = req.body as { sql: string; params?: any[] };
           if (!sql) return res.status(400).json({ error: 'sql is required' });
    +      await verifySqlAccess(agent, sql);
     
           let explainSql: string;
           if (dialect === 'postgres') {
    @@ -1260,7 +1365,7 @@ export function createStorageRoutes(
     
           const plan = await db.all(explainSql, params);
           res.json({ plan });
    -    } catch (err: any) { res.status(500).json({ error: err.message }); }
    +    } catch (err: any) { sendStorageError(res, err); }
       });
     
       return router;
    
  • packages/api/src/__tests__/security-routes.test.ts+176 0 added
    @@ -0,0 +1,176 @@
    +import { afterEach, describe, expect, it, vi } from 'vitest';
    +import express from 'express';
    +import { createServer, type Server } from 'node:http';
    +import { once } from 'node:events';
    +import Database from 'better-sqlite3';
    +import { createStorageRoutes } from '../routes/storage.js';
    +
    +vi.mock('@agenticmail/core', () => ({
    +  AGENT_ROLES: ['secretary', 'assistant', 'researcher', 'writer', 'custom'],
    +  AgentDeletionService: class {
    +    listReports() { return []; }
    +    getReport() { return null; }
    +  },
    +}));
    +
    +const { createAccountRoutes } = await import('../routes/accounts.js');
    +
    +type TestAgent = { id: string; email: string };
    +
    +const servers: Server[] = [];
    +
    +afterEach(async () => {
    +  await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
    +});
    +
    +async function listen(app: express.Express): Promise<string> {
    +  const server = createServer(app);
    +  servers.push(server);
    +  server.listen(0, '127.0.0.1');
    +  await once(server, 'listening');
    +  const address = server.address();
    +  if (!address || typeof address === 'string') throw new Error('Unexpected server address');
    +  return `http://127.0.0.1:${address.port}`;
    +}
    +
    +async function request(baseUrl: string, path: string, init?: RequestInit): Promise<{ status: number; body: any }> {
    +  const res = await fetch(`${baseUrl}${path}`, {
    +    ...init,
    +    headers: {
    +      'Content-Type': 'application/json',
    +      ...(init?.headers || {}),
    +    },
    +  });
    +  return { status: res.status, body: await res.json() };
    +}
    +
    +function createAccountApp(db: Database.Database): express.Express {
    +  const app = express();
    +  app.use(express.json());
    +  app.use((req, _res, next) => {
    +    req.isMaster = true;
    +    next();
    +  });
    +  app.use(createAccountRoutes({} as any, db, {} as any));
    +  return app;
    +}
    +
    +function createStorageDb(db: Database.Database) {
    +  return {
    +    run(sql: string, params?: any[]) {
    +      db.prepare(sql).run(...(params ?? []));
    +    },
    +    get(sql: string, params?: any[]) {
    +      return db.prepare(sql).get(...(params ?? []));
    +    },
    +    all(sql: string, params?: any[]) {
    +      return db.prepare(sql).all(...(params ?? []));
    +    },
    +  };
    +}
    +
    +function createStorageApp(db: Database.Database): express.Express {
    +  const agents: Record<string, TestAgent> = {
    +    owner: { id: 'owneragent000001', email: 'owner@example.com' },
    +    intruder: { id: 'intruderagent001', email: 'intruder@example.com' },
    +  };
    +  const app = express();
    +  app.use(express.json());
    +  app.use((req, _res, next) => {
    +    req.agent = agents[String(req.headers['x-agent'] || 'owner')];
    +    next();
    +  });
    +  app.use(createStorageRoutes(createStorageDb(db), {} as any, {} as any));
    +  return app;
    +}
    +
    +describe('account route security validation', () => {
    +  it('rejects non-integer inactive-hours input before building SQL', async () => {
    +    const db = new Database(':memory:');
    +    db.exec(`
    +      CREATE TABLE agents (
    +        id TEXT PRIMARY KEY,
    +        name TEXT,
    +        email TEXT,
    +        role TEXT,
    +        last_activity_at TEXT,
    +        persistent INTEGER,
    +        created_at TEXT
    +      )
    +    `);
    +    const baseUrl = await listen(createAccountApp(db));
    +
    +    const invalid = await request(baseUrl, '/accounts/inactive?hours=24x');
    +    expect(invalid.status).toBe(400);
    +
    +    const valid = await request(baseUrl, '/accounts/inactive?hours=24');
    +    expect(valid.status).toBe(200);
    +    expect(valid.body).toEqual({ agents: [], count: 0 });
    +
    +    db.close();
    +  });
    +});
    +
    +describe('storage route SQL guards', () => {
    +  it('rejects unsafe filter identifiers and allows valid filters', async () => {
    +    const db = new Database(':memory:');
    +    const baseUrl = await listen(createStorageApp(db));
    +
    +    const created = await request(baseUrl, '/storage/tables', {
    +      method: 'POST',
    +      body: JSON.stringify({
    +        name: 'notes',
    +        timestamps: false,
    +        columns: [
    +          { name: 'id', type: 'text', primaryKey: true },
    +          { name: 'title', type: 'text' },
    +        ],
    +      }),
    +    });
    +    expect(created.status).toBe(200);
    +
    +    await request(baseUrl, '/storage/insert', {
    +      method: 'POST',
    +      body: JSON.stringify({ table: 'notes', rows: [{ id: 'n1', title: 'hello' }] }),
    +    });
    +
    +    const invalid = await request(baseUrl, '/storage/query', {
    +      method: 'POST',
    +      body: JSON.stringify({ table: 'notes', where: { 'bad-key': 'n1' } }),
    +    });
    +    expect(invalid.status).toBe(400);
    +
    +    const valid = await request(baseUrl, '/storage/query', {
    +      method: 'POST',
    +      body: JSON.stringify({ table: 'notes', where: { id: 'n1' } }),
    +    });
    +    expect(valid.status).toBe(200);
    +    expect(valid.body.count).toBe(1);
    +
    +    db.close();
    +  });
    +
    +  it('denies raw SQL access to another agent private table', async () => {
    +    const db = new Database(':memory:');
    +    const baseUrl = await listen(createStorageApp(db));
    +
    +    const created = await request(baseUrl, '/storage/tables', {
    +      method: 'POST',
    +      body: JSON.stringify({
    +        name: 'secrets',
    +        timestamps: false,
    +        columns: [{ name: 'id', type: 'text', primaryKey: true }],
    +      }),
    +    });
    +    expect(created.status).toBe(200);
    +
    +    const denied = await request(baseUrl, '/storage/sql', {
    +      method: 'POST',
    +      headers: { 'x-agent': 'intruder' },
    +      body: JSON.stringify({ sql: `SELECT * FROM ${created.body.table}` }),
    +    });
    +    expect(denied.status).toBe(403);
    +
    +    db.close();
    +  });
    +});
    
  • packages/api/vitest.config.ts+10 0 added
    @@ -0,0 +1,10 @@
    +import { defineConfig } from 'vitest/config';
    +import { fileURLToPath } from 'node:url';
    +
    +export default defineConfig({
    +  resolve: {
    +    alias: {
    +      '@agenticmail/core': fileURLToPath(new URL('../core/src/index.ts', import.meta.url)),
    +    },
    +  },
    +});
    
  • packages/core/src/gateway/workers/metadata.json+0 1 modified
    @@ -3,7 +3,6 @@
       "compatibility_date": "2024-09-23",
       "compatibility_flags": ["nodejs_compat"],
       "bindings": [
    -    {"type": "plain_text", "name": "OUTBOUND_SECRET", "text": "outbound_2sabi_secret_key"},
         {"type": "plain_text", "name": "SMTP_HOST", "text": "smtp.gmail.com"},
         {"type": "plain_text", "name": "SMTP_PORT", "text": "465"},
         {"type": "send_email", "name": "SEND_EMAIL"}
    
  • packages/core/src/gateway/workers/outbound.js+43 2 modified
    @@ -91,6 +91,29 @@ function encodeBase64(str) {
       return btoa(str);
     }
     
    +class ClientRequestError extends Error {
    +  constructor(message, status = 400) {
    +    super(message);
    +    this.status = status;
    +  }
    +}
    +
    +const CONTROL_CHARS = /[\r\n\x00-\x1F\x7F]/;
    +
    +function assertHeaderValue(value, field) {
    +  if (value === undefined || value === null) return;
    +  if (typeof value !== "string" || CONTROL_CHARS.test(value)) {
    +    throw new ClientRequestError(`${field} contains invalid characters`);
    +  }
    +}
    +
    +function assertEnvelopeAddress(value, field) {
    +  assertHeaderValue(value, field);
    +  if (!value || /[<>\s]/.test(value) || !/^[^@]+@[^@]+$/.test(value)) {
    +    throw new ClientRequestError(`${field} must be a plain email address`);
    +  }
    +}
    +
     async function smtpLogin(smtp, user, pass) {
       // Try AUTH LOGIN
       const authResp = await smtp.command("AUTH LOGIN");
    @@ -111,6 +134,13 @@ async function smtpLogin(smtp, user, pass) {
     
     function buildRawEmail({ from, to, subject, text, html, replyTo, inReplyTo, references }) {
       const recipients = Array.isArray(to) ? to : [to];
    +  assertEnvelopeAddress(from, "from");
    +  for (const recipient of recipients) assertEnvelopeAddress(recipient, "recipient");
    +  assertHeaderValue(subject, "subject");
    +  if (replyTo) assertEnvelopeAddress(replyTo, "replyTo");
    +  assertHeaderValue(inReplyTo, "inReplyTo");
    +  assertHeaderValue(references, "references");
    +
       const domain = from.split("@")[1];
       const msgId = "<" + crypto.randomUUID() + "@" + domain + ">";
       const boundary = "----=_Part_" + Date.now().toString(36);
    @@ -150,6 +180,9 @@ function buildRawEmail({ from, to, subject, text, html, replyTo, inReplyTo, refe
     // --- SMTP Relay Delivery ---
     
     async function relayEmail(from, to, rawEmail, env) {
    +  assertEnvelopeAddress(from, "from");
    +  assertEnvelopeAddress(to, "recipient");
    +
       const host = env.SMTP_HOST || "smtp.gmail.com";
       const port = parseInt(env.SMTP_PORT || "465", 10);
       const user = env.SMTP_USER;
    @@ -248,8 +281,16 @@ export default {
           return new Response("Method not allowed", { status: 405 });
         }
     
    +    const configuredSecret = env.OUTBOUND_SECRET;
    +    if (!configuredSecret || typeof configuredSecret !== "string") {
    +      return new Response(JSON.stringify({ error: "OUTBOUND_SECRET must be configured" }), {
    +        status: 500,
    +        headers: { "Content-Type": "application/json" },
    +      });
    +    }
    +
         const secret = request.headers.get("X-Outbound-Secret");
    -    if (secret !== (env.OUTBOUND_SECRET || "outbound_2sabi_secret_key")) {
    +    if (secret !== configuredSecret) {
           return new Response(JSON.stringify({ error: "Unauthorized" }), {
             status: 401,
             headers: { "Content-Type": "application/json" },
    @@ -288,7 +329,7 @@ export default {
         } catch (err) {
           return new Response(
             JSON.stringify({ error: err.message }),
    -        { status: 500, headers: { "Content-Type": "application/json" } }
    +        { status: err.status || 500, headers: { "Content-Type": "application/json" } }
           );
         }
       },
    
  • packages/core/src/mail/sender.ts+2 1 modified
    @@ -10,6 +10,7 @@ export interface MailSenderOptions {
       password: string;
       authUser?: string;
       secure?: boolean;
    +  tlsRejectUnauthorized?: boolean;
     }
     
     export interface SendResultWithRaw extends SendResult {
    @@ -32,7 +33,7 @@ export class MailSender {
             pass: options.password,
           },
           tls: {
    -        rejectUnauthorized: false, // Local dev — no TLS
    +        rejectUnauthorized: options.tlsRejectUnauthorized ?? true,
           },
           connectionTimeout: 10_000, // 10s to establish TCP connection
           greetingTimeout: 10_000,   // 10s for SMTP greeting
    
  • packages/core/src/__tests__/mail-sender-security.test.ts+44 0 added
    @@ -0,0 +1,44 @@
    +import { describe, expect, it, vi } from 'vitest';
    +
    +const { createTransport } = vi.hoisted(() => ({
    +  createTransport: vi.fn(() => ({
    +    close: vi.fn(),
    +    sendMail: vi.fn(),
    +    verify: vi.fn(),
    +  })),
    +}));
    +
    +vi.mock('nodemailer', () => ({
    +  default: { createTransport },
    +}));
    +
    +const { MailSender } = await import('../mail/sender.js');
    +
    +describe('MailSender TLS defaults', () => {
    +  it('verifies TLS certificates by default', () => {
    +    new MailSender({
    +      host: 'smtp.example.com',
    +      port: 587,
    +      email: 'agent@example.com',
    +      password: 'secret',
    +    });
    +
    +    expect(createTransport).toHaveBeenLastCalledWith(expect.objectContaining({
    +      tls: { rejectUnauthorized: true },
    +    }));
    +  });
    +
    +  it('requires an explicit option to disable TLS verification', () => {
    +    new MailSender({
    +      host: 'localhost',
    +      port: 587,
    +      email: 'agent@localhost',
    +      password: 'secret',
    +      tlsRejectUnauthorized: false,
    +    });
    +
    +    expect(createTransport).toHaveBeenLastCalledWith(expect.objectContaining({
    +      tls: { rejectUnauthorized: false },
    +    }));
    +  });
    +});
    
  • packages/core/src/__tests__/outbound-worker-security.test.ts+27 0 added
    @@ -0,0 +1,27 @@
    +import { describe, expect, it } from 'vitest';
    +import { readFileSync } from 'node:fs';
    +import { fileURLToPath } from 'node:url';
    +import { dirname, resolve } from 'node:path';
    +
    +const here = dirname(fileURLToPath(import.meta.url));
    +const workerDir = resolve(here, '../gateway/workers');
    +
    +describe('outbound worker security defaults', () => {
    +  it('does not ship with a public outbound secret fallback', () => {
    +    const source = readFileSync(resolve(workerDir, 'outbound.js'), 'utf8');
    +    const metadata = JSON.parse(readFileSync(resolve(workerDir, 'metadata.json'), 'utf8'));
    +
    +    expect(source).not.toContain('outbound_2sabi_secret_key');
    +    expect(metadata.bindings).not.toEqual(expect.arrayContaining([
    +      expect.objectContaining({ name: 'OUTBOUND_SECRET', type: 'plain_text' }),
    +    ]));
    +  });
    +
    +  it('rejects control characters before SMTP envelope commands are built', () => {
    +    const source = readFileSync(resolve(workerDir, 'outbound.js'), 'utf8');
    +
    +    expect(source).toContain('assertEnvelopeAddress(from, "from")');
    +    expect(source).toContain('assertEnvelopeAddress(to, "recipient")');
    +    expect(source).toContain('CONTROL_CHARS');
    +  });
    +});
    
1408de543fa3

Harden security patch: close having-clause injection + comma-join table-ref gap

https://github.com/agenticmail/agenticmailOpe OlatunjiMay 18, 2026via ghsa
1 file changed · +34 1
  • packages/api/src/routes/storage.ts+34 1 modified
    @@ -209,9 +209,42 @@ function extractTableRefs(sql: string): string[] {
       const refs = new Set<string>();
       const refPattern = /\b(?:FROM|INTO|UPDATE|TABLE|JOIN)\s+["`[]?([A-Za-z_][A-Za-z0-9_]{0,63})["`\]]?/gi;
       for (const match of sql.matchAll(refPattern)) refs.add(match[1]);
    +  // Hardening — the FROM/JOIN-anchored scan above misses tables in a
    +  // comma-join list (`FROM agt_mine, agt_victim` only captures the
    +  // first). Catch EVERY storage-table-shaped token anywhere in the
    +  // query as a backstop: any agt_*/shared_*/metadata token must be
    +  // ownership-checked by verifySqlAccess, comma-join or not.
    +  const storageTokenPattern = /\b(agt_[A-Za-z0-9_]+|shared_[A-Za-z0-9_]+|agenticmail_storage_meta)\b/gi;
    +  for (const match of sql.matchAll(storageTokenPattern)) refs.add(match[1]);
       return [...refs];
     }
     
    +/**
    + * Hardening — `having` was the one query clause still interpolated raw
    + * into SQL after the #GHSA storage patch validated `groupBy`/`orderBy`.
    + * HAVING legitimately needs aggregate expressions (`COUNT(*) > 5`), so
    + * it can't be identifier-validated like the others. Instead: bound the
    + * length and reject statement-breaking constructs (stacked statements,
    + * comment markers) and any DDL/DML keyword. The caller already owns the
    + * table and is authenticated, so a HAVING expression that can't escape
    + * the SELECT or run DDL/DML has no meaningful blast radius.
    + */
    +function sanitizeHavingClause(having: string): string {
    +  if (typeof having !== 'string') {
    +    throw new StorageRouteError('having must be a string');
    +  }
    +  if (having.length > 200) {
    +    throw new StorageRouteError('having clause is too long (max 200 chars)');
    +  }
    +  if (/;|--|\/\*|\*\//.test(having)) {
    +    throw new StorageRouteError('having clause contains a forbidden token');
    +  }
    +  if (/\b(DROP|DELETE|INSERT|UPDATE|UNION|ATTACH|DETACH|PRAGMA|CREATE|ALTER|REPLACE|EXEC|VACUUM)\b/i.test(having)) {
    +    throw new StorageRouteError('having clause contains a forbidden keyword');
    +  }
    +  return having;
    +}
    +
     function nowExpr(dialect: string): string {
       return dialect === 'postgres' ? 'NOW()' : "datetime('now')";
     }
    @@ -900,7 +933,7 @@ export function createStorageRoutes(
           }
     
           if (groupBy) sql += ` GROUP BY ${buildGroupBy(groupBy)}`;
    -      if (having) sql += ` HAVING ${having}`;
    +      if (having) sql += ` HAVING ${sanitizeHavingClause(having)}`;
           if (orderBy) sql += ` ORDER BY ${buildOrderBy(orderBy)}`;
           if (limit) { sql += ' LIMIT ?'; params.push(limit); }
           if (offset) { sql += ' OFFSET ?'; params.push(offset); }
    

Vulnerability mechanics

Root cause

"Multiple missing input validations, a hardcoded cryptographic secret, and disabled TLS certificate verification allowed SQL injection, cross-agent data access, relay impersonation, and man-in-the-middle attacks."

Attack vector

An attacker can exploit multiple vectors: (1) read the public repository to obtain the hardcoded `OUTBOUND_SECRET` and impersonate the outbound relay worker (CWE-798); (2) send crafted `where` keys, `groupBy`, `orderBy`, or `having` parameters to the `/storage/query` endpoint to inject SQL (CWE-89); (3) use raw `/storage/sql` to query tables owned by other agents or the metadata table directly (CWE-284); (4) perform a man-in-the-middle attack against SMTP connections because `MailSender` previously accepted any TLS certificate (CWE-319); or (5) inject CR/LF control characters into SMTP envelope addresses or headers to manipulate mail commands (CWE-20) [ref_id=3].

Affected code

The vulnerability spans multiple areas: the outbound Cloudflare worker (`metadata.json` and `outbound.js`) shipped a hardcoded relay secret; the raw SQL storage routes in `packages/api/src/routes/storage.ts` lacked identifier validation and ownership checks; `MailSender` in `packages/core/src/mail/sender.ts` disabled TLS verification unconditionally; and the outbound worker did not validate SMTP envelope/header control characters. The `having` clause and comma-join table references were also missed in the initial storage patch [patch_id=3106185][patch_id=3106184].

What the fix does

The patch set removes the hardcoded `OUTBOUND_SECRET` and its fallback, requiring the secret to be configured externally [patch_id=3106185]. It introduces `requireIdentifier` and `requireIdentifierList` to validate all SQL identifiers (column names, table names, index names, conflict columns) against a strict regex, and adds `sanitizeHavingClause` to bound length and reject stacked statements, comment markers, and DDL/DML keywords [patch_id=3106184]. The `verifySqlAccess` function checks every table reference (including comma-join tokens) against ownership metadata and blocks direct access to `agenticmail_storage_meta` [patch_id=3106185]. `MailSender` now defaults `rejectUnauthorized` to `true` for remote hosts, with an explicit opt-out for local development [patch_id=3106186]. The outbound worker rejects CR/LF and control characters in envelope addresses and headers [patch_id=3106187].

Preconditions

  • configThe outbound worker must be deployed without configuring a new OUTBOUND_SECRET (for the hardcoded secret exploit).
  • authThe attacker must be an authenticated agent with access to the storage API (for SQL injection and cross-agent access).
  • networkThe attacker must be on the network path between MailSender and the SMTP server (for TLS bypass).
  • inputThe attacker must be able to send crafted JSON payloads to the storage or account endpoints (for SQL injection and hour-filter bypass).

Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

8

News mentions

0

No linked articles in our index yet.