Skip to content

Commit 89ae1c2

Browse files
CopilotlpcoxCopilot
authored
Fix argument injection in npm/pip/docker package validators (#23374)
* Initial plan * Fix argument injection in npm/pip/docker package validators - Add npm package name regex validation (validateNpmPackageName) in name_validation.go - Add PyPI package name regex validation (validatePipPackageName) per PEP 508 in name_validation.go - Add -- end-of-options separator to npm view command in npm_validation.go - Apply npm name regex validation before invoking npm CLI - Apply pip name regex validation in validatePythonPackagesWithPip - Add tests for single-character names and injection-style names in argument_injection_test.go Closes #XXX Agent-Logs-Url: https://github.com/github/gh-aw/sessions/db1ddaab-af87-4f48-8b54-597c4bcbe3de Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * Fix npm name regex to allow version suffixes in package specifiers The npmPackageNameRE regex rejected valid npm package specifiers that include version suffixes (e.g. @sentry/mcp-server@0.29.0). This caused mcp-inspector.md to fail compilation in CI (177/178 instead of 178/178). Update the regex to accept an optional @Version suffix and add test cases for scoped+versioned, unscoped+versioned, caret ranges, and tags. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> Co-authored-by: Landon Cox <landon.cox@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent eefc97d commit 89ae1c2

4 files changed

Lines changed: 236 additions & 2 deletions

File tree

pkg/workflow/argument_injection_test.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,184 @@ func TestCollectPackagesFromWorkflow_HyphenPrefixInArgs(t *testing.T) {
205205
}
206206
}
207207
}
208+
209+
// TestValidateNpmPackageName tests that npm package names are validated against
210+
// the npm naming rules, preventing argument injection via names that aren't caught
211+
// by the hyphen-prefix check alone.
212+
func TestValidateNpmPackageName(t *testing.T) {
213+
tests := []struct {
214+
name string
215+
pkg string
216+
expectError bool
217+
errContains string
218+
}{
219+
{
220+
name: "simple valid name",
221+
pkg: "express",
222+
expectError: false,
223+
},
224+
{
225+
name: "scoped valid name",
226+
pkg: "@scope/pkg",
227+
expectError: false,
228+
},
229+
{
230+
name: "name with hyphen",
231+
pkg: "my-package",
232+
expectError: false,
233+
},
234+
{
235+
name: "name with dot",
236+
pkg: "my.package",
237+
expectError: false,
238+
},
239+
{
240+
name: "single character name",
241+
pkg: "x",
242+
expectError: false,
243+
},
244+
{
245+
name: "scoped name with version suffix",
246+
pkg: "@sentry/mcp-server@0.29.0",
247+
expectError: false,
248+
},
249+
{
250+
name: "unscoped name with version suffix",
251+
pkg: "express@4.18.2",
252+
expectError: false,
253+
},
254+
{
255+
name: "name with semver caret range",
256+
pkg: "express@^4.0.0",
257+
expectError: false,
258+
},
259+
{
260+
name: "name with tag version",
261+
pkg: "express@latest",
262+
expectError: false,
263+
},
264+
{
265+
name: "name starting with hyphen is rejected",
266+
pkg: "--registry=https://attacker.example",
267+
expectError: true,
268+
errContains: "invalid npm package name",
269+
},
270+
{
271+
name: "name with equals sign is rejected",
272+
pkg: "pkg=https://attacker.example",
273+
expectError: true,
274+
errContains: "invalid npm package name",
275+
},
276+
{
277+
name: "name with spaces is rejected",
278+
pkg: "my package",
279+
expectError: true,
280+
errContains: "invalid npm package name",
281+
},
282+
{
283+
name: "uppercase name is rejected",
284+
pkg: "MyPackage",
285+
expectError: true,
286+
errContains: "invalid npm package name",
287+
},
288+
}
289+
290+
for _, tt := range tests {
291+
t.Run(tt.name, func(t *testing.T) {
292+
err := validateNpmPackageName(tt.pkg)
293+
if tt.expectError {
294+
if err == nil {
295+
t.Errorf("expected error for package %q but got none", tt.pkg)
296+
return
297+
}
298+
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
299+
t.Errorf("expected error to contain %q, got: %v", tt.errContains, err)
300+
}
301+
} else {
302+
if err != nil {
303+
t.Errorf("unexpected error for package %q: %v", tt.pkg, err)
304+
}
305+
}
306+
})
307+
}
308+
}
309+
310+
// TestValidatePipPackageName tests that PyPI package names are validated against
311+
// PEP 508 naming rules, preventing argument injection via names not caught by
312+
// the hyphen-prefix check alone.
313+
func TestValidatePipPackageName(t *testing.T) {
314+
tests := []struct {
315+
name string
316+
pkg string
317+
expectError bool
318+
errContains string
319+
}{
320+
{
321+
name: "simple valid name",
322+
pkg: "requests",
323+
expectError: false,
324+
},
325+
{
326+
name: "name with hyphen",
327+
pkg: "my-package",
328+
expectError: false,
329+
},
330+
{
331+
name: "name with underscore",
332+
pkg: "my_package",
333+
expectError: false,
334+
},
335+
{
336+
name: "name with dot",
337+
pkg: "my.package",
338+
expectError: false,
339+
},
340+
{
341+
name: "mixed case is valid per PEP 508",
342+
pkg: "MyPackage",
343+
expectError: false,
344+
},
345+
{
346+
name: "single character name",
347+
pkg: "z",
348+
expectError: false,
349+
},
350+
{
351+
name: "name starting with hyphen is rejected",
352+
pkg: "--index-url=https://attacker.example",
353+
expectError: true,
354+
errContains: "invalid pip package name",
355+
},
356+
{
357+
name: "name with equals sign is rejected",
358+
pkg: "pkg=https://attacker.example",
359+
expectError: true,
360+
errContains: "invalid pip package name",
361+
},
362+
{
363+
name: "name with spaces is rejected",
364+
pkg: "my package",
365+
expectError: true,
366+
errContains: "invalid pip package name",
367+
},
368+
}
369+
370+
for _, tt := range tests {
371+
t.Run(tt.name, func(t *testing.T) {
372+
err := validatePipPackageName(tt.pkg)
373+
if tt.expectError {
374+
if err == nil {
375+
t.Errorf("expected error for package %q but got none", tt.pkg)
376+
return
377+
}
378+
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
379+
t.Errorf("expected error to contain %q, got: %v", tt.errContains, err)
380+
}
381+
} else {
382+
if err != nil {
383+
t.Errorf("unexpected error for package %q: %v", tt.pkg, err)
384+
}
385+
}
386+
})
387+
}
388+
}

