Skip to content

Commit 688504b

Browse files
committed
fix(security): apply 2026-05 audit findings — MCP, records sort, trusted-proxy, Origin gate, sandbox
Two High + four Medium + one Low from a manual audit of the v0.11 surfaces. No critical bugs found; these are defense-in-depth / scope-mismatch / footgun fixes. Test suite: 861 / 861 pass. H-1 (MCP run_sql WITH-CTE write-bypass) src/mcp/admin-write-tools.ts — the allow_write:false gate previously only checked the prefix `^\s*(WITH|SELECT|EXPLAIN|PRAGMA)\b`. SQLite supports data-modifying CTEs (`WITH x AS (SELECT 1) UPDATE foo …`) which start with WITH and bypass the gate, executing on the writable raw connection. Mirror sql-runner.ts: strip comments + string literals, then scan for any of INSERT|UPDATE|DELETE|DROP|ALTER| CREATE|REPLACE|TRUNCATE|ATTACH|DETACH|REINDEX|VACUUM. Explicit error names the offending keyword. H-2 (list_settings / get_setting scope leak) src/mcp/admin-write-tools.ts — both tools advertised mcp:read scope but returned decrypted secrets (smtp.password, oauth2.<provider>. client_secret, FCM service-account JSON, OneSignal app key, …). The mcp:read scope is the least-privileged read scope used for observer/ auditor tokens — it must not see operator secrets. Raised both to mcp:admin. Test updated to match the new contract. M-1 + L-1 (records sort SQL-identifier injection + naive quoteIdent) src/core/records.ts — user-supplied `?sort=…` flowed unvalidated through a local quoteIdent that didn't escape embedded `"`. Single- statement prepare blunted classic injection today, but error-based oracles + future API changes (multi-stmt exec) would turn it into RCE. Fix: whitelist sort columns against the actual collection's field set (id/created_at/updated_at + non-system fields). View collections fall back to a regex check since their schema is inferred from the SELECT. Local quoteIdent now matches collections.ts (doubles embedded `"` per spec). M-2 (MCP SSE bypasses trusted-proxy) src/api/mcp.ts — /mcp/events read X-Forwarded-For inline without consulting VAULTBASE_TRUSTED_PROXIES. Replaced with trustedClientIp(request, peerIpOf(server, request)). Adapter strips the placeholder "unknown" trustedClientIp returns when no peer IP is resolvable (test transports / direct stdio). M-3 (isOriginAllowed null Origin bypass) src/server.ts — isOriginAllowed returned true when Origin was null, on the premise that "same-origin requests omit Origin in some clients". True for some HTTP fetches, false for browser WebSocket / EventSource (which always send Origin). Non-browser clients without Origin were skipping the allowlist entirely. Added a requireOrigin parameter (default false to preserve existing behaviour); both /realtime WS and GET /api/v1/realtime SSE call sites now pass true. M-4 (VAULTBASE_TRUSTED_PROXIES exact-match instead of CIDR) src/core/sec.ts — docs and SECURITY.md advertised CIDR-equivalent matching but the impl was Set.has(peerIp) exact-match. Operators setting VAULTBASE_TRUSTED_PROXIES=10.0.0.0/8 silently got zero trusted proxies. Re-exported parseCidr/ipInCidr/ParsedCidr from hook-egress.ts and reused them. List now accepts mixed bare IPs and CIDR ranges; per-process cache keyed on raw env string. L-2 (sandbox table-name interpolation) src/core/sql-sandbox.ts — INSERT INTO main."${obj.name}" SELECT * FROM _vb_live."${obj.name}" used naive interpolation. Tables created outside Vaultbase could conceivably have names containing `"` (assertSqlIdent rejects, but the sandbox copies live state). Wrapped with a local quoteIdent that doubles embedded quotes.
1 parent 57e7bf1 commit 688504b

8 files changed

Lines changed: 156 additions & 34 deletions

File tree

