Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
pathValidator-symlink.test.ts9.14 kB
import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import { PathValidator } from '../../../../src/security/pathValidator.js'; /** * SECURITY FIX #1290: Test symlink path traversal vulnerability * * This test verifies that PathValidator correctly resolves symlinks before * validating paths, preventing attackers from bypassing directory restrictions * by creating symlinks inside allowed directories that point to sensitive * files outside. * * Attack scenario: * 1. Attacker creates: /allowed/personas/evil.md -> /etc/passwd * 2. Without fix: path.resolve() returns /allowed/personas/evil.md (passes check) * 3. With fix: fs.realpath() returns /etc/passwd (fails check) */ /** * Helper function to create a symlink with proper error handling * @param target - The target path the symlink should point to * @param linkPath - The path where the symlink should be created * @returns true if symlink was created, false if skipped due to permissions */ async function createTestSymlink(target: string, linkPath: string): Promise<boolean> { try { await fs.symlink(target, linkPath); return true; } catch (err) { // On some systems (Windows), symlink creation may require admin privileges if ((err as NodeJS.ErrnoException).code === 'EPERM') { console.warn('Skipping symlink creation - insufficient permissions'); return false; } throw err; } } /** * Helper function to set up test directory structure */ async function setupTestDirectories(): Promise<{ tempDir: string; allowedDir: string; disallowedDir: string; }> { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dollhouse-symlink-test-')); const allowedDir = path.join(tempDir, 'allowed-personas'); const disallowedDir = path.join(tempDir, 'sensitive'); await fs.mkdir(allowedDir, { recursive: true }); await fs.mkdir(disallowedDir, { recursive: true }); // Resolve real paths (important on macOS where /tmp -> /private/var/folders) const realAllowedDir = await fs.realpath(allowedDir); const realDisallowedDir = await fs.realpath(disallowedDir); return { tempDir, allowedDir: realAllowedDir, disallowedDir: realDisallowedDir }; } describe('PathValidator - Symlink Security Tests (#1290)', () => { let tempDir: string; let allowedDir: string; let disallowedDir: string; let sensitiveFile: string; let symlinkPath: string; beforeAll(async () => { // Set up test directory structure const dirs = await setupTestDirectories(); tempDir = dirs.tempDir; allowedDir = dirs.allowedDir; disallowedDir = dirs.disallowedDir; // Create a sensitive file outside allowed directory sensitiveFile = path.join(disallowedDir, 'secrets.txt'); await fs.writeFile(sensitiveFile, 'SENSITIVE DATA - should not be accessible'); // Create a symlink inside allowed directory pointing to sensitive file symlinkPath = path.join(allowedDir, 'innocent-looking.md'); await createTestSymlink(sensitiveFile, symlinkPath); // Initialize PathValidator with allowed directory (only this directory, no defaults) PathValidator.initialize(allowedDir, ['.md', '.markdown', '.txt', '.yml', '.yaml']); }); afterAll(async () => { // Clean up temporary directory try { await fs.rm(tempDir, { recursive: true, force: true }); } catch (err) { console.warn('Failed to clean up temp directory:', err); } }); test('should reject symlinks pointing outside allowed directories', async () => { // Skip if we couldn't create symlinks in beforeAll try { await fs.lstat(symlinkPath); } catch { console.warn('Symlink not created, skipping test'); return; } // Verify symlink exists and points to sensitive file const stats = await fs.lstat(symlinkPath); expect(stats.isSymbolicLink()).toBe(true); const realTarget = await fs.realpath(symlinkPath); expect(realTarget).toBe(sensitiveFile); expect(realTarget).not.toContain('allowed-personas'); // PathValidator should reject the symlink await expect(async () => { await PathValidator.validatePersonaPath(symlinkPath); }).rejects.toThrow('Path access denied'); }); test('should allow normal files within allowed directories', async () => { // Create a legitimate file const legitFile = path.join(allowedDir, 'normal-persona.md'); await fs.writeFile(legitFile, '# Normal Persona'); // Should be allowed const validatedPath = await PathValidator.validatePersonaPath(legitFile); expect(validatedPath).toBeTruthy(); expect(validatedPath).toContain('allowed-personas'); // Clean up await fs.unlink(legitFile); }); test('should reject raw path strings with .. traversal patterns', async () => { // Use raw string with .. instead of path.join (which normalizes) const traversalPath = allowedDir + '/../sensitive/secrets.txt'; // Should be rejected due to .. pattern await expect(async () => { await PathValidator.validatePersonaPath(traversalPath); }).rejects.toThrow(); // Will be rejected for traversal or path access }); test('should handle symlinks in parent directories correctly', async () => { // Create a symlink to the allowed directory itself const symlinkToAllowedDir = path.join(tempDir, 'symlink-to-allowed'); const created = await createTestSymlink(allowedDir, symlinkToAllowedDir); if (!created) return; // Skip test if symlink creation failed // Create a file inside the symlinked directory const fileInSymlinkedDir = path.join(symlinkToAllowedDir, 'test.md'); await fs.writeFile(fileInSymlinkedDir, '# Test'); // This should be allowed because it resolves to inside allowed directory const validatedPath = await PathValidator.validatePersonaPath(fileInSymlinkedDir); expect(validatedPath).toBeTruthy(); // Clean up await fs.unlink(fileInSymlinkedDir); await fs.unlink(symlinkToAllowedDir); }); test('should handle non-existent paths with symlinked parent directories', async () => { // Create a symlink to the allowed directory const symlinkToAllowedDir = path.join(tempDir, 'symlink-to-allowed-2'); const created = await createTestSymlink(allowedDir, symlinkToAllowedDir); if (!created) return; // Skip test if symlink creation failed // Reference a non-existent file inside the symlinked directory const nonExistentFile = path.join(symlinkToAllowedDir, 'new-file.md'); // Should be allowed because parent resolves to inside allowed directory const validatedPath = await PathValidator.validatePersonaPath(nonExistentFile); expect(validatedPath).toBeTruthy(); expect(validatedPath).toContain('allowed-personas'); // Clean up await fs.unlink(symlinkToAllowedDir); }); test('should reject double symlink attack', async () => { // Create nested symlink attack: // innocent.md -> intermediate.md -> /sensitive/secrets.txt const intermediateSymlink = path.join(allowedDir, 'intermediate.md'); const finalSymlink = path.join(allowedDir, 'innocent.md'); // Create first symlink const created1 = await createTestSymlink(sensitiveFile, intermediateSymlink); if (!created1) return; // Skip test if symlink creation failed // Create second symlink const created2 = await createTestSymlink(intermediateSymlink, finalSymlink); if (!created2) { await fs.unlink(intermediateSymlink); return; // Skip test if second symlink creation failed } // Verify the symlink chain resolves to sensitive file const realTarget = await fs.realpath(finalSymlink); expect(realTarget).toBe(sensitiveFile); // PathValidator should reject the double-symlink await expect(async () => { await PathValidator.validatePersonaPath(finalSymlink); }).rejects.toThrow('Path access denied'); // Clean up try { await fs.unlink(finalSymlink); await fs.unlink(intermediateSymlink); } catch { // Ignore cleanup errors } }); test('should properly validate extension of symlink target, not symlink itself', async () => { // Create a symlink with .md extension pointing to a .txt file const txtFile = path.join(disallowedDir, 'config.txt'); await fs.writeFile(txtFile, 'CONFIG DATA'); const symlinkWithMdExt = path.join(allowedDir, 'looks-safe.md'); const created = await createTestSymlink(txtFile, symlinkWithMdExt); if (!created) { await fs.unlink(txtFile); return; // Skip test if symlink creation failed } // Should be rejected because target is outside allowed directory // (Not because of extension - we validate the real path location first) await expect(async () => { await PathValidator.validatePersonaPath(symlinkWithMdExt); }).rejects.toThrow('Path access denied'); // Clean up try { await fs.unlink(symlinkWithMdExt); await fs.unlink(txtFile); } catch { // Ignore cleanup errors } }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DollhouseMCP/DollhouseMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server