This is page 5 of 5. Use http://codebase.md/modelcontextprotocol/servers?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitattributes
├── .github
│ ├── pull_request_template.md
│ └── workflows
│ ├── claude.yml
│ ├── python.yml
│ ├── release.yml
│ └── typescript.yml
├── .gitignore
├── .mcp.json
├── .npmrc
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── scripts
│ └── release.py
├── SECURITY.md
├── src
│ ├── everything
│ │ ├── CLAUDE.md
│ │ ├── Dockerfile
│ │ ├── everything.ts
│ │ ├── index.ts
│ │ ├── instructions.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── sse.ts
│ │ ├── stdio.ts
│ │ ├── streamableHttp.ts
│ │ └── tsconfig.json
│ ├── fetch
│ │ ├── .python-version
│ │ ├── Dockerfile
│ │ ├── LICENSE
│ │ ├── pyproject.toml
│ │ ├── README.md
│ │ ├── src
│ │ │ └── mcp_server_fetch
│ │ │ ├── __init__.py
│ │ │ ├── __main__.py
│ │ │ └── server.py
│ │ └── uv.lock
│ ├── filesystem
│ │ ├── __tests__
│ │ │ ├── directory-tree.test.ts
│ │ │ ├── lib.test.ts
│ │ │ ├── path-utils.test.ts
│ │ │ ├── path-validation.test.ts
│ │ │ └── roots-utils.test.ts
│ │ ├── Dockerfile
│ │ ├── index.ts
│ │ ├── lib.ts
│ │ ├── package.json
│ │ ├── path-utils.ts
│ │ ├── path-validation.ts
│ │ ├── README.md
│ │ ├── roots-utils.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── git
│ │ ├── .gitignore
│ │ ├── .python-version
│ │ ├── Dockerfile
│ │ ├── LICENSE
│ │ ├── pyproject.toml
│ │ ├── README.md
│ │ ├── src
│ │ │ └── mcp_server_git
│ │ │ ├── __init__.py
│ │ │ ├── __main__.py
│ │ │ ├── py.typed
│ │ │ └── server.py
│ │ ├── tests
│ │ │ └── test_server.py
│ │ └── uv.lock
│ ├── memory
│ │ ├── __tests__
│ │ │ ├── file-path.test.ts
│ │ │ └── knowledge-graph.test.ts
│ │ ├── Dockerfile
│ │ ├── index.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── sequentialthinking
│ │ ├── __tests__
│ │ │ └── lib.test.ts
│ │ ├── Dockerfile
│ │ ├── index.ts
│ │ ├── lib.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ └── time
│ ├── .python-version
│ ├── Dockerfile
│ ├── pyproject.toml
│ ├── README.md
│ ├── src
│ │ └── mcp_server_time
│ │ ├── __init__.py
│ │ ├── __main__.py
│ │ └── server.py
│ ├── test
│ │ └── time_server_test.py
│ └── uv.lock
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/src/everything/everything.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2 | import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
3 | import {
4 | CallToolRequestSchema,
5 | ClientCapabilities,
6 | CompleteRequestSchema,
7 | CreateMessageRequest,
8 | CreateMessageResultSchema,
9 | ElicitResultSchema,
10 | GetPromptRequestSchema,
11 | ListPromptsRequestSchema,
12 | ListResourcesRequestSchema,
13 | ListResourceTemplatesRequestSchema,
14 | ListToolsRequestSchema,
15 | LoggingLevel,
16 | ReadResourceRequestSchema,
17 | Resource,
18 | RootsListChangedNotificationSchema,
19 | ServerNotification,
20 | ServerRequest,
21 | SubscribeRequestSchema,
22 | Tool,
23 | UnsubscribeRequestSchema,
24 | type Root
25 | } from "@modelcontextprotocol/sdk/types.js";
26 | import { z } from "zod";
27 | import { zodToJsonSchema } from "zod-to-json-schema";
28 | import { readFileSync } from "fs";
29 | import { fileURLToPath } from "url";
30 | import { dirname, join } from "path";
31 | import JSZip from "jszip";
32 |
33 | const __filename = fileURLToPath(import.meta.url);
34 | const __dirname = dirname(__filename);
35 | const instructions = readFileSync(join(__dirname, "instructions.md"), "utf-8");
36 |
37 | type ToolInput = Tool["inputSchema"];
38 | type ToolOutput = Tool["outputSchema"];
39 |
40 | type SendRequest = RequestHandlerExtra<ServerRequest, ServerNotification>["sendRequest"];
41 |
42 | /* Input schemas for tools implemented in this server */
43 | const EchoSchema = z.object({
44 | message: z.string().describe("Message to echo"),
45 | });
46 |
47 | const AddSchema = z.object({
48 | a: z.number().describe("First number"),
49 | b: z.number().describe("Second number"),
50 | });
51 |
52 | const LongRunningOperationSchema = z.object({
53 | duration: z
54 | .number()
55 | .default(10)
56 | .describe("Duration of the operation in seconds"),
57 | steps: z
58 | .number()
59 | .default(5)
60 | .describe("Number of steps in the operation"),
61 | });
62 |
63 | const PrintEnvSchema = z.object({});
64 |
65 | const SampleLLMSchema = z.object({
66 | prompt: z.string().describe("The prompt to send to the LLM"),
67 | maxTokens: z
68 | .number()
69 | .default(100)
70 | .describe("Maximum number of tokens to generate"),
71 | });
72 |
73 | const GetTinyImageSchema = z.object({});
74 |
75 | const AnnotatedMessageSchema = z.object({
76 | messageType: z
77 | .enum(["error", "success", "debug"])
78 | .describe("Type of message to demonstrate different annotation patterns"),
79 | includeImage: z
80 | .boolean()
81 | .default(false)
82 | .describe("Whether to include an example image"),
83 | });
84 |
85 | const GetResourceReferenceSchema = z.object({
86 | resourceId: z
87 | .number()
88 | .min(1)
89 | .max(100)
90 | .describe("ID of the resource to reference (1-100)"),
91 | });
92 |
93 | const ElicitationSchema = z.object({});
94 |
95 | const GetResourceLinksSchema = z.object({
96 | count: z
97 | .number()
98 | .min(1)
99 | .max(10)
100 | .default(3)
101 | .describe("Number of resource links to return (1-10)"),
102 | });
103 |
104 | const ListRootsSchema = z.object({});
105 |
106 | const StructuredContentSchema = {
107 | input: z.object({
108 | location: z
109 | .string()
110 | .trim()
111 | .min(1)
112 | .describe("City name or zip code"),
113 | }),
114 |
115 | output: z.object({
116 | temperature: z
117 | .number()
118 | .describe("Temperature in celsius"),
119 | conditions: z
120 | .string()
121 | .describe("Weather conditions description"),
122 | humidity: z
123 | .number()
124 | .describe("Humidity percentage"),
125 | })
126 | };
127 |
128 | const ZipResourcesInputSchema = z.object({
129 | files: z.record(z.string().url().describe("URL of the file to include in the zip")).describe("Mapping of file names to URLs to include in the zip"),
130 | });
131 |
132 | enum ToolName {
133 | ECHO = "echo",
134 | ADD = "add",
135 | LONG_RUNNING_OPERATION = "longRunningOperation",
136 | PRINT_ENV = "printEnv",
137 | SAMPLE_LLM = "sampleLLM",
138 | GET_TINY_IMAGE = "getTinyImage",
139 | ANNOTATED_MESSAGE = "annotatedMessage",
140 | GET_RESOURCE_REFERENCE = "getResourceReference",
141 | ELICITATION = "startElicitation",
142 | GET_RESOURCE_LINKS = "getResourceLinks",
143 | STRUCTURED_CONTENT = "structuredContent",
144 | ZIP_RESOURCES = "zip",
145 | LIST_ROOTS = "listRoots"
146 | }
147 |
148 | enum PromptName {
149 | SIMPLE = "simple_prompt",
150 | COMPLEX = "complex_prompt",
151 | RESOURCE = "resource_prompt",
152 | }
153 |
154 | // Example completion values
155 | const EXAMPLE_COMPLETIONS = {
156 | style: ["casual", "formal", "technical", "friendly"],
157 | temperature: ["0", "0.5", "0.7", "1.0"],
158 | resourceId: ["1", "2", "3", "4", "5"],
159 | };
160 |
161 | export const createServer = () => {
162 | const server = new Server(
163 | {
164 | name: "example-servers/everything",
165 | title: "Everything Example Server",
166 | version: "1.0.0",
167 | },
168 | {
169 | capabilities: {
170 | prompts: {},
171 | resources: { subscribe: true },
172 | tools: {},
173 | logging: {},
174 | completions: {}
175 | },
176 | instructions
177 | }
178 | );
179 |
180 | let subscriptions: Set<string> = new Set();
181 | let subsUpdateInterval: NodeJS.Timeout | undefined;
182 | let stdErrUpdateInterval: NodeJS.Timeout | undefined;
183 |
184 | let logsUpdateInterval: NodeJS.Timeout | undefined;
185 | // Store client capabilities
186 | let clientCapabilities: ClientCapabilities | undefined;
187 |
188 | // Roots state management
189 | let currentRoots: Root[] = [];
190 | let clientSupportsRoots = false;
191 | let sessionId: string | undefined;
192 |
193 | // Function to start notification intervals when a client connects
194 | const startNotificationIntervals = (sid?: string|undefined) => {
195 | sessionId = sid;
196 | if (!subsUpdateInterval) {
197 | subsUpdateInterval = setInterval(() => {
198 | for (const uri of subscriptions) {
199 | server.notification({
200 | method: "notifications/resources/updated",
201 | params: { uri },
202 | });
203 | }
204 | }, 10000);
205 | }
206 |
207 | const maybeAppendSessionId = sessionId ? ` - SessionId ${sessionId}`: "";
208 | const messages: { level: LoggingLevel; data: string }[] = [
209 | { level: "debug", data: `Debug-level message${maybeAppendSessionId}` },
210 | { level: "info", data: `Info-level message${maybeAppendSessionId}` },
211 | { level: "notice", data: `Notice-level message${maybeAppendSessionId}` },
212 | { level: "warning", data: `Warning-level message${maybeAppendSessionId}` },
213 | { level: "error", data: `Error-level message${maybeAppendSessionId}` },
214 | { level: "critical", data: `Critical-level message${maybeAppendSessionId}` },
215 | { level: "alert", data: `Alert level-message${maybeAppendSessionId}` },
216 | { level: "emergency", data: `Emergency-level message${maybeAppendSessionId}` },
217 | ];
218 |
219 | if (!logsUpdateInterval) {
220 | console.error("Starting logs update interval");
221 | logsUpdateInterval = setInterval(async () => {
222 | await server.sendLoggingMessage( messages[Math.floor(Math.random() * messages.length)], sessionId);
223 | }, 15000);
224 | }
225 | };
226 |
227 | // Helper method to request sampling from client
228 | const requestSampling = async (
229 | context: string,
230 | uri: string,
231 | maxTokens: number = 100,
232 | sendRequest: SendRequest
233 | ) => {
234 | const request: CreateMessageRequest = {
235 | method: "sampling/createMessage",
236 | params: {
237 | messages: [
238 | {
239 | role: "user",
240 | content: {
241 | type: "text",
242 | text: `Resource ${uri} context: ${context}`,
243 | },
244 | },
245 | ],
246 | systemPrompt: "You are a helpful test server.",
247 | maxTokens,
248 | temperature: 0.7,
249 | includeContext: "thisServer",
250 | },
251 | };
252 |
253 | return await sendRequest(request, CreateMessageResultSchema);
254 |
255 | };
256 |
257 | const ALL_RESOURCES: Resource[] = Array.from({ length: 100 }, (_, i) => {
258 | const uri = `test://static/resource/${i + 1}`;
259 | if (i % 2 === 0) {
260 | return {
261 | uri,
262 | name: `Resource ${i + 1}`,
263 | mimeType: "text/plain",
264 | text: `Resource ${i + 1}: This is a plaintext resource`,
265 | };
266 | } else {
267 | const buffer = Buffer.from(`Resource ${i + 1}: This is a base64 blob`);
268 | return {
269 | uri,
270 | name: `Resource ${i + 1}`,
271 | mimeType: "application/octet-stream",
272 | blob: buffer.toString("base64"),
273 | };
274 | }
275 | });
276 |
277 | const PAGE_SIZE = 10;
278 |
279 | server.setRequestHandler(ListResourcesRequestSchema, async (request) => {
280 | const cursor = request.params?.cursor;
281 | let startIndex = 0;
282 |
283 | if (cursor) {
284 | const decodedCursor = parseInt(atob(cursor), 10);
285 | if (!isNaN(decodedCursor)) {
286 | startIndex = decodedCursor;
287 | }
288 | }
289 |
290 | const endIndex = Math.min(startIndex + PAGE_SIZE, ALL_RESOURCES.length);
291 | const resources = ALL_RESOURCES.slice(startIndex, endIndex);
292 |
293 | let nextCursor: string | undefined;
294 | if (endIndex < ALL_RESOURCES.length) {
295 | nextCursor = btoa(endIndex.toString());
296 | }
297 |
298 | return {
299 | resources,
300 | nextCursor,
301 | };
302 | });
303 |
304 | server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
305 | return {
306 | resourceTemplates: [
307 | {
308 | uriTemplate: "test://static/resource/{id}",
309 | name: "Static Resource",
310 | description: "A static resource with a numeric ID",
311 | },
312 | ],
313 | };
314 | });
315 |
316 | server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
317 | const uri = request.params.uri;
318 |
319 | if (uri.startsWith("test://static/resource/")) {
320 | const index = parseInt(uri.split("/").pop() ?? "", 10) - 1;
321 | if (index >= 0 && index < ALL_RESOURCES.length) {
322 | const resource = ALL_RESOURCES[index];
323 | return {
324 | contents: [resource],
325 | };
326 | }
327 | }
328 |
329 | throw new Error(`Unknown resource: ${uri}`);
330 | });
331 |
332 | server.setRequestHandler(SubscribeRequestSchema, async (request, extra) => {
333 | const { uri } = request.params;
334 | subscriptions.add(uri);
335 | return {};
336 | });
337 |
338 | server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
339 | subscriptions.delete(request.params.uri);
340 | return {};
341 | });
342 |
343 | server.setRequestHandler(ListPromptsRequestSchema, async () => {
344 | return {
345 | prompts: [
346 | {
347 | name: PromptName.SIMPLE,
348 | description: "A prompt without arguments",
349 | },
350 | {
351 | name: PromptName.COMPLEX,
352 | description: "A prompt with arguments",
353 | arguments: [
354 | {
355 | name: "temperature",
356 | description: "Temperature setting",
357 | required: true,
358 | },
359 | {
360 | name: "style",
361 | description: "Output style",
362 | required: false,
363 | },
364 | ],
365 | },
366 | {
367 | name: PromptName.RESOURCE,
368 | description: "A prompt that includes an embedded resource reference",
369 | arguments: [
370 | {
371 | name: "resourceId",
372 | description: "Resource ID to include (1-100)",
373 | required: true,
374 | },
375 | ],
376 | },
377 | ],
378 | };
379 | });
380 |
381 | server.setRequestHandler(GetPromptRequestSchema, async (request) => {
382 | const { name, arguments: args } = request.params;
383 |
384 | if (name === PromptName.SIMPLE) {
385 | return {
386 | messages: [
387 | {
388 | role: "user",
389 | content: {
390 | type: "text",
391 | text: "This is a simple prompt without arguments.",
392 | },
393 | },
394 | ],
395 | };
396 | }
397 |
398 | if (name === PromptName.COMPLEX) {
399 | return {
400 | messages: [
401 | {
402 | role: "user",
403 | content: {
404 | type: "text",
405 | text: `This is a complex prompt with arguments: temperature=${args?.temperature}, style=${args?.style}`,
406 | },
407 | },
408 | {
409 | role: "assistant",
410 | content: {
411 | type: "text",
412 | text: "I understand. You've provided a complex prompt with temperature and style arguments. How would you like me to proceed?",
413 | },
414 | },
415 | {
416 | role: "user",
417 | content: {
418 | type: "image",
419 | data: MCP_TINY_IMAGE,
420 | mimeType: "image/png",
421 | },
422 | },
423 | ],
424 | };
425 | }
426 |
427 | if (name === PromptName.RESOURCE) {
428 | const resourceId = parseInt(args?.resourceId as string, 10);
429 | if (isNaN(resourceId) || resourceId < 1 || resourceId > 100) {
430 | throw new Error(
431 | `Invalid resourceId: ${args?.resourceId}. Must be a number between 1 and 100.`
432 | );
433 | }
434 |
435 | const resourceIndex = resourceId - 1;
436 | const resource = ALL_RESOURCES[resourceIndex];
437 |
438 | return {
439 | messages: [
440 | {
441 | role: "user",
442 | content: {
443 | type: "text",
444 | text: `This prompt includes Resource ${resourceId}. Please analyze the following resource:`,
445 | },
446 | },
447 | {
448 | role: "user",
449 | content: {
450 | type: "resource",
451 | resource: resource,
452 | },
453 | },
454 | ],
455 | };
456 | }
457 |
458 | throw new Error(`Unknown prompt: ${name}`);
459 | });
460 |
461 | server.setRequestHandler(ListToolsRequestSchema, async () => {
462 | const tools: Tool[] = [
463 | {
464 | name: ToolName.ECHO,
465 | description: "Echoes back the input",
466 | inputSchema: zodToJsonSchema(EchoSchema) as ToolInput,
467 | },
468 | {
469 | name: ToolName.ADD,
470 | description: "Adds two numbers",
471 | inputSchema: zodToJsonSchema(AddSchema) as ToolInput,
472 | },
473 | {
474 | name: ToolName.LONG_RUNNING_OPERATION,
475 | description:
476 | "Demonstrates a long running operation with progress updates",
477 | inputSchema: zodToJsonSchema(LongRunningOperationSchema) as ToolInput,
478 | },
479 | {
480 | name: ToolName.PRINT_ENV,
481 | description:
482 | "Prints all environment variables, helpful for debugging MCP server configuration",
483 | inputSchema: zodToJsonSchema(PrintEnvSchema) as ToolInput,
484 | },
485 | {
486 | name: ToolName.SAMPLE_LLM,
487 | description: "Samples from an LLM using MCP's sampling feature",
488 | inputSchema: zodToJsonSchema(SampleLLMSchema) as ToolInput,
489 | },
490 | {
491 | name: ToolName.GET_TINY_IMAGE,
492 | description: "Returns the MCP_TINY_IMAGE",
493 | inputSchema: zodToJsonSchema(GetTinyImageSchema) as ToolInput,
494 | },
495 | {
496 | name: ToolName.ANNOTATED_MESSAGE,
497 | description:
498 | "Demonstrates how annotations can be used to provide metadata about content",
499 | inputSchema: zodToJsonSchema(AnnotatedMessageSchema) as ToolInput,
500 | },
501 | {
502 | name: ToolName.GET_RESOURCE_REFERENCE,
503 | description:
504 | "Returns a resource reference that can be used by MCP clients",
505 | inputSchema: zodToJsonSchema(GetResourceReferenceSchema) as ToolInput,
506 | },
507 | {
508 | name: ToolName.GET_RESOURCE_LINKS,
509 | description:
510 | "Returns multiple resource links that reference different types of resources",
511 | inputSchema: zodToJsonSchema(GetResourceLinksSchema) as ToolInput,
512 | },
513 | {
514 | name: ToolName.STRUCTURED_CONTENT,
515 | description:
516 | "Returns structured content along with an output schema for client data validation",
517 | inputSchema: zodToJsonSchema(StructuredContentSchema.input) as ToolInput,
518 | outputSchema: zodToJsonSchema(StructuredContentSchema.output) as ToolOutput,
519 | },
520 | {
521 | name: ToolName.ZIP_RESOURCES,
522 | description: "Compresses the provided resource files (mapping of name to URI, which can be a data URI) to a zip file, which it returns as a data URI resource link.",
523 | inputSchema: zodToJsonSchema(ZipResourcesInputSchema) as ToolInput,
524 | }
525 | ];
526 | if (clientCapabilities!.roots) tools.push ({
527 | name: ToolName.LIST_ROOTS,
528 | description:
529 | "Lists the current MCP roots provided by the client. Demonstrates the roots protocol capability even though this server doesn't access files.",
530 | inputSchema: zodToJsonSchema(ListRootsSchema) as ToolInput,
531 | });
532 | if (clientCapabilities!.elicitation) tools.push ({
533 | name: ToolName.ELICITATION,
534 | description: "Elicitation test tool that demonstrates how to request user input with various field types (string, boolean, email, uri, date, integer, number, enum)",
535 | inputSchema: zodToJsonSchema(ElicitationSchema) as ToolInput,
536 | });
537 |
538 | return { tools };
539 | });
540 |
541 | server.setRequestHandler(CallToolRequestSchema, async (request,extra) => {
542 | const { name, arguments: args } = request.params;
543 |
544 | if (name === ToolName.ECHO) {
545 | const validatedArgs = EchoSchema.parse(args);
546 | return {
547 | content: [{ type: "text", text: `Echo: ${validatedArgs.message}` }],
548 | };
549 | }
550 |
551 | if (name === ToolName.ADD) {
552 | const validatedArgs = AddSchema.parse(args);
553 | const sum = validatedArgs.a + validatedArgs.b;
554 | return {
555 | content: [
556 | {
557 | type: "text",
558 | text: `The sum of ${validatedArgs.a} and ${validatedArgs.b} is ${sum}.`,
559 | },
560 | ],
561 | };
562 | }
563 |
564 | if (name === ToolName.LONG_RUNNING_OPERATION) {
565 | const validatedArgs = LongRunningOperationSchema.parse(args);
566 | const { duration, steps } = validatedArgs;
567 | const stepDuration = duration / steps;
568 | const progressToken = request.params._meta?.progressToken;
569 |
570 | for (let i = 1; i < steps + 1; i++) {
571 | await new Promise((resolve) =>
572 | setTimeout(resolve, stepDuration * 1000)
573 | );
574 |
575 | if (progressToken !== undefined) {
576 | await server.notification({
577 | method: "notifications/progress",
578 | params: {
579 | progress: i,
580 | total: steps,
581 | progressToken,
582 | },
583 | },{relatedRequestId: extra.requestId});
584 | }
585 | }
586 |
587 | return {
588 | content: [
589 | {
590 | type: "text",
591 | text: `Long running operation completed. Duration: ${duration} seconds, Steps: ${steps}.`,
592 | },
593 | ],
594 | };
595 | }
596 |
597 | if (name === ToolName.PRINT_ENV) {
598 | return {
599 | content: [
600 | {
601 | type: "text",
602 | text: JSON.stringify(process.env, null, 2),
603 | },
604 | ],
605 | };
606 | }
607 |
608 | if (name === ToolName.SAMPLE_LLM) {
609 | const validatedArgs = SampleLLMSchema.parse(args);
610 | const { prompt, maxTokens } = validatedArgs;
611 |
612 | const result = await requestSampling(
613 | prompt,
614 | ToolName.SAMPLE_LLM,
615 | maxTokens,
616 | extra.sendRequest
617 | );
618 | return {
619 | content: [
620 | { type: "text", text: `LLM sampling result: ${Array.isArray(result.content) ? result.content.map(c => c.type === "text" ? c.text : JSON.stringify(c)).join("") : (result.content.type === "text" ? result.content.text : JSON.stringify(result.content))}` },
621 | ],
622 | };
623 | }
624 |
625 | if (name === ToolName.GET_TINY_IMAGE) {
626 | GetTinyImageSchema.parse(args);
627 | return {
628 | content: [
629 | {
630 | type: "text",
631 | text: "This is a tiny image:",
632 | },
633 | {
634 | type: "image",
635 | data: MCP_TINY_IMAGE,
636 | mimeType: "image/png",
637 | },
638 | {
639 | type: "text",
640 | text: "The image above is the MCP tiny image.",
641 | },
642 | ],
643 | };
644 | }
645 |
646 | if (name === ToolName.ANNOTATED_MESSAGE) {
647 | const { messageType, includeImage } = AnnotatedMessageSchema.parse(args);
648 |
649 | const content = [];
650 |
651 | // Main message with different priorities/audiences based on type
652 | if (messageType === "error") {
653 | content.push({
654 | type: "text",
655 | text: "Error: Operation failed",
656 | annotations: {
657 | priority: 1.0, // Errors are highest priority
658 | audience: ["user", "assistant"], // Both need to know about errors
659 | },
660 | });
661 | } else if (messageType === "success") {
662 | content.push({
663 | type: "text",
664 | text: "Operation completed successfully",
665 | annotations: {
666 | priority: 0.7, // Success messages are important but not critical
667 | audience: ["user"], // Success mainly for user consumption
668 | },
669 | });
670 | } else if (messageType === "debug") {
671 | content.push({
672 | type: "text",
673 | text: "Debug: Cache hit ratio 0.95, latency 150ms",
674 | annotations: {
675 | priority: 0.3, // Debug info is low priority
676 | audience: ["assistant"], // Technical details for assistant
677 | },
678 | });
679 | }
680 |
681 | // Optional image with its own annotations
682 | if (includeImage) {
683 | content.push({
684 | type: "image",
685 | data: MCP_TINY_IMAGE,
686 | mimeType: "image/png",
687 | annotations: {
688 | priority: 0.5,
689 | audience: ["user"], // Images primarily for user visualization
690 | },
691 | });
692 | }
693 |
694 | return { content };
695 | }
696 |
697 | if (name === ToolName.GET_RESOURCE_REFERENCE) {
698 | const validatedArgs = GetResourceReferenceSchema.parse(args);
699 | const resourceId = validatedArgs.resourceId;
700 |
701 | const resourceIndex = resourceId - 1;
702 | if (resourceIndex < 0 || resourceIndex >= ALL_RESOURCES.length) {
703 | throw new Error(`Resource with ID ${resourceId} does not exist`);
704 | }
705 |
706 | const resource = ALL_RESOURCES[resourceIndex];
707 |
708 | return {
709 | content: [
710 | {
711 | type: "text",
712 | text: `Returning resource reference for Resource ${resourceId}:`,
713 | },
714 | {
715 | type: "resource",
716 | resource: resource,
717 | },
718 | {
719 | type: "text",
720 | text: `You can access this resource using the URI: ${resource.uri}`,
721 | },
722 | ],
723 | };
724 | }
725 |
726 | if (name === ToolName.ELICITATION) {
727 | ElicitationSchema.parse(args);
728 |
729 | const elicitationResult = await extra.sendRequest({
730 | method: 'elicitation/create',
731 | params: {
732 | message: 'Please provide inputs for the following fields:',
733 | requestedSchema: {
734 | type: 'object',
735 | properties: {
736 | name: {
737 | title: 'Full Name',
738 | type: 'string',
739 | description: 'Your full, legal name',
740 | },
741 | check: {
742 | title: 'Agree to terms',
743 | type: 'boolean',
744 | description: 'A boolean check',
745 | },
746 | color: {
747 | title: 'Favorite Color',
748 | type: 'string',
749 | description: 'Favorite color (open text)',
750 | default: 'blue',
751 | },
752 | email: {
753 | title: 'Email Address',
754 | type: 'string',
755 | format: 'email',
756 | description: 'Your email address (will be verified, and never shared with anyone else)',
757 | },
758 | homepage: {
759 | type: 'string',
760 | format: 'uri',
761 | description: 'Homepage / personal site',
762 | },
763 | birthdate: {
764 | title: 'Birthdate',
765 | type: 'string',
766 | format: 'date',
767 | description: 'Your date of birth (will never be shared with anyone else)',
768 | },
769 | integer: {
770 | title: 'Favorite Integer',
771 | type: 'integer',
772 | description: 'Your favorite integer (do not give us your phone number, pin, or other sensitive info)',
773 | minimum: 1,
774 | maximum: 100,
775 | default: 42,
776 | },
777 | number: {
778 | title: 'Favorite Number',
779 | type: 'number',
780 | description: 'Favorite number (there are no wrong answers)',
781 | minimum: 0,
782 | maximum: 1000,
783 | default: 3.14,
784 | },
785 | petType: {
786 | title: 'Pet type',
787 | type: 'string',
788 | enum: ['cats', 'dogs', 'birds', 'fish', 'reptiles'],
789 | enumNames: ['Cats', 'Dogs', 'Birds', 'Fish', 'Reptiles'],
790 | default: 'dogs',
791 | description: 'Your favorite pet type',
792 | },
793 | },
794 | required: ['name'],
795 | },
796 | },
797 | }, ElicitResultSchema, { timeout: 10 * 60 * 1000 /* 10 minutes */ });
798 |
799 | // Handle different response actions
800 | const content = [];
801 |
802 | if (elicitationResult.action === 'accept' && elicitationResult.content) {
803 | content.push({
804 | type: "text",
805 | text: `✅ User provided the requested information!`,
806 | });
807 |
808 | // Only access elicitationResult.content when action is accept
809 | const userData = elicitationResult.content;
810 | const lines = [];
811 | if (userData.name) lines.push(`- Name: ${userData.name}`);
812 | if (userData.check !== undefined) lines.push(`- Agreed to terms: ${userData.check}`);
813 | if (userData.color) lines.push(`- Favorite Color: ${userData.color}`);
814 | if (userData.email) lines.push(`- Email: ${userData.email}`);
815 | if (userData.homepage) lines.push(`- Homepage: ${userData.homepage}`);
816 | if (userData.birthdate) lines.push(`- Birthdate: ${userData.birthdate}`);
817 | if (userData.integer !== undefined) lines.push(`- Favorite Integer: ${userData.integer}`);
818 | if (userData.number !== undefined) lines.push(`- Favorite Number: ${userData.number}`);
819 | if (userData.petType) lines.push(`- Pet Type: ${userData.petType}`);
820 |
821 | content.push({
822 | type: "text",
823 | text: `User inputs:\n${lines.join('\n')}`,
824 | });
825 | } else if (elicitationResult.action === 'decline') {
826 | content.push({
827 | type: "text",
828 | text: `❌ User declined to provide the requested information.`,
829 | });
830 | } else if (elicitationResult.action === 'cancel') {
831 | content.push({
832 | type: "text",
833 | text: `⚠️ User cancelled the elicitation dialog.`,
834 | });
835 | }
836 |
837 | // Include raw result for debugging
838 | content.push({
839 | type: "text",
840 | text: `\nRaw result: ${JSON.stringify(elicitationResult, null, 2)}`,
841 | });
842 |
843 | return { content };
844 | }
845 |
846 | if (name === ToolName.GET_RESOURCE_LINKS) {
847 | const { count } = GetResourceLinksSchema.parse(args);
848 | const content = [];
849 |
850 | // Add intro text
851 | content.push({
852 | type: "text",
853 | text: `Here are ${count} resource links to resources available in this server (see full output in tool response if your client does not support resource_link yet):`,
854 | });
855 |
856 | // Return resource links to actual resources from ALL_RESOURCES
857 | const actualCount = Math.min(count, ALL_RESOURCES.length);
858 | for (let i = 0; i < actualCount; i++) {
859 | const resource = ALL_RESOURCES[i];
860 | content.push({
861 | type: "resource_link",
862 | uri: resource.uri,
863 | name: resource.name,
864 | description: `Resource ${i + 1}: ${resource.mimeType === "text/plain"
865 | ? "plaintext resource"
866 | : "binary blob resource"
867 | }`,
868 | mimeType: resource.mimeType,
869 | });
870 | }
871 |
872 | return { content };
873 | }
874 |
875 | if (name === ToolName.STRUCTURED_CONTENT) {
876 | // The same response is returned for every input.
877 | const validatedArgs = StructuredContentSchema.input.parse(args);
878 |
879 | const weather = {
880 | temperature: 22.5,
881 | conditions: "Partly cloudy",
882 | humidity: 65
883 | }
884 |
885 | const backwardCompatiblecontent = {
886 | type: "text",
887 | text: JSON.stringify(weather)
888 | }
889 |
890 | return {
891 | content: [backwardCompatiblecontent],
892 | structuredContent: weather
893 | };
894 | }
895 |
896 | if (name === ToolName.ZIP_RESOURCES) {
897 | const { files } = ZipResourcesInputSchema.parse(args);
898 |
899 | const zip = new JSZip();
900 |
901 | for (const [fileName, fileUrl] of Object.entries(files)) {
902 | try {
903 | const response = await fetch(fileUrl);
904 | if (!response.ok) {
905 | throw new Error(`Failed to fetch ${fileUrl}: ${response.statusText}`);
906 | }
907 | const arrayBuffer = await response.arrayBuffer();
908 | zip.file(fileName, arrayBuffer);
909 | } catch (error) {
910 | throw new Error(`Error fetching file ${fileUrl}: ${error instanceof Error ? error.message : String(error)}`);
911 | }
912 | }
913 |
914 | const uri = `data:application/zip;base64,${await zip.generateAsync({ type: "base64" })}`;
915 |
916 | return {
917 | content: [
918 | {
919 | type: "resource_link",
920 | mimeType: "application/zip",
921 | uri,
922 | },
923 | ],
924 | };
925 | }
926 |
927 | if (name === ToolName.LIST_ROOTS) {
928 | ListRootsSchema.parse(args);
929 |
930 | if (!clientSupportsRoots) {
931 | return {
932 | content: [
933 | {
934 | type: "text",
935 | text: "The MCP client does not support the roots protocol.\n\n" +
936 | "This means the server cannot access information about the client's workspace directories or file system roots."
937 | }
938 | ]
939 | };
940 | }
941 |
942 | if (currentRoots.length === 0) {
943 | return {
944 | content: [
945 | {
946 | type: "text",
947 | text: "The client supports roots but no roots are currently configured.\n\n" +
948 | "This could mean:\n" +
949 | "1. The client hasn't provided any roots yet\n" +
950 | "2. The client provided an empty roots list\n" +
951 | "3. The roots configuration is still being loaded"
952 | }
953 | ]
954 | };
955 | }
956 |
957 | const rootsList = currentRoots.map((root, index) => {
958 | return `${index + 1}. ${root.name || 'Unnamed Root'}\n URI: ${root.uri}`;
959 | }).join('\n\n');
960 |
961 | return {
962 | content: [
963 | {
964 | type: "text",
965 | text: `Current MCP Roots (${currentRoots.length} total):\n\n${rootsList}\n\n` +
966 | "Note: This server demonstrates the roots protocol capability but doesn't actually access files. " +
967 | "The roots are provided by the MCP client and can be used by servers that need file system access."
968 | }
969 | ]
970 | };
971 | }
972 |
973 | throw new Error(`Unknown tool: ${name}`);
974 | });
975 |
976 | server.setRequestHandler(CompleteRequestSchema, async (request) => {
977 | const { ref, argument } = request.params;
978 |
979 | if (ref.type === "ref/resource") {
980 | const resourceId = ref.uri.split("/").pop();
981 | if (!resourceId) return { completion: { values: [] } };
982 |
983 | // Filter resource IDs that start with the input value
984 | const values = EXAMPLE_COMPLETIONS.resourceId.filter((id) =>
985 | id.startsWith(argument.value)
986 | );
987 | return { completion: { values, hasMore: false, total: values.length } };
988 | }
989 |
990 | if (ref.type === "ref/prompt") {
991 | // Handle completion for prompt arguments
992 | const completions =
993 | EXAMPLE_COMPLETIONS[argument.name as keyof typeof EXAMPLE_COMPLETIONS];
994 | if (!completions) return { completion: { values: [] } };
995 |
996 | const values = completions.filter((value) =>
997 | value.startsWith(argument.value)
998 | );
999 | return { completion: { values, hasMore: false, total: values.length } };
1000 | }
1001 |
1002 | throw new Error(`Unknown reference type`);
1003 | });
1004 |
1005 | // Roots protocol handlers
1006 | server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
1007 | try {
1008 | // Request the updated roots list from the client
1009 | const response = await server.listRoots();
1010 | if (response && 'roots' in response) {
1011 | currentRoots = response.roots;
1012 |
1013 | // Log the roots update for demonstration
1014 | await server.sendLoggingMessage({
1015 | level: "info",
1016 | logger: "everything-server",
1017 | data: `Roots updated: ${currentRoots.length} root(s) received from client`,
1018 | }, sessionId);
1019 | }
1020 | } catch (error) {
1021 | await server.sendLoggingMessage({
1022 | level: "error",
1023 | logger: "everything-server",
1024 | data: `Failed to request roots from client: ${error instanceof Error ? error.message : String(error)}`,
1025 | }, sessionId);
1026 | }
1027 | });
1028 |
1029 | // Handle post-initialization setup for roots
1030 | server.oninitialized = async () => {
1031 | clientCapabilities = server.getClientCapabilities();
1032 |
1033 | if (clientCapabilities?.roots) {
1034 | clientSupportsRoots = true;
1035 | try {
1036 | const response = await server.listRoots();
1037 | if (response && 'roots' in response) {
1038 | currentRoots = response.roots;
1039 |
1040 | await server.sendLoggingMessage({
1041 | level: "info",
1042 | logger: "everything-server",
1043 | data: `Initial roots received: ${currentRoots.length} root(s) from client`,
1044 | }, sessionId);
1045 | } else {
1046 | await server.sendLoggingMessage({
1047 | level: "warning",
1048 | logger: "everything-server",
1049 | data: "Client returned no roots set",
1050 | }, sessionId);
1051 | }
1052 | } catch (error) {
1053 | await server.sendLoggingMessage({
1054 | level: "error",
1055 | logger: "everything-server",
1056 | data: `Failed to request initial roots from client: ${error instanceof Error ? error.message : String(error)}`,
1057 | }, sessionId);
1058 | }
1059 | } else {
1060 | await server.sendLoggingMessage({
1061 | level: "info",
1062 | logger: "everything-server",
1063 | data: "Client does not support MCP roots protocol",
1064 | }, sessionId);
1065 | }
1066 | };
1067 |
1068 | const cleanup = async () => {
1069 | if (subsUpdateInterval) clearInterval(subsUpdateInterval);
1070 | if (logsUpdateInterval) clearInterval(logsUpdateInterval);
1071 | if (stdErrUpdateInterval) clearInterval(stdErrUpdateInterval);
1072 | };
1073 |
1074 | return { server, cleanup, startNotificationIntervals };
1075 | };
1076 |
1077 | const MCP_TINY_IMAGE =
1078 | "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";
1079 |
```
--------------------------------------------------------------------------------
/src/filesystem/__tests__/path-validation.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2 | import * as path from 'path';
3 | import * as fs from 'fs/promises';
4 | import * as os from 'os';
5 | import { isPathWithinAllowedDirectories } from '../path-validation.js';
6 |
7 | /**
8 | * Check if the current environment supports symlink creation
9 | */
10 | async function checkSymlinkSupport(): Promise<boolean> {
11 | const testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'symlink-test-'));
12 | try {
13 | const targetFile = path.join(testDir, 'target.txt');
14 | const linkFile = path.join(testDir, 'link.txt');
15 |
16 | await fs.writeFile(targetFile, 'test');
17 | await fs.symlink(targetFile, linkFile);
18 |
19 | // If we get here, symlinks are supported
20 | return true;
21 | } catch (error) {
22 | // EPERM indicates no symlink permissions
23 | if ((error as NodeJS.ErrnoException).code === 'EPERM') {
24 | return false;
25 | }
26 | // Other errors might indicate a real problem
27 | throw error;
28 | } finally {
29 | await fs.rm(testDir, { recursive: true, force: true });
30 | }
31 | }
32 |
33 | // Global variable to store symlink support status
34 | let symlinkSupported: boolean | null = null;
35 |
36 | /**
37 | * Get cached symlink support status, checking once per test run
38 | */
39 | async function getSymlinkSupport(): Promise<boolean> {
40 | if (symlinkSupported === null) {
41 | symlinkSupported = await checkSymlinkSupport();
42 | if (!symlinkSupported) {
43 | console.log('\n⚠️ Symlink tests will be skipped - symlink creation not supported in this environment');
44 | console.log(' On Windows, enable Developer Mode or run as Administrator to enable symlink tests');
45 | }
46 | }
47 | return symlinkSupported;
48 | }
49 |
50 | describe('Path Validation', () => {
51 | it('allows exact directory match', () => {
52 | const allowed = ['/home/user/project'];
53 | expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(true);
54 | });
55 |
56 | it('allows subdirectories', () => {
57 | const allowed = ['/home/user/project'];
58 | expect(isPathWithinAllowedDirectories('/home/user/project/src', allowed)).toBe(true);
59 | expect(isPathWithinAllowedDirectories('/home/user/project/src/index.js', allowed)).toBe(true);
60 | expect(isPathWithinAllowedDirectories('/home/user/project/deeply/nested/file.txt', allowed)).toBe(true);
61 | });
62 |
63 | it('blocks similar directory names (prefix vulnerability)', () => {
64 | const allowed = ['/home/user/project'];
65 | expect(isPathWithinAllowedDirectories('/home/user/project2', allowed)).toBe(false);
66 | expect(isPathWithinAllowedDirectories('/home/user/project_backup', allowed)).toBe(false);
67 | expect(isPathWithinAllowedDirectories('/home/user/project-old', allowed)).toBe(false);
68 | expect(isPathWithinAllowedDirectories('/home/user/projectile', allowed)).toBe(false);
69 | expect(isPathWithinAllowedDirectories('/home/user/project.bak', allowed)).toBe(false);
70 | });
71 |
72 | it('blocks paths outside allowed directories', () => {
73 | const allowed = ['/home/user/project'];
74 | expect(isPathWithinAllowedDirectories('/home/user/other', allowed)).toBe(false);
75 | expect(isPathWithinAllowedDirectories('/etc/passwd', allowed)).toBe(false);
76 | expect(isPathWithinAllowedDirectories('/home/user', allowed)).toBe(false);
77 | expect(isPathWithinAllowedDirectories('/', allowed)).toBe(false);
78 | });
79 |
80 | it('handles multiple allowed directories', () => {
81 | const allowed = ['/home/user/project1', '/home/user/project2'];
82 | expect(isPathWithinAllowedDirectories('/home/user/project1/src', allowed)).toBe(true);
83 | expect(isPathWithinAllowedDirectories('/home/user/project2/src', allowed)).toBe(true);
84 | expect(isPathWithinAllowedDirectories('/home/user/project3', allowed)).toBe(false);
85 | expect(isPathWithinAllowedDirectories('/home/user/project1_backup', allowed)).toBe(false);
86 | expect(isPathWithinAllowedDirectories('/home/user/project2-old', allowed)).toBe(false);
87 | });
88 |
89 | it('blocks parent and sibling directories', () => {
90 | const allowed = ['/test/allowed'];
91 |
92 | // Parent directory
93 | expect(isPathWithinAllowedDirectories('/test', allowed)).toBe(false);
94 | expect(isPathWithinAllowedDirectories('/', allowed)).toBe(false);
95 |
96 | // Sibling with common prefix
97 | expect(isPathWithinAllowedDirectories('/test/allowed_sibling', allowed)).toBe(false);
98 | expect(isPathWithinAllowedDirectories('/test/allowed2', allowed)).toBe(false);
99 | });
100 |
101 | it('handles paths with special characters', () => {
102 | const allowed = ['/home/user/my-project (v2)'];
103 |
104 | expect(isPathWithinAllowedDirectories('/home/user/my-project (v2)', allowed)).toBe(true);
105 | expect(isPathWithinAllowedDirectories('/home/user/my-project (v2)/src', allowed)).toBe(true);
106 | expect(isPathWithinAllowedDirectories('/home/user/my-project (v2)_backup', allowed)).toBe(false);
107 | expect(isPathWithinAllowedDirectories('/home/user/my-project', allowed)).toBe(false);
108 | });
109 |
110 | describe('Input validation', () => {
111 | it('rejects empty inputs', () => {
112 | const allowed = ['/home/user/project'];
113 |
114 | expect(isPathWithinAllowedDirectories('', allowed)).toBe(false);
115 | expect(isPathWithinAllowedDirectories('/home/user/project', [])).toBe(false);
116 | });
117 |
118 | it('handles trailing separators correctly', () => {
119 | const allowed = ['/home/user/project'];
120 |
121 | // Path with trailing separator should still match
122 | expect(isPathWithinAllowedDirectories('/home/user/project/', allowed)).toBe(true);
123 |
124 | // Allowed directory with trailing separator
125 | const allowedWithSep = ['/home/user/project/'];
126 | expect(isPathWithinAllowedDirectories('/home/user/project', allowedWithSep)).toBe(true);
127 | expect(isPathWithinAllowedDirectories('/home/user/project/', allowedWithSep)).toBe(true);
128 |
129 | // Should still block similar names with or without trailing separators
130 | expect(isPathWithinAllowedDirectories('/home/user/project2', allowedWithSep)).toBe(false);
131 | expect(isPathWithinAllowedDirectories('/home/user/project2', allowed)).toBe(false);
132 | expect(isPathWithinAllowedDirectories('/home/user/project2/', allowed)).toBe(false);
133 | });
134 |
135 | it('skips empty directory entries in allowed list', () => {
136 | const allowed = ['', '/home/user/project', ''];
137 | expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(true);
138 | expect(isPathWithinAllowedDirectories('/home/user/project/src', allowed)).toBe(true);
139 |
140 | // Should still validate properly with empty entries
141 | expect(isPathWithinAllowedDirectories('/home/user/other', allowed)).toBe(false);
142 | });
143 |
144 | it('handles Windows paths with trailing separators', () => {
145 | if (path.sep === '\\') {
146 | const allowed = ['C:\\Users\\project'];
147 |
148 | // Path with trailing separator
149 | expect(isPathWithinAllowedDirectories('C:\\Users\\project\\', allowed)).toBe(true);
150 |
151 | // Allowed with trailing separator
152 | const allowedWithSep = ['C:\\Users\\project\\'];
153 | expect(isPathWithinAllowedDirectories('C:\\Users\\project', allowedWithSep)).toBe(true);
154 | expect(isPathWithinAllowedDirectories('C:\\Users\\project\\', allowedWithSep)).toBe(true);
155 |
156 | // Should still block similar names
157 | expect(isPathWithinAllowedDirectories('C:\\Users\\project2\\', allowed)).toBe(false);
158 | }
159 | });
160 | });
161 |
162 | describe('Error handling', () => {
163 | it('normalizes relative paths to absolute', () => {
164 | const allowed = [process.cwd()];
165 |
166 | // Relative paths get normalized to absolute paths based on cwd
167 | expect(isPathWithinAllowedDirectories('relative/path', allowed)).toBe(true);
168 | expect(isPathWithinAllowedDirectories('./file', allowed)).toBe(true);
169 |
170 | // Parent directory references that escape allowed directory
171 | const parentAllowed = ['/home/user/project'];
172 | expect(isPathWithinAllowedDirectories('../parent', parentAllowed)).toBe(false);
173 | });
174 |
175 | it('returns false for relative paths in allowed directories', () => {
176 | const badAllowed = ['relative/path', '/some/other/absolute/path'];
177 |
178 | // Relative paths in allowed dirs are normalized to absolute based on cwd
179 | // The normalized 'relative/path' won't match our test path
180 | expect(isPathWithinAllowedDirectories('/some/other/absolute/path/file', badAllowed)).toBe(true);
181 | expect(isPathWithinAllowedDirectories('/absolute/path/file', badAllowed)).toBe(false);
182 | });
183 |
184 | it('handles null and undefined inputs gracefully', () => {
185 | const allowed = ['/home/user/project'];
186 |
187 | // Should return false, not crash
188 | expect(isPathWithinAllowedDirectories(null as any, allowed)).toBe(false);
189 | expect(isPathWithinAllowedDirectories(undefined as any, allowed)).toBe(false);
190 | expect(isPathWithinAllowedDirectories('/path', null as any)).toBe(false);
191 | expect(isPathWithinAllowedDirectories('/path', undefined as any)).toBe(false);
192 | });
193 | });
194 |
195 | describe('Unicode and special characters', () => {
196 | it('handles unicode characters in paths', () => {
197 | const allowed = ['/home/user/café'];
198 |
199 | expect(isPathWithinAllowedDirectories('/home/user/café', allowed)).toBe(true);
200 | expect(isPathWithinAllowedDirectories('/home/user/café/file', allowed)).toBe(true);
201 |
202 | // Different unicode representation won't match (not normalized)
203 | const decomposed = '/home/user/cafe\u0301'; // e + combining accent
204 | expect(isPathWithinAllowedDirectories(decomposed, allowed)).toBe(false);
205 | });
206 |
207 | it('handles paths with spaces correctly', () => {
208 | const allowed = ['/home/user/my project'];
209 |
210 | expect(isPathWithinAllowedDirectories('/home/user/my project', allowed)).toBe(true);
211 | expect(isPathWithinAllowedDirectories('/home/user/my project/file', allowed)).toBe(true);
212 |
213 | // Partial matches should fail
214 | expect(isPathWithinAllowedDirectories('/home/user/my', allowed)).toBe(false);
215 | expect(isPathWithinAllowedDirectories('/home/user/my proj', allowed)).toBe(false);
216 | });
217 | });
218 |
219 | describe('Overlapping allowed directories', () => {
220 | it('handles nested allowed directories correctly', () => {
221 | const allowed = ['/home', '/home/user', '/home/user/project'];
222 |
223 | // All paths under /home are allowed
224 | expect(isPathWithinAllowedDirectories('/home/anything', allowed)).toBe(true);
225 | expect(isPathWithinAllowedDirectories('/home/user/anything', allowed)).toBe(true);
226 | expect(isPathWithinAllowedDirectories('/home/user/project/anything', allowed)).toBe(true);
227 |
228 | // First match wins (most permissive)
229 | expect(isPathWithinAllowedDirectories('/home/other/deep/path', allowed)).toBe(true);
230 | });
231 |
232 | it('handles root directory as allowed', () => {
233 | const allowed = ['/'];
234 |
235 | // Everything is allowed under root (dangerous configuration)
236 | expect(isPathWithinAllowedDirectories('/', allowed)).toBe(true);
237 | expect(isPathWithinAllowedDirectories('/any/path', allowed)).toBe(true);
238 | expect(isPathWithinAllowedDirectories('/etc/passwd', allowed)).toBe(true);
239 | expect(isPathWithinAllowedDirectories('/home/user/secret', allowed)).toBe(true);
240 |
241 | // But only on the same filesystem root
242 | if (path.sep === '\\') {
243 | expect(isPathWithinAllowedDirectories('D:\\other', ['/'])).toBe(false);
244 | }
245 | });
246 | });
247 |
248 | describe('Cross-platform behavior', () => {
249 | it('handles Windows-style paths on Windows', () => {
250 | if (path.sep === '\\') {
251 | const allowed = ['C:\\Users\\project'];
252 | expect(isPathWithinAllowedDirectories('C:\\Users\\project', allowed)).toBe(true);
253 | expect(isPathWithinAllowedDirectories('C:\\Users\\project\\src', allowed)).toBe(true);
254 | expect(isPathWithinAllowedDirectories('C:\\Users\\project2', allowed)).toBe(false);
255 | expect(isPathWithinAllowedDirectories('C:\\Users\\project_backup', allowed)).toBe(false);
256 | }
257 | });
258 |
259 | it('handles Unix-style paths on Unix', () => {
260 | if (path.sep === '/') {
261 | const allowed = ['/home/user/project'];
262 | expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(true);
263 | expect(isPathWithinAllowedDirectories('/home/user/project/src', allowed)).toBe(true);
264 | expect(isPathWithinAllowedDirectories('/home/user/project2', allowed)).toBe(false);
265 | }
266 | });
267 | });
268 |
269 | describe('Validation Tests - Path Traversal', () => {
270 | it('blocks path traversal attempts', () => {
271 | const allowed = ['/home/user/project'];
272 |
273 | // Basic traversal attempts
274 | expect(isPathWithinAllowedDirectories('/home/user/project/../../../etc/passwd', allowed)).toBe(false);
275 | expect(isPathWithinAllowedDirectories('/home/user/project/../../other', allowed)).toBe(false);
276 | expect(isPathWithinAllowedDirectories('/home/user/project/../project2', allowed)).toBe(false);
277 |
278 | // Mixed traversal with valid segments
279 | expect(isPathWithinAllowedDirectories('/home/user/project/src/../../project2', allowed)).toBe(false);
280 | expect(isPathWithinAllowedDirectories('/home/user/project/./../../other', allowed)).toBe(false);
281 |
282 | // Multiple traversal sequences
283 | expect(isPathWithinAllowedDirectories('/home/user/project/../project/../../../etc', allowed)).toBe(false);
284 | });
285 |
286 | it('blocks traversal in allowed directories', () => {
287 | const allowed = ['/home/user/project/../safe'];
288 |
289 | // The allowed directory itself should be normalized and safe
290 | expect(isPathWithinAllowedDirectories('/home/user/safe/file', allowed)).toBe(true);
291 | expect(isPathWithinAllowedDirectories('/home/user/project/file', allowed)).toBe(false);
292 | });
293 |
294 | it('handles complex traversal patterns', () => {
295 | const allowed = ['/home/user/project'];
296 |
297 | // Double dots in filenames (not traversal) - these normalize to paths within allowed dir
298 | expect(isPathWithinAllowedDirectories('/home/user/project/..test', allowed)).toBe(true); // Not traversal
299 | expect(isPathWithinAllowedDirectories('/home/user/project/test..', allowed)).toBe(true); // Not traversal
300 | expect(isPathWithinAllowedDirectories('/home/user/project/te..st', allowed)).toBe(true); // Not traversal
301 |
302 | // Actual traversal
303 | expect(isPathWithinAllowedDirectories('/home/user/project/../test', allowed)).toBe(false); // Is traversal - goes to /home/user/test
304 |
305 | // Edge case: /home/user/project/.. normalizes to /home/user (parent dir)
306 | expect(isPathWithinAllowedDirectories('/home/user/project/..', allowed)).toBe(false); // Goes to parent
307 | });
308 | });
309 |
310 | describe('Validation Tests - Null Bytes', () => {
311 | it('rejects paths with null bytes', () => {
312 | const allowed = ['/home/user/project'];
313 |
314 | expect(isPathWithinAllowedDirectories('/home/user/project\x00/etc/passwd', allowed)).toBe(false);
315 | expect(isPathWithinAllowedDirectories('/home/user/project/test\x00.txt', allowed)).toBe(false);
316 | expect(isPathWithinAllowedDirectories('\x00/home/user/project', allowed)).toBe(false);
317 | expect(isPathWithinAllowedDirectories('/home/user/project/\x00', allowed)).toBe(false);
318 | });
319 |
320 | it('rejects allowed directories with null bytes', () => {
321 | const allowed = ['/home/user/project\x00'];
322 |
323 | expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(false);
324 | expect(isPathWithinAllowedDirectories('/home/user/project/file', allowed)).toBe(false);
325 | });
326 | });
327 |
328 | describe('Validation Tests - Special Characters', () => {
329 | it('allows percent signs in filenames', () => {
330 | const allowed = ['/home/user/project'];
331 |
332 | // Percent is a valid filename character
333 | expect(isPathWithinAllowedDirectories('/home/user/project/report_50%.pdf', allowed)).toBe(true);
334 | expect(isPathWithinAllowedDirectories('/home/user/project/Q1_25%_growth', allowed)).toBe(true);
335 | expect(isPathWithinAllowedDirectories('/home/user/project/%41', allowed)).toBe(true); // File named %41
336 |
337 | // URL encoding is NOT decoded by path.normalize, so these are just odd filenames
338 | expect(isPathWithinAllowedDirectories('/home/user/project/%2e%2e', allowed)).toBe(true); // File named "%2e%2e"
339 | expect(isPathWithinAllowedDirectories('/home/user/project/file%20name', allowed)).toBe(true); // File with %20 in name
340 | });
341 |
342 | it('handles percent signs in allowed directories', () => {
343 | const allowed = ['/home/user/project%20files'];
344 |
345 | // This is a directory literally named "project%20files"
346 | expect(isPathWithinAllowedDirectories('/home/user/project%20files/test', allowed)).toBe(true);
347 | expect(isPathWithinAllowedDirectories('/home/user/project files/test', allowed)).toBe(false); // Different dir
348 | });
349 | });
350 |
351 | describe('Path Normalization', () => {
352 | it('normalizes paths before comparison', () => {
353 | const allowed = ['/home/user/project'];
354 |
355 | // Trailing slashes
356 | expect(isPathWithinAllowedDirectories('/home/user/project/', allowed)).toBe(true);
357 | expect(isPathWithinAllowedDirectories('/home/user/project//', allowed)).toBe(true);
358 | expect(isPathWithinAllowedDirectories('/home/user/project///', allowed)).toBe(true);
359 |
360 | // Current directory references
361 | expect(isPathWithinAllowedDirectories('/home/user/project/./src', allowed)).toBe(true);
362 | expect(isPathWithinAllowedDirectories('/home/user/./project/src', allowed)).toBe(true);
363 |
364 | // Multiple slashes
365 | expect(isPathWithinAllowedDirectories('/home/user/project//src//file', allowed)).toBe(true);
366 | expect(isPathWithinAllowedDirectories('/home//user//project//src', allowed)).toBe(true);
367 |
368 | // Should still block outside paths
369 | expect(isPathWithinAllowedDirectories('/home/user//project2', allowed)).toBe(false);
370 | });
371 |
372 | it('handles mixed separators correctly', () => {
373 | if (path.sep === '\\') {
374 | const allowed = ['C:\\Users\\project'];
375 |
376 | // Mixed separators should be normalized
377 | expect(isPathWithinAllowedDirectories('C:/Users/project', allowed)).toBe(true);
378 | expect(isPathWithinAllowedDirectories('C:\\Users/project\\src', allowed)).toBe(true);
379 | expect(isPathWithinAllowedDirectories('C:/Users\\project/src', allowed)).toBe(true);
380 | }
381 | });
382 | });
383 |
384 | describe('Edge Cases', () => {
385 | it('rejects non-string inputs safely', () => {
386 | const allowed = ['/home/user/project'];
387 |
388 | expect(isPathWithinAllowedDirectories(123 as any, allowed)).toBe(false);
389 | expect(isPathWithinAllowedDirectories({} as any, allowed)).toBe(false);
390 | expect(isPathWithinAllowedDirectories([] as any, allowed)).toBe(false);
391 | expect(isPathWithinAllowedDirectories(null as any, allowed)).toBe(false);
392 | expect(isPathWithinAllowedDirectories(undefined as any, allowed)).toBe(false);
393 |
394 | // Non-string in allowed directories
395 | expect(isPathWithinAllowedDirectories('/home/user/project', [123 as any])).toBe(false);
396 | expect(isPathWithinAllowedDirectories('/home/user/project', [{} as any])).toBe(false);
397 | });
398 |
399 | it('handles very long paths', () => {
400 | const allowed = ['/home/user/project'];
401 |
402 | // Create a very long path that's still valid
403 | const longSubPath = 'a/'.repeat(1000) + 'file.txt';
404 | expect(isPathWithinAllowedDirectories(`/home/user/project/${longSubPath}`, allowed)).toBe(true);
405 |
406 | // Very long path that escapes
407 | const escapePath = 'a/'.repeat(1000) + '../'.repeat(1001) + 'etc/passwd';
408 | expect(isPathWithinAllowedDirectories(`/home/user/project/${escapePath}`, allowed)).toBe(false);
409 | });
410 | });
411 |
412 | describe('Additional Coverage', () => {
413 | it('handles allowed directories with traversal that normalizes safely', () => {
414 | // These allowed dirs contain traversal but normalize to valid paths
415 | const allowed = ['/home/user/../user/project'];
416 |
417 | // Should normalize to /home/user/project and work correctly
418 | expect(isPathWithinAllowedDirectories('/home/user/project/file', allowed)).toBe(true);
419 | expect(isPathWithinAllowedDirectories('/home/user/other', allowed)).toBe(false);
420 | });
421 |
422 | it('handles symbolic dots in filenames', () => {
423 | const allowed = ['/home/user/project'];
424 |
425 | // Single and double dots as actual filenames (not traversal)
426 | expect(isPathWithinAllowedDirectories('/home/user/project/.', allowed)).toBe(true);
427 | expect(isPathWithinAllowedDirectories('/home/user/project/..', allowed)).toBe(false); // This normalizes to parent
428 | expect(isPathWithinAllowedDirectories('/home/user/project/...', allowed)).toBe(true); // Three dots is a valid filename
429 | expect(isPathWithinAllowedDirectories('/home/user/project/....', allowed)).toBe(true); // Four dots is a valid filename
430 | });
431 |
432 | it('handles UNC paths on Windows', () => {
433 | if (path.sep === '\\') {
434 | const allowed = ['\\\\server\\share\\project'];
435 |
436 | expect(isPathWithinAllowedDirectories('\\\\server\\share\\project', allowed)).toBe(true);
437 | expect(isPathWithinAllowedDirectories('\\\\server\\share\\project\\file', allowed)).toBe(true);
438 | expect(isPathWithinAllowedDirectories('\\\\server\\share\\other', allowed)).toBe(false);
439 | expect(isPathWithinAllowedDirectories('\\\\other\\share\\project', allowed)).toBe(false);
440 | }
441 | });
442 | });
443 |
444 | describe('Symlink Tests', () => {
445 | let testDir: string;
446 | let allowedDir: string;
447 | let forbiddenDir: string;
448 |
449 | beforeEach(async () => {
450 | testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fs-error-test-'));
451 | allowedDir = path.join(testDir, 'allowed');
452 | forbiddenDir = path.join(testDir, 'forbidden');
453 |
454 | await fs.mkdir(allowedDir, { recursive: true });
455 | await fs.mkdir(forbiddenDir, { recursive: true });
456 | });
457 |
458 | afterEach(async () => {
459 | await fs.rm(testDir, { recursive: true, force: true });
460 | });
461 |
462 | it('validates symlink handling', async () => {
463 | // Test with symlinks
464 | try {
465 | const linkPath = path.join(allowedDir, 'bad-link');
466 | const targetPath = path.join(forbiddenDir, 'target.txt');
467 |
468 | await fs.writeFile(targetPath, 'content');
469 | await fs.symlink(targetPath, linkPath);
470 |
471 | // In real implementation, this would throw with the resolved path
472 | const realPath = await fs.realpath(linkPath);
473 | const allowed = [allowedDir];
474 |
475 | // Symlink target should be outside allowed directory
476 | expect(isPathWithinAllowedDirectories(realPath, allowed)).toBe(false);
477 | } catch (error) {
478 | // Skip if no symlink permissions
479 | }
480 | });
481 |
482 | it('handles non-existent paths correctly', async () => {
483 | const newFilePath = path.join(allowedDir, 'subdir', 'newfile.txt');
484 |
485 | // Parent directory doesn't exist
486 | try {
487 | await fs.access(newFilePath);
488 | } catch (error) {
489 | expect((error as NodeJS.ErrnoException).code).toBe('ENOENT');
490 | }
491 |
492 | // After creating parent, validation should work
493 | await fs.mkdir(path.dirname(newFilePath), { recursive: true });
494 | const allowed = [allowedDir];
495 | expect(isPathWithinAllowedDirectories(newFilePath, allowed)).toBe(true);
496 | });
497 |
498 | // Test path resolution consistency for symlinked files
499 | it('validates symlinked files consistently between path and resolved forms', async () => {
500 | try {
501 | // Setup: Create target file in forbidden area
502 | const targetFile = path.join(forbiddenDir, 'target.txt');
503 | await fs.writeFile(targetFile, 'TARGET_CONTENT');
504 |
505 | // Create symlink inside allowed directory pointing to forbidden file
506 | const symlinkPath = path.join(allowedDir, 'link-to-target.txt');
507 | await fs.symlink(targetFile, symlinkPath);
508 |
509 | // The symlink path itself passes validation (looks like it's in allowed dir)
510 | expect(isPathWithinAllowedDirectories(symlinkPath, [allowedDir])).toBe(true);
511 |
512 | // But the resolved path should fail validation
513 | const resolvedPath = await fs.realpath(symlinkPath);
514 | expect(isPathWithinAllowedDirectories(resolvedPath, [allowedDir])).toBe(false);
515 |
516 | // Verify the resolved path goes to the forbidden location (normalize both paths for macOS temp dirs)
517 | expect(await fs.realpath(resolvedPath)).toBe(await fs.realpath(targetFile));
518 | } catch (error) {
519 | // Skip if no symlink permissions on the system
520 | if ((error as NodeJS.ErrnoException).code !== 'EPERM') {
521 | throw error;
522 | }
523 | }
524 | });
525 |
526 | // Test allowed directory resolution behavior
527 | it('validates paths correctly when allowed directory is resolved from symlink', async () => {
528 | try {
529 | // Setup: Create the actual target directory with content
530 | const actualTargetDir = path.join(testDir, 'actual-target');
531 | await fs.mkdir(actualTargetDir, { recursive: true });
532 | const targetFile = path.join(actualTargetDir, 'file.txt');
533 | await fs.writeFile(targetFile, 'FILE_CONTENT');
534 |
535 | // Setup: Create symlink directory that points to target
536 | const symlinkDir = path.join(testDir, 'symlink-dir');
537 | await fs.symlink(actualTargetDir, symlinkDir);
538 |
539 | // Simulate resolved allowed directory (what the server startup should do)
540 | const resolvedAllowedDir = await fs.realpath(symlinkDir);
541 | const resolvedTargetDir = await fs.realpath(actualTargetDir);
542 | expect(resolvedAllowedDir).toBe(resolvedTargetDir);
543 |
544 | // Test 1: File access through original symlink path should pass validation with resolved allowed dir
545 | const fileViaSymlink = path.join(symlinkDir, 'file.txt');
546 | const resolvedFile = await fs.realpath(fileViaSymlink);
547 | expect(isPathWithinAllowedDirectories(resolvedFile, [resolvedAllowedDir])).toBe(true);
548 |
549 | // Test 2: File access through resolved path should also pass validation
550 | const fileViaResolved = path.join(resolvedTargetDir, 'file.txt');
551 | expect(isPathWithinAllowedDirectories(fileViaResolved, [resolvedAllowedDir])).toBe(true);
552 |
553 | // Test 3: Demonstrate inconsistent behavior with unresolved allowed directories
554 | // If allowed dirs were not resolved (storing symlink paths instead):
555 | const unresolvedAllowedDirs = [symlinkDir];
556 | // This validation would incorrectly fail for the same content:
557 | expect(isPathWithinAllowedDirectories(resolvedFile, unresolvedAllowedDirs)).toBe(false);
558 |
559 | } catch (error) {
560 | // Skip if no symlink permissions on the system
561 | if ((error as NodeJS.ErrnoException).code !== 'EPERM') {
562 | throw error;
563 | }
564 | }
565 | });
566 |
567 | it('resolves nested symlink chains completely', async () => {
568 | try {
569 | // Setup: Create target file in forbidden area
570 | const actualTarget = path.join(forbiddenDir, 'target-file.txt');
571 | await fs.writeFile(actualTarget, 'FINAL_CONTENT');
572 |
573 | // Create chain of symlinks: allowedFile -> link2 -> link1 -> actualTarget
574 | const link1 = path.join(testDir, 'intermediate-link1');
575 | const link2 = path.join(testDir, 'intermediate-link2');
576 | const allowedFile = path.join(allowedDir, 'seemingly-safe-file');
577 |
578 | await fs.symlink(actualTarget, link1);
579 | await fs.symlink(link1, link2);
580 | await fs.symlink(link2, allowedFile);
581 |
582 | // The allowed file path passes basic validation
583 | expect(isPathWithinAllowedDirectories(allowedFile, [allowedDir])).toBe(true);
584 |
585 | // But complete resolution reveals the forbidden target
586 | const fullyResolvedPath = await fs.realpath(allowedFile);
587 | expect(isPathWithinAllowedDirectories(fullyResolvedPath, [allowedDir])).toBe(false);
588 | expect(await fs.realpath(fullyResolvedPath)).toBe(await fs.realpath(actualTarget));
589 |
590 | } catch (error) {
591 | // Skip if no symlink permissions on the system
592 | if ((error as NodeJS.ErrnoException).code !== 'EPERM') {
593 | throw error;
594 | }
595 | }
596 | });
597 | });
598 |
599 | describe('Path Validation Race Condition Tests', () => {
600 | let testDir: string;
601 | let allowedDir: string;
602 | let forbiddenDir: string;
603 | let targetFile: string;
604 | let testPath: string;
605 |
606 | beforeEach(async () => {
607 | testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'race-test-'));
608 | allowedDir = path.join(testDir, 'allowed');
609 | forbiddenDir = path.join(testDir, 'outside');
610 | targetFile = path.join(forbiddenDir, 'target.txt');
611 | testPath = path.join(allowedDir, 'test.txt');
612 |
613 | await fs.mkdir(allowedDir, { recursive: true });
614 | await fs.mkdir(forbiddenDir, { recursive: true });
615 | await fs.writeFile(targetFile, 'ORIGINAL CONTENT', 'utf-8');
616 | });
617 |
618 | afterEach(async () => {
619 | await fs.rm(testDir, { recursive: true, force: true });
620 | });
621 |
622 | it('validates non-existent file paths based on parent directory', async () => {
623 | const allowed = [allowedDir];
624 |
625 | expect(isPathWithinAllowedDirectories(testPath, allowed)).toBe(true);
626 | await expect(fs.access(testPath)).rejects.toThrow();
627 |
628 | const parentDir = path.dirname(testPath);
629 | expect(isPathWithinAllowedDirectories(parentDir, allowed)).toBe(true);
630 | });
631 |
632 | it('demonstrates symlink race condition allows writing outside allowed directories', async () => {
633 | const symlinkSupported = await getSymlinkSupport();
634 | if (!symlinkSupported) {
635 | console.log(' ⏭️ Skipping symlink race condition test - symlinks not supported');
636 | return;
637 | }
638 |
639 | const allowed = [allowedDir];
640 |
641 | await expect(fs.access(testPath)).rejects.toThrow();
642 | expect(isPathWithinAllowedDirectories(testPath, allowed)).toBe(true);
643 |
644 | await fs.symlink(targetFile, testPath);
645 | await fs.writeFile(testPath, 'MODIFIED CONTENT', 'utf-8');
646 |
647 | const targetContent = await fs.readFile(targetFile, 'utf-8');
648 | expect(targetContent).toBe('MODIFIED CONTENT');
649 |
650 | const resolvedPath = await fs.realpath(testPath);
651 | expect(isPathWithinAllowedDirectories(resolvedPath, allowed)).toBe(false);
652 | });
653 |
654 | it('shows timing differences between validation approaches', async () => {
655 | const symlinkSupported = await getSymlinkSupport();
656 | if (!symlinkSupported) {
657 | console.log(' ⏭️ Skipping timing validation test - symlinks not supported');
658 | return;
659 | }
660 |
661 | const allowed = [allowedDir];
662 |
663 | const validation1 = isPathWithinAllowedDirectories(testPath, allowed);
664 | expect(validation1).toBe(true);
665 |
666 | await fs.symlink(targetFile, testPath);
667 |
668 | const resolvedPath = await fs.realpath(testPath);
669 | const validation2 = isPathWithinAllowedDirectories(resolvedPath, allowed);
670 | expect(validation2).toBe(false);
671 |
672 | expect(validation1).not.toBe(validation2);
673 | });
674 |
675 | it('validates directory creation timing', async () => {
676 | const symlinkSupported = await getSymlinkSupport();
677 | if (!symlinkSupported) {
678 | console.log(' ⏭️ Skipping directory creation timing test - symlinks not supported');
679 | return;
680 | }
681 |
682 | const allowed = [allowedDir];
683 | const testDir = path.join(allowedDir, 'newdir');
684 |
685 | expect(isPathWithinAllowedDirectories(testDir, allowed)).toBe(true);
686 |
687 | await fs.symlink(forbiddenDir, testDir);
688 |
689 | expect(isPathWithinAllowedDirectories(testDir, allowed)).toBe(true);
690 |
691 | const resolved = await fs.realpath(testDir);
692 | expect(isPathWithinAllowedDirectories(resolved, allowed)).toBe(false);
693 | });
694 |
695 | it('demonstrates exclusive file creation behavior', async () => {
696 | const symlinkSupported = await getSymlinkSupport();
697 | if (!symlinkSupported) {
698 | console.log(' ⏭️ Skipping exclusive file creation test - symlinks not supported');
699 | return;
700 | }
701 |
702 | const allowed = [allowedDir];
703 |
704 | await fs.symlink(targetFile, testPath);
705 |
706 | await expect(fs.open(testPath, 'wx')).rejects.toThrow(/EEXIST/);
707 |
708 | await fs.writeFile(testPath, 'NEW CONTENT', 'utf-8');
709 | const targetContent = await fs.readFile(targetFile, 'utf-8');
710 | expect(targetContent).toBe('NEW CONTENT');
711 | });
712 |
713 | it('should use resolved parent paths for non-existent files', async () => {
714 | const symlinkSupported = await getSymlinkSupport();
715 | if (!symlinkSupported) {
716 | console.log(' ⏭️ Skipping resolved parent paths test - symlinks not supported');
717 | return;
718 | }
719 |
720 | const allowed = [allowedDir];
721 |
722 | const symlinkDir = path.join(allowedDir, 'link');
723 | await fs.symlink(forbiddenDir, symlinkDir);
724 |
725 | const fileThroughSymlink = path.join(symlinkDir, 'newfile.txt');
726 |
727 | expect(fileThroughSymlink.startsWith(allowedDir)).toBe(true);
728 |
729 | const parentDir = path.dirname(fileThroughSymlink);
730 | const resolvedParent = await fs.realpath(parentDir);
731 | expect(isPathWithinAllowedDirectories(resolvedParent, allowed)).toBe(false);
732 |
733 | const expectedSafePath = path.join(resolvedParent, path.basename(fileThroughSymlink));
734 | expect(isPathWithinAllowedDirectories(expectedSafePath, allowed)).toBe(false);
735 | });
736 |
737 | it('demonstrates parent directory symlink traversal', async () => {
738 | const symlinkSupported = await getSymlinkSupport();
739 | if (!symlinkSupported) {
740 | console.log(' ⏭️ Skipping parent directory symlink traversal test - symlinks not supported');
741 | return;
742 | }
743 |
744 | const allowed = [allowedDir];
745 | const deepPath = path.join(allowedDir, 'sub1', 'sub2', 'file.txt');
746 |
747 | expect(isPathWithinAllowedDirectories(deepPath, allowed)).toBe(true);
748 |
749 | const sub1Path = path.join(allowedDir, 'sub1');
750 | await fs.symlink(forbiddenDir, sub1Path);
751 |
752 | await fs.mkdir(path.join(sub1Path, 'sub2'), { recursive: true });
753 | await fs.writeFile(deepPath, 'CONTENT', 'utf-8');
754 |
755 | const realPath = await fs.realpath(deepPath);
756 | const realAllowedDir = await fs.realpath(allowedDir);
757 | const realForbiddenDir = await fs.realpath(forbiddenDir);
758 |
759 | expect(realPath.startsWith(realAllowedDir)).toBe(false);
760 | expect(realPath.startsWith(realForbiddenDir)).toBe(true);
761 | });
762 |
763 | it('should prevent race condition between validatePath and file operation', async () => {
764 | const symlinkSupported = await getSymlinkSupport();
765 | if (!symlinkSupported) {
766 | console.log(' ⏭️ Skipping race condition prevention test - symlinks not supported');
767 | return;
768 | }
769 |
770 | const allowed = [allowedDir];
771 | const racePath = path.join(allowedDir, 'race-file.txt');
772 | const targetFile = path.join(forbiddenDir, 'target.txt');
773 |
774 | await fs.writeFile(targetFile, 'ORIGINAL CONTENT', 'utf-8');
775 |
776 | // Path validation would pass (file doesn't exist, parent is in allowed dir)
777 | expect(await fs.access(racePath).then(() => false).catch(() => true)).toBe(true);
778 | expect(isPathWithinAllowedDirectories(racePath, allowed)).toBe(true);
779 |
780 | // Race condition: symlink created after validation but before write
781 | await fs.symlink(targetFile, racePath);
782 |
783 | // With exclusive write flag, write should fail on symlink
784 | await expect(
785 | fs.writeFile(racePath, 'NEW CONTENT', { encoding: 'utf-8', flag: 'wx' })
786 | ).rejects.toThrow(/EEXIST/);
787 |
788 | // Verify content unchanged
789 | const targetContent = await fs.readFile(targetFile, 'utf-8');
790 | expect(targetContent).toBe('ORIGINAL CONTENT');
791 |
792 | // The symlink exists but write was blocked
793 | const actualWritePath = await fs.realpath(racePath);
794 | expect(actualWritePath).toBe(await fs.realpath(targetFile));
795 | expect(isPathWithinAllowedDirectories(actualWritePath, allowed)).toBe(false);
796 | });
797 |
798 | it('should allow overwrites to legitimate files within allowed directories', async () => {
799 | const allowed = [allowedDir];
800 | const legitFile = path.join(allowedDir, 'legit-file.txt');
801 |
802 | // Create a legitimate file
803 | await fs.writeFile(legitFile, 'ORIGINAL', 'utf-8');
804 |
805 | // Opening with w should work for legitimate files
806 | const fd = await fs.open(legitFile, 'w');
807 | try {
808 | await fd.write('UPDATED', 0, 'utf-8');
809 | } finally {
810 | await fd.close();
811 | }
812 |
813 | const content = await fs.readFile(legitFile, 'utf-8');
814 | expect(content).toBe('UPDATED');
815 | });
816 |
817 | it('should handle symlinks that point within allowed directories', async () => {
818 | const symlinkSupported = await getSymlinkSupport();
819 | if (!symlinkSupported) {
820 | console.log(' ⏭️ Skipping symlinks within allowed directories test - symlinks not supported');
821 | return;
822 | }
823 |
824 | const allowed = [allowedDir];
825 | const targetFile = path.join(allowedDir, 'target.txt');
826 | const symlinkPath = path.join(allowedDir, 'symlink.txt');
827 |
828 | // Create target file within allowed directory
829 | await fs.writeFile(targetFile, 'TARGET CONTENT', 'utf-8');
830 |
831 | // Create symlink pointing to allowed file
832 | await fs.symlink(targetFile, symlinkPath);
833 |
834 | // Opening symlink with w follows it to the target
835 | const fd = await fs.open(symlinkPath, 'w');
836 | try {
837 | await fd.write('UPDATED VIA SYMLINK', 0, 'utf-8');
838 | } finally {
839 | await fd.close();
840 | }
841 |
842 | // Both symlink and target should show updated content
843 | const symlinkContent = await fs.readFile(symlinkPath, 'utf-8');
844 | const targetContent = await fs.readFile(targetFile, 'utf-8');
845 | expect(symlinkContent).toBe('UPDATED VIA SYMLINK');
846 | expect(targetContent).toBe('UPDATED VIA SYMLINK');
847 | });
848 |
849 | it('should prevent overwriting files through symlinks pointing outside allowed directories', async () => {
850 | const symlinkSupported = await getSymlinkSupport();
851 | if (!symlinkSupported) {
852 | console.log(' ⏭️ Skipping symlink overwrite prevention test - symlinks not supported');
853 | return;
854 | }
855 |
856 | const allowed = [allowedDir];
857 | const legitFile = path.join(allowedDir, 'existing.txt');
858 | const targetFile = path.join(forbiddenDir, 'target.txt');
859 |
860 | // Create a legitimate file first
861 | await fs.writeFile(legitFile, 'LEGIT CONTENT', 'utf-8');
862 |
863 | // Create target file in forbidden directory
864 | await fs.writeFile(targetFile, 'FORBIDDEN CONTENT', 'utf-8');
865 |
866 | // Now replace the legitimate file with a symlink to forbidden location
867 | await fs.unlink(legitFile);
868 | await fs.symlink(targetFile, legitFile);
869 |
870 | // Simulate the server's validation logic
871 | const stats = await fs.lstat(legitFile);
872 | expect(stats.isSymbolicLink()).toBe(true);
873 |
874 | const realPath = await fs.realpath(legitFile);
875 | expect(isPathWithinAllowedDirectories(realPath, allowed)).toBe(false);
876 |
877 | // With atomic rename, symlinks are replaced not followed
878 | // So this test now demonstrates the protection
879 |
880 | // Verify content remains unchanged
881 | const targetContent = await fs.readFile(targetFile, 'utf-8');
882 | expect(targetContent).toBe('FORBIDDEN CONTENT');
883 | });
884 |
885 | it('demonstrates race condition in read operations', async () => {
886 | const symlinkSupported = await getSymlinkSupport();
887 | if (!symlinkSupported) {
888 | console.log(' ⏭️ Skipping race condition in read operations test - symlinks not supported');
889 | return;
890 | }
891 |
892 | const allowed = [allowedDir];
893 | const legitFile = path.join(allowedDir, 'readable.txt');
894 | const secretFile = path.join(forbiddenDir, 'secret.txt');
895 |
896 | // Create legitimate file
897 | await fs.writeFile(legitFile, 'PUBLIC CONTENT', 'utf-8');
898 |
899 | // Create secret file in forbidden directory
900 | await fs.writeFile(secretFile, 'SECRET CONTENT', 'utf-8');
901 |
902 | // Step 1: validatePath would pass for legitimate file
903 | expect(isPathWithinAllowedDirectories(legitFile, allowed)).toBe(true);
904 |
905 | // Step 2: Race condition - replace file with symlink after validation
906 | await fs.unlink(legitFile);
907 | await fs.symlink(secretFile, legitFile);
908 |
909 | // Step 3: Read operation follows symlink to forbidden location
910 | const content = await fs.readFile(legitFile, 'utf-8');
911 |
912 | // This shows the vulnerability - we read forbidden content
913 | expect(content).toBe('SECRET CONTENT');
914 | expect(isPathWithinAllowedDirectories(await fs.realpath(legitFile), allowed)).toBe(false);
915 | });
916 |
917 | it('verifies rename does not follow symlinks', async () => {
918 | const symlinkSupported = await getSymlinkSupport();
919 | if (!symlinkSupported) {
920 | console.log(' ⏭️ Skipping rename symlink test - symlinks not supported');
921 | return;
922 | }
923 |
924 | const allowed = [allowedDir];
925 | const tempFile = path.join(allowedDir, 'temp.txt');
926 | const targetSymlink = path.join(allowedDir, 'target-symlink.txt');
927 | const forbiddenTarget = path.join(forbiddenDir, 'forbidden-target.txt');
928 |
929 | // Create forbidden target
930 | await fs.writeFile(forbiddenTarget, 'ORIGINAL CONTENT', 'utf-8');
931 |
932 | // Create symlink pointing to forbidden location
933 | await fs.symlink(forbiddenTarget, targetSymlink);
934 |
935 | // Write temp file
936 | await fs.writeFile(tempFile, 'NEW CONTENT', 'utf-8');
937 |
938 | // Rename temp file to symlink path
939 | await fs.rename(tempFile, targetSymlink);
940 |
941 | // Check what happened
942 | const symlinkExists = await fs.lstat(targetSymlink).then(() => true).catch(() => false);
943 | const isSymlink = symlinkExists && (await fs.lstat(targetSymlink)).isSymbolicLink();
944 | const targetContent = await fs.readFile(targetSymlink, 'utf-8');
945 | const forbiddenContent = await fs.readFile(forbiddenTarget, 'utf-8');
946 |
947 | // Rename should replace the symlink with a regular file
948 | expect(isSymlink).toBe(false);
949 | expect(targetContent).toBe('NEW CONTENT');
950 | expect(forbiddenContent).toBe('ORIGINAL CONTENT'); // Unchanged
951 | });
952 | });
953 | });
954 |
```