openpencil/tests/engine/mcp/path-scoping.test.ts
Danila Poyarkov 5e8d189f2e fix(mcp): harden local file and token access
- Resolve configured root paths through symlinks before file operations

- Compare authentication tokens through fixed-length digests
2026-07-25 21:03:29 +03:00

328 lines
14 KiB
TypeScript

import { describe, test, expect } from 'bun:test'
import { randomUUID } from 'node:crypto'
import { mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { resolveSafePath } from '#mcp/tool/output'
const isUnix = process.platform !== 'win32'
const TEST_ID = randomUUID().slice(0, 8)
describe('MCP path scoping', () => {
const root = resolve(tmpdir(), 'mcp-' + TEST_ID + '-test-root')
test('allows path inside root', async () => {
try {
await mkdir(root, { recursive: true })
const result = await resolveSafePath(`${root}/design.fig`, root)
expect(result.resolved).toBe(resolve(`${root}/design.fig`))
// realPath is the canonical form (realpath-resolved), which may differ
// from resolve() on macOS where /var -> /private/var
const canonicalRoot = await realpath(root)
expect(result.realPath).toBe(join(canonicalRoot, 'design.fig'))
} finally {
await rm(root, { recursive: true, force: true })
}
})
test('resolves relative path inside root', async () => {
const result = await resolveSafePath('design.fig', root)
expect(result.resolved).toBe(resolve(`${root}/design.fig`))
})
test('resolves nested relative path inside root', async () => {
const result = await resolveSafePath('sub/dir/file.fig', root)
expect(result.resolved).toBe(resolve(`${root}/sub/dir/file.fig`))
})
test('allows nested path inside root', async () => {
const result = await resolveSafePath(`${root}/sub/dir/file.fig`, root)
expect(result.resolved).toBe(resolve(`${root}/sub/dir/file.fig`))
})
test('allows root itself', async () => {
const result = await resolveSafePath(root, root)
expect(result.resolved).toBe(root)
})
test('rejects path outside root', async () => {
const outsideRoot = resolve(tmpdir(), 'mcp-' + TEST_ID + '-outside-root')
await expect(resolveSafePath(`${outsideRoot}/passwd`, root)).rejects.toThrow(
'outside the allowed root'
)
})
test('rejects path traversal', async () => {
await expect(resolveSafePath(`${root}/../../../etc/passwd`, root)).rejects.toThrow(
'outside the allowed root'
)
})
test('rejects sibling directory', async () => {
await expect(resolveSafePath(`${root}/../other-root/file.fig`, root)).rejects.toThrow(
'outside the allowed root'
)
})
test('rejects root prefix trick (root-evil)', async () => {
await expect(resolveSafePath(`${root}-evil/file.fig`, root)).rejects.toThrow(
'outside the allowed root'
)
})
test.skipIf(!isUnix)('rejects non-dangling symlink pointing outside root', async () => {
const testDir = resolve(tmpdir(), 'mcp-' + TEST_ID + '-symlink-test')
const linkPath = `${testDir}/escape.fig`
const outsideTarget = resolve(tmpdir(), 'mcp-' + TEST_ID + '-symlink-outside-target')
try {
await mkdir(testDir, { recursive: true })
// Create the target so the symlink is NOT dangling — this exercises
// the realpath → outside-root branch rather than the dangling path.
await mkdir(outsideTarget, { recursive: true })
await symlink(outsideTarget, linkPath)
await expect(resolveSafePath(linkPath, testDir)).rejects.toThrow('outside the allowed root')
} finally {
await rm(testDir, { recursive: true, force: true })
await rm(outsideTarget, { recursive: true, force: true })
}
})
test.skipIf(!isUnix)('allows dangling symlink pointing inside root', async () => {
const testDir = resolve(tmpdir(), 'mcp-' + TEST_ID + '-symlink-dangling-inside-test')
const linkPath = `${testDir}/dangling.fig`
const insideTarget = `${testDir}/nonexistent-target.fig`
try {
await mkdir(testDir, { recursive: true })
// Dangling symlink: target doesn't exist, but points inside root.
// This is a legitimate use case — the target will be created by the
// write operation. The target path is validated to be inside root.
await symlink(insideTarget, linkPath)
const result = await resolveSafePath(linkPath, testDir)
expect(result.resolved).toBe(resolve(linkPath))
// realPath should resolve to the target's canonical parent + filename
const canonicalParent = await realpath(testDir)
expect(result.realPath).toBe(join(canonicalParent, 'nonexistent-target.fig'))
} finally {
await rm(testDir, { recursive: true, force: true })
}
})
test.skipIf(!isUnix)('allows symlink pointing inside root', async () => {
const testDir = resolve(tmpdir(), 'mcp-' + TEST_ID + '-symlink-safe-test')
const targetDir = `${testDir}/targets`
const targetFile = `${targetDir}/real.fig`
const linkPath = `${testDir}/link.fig`
try {
await mkdir(targetDir, { recursive: true })
await writeFile(targetFile, 'test')
await symlink(targetFile, linkPath)
// Returns both resolved (for display) and realPath (canonical, for writes).
// The resolved path is the user-provided normalized path for usability.
const result = await resolveSafePath(linkPath, testDir)
expect(result.resolved).toBe(resolve(linkPath))
// realPath must be the canonical target (not the symlink itself)
const canonicalTarget = await realpath(targetFile)
expect(result.realPath).toBe(canonicalTarget)
} finally {
await rm(testDir, { recursive: true, force: true })
}
})
test.skipIf(!isUnix)('rejects dangling symlink pointing outside root', async () => {
const testDir = resolve(tmpdir(), 'mcp-' + TEST_ID + '-symlink-dangling-test')
const linkPath = `${testDir}/dangling.fig`
const outsideTarget = resolve(tmpdir(), 'mcp-' + TEST_ID + '-symlink-dangling-outside')
try {
await mkdir(testDir, { recursive: true })
// Dangling symlink: target doesn't exist, points outside root
await symlink(outsideTarget, linkPath)
await expect(resolveSafePath(linkPath, testDir)).rejects.toThrow('outside the allowed root')
} finally {
await rm(testDir, { recursive: true, force: true })
}
})
test('allows nonexistent file inside root', async () => {
const testDir = resolve(tmpdir(), 'mcp-' + TEST_ID + '-nonexistent-test')
const filePath = `${testDir}/new.fig`
try {
await mkdir(testDir, { recursive: true })
// File doesn't exist yet (common for save_file / export operations)
const result = await resolveSafePath(filePath, testDir)
expect(result.resolved).toBe(resolve(filePath))
// realPath uses the canonical parent directory + filename
const canonicalParent = await realpath(testDir)
expect(result.realPath).toBe(join(canonicalParent, 'new.fig'))
} finally {
await rm(testDir, { recursive: true, force: true })
}
})
test('rejects trivially broad root "/"', async () => {
await expect(resolveSafePath('/some/path', '/')).rejects.toThrow('Root path is too broad')
})
test.skipIf(process.platform !== 'win32')(
'rejects drive root as broad root on Windows',
async () => {
// On Windows, path.parse('C:\\').root === 'C:\\', which resolveSafePath
// should reject. This test runs only on Windows where drive roots exist.
const { parse, resolve: winResolve } = await import('node:path')
const driveRoot = parse(winResolve('C:\\')).root
await expect(resolveSafePath('C:\\some\\path', driveRoot)).rejects.toThrow(
'Root path is too broad'
)
}
)
test('rejects path exceeding resolution depth limit', async () => {
// resolveRealAncestor has a 64-iteration depth cap. When the entire
// ancestor chain doesn't exist (e.g., a deep non-existent root path),
// the function must fail closed rather than returning a partially-resolved
// path that could bypass containment checks.
const deepSegments = Array.from({ length: 70 }, () => 'sub').join('/')
const deepRoot = resolve(tmpdir(), deepSegments)
await expect(resolveSafePath(`${deepRoot}/file.fig`, deepRoot)).rejects.toThrow(
'depth limit exceeded'
)
})
test('rejects deep file path exceeding resolution depth limit with existing root', async () => {
// When the root exists but the file path has >64 non-existent ancestor
// segments, resolveRealAncestor is called for the parent directory and
// must fail closed. This exercises the second call site (line 142 in
// output.ts: resolveRealAncestor(parentDir)).
const testDir = resolve(tmpdir(), 'mcp-' + TEST_ID + '-deep-path-test')
try {
await mkdir(testDir, { recursive: true })
// 70 non-existent subdirectories under an existing root
const deepFile = Array.from({ length: 70 }, () => 'sub').join('/') + '/file.fig'
await expect(resolveSafePath(deepFile, testDir)).rejects.toThrow('depth limit exceeded')
} finally {
await rm(testDir, { recursive: true, force: true })
}
})
test.skipIf(!isUnix)('rejects symlinked root that resolves to /', async () => {
const testDir = resolve(tmpdir(), 'mcp-' + TEST_ID + '-symlink-root-test')
const rootLink = `${testDir}/root-link`
try {
await mkdir(testDir, { recursive: true })
await symlink('/', rootLink)
await expect(resolveSafePath(`${rootLink}/file.fig`, rootLink)).rejects.toThrow(
'Root path is too broad'
)
} finally {
await rm(testDir, { recursive: true, force: true })
}
})
test.skipIf(!isUnix)('rejects circular symlinks', async () => {
const testDir = resolve(tmpdir(), 'mcp-' + TEST_ID + '-symlink-circular-test')
const linkA = `${testDir}/a.fig`
const linkB = `${testDir}/b.fig`
try {
await mkdir(testDir, { recursive: true })
// Circular symlink chain: a -> b -> a
await symlink(linkB, linkA)
await symlink(linkA, linkB)
await expect(resolveSafePath(linkA, testDir)).rejects.toThrow('depth limit exceeded')
} finally {
await rm(testDir, { recursive: true, force: true })
}
})
test.skipIf(!isUnix)('allows absolute symlink target inside root', async () => {
const testDir = resolve(tmpdir(), 'mcp-' + TEST_ID + '-symlink-abs-inside-test')
const targetDir = `${testDir}/targets`
const targetFile = `${targetDir}/real.fig`
const linkPath = `${testDir}/link.fig`
try {
await mkdir(targetDir, { recursive: true })
await writeFile(targetFile, 'test')
// Absolute symlink target pointing inside root
await symlink(targetFile, linkPath)
const result = await resolveSafePath(linkPath, testDir)
expect(result.resolved).toBe(resolve(linkPath))
const canonicalTarget = await realpath(targetFile)
expect(result.realPath).toBe(canonicalTarget)
} finally {
await rm(testDir, { recursive: true, force: true })
}
})
test.skipIf(!isUnix)('rejects absolute symlink target outside root', async () => {
const testDir = resolve(tmpdir(), 'mcp-' + TEST_ID + '-symlink-abs-outside-test')
const outsideDir = resolve(tmpdir(), 'mcp-' + TEST_ID + '-symlink-abs-outside-target')
const outsideFile = `${outsideDir}/secret.fig`
const linkPath = `${testDir}/link.fig`
try {
await mkdir(testDir, { recursive: true })
await mkdir(outsideDir, { recursive: true })
await writeFile(outsideFile, 'secret')
// Absolute symlink target pointing outside root
await symlink(outsideFile, linkPath)
await expect(resolveSafePath(linkPath, testDir)).rejects.toThrow('outside the allowed root')
} finally {
await rm(testDir, { recursive: true, force: true })
await rm(outsideDir, { recursive: true, force: true })
}
})
test.skipIf(!isUnix)(
'allows relative symlink with traversal that stays inside root',
async () => {
const testDir = resolve(tmpdir(), 'mcp-' + TEST_ID + '-symlink-rel-traversal-test')
const subDir = `${testDir}/sub`
const targetFile = `${testDir}/target.fig`
const linkPath = `${subDir}/link.fig`
try {
await mkdir(subDir, { recursive: true })
await writeFile(targetFile, 'test')
// Relative symlink: sub/link.fig -> ../target.fig (resolves inside root)
await symlink('../target.fig', linkPath)
const result = await resolveSafePath(linkPath, testDir)
expect(result.resolved).toBe(resolve(linkPath))
const canonicalTarget = await realpath(targetFile)
expect(result.realPath).toBe(canonicalTarget)
} finally {
await rm(testDir, { recursive: true, force: true })
}
}
)
test.skipIf(!isUnix)('rejects self-referencing symlink', async () => {
const testDir = resolve(tmpdir(), 'mcp-' + TEST_ID + '-symlink-self-test')
const linkPath = `${testDir}/self.fig`
try {
await mkdir(testDir, { recursive: true })
// Self-referencing symlink: link.fig -> link.fig
await symlink(linkPath, linkPath)
await expect(resolveSafePath(linkPath, testDir)).rejects.toThrow('depth limit exceeded')
} finally {
await rm(testDir, { recursive: true, force: true })
}
})
test.skipIf(!isUnix)('allows symlink chain inside root (non-dangling)', async () => {
const testDir = resolve(tmpdir(), 'mcp-' + TEST_ID + '-symlink-chain-test')
const targetFile = `${testDir}/real.fig`
const link1 = `${testDir}/link1.fig`
const link2 = `${testDir}/link2.fig`
try {
await mkdir(testDir, { recursive: true })
await writeFile(targetFile, 'test')
// Chain: link2 -> link1 -> real.fig
await symlink(targetFile, link1)
await symlink(link1, link2)
const result = await resolveSafePath(link2, testDir)
expect(result.resolved).toBe(resolve(link2))
const canonicalTarget = await realpath(targetFile)
expect(result.realPath).toBe(canonicalTarget)
} finally {
await rm(testDir, { recursive: true, force: true })
}
})
})