fix(#1615): validate commandName to prevent workflow prompt injection

Codex peer review of PR #1622 surfaced that convertClaudeCommandToWindsurfWorkflow interpolated commandName unsanitized into a markdown body that Windsurf loads as an LLM-readable workflow. A plugin author who controls a commands/gsd/*.md filename could inject newlines, markdown structure, or path components (..) to manipulate the workflow body.

Validate commandName at function entry against /^(?:gsd-)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ — rejects slashes, backslashes, spaces, dots, control chars, trailing dash. Pattern requires alphanumeric ending so gsd- alone (which would slice to empty stem) is also rejected. Throws with a JSON.stringify-escaped preview (no literal newlines in the error message).

Applied to both bin/install.js (where tests import from) and src/runtime-artifact-conversion.cts (production source). 18 positive + 22 negative test cases lock in the validation.
This commit is contained in:
Tom Boucher
2026-06-23 14:38:38 -04:00
parent 527142ad2e
commit 4ed208e74b
3 changed files with 108 additions and 0 deletions

View File

@@ -1007,6 +1007,20 @@ function convertClaudeCommandToWindsurfSkill(content, skillName) {
}
function convertClaudeCommandToWindsurfWorkflow(content, commandName) {
// #1615 security: commandName flows unsanitized into a markdown body that
// Windsurf loads as an LLM-readable workflow. Validate at entry to prevent
// (a) prompt injection via newlines / markdown structure in the filename,
// (b) path-component injection via .., /, \ in stem → @-reference target.
// Pattern: optional gsd- prefix + lowercase alphanumeric + dashes; rejects
// everything else. See DEFECT.PROMPT-INJECTION-SCAN-COLLISION and the
// PR #1622 security review.
if (typeof commandName !== 'string' || !/^(?:gsd-)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(commandName)) {
const preview = typeof commandName === 'string' ? JSON.stringify(commandName.slice(0, 60)) : String(commandName);
throw new Error(
`convertClaudeCommandToWindsurfWorkflow: rejected commandName ${preview}; ` +
'must match /^(?:gsd-)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ (no slashes, backslashes, spaces, dots, trailing dash, or control chars — prevents prompt injection and path-component injection into the workflow body)'
);
}
const converted = convertClaudeToWindsurfMarkdown(content);
const { frontmatter } = extractFrontmatterAndBody(converted);
const description = frontmatter ? extractFrontmatterField(frontmatter, 'description') : '';