pkg/workflow/name_validation.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,39 @@ package workflow
1616

1717
import (
1818
"fmt"
19+
"regexp"
1920
"strings"
2021
)
2122

23+
// npmPackageNameRE matches valid npm package specifiers:
24+
// - Scoped: @scope/name where scope and name are lowercase alphanumeric + hyphens + dots + underscores
25+
// - Unscoped: lowercase alphanumeric + hyphens + dots + underscores
26+
// - Optional version suffix: @version (e.g. @1.2.3, @^1.0.0, @latest)
27+
// This rejects any name that could be interpreted as a CLI flag.
28+
var npmPackageNameRE = regexp.MustCompile(`^(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*(@[a-zA-Z0-9^~>=<.*|-]+)?$`)
29+
30+
// pypiPackageNameRE matches valid PyPI package names per PEP 508 / PEP 625:
31+
// must start and end with alphanumeric; interior may include dots, hyphens, underscores.
32+
var pypiPackageNameRE = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$`)
33+
34+
// validateNpmPackageName returns an error if the package name does not conform
35+
// to the npm package naming rules. This prevents argument injection into the npm CLI.
36+
func validateNpmPackageName(pkg string) error {
37+
if !npmPackageNameRE.MatchString(pkg) {
38+
return fmt.Errorf("invalid npm package name: %q", pkg)
39+
}
40+
return nil
41+
}
42+
43+
// validatePipPackageName returns an error if the package name does not conform
44+
// to the PyPI naming rules (PEP 508). This prevents argument injection into pip/uv.
45+
func validatePipPackageName(pkgName string) error {
46+
if !pypiPackageNameRE.MatchString(pkgName) {
47+
return fmt.Errorf("invalid pip package name: %q", pkgName)
48+
}
49+
return nil
50+
}
51+
2252
// rejectHyphenPrefixPackages returns a ValidationError if any of the provided
2353
// names starts with '-'. The kind parameter (e.g. "npx", "pip", "uv") is used
2454
// in the error messages.

pkg/workflow/npm_validation.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@ func (c *Compiler) validateNpxPackages(workflowData *WorkflowData) error {
7272
return err
7373
}
7474

75+
// Validate each package name against the npm naming rules.
76+
// This provides a second layer of defence against names that could be
77+
// misinterpreted by the npm CLI even after the hyphen-prefix check.
78+
for _, pkg := range packages {
79+
if err := validateNpmPackageName(pkg); err != nil {
80+
npmValidationLog.Printf("npm package name validation failed: %v", err)
81+
return NewValidationError(
82+
"npx.packages",
83+
"invalid npm package name",
84+
err.Error(),
85+
fmt.Sprintf("npm package names must match @scope/name or name (lowercase alphanumeric, hyphens, dots, underscores).\n\nInvalid name: %q", pkg),
86+
)
87+
}
88+
}
89+
7590
// Check if npm is available
7691
_, err := exec.LookPath("npm")
7792
if err != nil {
@@ -83,8 +98,9 @@ func (c *Compiler) validateNpxPackages(workflowData *WorkflowData) error {
8398
for _, pkg := range packages {
8499
npmValidationLog.Printf("Validating npm package: %s", pkg)
85100

86-
// Use npm view to check if package exists
87-
cmd := exec.Command("npm", "view", pkg, "name")
101+
// Use npm view to check if package exists.
102+
// Pass -- to prevent the package name being interpreted as a flag (argument injection defence).
103+
cmd := exec.Command("npm", "view", "--", pkg, "name")
88104
output, err := cmd.CombinedOutput()
89105

90106
if err != nil {

pkg/workflow/pip_validation.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ func (c *Compiler) validatePythonPackagesWithPip(packages []string, packageType
6565
continue
6666
}
6767

68+
// Validate the package name against PyPI naming rules (PEP 508).
69+
// pip does not universally honour '--', so we validate upfront.
70+
if err := validatePipPackageName(pkgName); err != nil {
71+
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("%s package name '%s' is invalid: %v", packageType, pkg, err)))
72+
continue
73+
}
74+
6875
pipValidationLog.Printf("Validating %s package: %s", packageType, pkgName)
6976

7077
// Use pip index to check if package exists on PyPI

0 commit comments

Comments
 (0)