- Resolve configured root paths through symlinks before file operations - Compare authentication tokens through fixed-length digests
328 lines
14 KiB
TypeScript
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 })
|
|
}
|
|
})
|
|
})
|