Skip to content

Commit c4aa708

Browse files
committed
fix: unify security validators and finalize 2.2.7 persistence
1 parent 3dbb073 commit c4aa708

3 files changed

Lines changed: 129 additions & 81 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file.
33

44
The format is based on Keep a Changelog and the project follows semantic versioning.
55

6+
7+
## 2.2.7 - 2026-05-13
8+
- Fixes critical security regression in stdio path (0 false negatives in benchmark)
9+
- Aligns stdio and HTTP security validators (AST, Scope, Color, Preflight)
10+
- Adds persistent security event logging in SQLite (7-day history)
11+
- Implements resilient multi-target Gateway mode with Zod validation
12+
- Adds "Clear History" and Target Status indicators to UI Dashboard
13+
614
## 2.2.6 - 2026-04-07
715

816
- cuts the first Toolwall release-candidate boundary as a dedicated `2.2.6` version instead of reusing the already-published `2.2.5` line

src/middleware/ast-egress-filter.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextFunction, Request, Response } from 'express';
22
import { EpistemicSecurityException } from '../errors.js';
3+
import { extractToolInvocations, isRecord } from '../utils/mcp-request.js';
34

45
const SENSITIVE_PATH_PATTERNS = [
56
/\.env(\.|$)/i,
@@ -98,20 +99,27 @@ const checkArguments = (toolName: string, args: Record<string, unknown>): Episte
9899
return null;
99100
};
100101