src/__tests__/mcp.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,12 +376,14 @@ describe("MCP — Phase 2 admin tools", () => {
376376
) as JsonRpcSuccess;
377377
expect((set.result as CallToolResult).isError).toBeFalsy();
378378

379+
// get_setting requires mcp:admin — secrets are decrypted in the response,
380+
// so a read-only token must not see them.
379381
const get = await dispatch(
380382
{
381383
jsonrpc: "2.0", id: 106, method: "tools/call",
382384
params: { name: "vaultbase.get_setting", arguments: { key: "test.foo" } },
383385
},
384-
mkCtx(["mcp:read"]),
386+
mkCtx(["mcp:admin"]),
385387
) as JsonRpcSuccess;
386388
const text = ((get.result as CallToolResult).content[0] as { text: string }).text;
387389
const got = JSON.parse(text) as { value: string };

src/api/mcp.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
*/
1818

1919
import Elysia from "elysia";
20-
import { verifyAuthToken, extractBearer } from "../core/sec.ts";
20+
import { verifyAuthToken, extractBearer, trustedClientIp } from "../core/sec.ts";
2121
import { hasScope } from "../core/api-tokens.ts";
2222
import { buildRegistry, createDispatcher } from "../mcp/server.ts";
2323
import { RPC_ERR, type JsonRpcRequest, type JsonRpcResponse } from "../mcp/types.ts";
@@ -84,7 +84,8 @@ function isJsonRpcRequest(v: unknown): v is JsonRpcRequest {
8484

8585
export function makeMcpPlugin(jwtSecret: string) {
8686
return new Elysia({ name: "mcp-http" })
87-
.post("/mcp", async ({ request, set, body }) => {
87+
.post("/mcp", async ({ request, set, body, server }) => {
88+
void server; // peer-IP not needed for the POST path
8889
const auth = await authenticate(request, jwtSecret);
8990
if ("status" in auth) {
9091
set.status = auth.status;
@@ -113,7 +114,7 @@ export function makeMcpPlugin(jwtSecret: string) {
113114
}
114115
return res;
115116
})
116-
.get("/mcp/events", ({ request, set }) => {
117+
.get("/mcp/events", ({ request, set, server }) => {
117118
// Auth gate: SSE handlers can't easily return JSON, so we return a
118119
// small text body with the right status. EventSource clients surface
119120
// this as `onerror` with the status; that's the Right Thing™.
@@ -141,10 +142,15 @@ export function makeMcpPlugin(jwtSecret: string) {
141142
controller = null;
142143
};
143144

144-
const ip =
145-
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
146-
request.headers.get("x-real-ip") ??
147-
null;
145+
// Honor VAULTBASE_TRUSTED_PROXIES: only consult X-Forwarded-For when
146+
// the immediate peer is in the trust list. Otherwise the peer IP is
147+
// authoritative — non-proxied clients can't spoof the origin field.
148+
let peerIp: string | null = null;
149+
try {
150+
const s = server as { requestIP?: (r: Request) => { address: string } | null };
151+
peerIp = s?.requestIP?.(request)?.address ?? null;
152+
} catch { /* requestIP unsupported in tests */ }
153+
const ip = trustedClientIp(request, peerIp);
148154
const ua = request.headers.get("user-agent");
149155

150156
const stream = new ReadableStream<Uint8Array>({
@@ -156,7 +162,9 @@ export function makeMcpPlugin(jwtSecret: string) {
156162
scopes: auth.ctx.scopes,
157163
adminId: auth.ctx.adminId,
158164
adminEmail: auth.ctx.adminEmail,
159-
...(ip ? { ip } : {}),
165+
// Skip the placeholder "unknown" trustedClientIp returns when
166+
// no peer IP is resolvable (test transports / direct stdio).
167+
...(ip && ip !== "unknown" ? { ip } : {}),
160168
...(ua ? { userAgent: ua } : {}),
161169
connectedAt: Math.floor(Date.now() / 1000),
162170
send(payload) {

src/core/hook-egress.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ const DEFAULT_DENY: readonly string[] = [
7777

7878
// ── CIDR parsing ────────────────────────────────────────────────────────────
7979

80-
interface ParsedCidr {
80+
export interface ParsedCidr {
8181
/** "v4" or "v6" */
8282
family: "v4" | "v6";
8383
/** Network bytes — 4 for v4, 16 for v6. */

src/core/records.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,15 @@ function rawClient(): Database {
183183
return (getDb() as unknown as { $client: Database }).$client;
184184
}
185185

186+
/**
187+
* SQLite identifier quoting — doubles up embedded `"` per spec. Mirrors
188+
* `core/collections.ts::quoteIdent`. All identifier inputs at this layer
189+
* already pass `assertSqlIdent` upstream (no quote chars possible), but the
190+
* defense-in-depth match prevents future bypasses if a path skips that
191+
* upstream check.
192+
*/
186193
function quoteIdent(name: string): string {
187-
return `"${name}"`;
194+
return `"${name.replace(/"/g, '""')}"`;
188195
}
189196

190197
function isJsonField(field: FieldDef): boolean {
@@ -299,13 +306,35 @@ export async function listRecords(
299306
// Build ORDER BY. View collections don't necessarily expose created_at /
300307
// updated_at, so skip the default sort there — caller can opt in by passing
301308
// a `sort` they know is valid for their query.
309+
//
310+
// Whitelist sort columns against the actual collection schema to refuse any
311+
// attacker-supplied identifier from reaching the SQL string. Without this
312+
// gate, a value like `sort=name";<sql>;--` would land inside the prepared
313+
// statement string. SQLite's prepare_v2 only compiles the first statement
314+
// (so multi-statement injection is blocked today), but error-based oracles
315+
// and any future API change to multi-statement exec would turn this into
316+
// RCE — fail fast instead of relying on the prepare contract.
317+
const sortableCols = new Set<string>([
318+
"id", "created_at", "updated_at",
319+
...fields.filter((f) => !f.system).map((f) => f.name),
320+
]);
302321
const sortSpec = opts.sort ?? (col.type === "view" ? "" : "-created_at");
303-
const orderClauses = sortSpec.split(",").map((s) => s.trim()).filter(Boolean).map((s) => {
322+
const orderClauses: string[] = [];
323+
for (const s of sortSpec.split(",").map((x) => x.trim()).filter(Boolean)) {
304324
const desc = s.startsWith("-");
305325
const field = desc ? s.slice(1) : s;
306-
const colName = field === "created" ? "created_at" : field === "updated" ? "updated_at" : field;
307-
return `${tableRef}.${quoteIdent(colName)} ${desc ? "DESC" : "ASC"}`;
308-
});
326+
const colName = field === "created" ? "created_at"
327+
: field === "updated" ? "updated_at" : field;
328+
// View collections accept any column name (the schema is inferred from
329+
// the SELECT and may not be fully reflected in `fields`); base + auth
330+
// collections strictly whitelist.
331+
if (col.type !== "view" && !sortableCols.has(colName)) continue;
332+
// Defense-in-depth even after whitelist: reject any value that doesn't
333+
// match a SQL identifier regex. Whitelist covers it for non-view, this
334+
// is the view-collection-fallback safety net.
335+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,62}$/.test(colName)) continue;
336+
orderClauses.push(`${tableRef}.${quoteIdent(colName)} ${desc ? "DESC" : "ASC"}`);
337+
}
309338
const orderSql = orderClauses.length > 0 ? `ORDER BY ${orderClauses.join(", ")}` : "";
310339

311340
// Execute SELECT — prepared statement cached by SQL string shape.

src/core/sec.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
33
import { getDb } from "../db/client.ts";
44
import { admin, apiTokens, tokenRevocations } from "../db/schema.ts";
55
import type { AuthContext } from "./rules.ts";
6+
import { parseCidr, ipInCidr, type ParsedCidr } from "./hook-egress.ts";
67

78
/**
89
* Centralized auth-token verification.
@@ -265,14 +266,54 @@ export function isAllowedUploadMime(mime: string): boolean {
265266
}
266267

267268
/**
268-
* IP extraction that respects only the immediate proxy when the listening
269-
* peer is in `VAULTBASE_TRUSTED_PROXIES`. Returns the socket peer otherwise.
269+
* IP extraction that respects `X-Forwarded-For` only when the immediate peer
270+
* is in `VAULTBASE_TRUSTED_PROXIES`. Returns the socket peer otherwise.
271+
*
272+
* Trust list accepts both bare IPs (`10.0.0.5`) and CIDR ranges
273+
* (`10.0.0.0/8`, `2001:db8::/32`) — the typical "everything from my private
274+
* cluster" case shouldn't require enumerating every replica IP. Backwards-
275+
* compatible with the v0.10 string-set form: bare IPs without `/` are still
276+
* matched as exact peers.
277+
*
278+
* Parsed CIDR list is cached per-process; env reads are cheap but the regex
279+
* parse isn't.
270280
*/
281+
let _trustedProxiesCache: { raw: string; cidrs: ParsedCidr[]; bare: Set<string> } | null = null;
282+
283+
function getTrustedProxies(): { cidrs: ParsedCidr[]; bare: Set<string> } | null {
284+
const raw = process.env["VAULTBASE_TRUSTED_PROXIES"] ?? "";
285+
if (!raw) return null;
286+
if (_trustedProxiesCache && _trustedProxiesCache.raw === raw) {
287+
return { cidrs: _trustedProxiesCache.cidrs, bare: _trustedProxiesCache.bare };
288+
}
289+
const cidrs: ParsedCidr[] = [];
290+
const bare = new Set<string>();
291+
for (const tok of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
292+
if (tok.includes("/")) {
293+
const c = parseCidr(tok);
294+
if (c) cidrs.push(c);
295+
// Silently skip malformed entries; operator notices via logs/metrics.
296+
} else {
297+
bare.add(tok);
298+
}
299+
}
300+
_trustedProxiesCache = { raw, cidrs, bare };
301+
return { cidrs, bare };
302+
}
303+
304+
function isTrustedProxy(ip: string): boolean {
305+
const t = getTrustedProxies();
306+
if (!t) return false;
307+
if (t.bare.has(ip)) return true;
308+
for (const c of t.cidrs) {
309+
if (ipInCidr(ip, c)) return true;
310+
}
311+
return false;
312+
}
313+
271314
export function trustedClientIp(request: Request, peerIp: string | null): string {
272-
const trustedRaw = process.env["VAULTBASE_TRUSTED_PROXIES"] ?? "";
273-
if (!trustedRaw || !peerIp) return peerIp ?? "unknown";
274-
const trusted = new Set(trustedRaw.split(",").map((s) => s.trim()).filter(Boolean));
275-
if (!trusted.has(peerIp)) return peerIp;
315+
if (!peerIp) return "unknown";
316+
if (!isTrustedProxy(peerIp)) return peerIp;
276317
const fwd = request.headers.get("x-forwarded-for");
277318
if (fwd) {
278319
const first = fwd.split(",")[0]?.trim();

src/core/sql-sandbox.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ import { Database } from "bun:sqlite";
2525

2626
export const SANDBOX_IDLE_TTL_SEC = 60 * 60; // 1h
2727

28+
/** SQLite identifier quoting — doubles up embedded `"` per spec. */
29+
function quoteIdent(name: string): string {
30+
return `"${name.replace(/"/g, '""')}"`;
31+
}
32+
2833
interface SandboxSlot {
2934
db: Database;
3035
/** Unix-seconds the snapshot was created. */
@@ -103,7 +108,11 @@ export function resetSandbox(adminId: string, livePath: string): SandboxInfo {
103108
try {
104109
db.exec(obj.sql);
105110
if (obj.type === "table") {
106-
db.exec(`INSERT INTO main."${obj.name}" SELECT * FROM _vb_live."${obj.name}"`);
111+
// quoteIdent escapes embedded `"`. Tables created outside Vaultbase
112+
// could conceivably have such names; the upstream `assertSqlIdent`
113+
// path can't (rejects quote chars), so this is defense-in-depth.
114+
const ident = quoteIdent(obj.name);
115+
db.exec(`INSERT INTO main.${ident} SELECT * FROM _vb_live.${ident}`);
107116
}
108117
} catch { /* skip objects that fail to recreate (rare, e.g. virtual tables) */ }
109118
}

src/mcp/admin-write-tools.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -529,21 +529,25 @@ export function registerAdminWriteTools(reg: ToolRegistry): void {
529529

530530
// ── settings ───────────────────────────────────────────────────────────
531531

532+
// Settings tools require mcp:admin (not mcp:read) — the response decrypts
533+
// smtp.password / oauth2.<provider>.client_secret / OneSignal app key /
534+
// FCM service-account JSON / etc. An mcp:read token is the "least-privileged
535+
// observer" scope and must never see operator secrets.
532536
reg.register({
533-
requiredScope: "mcp:read",
537+
requiredScope: "mcp:admin",
534538
definition: {
535539
name: "vaultbase.list_settings",
536-
description: "List every setting key/value. Encrypted-at-rest keys are decrypted in this response (admin-equivalent visibility) — treat as sensitive.",
540+
description: "List every setting key/value. Encrypted-at-rest keys are decrypted in this response (admin-equivalent visibility) — requires mcp:admin scope.",
537541
inputSchema: { type: "object", properties: {}, additionalProperties: false },
538542
},
539543
handler: async () => asJsonText(getAllSettings()),
540544
});
541545

542546
reg.register({
543-
requiredScope: "mcp:read",
547+
requiredScope: "mcp:admin",
544548
definition: {
545549
name: "vaultbase.get_setting",
546-
description: "Read a single setting by key. Returns the (decrypted, when applicable) value.",
550+
description: "Read a single setting by key. Returns the (decrypted, when applicable) value — requires mcp:admin scope.",
547551
inputSchema: {
548552
type: "object",
549553
properties: { key: { type: "string" } },
@@ -599,13 +603,32 @@ export function registerAdminWriteTools(reg: ToolRegistry): void {
599603
handler: async (args) => {
600604
const q = String(args.query ?? "").trim();
601605
if (!q) throw new Error("query is required");
602-
const isSelect = /^\s*(WITH|SELECT|EXPLAIN|PRAGMA)\b/i.test(q);
603-
if (!isSelect && args.allow_write !== true) {
604-
throw new Error("non-SELECT requires allow_write: true (write paths bypass RBAC + validation; consider the typed tool instead)");
606+
const looksLikeRead = /^\s*(WITH|SELECT|EXPLAIN|PRAGMA)\b/i.test(q);
607+
if (args.allow_write !== true) {
608+
// SQLite supports data-modifying CTEs (`WITH x AS (...) UPDATE ...`),
609+
// and PRAGMA can mutate session state — the prefix check above is
610+
// necessary but not sufficient. Strip comments + string literals so
611+
// the literal "DELETE" inside a WHERE doesn't false-positive, then
612+
// scan for any mutating keyword. Mirrors core/sql-runner.ts.
613+
if (!looksLikeRead) {
614+
throw new Error("non-SELECT requires allow_write: true (write paths bypass RBAC + validation; consider the typed tool instead)");
615+
}
616+
const stripped = q
617+
.replace(/--[^\n]*/g, " ")
618+
.replace(/\/\*[\s\S]*?\*\//g, " ")
619+
.replace(/'(?:[^']|'')*'/g, "''")
620+
.replace(/"(?:[^"]|"")*"/g, '""');
621+
const upper = stripped.toUpperCase();
622+
const banned = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "REPLACE", "TRUNCATE", "ATTACH", "DETACH", "REINDEX", "VACUUM"];
623+
for (const kw of banned) {
624+
if (new RegExp(`\\b${kw}\\b`).test(upper)) {
625+
throw new Error(`'${kw}' is blocked without allow_write: true (data-modifying ${kw} can hide inside a WITH-CTE).`);
626+
}
627+
}
605628
}
606629
const params = (Array.isArray(args.params) ? args.params : []) as Array<string | number | bigint | boolean | null | Uint8Array>;
607630
const client = getRawClient();
608-
if (isSelect) {
631+
if (looksLikeRead) {
609632
const stmt = client.query(q);
610633
const rows = stmt.all(...params) as unknown[];
611634
const truncated = rows.slice(0, 100);

src/server.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,15 @@ async function verifyTokenForWS(token: string, jwtSecret: string): Promise<WSAut
7979
* True if `origin` is in the configured allowlist. Empty/missing settings →
8080
* deny cross-origin (WS upgrades from any non-same-origin caller fail).
8181
* Comma-separated list under `security.allowed_origins`.
82+
*
83+
* Null Origin: handled per call-site — pass `requireOrigin: true` for WS/SSE
84+
* upgrade paths where browsers ALWAYS send Origin (so a missing header is
85+
* either a non-browser client we can't authenticate by origin, or a
86+
* deliberate spoof attempt). Pass false (the default) for cross-cutting
87+
* checks where same-origin browser fetches may legitimately omit Origin.
8288
*/
83-
function isOriginAllowed(origin: string | null): boolean {
84-
if (!origin) return true; // same-origin requests omit Origin in some clients
89+
function isOriginAllowed(origin: string | null, requireOrigin = false): boolean {
90+
if (!origin) return !requireOrigin;
8591
const settings = getAllSettings();
8692
const raw = settings["security.allowed_origins"] ?? "";
8793
if (!raw) return false;
@@ -243,7 +249,10 @@ export function createServer(config: Config) {
243249
// `POST /api/v1/realtime` for setting subscriptions.
244250
.get("/api/v1/realtime", ({ request, set }) => {
245251
const origin = request.headers.get("origin");
246-
if (!isOriginAllowed(origin)) {
252+
// requireOrigin: true — browsers always send Origin on EventSource;
253+
// a missing header here is either a non-browser client we can't
254+
// authenticate by origin, or a deliberate spoof attempt.
255+
if (!isOriginAllowed(origin, true)) {
247256
set.status = 403;
248257
return { error: "Origin not allowed", code: 403 };
249258
}
@@ -284,7 +293,8 @@ export function createServer(config: Config) {
284293
const req = (ws.data as { request?: Request } | undefined)?.request;
285294
if (req) {
286295
const origin = req.headers.get("origin");
287-
if (!isOriginAllowed(origin)) {
296+
// requireOrigin: true — browsers always send Origin on WS upgrades.
297+
if (!isOriginAllowed(origin, true)) {
288298
ws.send(JSON.stringify({ type: "error", reason: "origin_not_allowed" }));
289299
ws.close();
290300
return;

0 commit comments

Comments
 (0)