Skip to content

Commit 361a4ac

Browse files
committed
Harden skill install paths before validation
The install path used registry or SKILL.md names to create temporary and target directories before validation rejected unsafe names. This validates the install name before path construction, keeps the temp root as an explicit cleanup target, and resolves install targets under the selected skills root before copy or force removal. Constraint: Registry and raw SKILL.md sources are untrusted until validation completes. Rejected: Rely on validateSkillPath after temp construction | unsafe names can affect filesystem paths before validation runs. Confidence: high Scope-risk: narrow Tested: bun test src/cli/handlers/skills.test.ts src/skills/loadSkillsDir.test.ts src/commands.test.ts Tested: bun run build Tested: bun run smoke Tested: git diff --check
1 parent dfe2389 commit 361a4ac

2 files changed

Lines changed: 115 additions & 11 deletions

File tree

src/cli/handlers/skills.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import assert from 'node:assert/strict'
22
import { createHash } from 'node:crypto'
3-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3+
import {
4+
existsSync,
5+
mkdirSync,
6+
mkdtempSync,
7+
readFileSync,
8+
rmSync,
9+
writeFileSync,
10+
} from 'node:fs'
411
import { tmpdir } from 'node:os'
512
import { join } from 'node:path'
613
import { test } from 'bun:test'
@@ -31,6 +38,20 @@ Use this skill for install tests.
3138
Document token scopes without storing secret values.
3239
`
3340

41+
const PATH_TRAVERSAL_SKILL = `---
42+
name: ../escape
43+
title: Unsafe Skill
44+
description: Invalid skill used by install tests.
45+
version: 0.1.0
46+
category: test
47+
author: OpenClaude Tests
48+
license: MIT
49+
trust: local
50+
---
51+
52+
# Unsafe Skill
53+
`
54+
3455
function skill(
3556
name: string,
3657
description: string | undefined,
@@ -231,3 +252,54 @@ test.serial('installs a registry skill by id from a local registry file', async
231252
assert.equal(installedMetadata.sha256, sha256OfSkillSource(VALID_SKILL))
232253
})
233254
})
255+
256+
test.serial('rejects path-like skill names before installing raw markdown', async () => {
257+
await withTempDir(async tempDir => {
258+
const cwd = join(tempDir, 'project')
259+
const sourceDir = join(tempDir, 'source')
260+
const sourceFile = join(sourceDir, 'SKILL.md')
261+
mkdirSync(sourceDir, { recursive: true })
262+
mkdirSync(cwd, { recursive: true })
263+
writeFileSync(sourceFile, PATH_TRAVERSAL_SKILL, 'utf8')
264+
265+
await skillsInstallHandler(sourceFile, { projectDir: cwd })
266+
267+
assert.equal(process.exitCode, 1)
268+
assert.equal(existsSync(join(cwd, '.openclaude', 'skills')), false)
269+
})
270+
})
271+
272+
test.serial('rejects registry names that would escape the install root', async () => {
273+
await withTempDir(async tempDir => {
274+
const cwd = join(tempDir, 'project')
275+
const sourceDir = writeSkillDir(join(tempDir, 'registry-source'))
276+
const registryPath = join(tempDir, 'registry.json')
277+
mkdirSync(cwd, { recursive: true })
278+
writeFileSync(
279+
registryPath,
280+
JSON.stringify([
281+
{
282+
id: 'gitlawb/sample-skill',
283+
name: '../escape',
284+
title: 'Sample Skill',
285+
description: 'Sample skill used by install tests.',
286+
trust: 'official',
287+
version: '0.1.0',
288+
license: 'MIT',
289+
author: 'OpenClaude Tests',
290+
source: join(sourceDir, 'SKILL.md'),
291+
sha256: sha256OfSkillSource(VALID_SKILL),
292+
},
293+
]),
294+
'utf8',
295+
)
296+
297+
await skillsInstallHandler('sample-skill', {
298+
projectDir: cwd,
299+
registry: registryPath,
300+
})
301+
302+
assert.equal(process.exitCode, 1)
303+
assert.equal(existsSync(join(cwd, '.openclaude', 'skills')), false)
304+
})
305+
})

src/cli/handlers/skillsInstall.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createHash } from 'crypto'
22
import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises'
33
import { tmpdir } from 'os'
4-
import { basename, dirname, join, resolve } from 'path'
4+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'path'
55
import { getCwd } from '../../utils/cwd.js'
66
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
77
import { getDisplayPath } from '../../utils/file.js'
@@ -35,6 +35,7 @@ type SkillRegistryEntry = {
3535

3636
const DEFAULT_SKILLS_REGISTRY_URL =
3737
'https://raw.githubusercontent.com/Gitlawb/openclaude-skills/main/registry.json'
38+
const VALID_INSTALL_SKILL_NAME = /^[a-z0-9][a-z0-9-]*(?::[a-z0-9][a-z0-9-]*)*$/
3839

3940
function isPlainObject(value: unknown): value is Record<string, unknown> {
4041
return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -180,6 +181,34 @@ function skillNameFromSource(source: string): string {
180181
return leaf.replace(/\.md$/i, '') || 'skill'
181182
}
182183

184+
function normalizeInstallSkillName(value: string): string {
185+
const skillName = value.trim()
186+
if (!VALID_INSTALL_SKILL_NAME.test(skillName)) {
187+
throw new Error(
188+
`Invalid skill name "${value}". Use lowercase letters, numbers, dashes, and optional colon namespaces.`,
189+
)
190+
}
191+
return skillName
192+
}
193+
194+
function resolveContainedPath(root: string, child: string): string {
195+
const resolvedRoot = resolve(root)
196+
const resolvedChild = resolve(resolvedRoot, child)
197+
const relativePath = relative(resolvedRoot, resolvedChild)
198+
199+
if (
200+
relativePath === '' ||
201+
relativePath.startsWith('..') ||
202+
isAbsolute(relativePath)
203+
) {
204+
throw new Error(
205+
`Invalid skill install path "${child}". Skill paths must stay inside ${getDisplayPath(resolvedRoot)}.`,
206+
)
207+
}
208+
209+
return resolvedChild
210+
}
211+
183212
async function prepareSkillFromMarkdown({
184213
markdown,
185214
fallbackName,
@@ -188,13 +217,14 @@ async function prepareSkillFromMarkdown({
188217
markdown: string
189218
fallbackName: string
190219
registryEntry?: SkillRegistryEntry
191-
}): Promise<{ tempDir: string; skillName: string }> {
192-
const skillName =
220+
}): Promise<{ tempRoot: string; tempDir: string; skillName: string }> {
221+
const skillName = normalizeInstallSkillName(
193222
typeof registryEntry?.name === 'string'
194223
? registryEntry.name
195-
: getSkillNameFromMarkdown(markdown, fallbackName)
224+
: getSkillNameFromMarkdown(markdown, fallbackName),
225+
)
196226
const tempRoot = await mkdtemp(join(tmpdir(), 'openclaude-skill-install-'))
197-
const tempDir = join(tempRoot, skillName)
227+
const tempDir = resolveContainedPath(tempRoot, skillName)
198228
await mkdir(tempDir, { recursive: true })
199229
await writeFile(join(tempDir, 'SKILL.md'), markdown, 'utf8')
200230
if (registryEntry) {
@@ -204,14 +234,15 @@ async function prepareSkillFromMarkdown({
204234
'utf8',
205235
)
206236
}
207-
return { tempDir, skillName }
237+
return { tempRoot, tempDir, skillName }
208238
}
209239

210240
async function prepareInstallCandidate(
211241
spec: string,
212242
options: InstallOptions,
213243
): Promise<{
214244
tempDir: string
245+
tempRoot: string
215246
skillName: string
216247
sourceDescription: string
217248
trust: string
@@ -220,16 +251,17 @@ async function prepareInstallCandidate(
220251
const sourcePath = resolve(spec)
221252
const sourceStats = await stat(sourcePath)
222253
if (sourceStats.isDirectory()) {
223-
const skillName = basename(sourcePath)
254+
const skillName = normalizeInstallSkillName(basename(sourcePath))
224255
const tempRoot = await mkdtemp(join(tmpdir(), 'openclaude-skill-install-'))
225-
const tempDir = join(tempRoot, skillName)
256+
const tempDir = resolveContainedPath(tempRoot, skillName)
226257
await cp(sourcePath, tempDir, {
227258
recursive: true,
228259
errorOnExist: true,
229260
force: false,
230261
preserveTimestamps: false,
231262
})
232263
return {
264+
tempRoot,
233265
tempDir,
234266
skillName,
235267
sourceDescription: getDisplayPath(sourcePath),
@@ -308,7 +340,7 @@ export async function skillsInstallHandler(
308340
}
309341

310342
const root = installRoot(options)
311-
const targetDir = join(root, candidate.skillName)
343+
const targetDir = resolveContainedPath(root, candidate.skillName)
312344
if ((await pathExists(targetDir)) && !options.force) {
313345
console.error(
314346
`Skill "${candidate.skillName}" already exists at ${getDisplayPath(targetDir)}. Use --force to overwrite.`,
@@ -339,7 +371,7 @@ export async function skillsInstallHandler(
339371
process.exitCode = 1
340372
} finally {
341373
if (candidate) {
342-
await rm(dirname(candidate.tempDir), { recursive: true, force: true })
374+
await rm(candidate.tempRoot, { recursive: true, force: true })
343375
}
344376
}
345377
}

0 commit comments

Comments
 (0)