101-
export const astEgressFilter = async (req: Request, _res: Response, next: NextFunction): Promise<void> => {
102-
const body = (req.body ?? {}) as Record<string, unknown>;
103-
const params = body.params as Record<string, unknown> | undefined;
102+
export const validateAstEgress = (body: Record<string, unknown>): void => {
103+
const tools = extractToolInvocations(body);
104104

105-
if (!params) {
106-
next();
107-
return;
105+
for (const tool of tools) {
106+
if (!tool.name || !isRecord(tool.arguments)) {
107+
continue;
108+
}
109+
110+
const error = checkArguments(tool.name, tool.arguments);
111+
if (error) {
112+
throw error;
113+
}
108114
}
115+
};
109116

110-
const toolName = (params.name as string | undefined) ?? '';
111-
const args = (params.arguments as Record<string, unknown> | undefined) ?? {};
117+
export const astEgressFilter = async (req: Request, _res: Response, next: NextFunction): Promise<void> => {
118+
const body = (req.body ?? {}) as Record<string, unknown>;
112119

113-
const error = checkArguments(toolName, args);
114-
if (error) {
120+
try {
121+
validateAstEgress(body);
122+
} catch (error) {
115123
next(error);
116124
return;
117125
}

src/stdio/proxy.ts

Lines changed: 103 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@ import readline from 'node:readline';
44
import { Readable, Writable } from 'node:stream';
55
import { initializeCache } from '../cache/index.js';
66
import { stopAdminServer, startAdminServer } from '../admin/index.js';
7+
import { EpistemicSecurityException, TrustGateError } from '../errors.js';
8+
import { validateAstEgress } from '../middleware/ast-egress-filter.js';
9+
import { parseNhiAuthorizationHeader } from '../middleware/nhi-auth-validator.js';
10+
import { validatePreflight } from '../middleware/preflight-validator.js';
11+
import { validateScopes } from '../middleware/scope-validator.js';
712
import { sanitizeResponse } from '../proxy/shadow-leak-sanitizer.js';
813
import { auditLog } from '../utils/auditLogger.js';
9-
import { getPrimaryToolInvocation, isRecord } from '../utils/mcp-request.js';
14+
import { extractAuthorizationFromBody, extractToolInvocations, getPrimaryToolInvocation, isRecord } from '../utils/mcp-request.js';
1015

1116
const OOM_MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
1217
const DEFAULT_TARGET_TIMEOUT_MS = 30000;
1318

14-
const PREFLIGHT_REQUIRED_TOOLS = new Set(['execute_command', 'execute']);
15-
1619
type JsonRpcId = string | number | null;
20+
type SessionColor = 'red' | 'blue' | null;
1721

1822
interface JsonRpcRequest {
1923
jsonrpc: '2.0';
@@ -66,32 +70,6 @@ export interface StdioFirewallProxy {
6670
stop: () => Promise<void>;
6771
}
6872

69-
const isShadowLeakUrl = (url: string): boolean => {
70-
let parsed: URL;
71-
try {
72-
parsed = new URL(url);
73-
} catch {
74-
return false;
75-
}
76-
77-
const params = [...parsed.searchParams.entries()];
78-
if (params.length === 0) return false;
79-
80-
const singleCharParams = params.filter(([k]) => k.length === 1);
81-
if (singleCharParams.length >= 3) return true;
82-
83-
const valueLengths = params.map(([, v]) => v.length);
84-
const shortValues = valueLengths.filter(len => len <= 2);
85-
const byKey = new Map<string, number>();
86-
for (const [k] of params) {
87-
byKey.set(k, (byKey.get(k) ?? 0) + 1);
88-
}
89-
const repeatedKeys = [...byKey.values()].filter(count => count > 1);
90-
if (repeatedKeys.length > 0 && shortValues.length >= 4) return true;
91-
92-
return false;
93-
};
94-
9573
const isJsonRpcRequest = (value: unknown): value is JsonRpcRequest => {
9674
if (!isRecord(value)) return false;
9775
return value.jsonrpc === '2.0' && typeof value.method === 'string';
@@ -107,25 +85,70 @@ const buildRpcErrorResponse = (id: JsonRpcId, code: number, message: string, dat
10785
jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) },
10886
});
10987

110-
const validateAuth = (proxyAuthToken: string | undefined, message: JsonRpcRequest): boolean => {
111-
if (!proxyAuthToken) return true;
88+
const extractStdioAuthorization = (message: JsonRpcRequest): string | undefined => {
89+
const fromBody = extractAuthorizationFromBody(message as unknown as Record<string, unknown>);
90+
if (fromBody) return fromBody;
11291

113-
const params = message.params as Record<string, unknown> | undefined;
114-
const meta = params?._meta as Record<string, unknown> | undefined;
115-
const authorization = meta?.authorization as string | undefined;
116-
if (!authorization) return false;
92+
if (!isRecord(message.params) || !isRecord(message.params._meta)) {
93+
return undefined;
94+
}
11795

118-
const bearerPrefix = 'Bearer ';
119-
if (!authorization.startsWith(bearerPrefix)) return false;
96+
const authorization = message.params._meta.authorization;
97+
return typeof authorization === 'string' ? authorization : undefined;
98+
};
12099

100+
const parseStdioAuthScopes = (proxyAuthToken: string | undefined, message: JsonRpcRequest): string[] => {
101+
if (!proxyAuthToken) return [];
102+
const parsed = parseNhiAuthorizationHeader(extractStdioAuthorization(message), proxyAuthToken, 'stdio');
103+
return parsed.scopes;
104+
};
105+
106+
const createSnippet = (message: JsonRpcRequest): string => {
107+
const tool = getPrimaryToolInvocation(message as unknown as Record<string, unknown>);
121108
try {
122-
const encoded = authorization.slice(bearerPrefix.length);
123-
const decoded = Buffer.from(encoded, 'base64').toString('utf8');
124-
const payload = JSON.parse(decoded) as Record<string, unknown>;
125-
return payload.token === proxyAuthToken;
109+
return JSON.stringify(tool?.arguments ?? message.params ?? {}).slice(0, 240);
126110
} catch {
127-
return false;
111+
return String(tool?.name ?? message.method).slice(0, 240);
112+
}
113+
};
114+
115+
const validateColorBoundary = (message: JsonRpcRequest, sessionColor: SessionColor): SessionColor => {
116+
const tools = extractToolInvocations(message as unknown as Record<string, unknown>);
117+
const reds = tools.filter((tool) => tool._meta?.color === 'red').map((tool) => tool.name ?? 'unknown');
118+
const blues = tools.filter((tool) => tool._meta?.color === 'blue').map((tool) => tool.name ?? 'unknown');
119+
120+
if (reds.length > 0 && blues.length > 0) {
121+
throw new TrustGateError(
122+
`Cross-Tool Hijack Attempt detected: ${[...reds, ...blues].join(', ')}`,
123+
'CROSS_TOOL_HIJACK_ATTEMPT',
124+
403,
125+
{ redTools: reds, blueTools: blues },
126+
);
128127
}
128+
129+
const requestColor: SessionColor = reds.length > 0 ? 'red' : blues.length > 0 ? 'blue' : null;
130+
if (requestColor !== null && sessionColor !== null && requestColor !== sessionColor) {
131+
throw new TrustGateError(
132+
`Cross-Tool Hijack Attempt detected: ${tools.map((tool) => tool.name ?? 'unknown').join(', ')}`,
133+
'CROSS_TOOL_HIJACK_ATTEMPT',
134+
403,
135+
{ sessionColor, requestColor },
136+
);
137+
}
138+
139+
return requestColor ?? sessionColor;
140+
};
141+
142+
const getSecurityErrorCode = (error: unknown): string => {
143+
if (error instanceof EpistemicSecurityException || error instanceof TrustGateError) {
144+
return error.code;
145+
}
146+
147+
return 'SECURITY_VALIDATION_FAILED';
148+
};
149+
150+
const getSecurityErrorMessage = (error: unknown): string => {
151+
return error instanceof Error ? error.message : 'Fail-Closed: security validation failed.';
129152
};
130153

131154
export const createStdioFirewallProxy = (options: StdioFirewallOptions) => {
@@ -150,6 +173,7 @@ export const createStdioFirewallProxy = (options: StdioFirewallOptions) => {
150173
let targetProcess: ChildProcessWithoutNullStreams | null = null;
151174
let stopped = false;
152175
let draining = false;
176+
let stdioSessionColor: SessionColor = null;
153177

154178
const writeRawJson = (message: unknown): void => {
155179
output.write(JSON.stringify(message) + '\n');
@@ -213,45 +237,53 @@ export const createStdioFirewallProxy = (options: StdioFirewallOptions) => {
213237

214238
const requestId = message.id ?? null;
215239
const tool = getPrimaryToolInvocation(message as unknown as Record<string, unknown>);
216-
217-
if (message.method === 'tools/call' && !validateAuth(options.proxyAuthToken, message)) {
218-
auditLog('AUTH_FAILURE', {
219-
code: 'AUTH_FAILURE',
220-
reason: 'Missing or invalid NHI authorization',
221-
toolName: tool?.name,
222-
snippet: JSON.stringify(tool?.arguments ?? {}).slice(0, 240),
223-
});
224-
writeRawJson(buildRpcErrorResponse(requestId, -32001, 'Fail-Closed: authentication required.', { code: 'AUTH_FAILURE' }));
225-
return;
240+
let availableScopes: string[] = [];
241+
242+
if (message.method === 'tools/call' && options.proxyAuthToken) {
243+
try {
244+
availableScopes = parseStdioAuthScopes(options.proxyAuthToken, message);
245+
} catch {
246+
auditLog('AUTH_FAILURE', {
247+
code: 'AUTH_FAILURE',
248+
reason: 'Missing or invalid NHI authorization',
249+
toolName: tool?.name,
250+
snippet: createSnippet(message),
251+
});
252+
writeRawJson(buildRpcErrorResponse(requestId, -32001, 'Fail-Closed: authentication required.', { code: 'AUTH_FAILURE' }));
253+
return;
254+
}
226255
}
227256

228257
if (stopped || !targetProcess?.stdin.writable) {
229258
writeRawJson(buildRpcErrorResponse(requestId, -32004, 'Fail-Closed: target process is unavailable.', { code: 'TARGET_UNAVAILABLE' }));
230259
return;
231260
}
232261

233-
if (message.method === 'tools/call' && tool?.name) {
234-
if (PREFLIGHT_REQUIRED_TOOLS.has(tool.name)) {
235-
writeRawJson(buildRpcErrorResponse(requestId, -32002, 'Fail-Closed: preflight approval required.', { code: 'PREFLIGHT_REQUIRED' }));
236-
return;
237-
}
238-
239-
if ((tool.name === 'fetch_url' || tool.name === 'fetch') && tool.arguments) {
240-
const args = tool.arguments as Record<string, unknown>;
241-
for (const val of Object.values(args)) {
242-
if (typeof val === 'string' && isShadowLeakUrl(val)) {
243-
auditLog('SHADOWLEAK_BLOCKED_STDIO', {
244-
code: 'SHADOWLEAK_DETECTED',
245-
reason: 'ShadowLeak exfiltration pattern detected',
246-
toolName: tool.name,
247-
url: val,
248-
});
249-
writeRawJson(buildRpcErrorResponse(requestId, -32003, 'Fail-Closed: ShadowLeak exfiltration pattern detected.', { code: 'SHADOWLEAK_DETECTED' }));
250-
return;
251-
}
262+
if (message.method === 'tools/call') {
263+
try {
264+
validateAstEgress(message as unknown as Record<string, unknown>);
265+
if (options.proxyAuthToken) {
266+
validateScopes(message as unknown as Record<string, unknown>, availableScopes, 'stdio');
267+
}
268+
stdioSessionColor = validateColorBoundary(message, stdioSessionColor);
269+
validatePreflight(message as unknown as Record<string, unknown>, 'stdio');
270+
} catch (error) {
271+
const code = getSecurityErrorCode(error);
272+
const messageText = getSecurityErrorMessage(error);
273+
if (error instanceof EpistemicSecurityException || code === 'CROSS_TOOL_HIJACK_ATTEMPT') {
274+
auditLog(code, {
275+
code,
276+
reason: messageText,
277+
toolName: tool?.name,
278+
snippet: createSnippet(message),
279+
});
252280
}
281+
writeRawJson(buildRpcErrorResponse(requestId, -32003, messageText, { code }));
282+
return;
253283
}
284+
}
254285

286+
if (message.method === 'tools/call' && tool?.name) {
255287
if (requestId !== null) {
256288
const cached = cacheManager.get(tool.name, tool.arguments ?? {});
257289
if (cached !== undefined) {

0 commit comments

Comments
 (0)