Add content expander for parameters, commands, and file paths with injection protection#151
Conversation
Co-authored-by: alexec <1142830+alexec@users.noreply.github.com>
Co-authored-by: alexec <1142830+alexec@users.noreply.github.com>
Co-authored-by: alexec <1142830+alexec@users.noreply.github.com>
|
@copilot The current solution could expand a parameter which then expands a command, causing command injection attack. You must process the file rune by rune. |
Process expansions in a single pass, rune-by-rune, to prevent command
injection where expanded content (from commands or files) contains
further expansion sequences that would be re-expanded.
Security issue: Previously, expansions were done in multiple passes
(commands -> paths -> parameters), allowing:
- Command output containing ${param} to be expanded
- File content containing ${param} or !`cmd` to be expanded
- Parameters containing !`cmd` or @path to be expanded
Fix: Process all three expansion types in a single pass by scanning
rune-by-rune, which ensures expanded content is never re-processed.
Tests updated to verify security fix and ensure no re-expansion occurs.
Co-authored-by: alexec <1142830+alexec@users.noreply.github.com>
Fixed in commit 3f3f3f3. Rewrote the expander to process content rune-by-rune in a single pass instead of multiple passes. This prevents command injection where expanded content (from commands or files) contains further expansion sequences. Security tests added to verify:
All existing tests pass. |
- Change Expander to expander (private struct) - Change params field from Params type to map[string]string directly - Update all references to use lowercase expander Co-authored-by: alexec <1142830+alexec@users.noreply.github.com>
Replace string concatenation with rune slice notation for better
efficiency and to preserve exact original text when parameters are
not found or commands fail.
Changes:
- Line 54: "${" + paramName + "}" → string(runes[i : end+1])
- Line 76: "!`" + command + "`" → string(runes[i : end+1])
Co-authored-by: alexec <1142830+alexec@users.noreply.github.com>
Refactored per @alexec feedback: - Removed expander struct - Removed NewExpander function - Made expand function private (lowercase) - Pass params and logger as function parameters - Updated all tests to use new function signature - Simplified TestNewExpander to TestExpandWithNilLogger This results in a simpler, more functional API with no unnecessary struct wrapper. Co-authored-by: alexec <1142830+alexec@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR implements a new content expander that supports three types of expansions: parameter expansion (${param_name}), command expansion (!`command`), and path expansion (@path). The implementation includes a critical security fix that processes expansions in a single rune-by-rune pass to prevent injection attacks where expanded content contains further expansion sequences. The PR also incorporates code review feedback to use a functional approach with a private expand function.
Key Changes
- Single-pass rune-by-rune expansion processing to prevent command injection vulnerabilities
- Support for three expansion types: parameters, shell commands, and file paths
- Integration with existing parameter expansion in
context.go
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 10 comments.
| File | Description |
|---|---|
| pkg/codingcontext/expander.go | New file implementing single-pass expansion logic with parameter, command, and path expansion support |
| pkg/codingcontext/expander_test.go | Comprehensive test suite with 45+ test cases covering all expansion types, edge cases, and security scenarios |
| pkg/codingcontext/context.go | Updated expandParams method to use new expand function and merge parameters correctly |
| integration_test.go | New integration test verifying all three expansion types work together end-to-end |
| func TestExpandParameters(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| params Params | ||
| content string | ||
| expected string | ||
| }{ | ||
| { | ||
| name: "single parameter expansion", | ||
| params: Params{"name": "Alice"}, | ||
| content: "Hello, ${name}!", | ||
| expected: "Hello, Alice!", | ||
| }, | ||
| { | ||
| name: "multiple parameter expansions", | ||
| params: Params{"first": "John", "last": "Doe"}, | ||
| content: "${first} ${last}", | ||
| expected: "John Doe", | ||
| }, | ||
| { | ||
| name: "parameter not found - returns unchanged with warning", | ||
| params: Params{}, | ||
| content: "Value: ${missing}", | ||
| expected: "Value: ${missing}", | ||
| }, | ||
| { | ||
| name: "mixed found and not found parameters", | ||
| params: Params{"found": "yes"}, | ||
| content: "${found} and ${notfound}", | ||
| expected: "yes and ${notfound}", | ||
| }, | ||
| { | ||
| name: "no parameters to expand", | ||
| params: Params{"key": "value"}, | ||
| content: "Plain text without parameters", | ||
| expected: "Plain text without parameters", | ||
| }, | ||
| { | ||
| name: "parameter with special characters", | ||
| params: Params{"path": "/tmp/file.txt"}, | ||
| content: "File: ${path}", | ||
| expected: "File: /tmp/file.txt", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| result := expand(tt.content, tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) | ||
| if result != tt.expected { | ||
| t.Errorf("expand() = %q, want %q", result, tt.expected) | ||
| } | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
The parameter expansion tests are missing important edge cases. Consider adding tests for: unclosed parameter syntax like ${name (should be treated as literal text), empty parameter name ${}, nested or malformed patterns like ${${nested}}, and parameters at the very end of the string. These edge cases are important for ensuring the parser handles malformed input gracefully.
There was a problem hiding this comment.
Done in commit e637274. Added edge case tests for: unclosed parameter syntax, empty parameter name, parameter at end of string, and nested braces.
Code changes per @alexec feedback: - Removed nil logger check in expand function (line 18) - Preserve command output as-is, no longer trim trailing newlines (line 65) - Updated all test expectations to account for newlines in command output - Changed TestExpandWithNilLogger to TestExpandBasic Documentation updates: - Expanded "Parameter Substitution" section to "Content Expansion" - Added documentation for all three expansion types: - Parameter expansion (${param}) - Command expansion (!`cmd`) - Path expansion (@path) - Documented security model (single-pass, no re-expansion) - Added examples and usage notes for each expansion type Co-authored-by: alexec <1142830+alexec@users.noreply.github.com>
Head branch was pushed to by a user without write access
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
| if err != nil { | ||
| logger.Warn("command expansion failed", "command", command, "error", err) | ||
| // Return the original !`command` if command fails | ||
| result.WriteString(string(runes[i : end+1])) |
There was a problem hiding this comment.
@copilot if the command fails, still put the output in place.
There was a problem hiding this comment.
Done in commit e637274. Command expansion now outputs the combined stdout/stderr even when the command fails, allowing error messages to be included in the output.
| func TestExpandParameters(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| params Params | ||
| content string | ||
| expected string | ||
| }{ | ||
| { | ||
| name: "single parameter expansion", | ||
| params: Params{"name": "Alice"}, | ||
| content: "Hello, ${name}!", | ||
| expected: "Hello, Alice!", | ||
| }, | ||
| { | ||
| name: "multiple parameter expansions", | ||
| params: Params{"first": "John", "last": "Doe"}, | ||
| content: "${first} ${last}", | ||
| expected: "John Doe", | ||
| }, | ||
| { | ||
| name: "parameter not found - returns unchanged with warning", | ||
| params: Params{}, | ||
| content: "Value: ${missing}", | ||
| expected: "Value: ${missing}", | ||
| }, | ||
| { | ||
| name: "mixed found and not found parameters", | ||
| params: Params{"found": "yes"}, | ||
| content: "${found} and ${notfound}", | ||
| expected: "yes and ${notfound}", | ||
| }, | ||
| { | ||
| name: "no parameters to expand", | ||
| params: Params{"key": "value"}, | ||
| content: "Plain text without parameters", | ||
| expected: "Plain text without parameters", | ||
| }, | ||
| { | ||
| name: "parameter with special characters", | ||
| params: Params{"path": "/tmp/file.txt"}, | ||
| content: "File: ${path}", | ||
| expected: "File: /tmp/file.txt", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| result := expand(tt.content, tt.params, slog.New(slog.NewTextHandler(os.Stderr, nil))) | ||
| if result != tt.expected { | ||
| t.Errorf("expand() = %q, want %q", result, tt.expected) | ||
| } | ||
| }) | ||
| } | ||
| } |
Changes based on code review feedback: - Command expansion now outputs stderr even when command fails - Added edge case tests for parameter expansion: - Unclosed parameter syntax - Empty parameter name - Parameter at end of string - Nested braces - Added edge case tests for command expansion: - Unclosed backtick - Empty command - Command at end of string - Command with error output - Added edge case tests for path expansion: - Path at end without trailing whitespace - Lone @ with no path - Multiple consecutive @ symbols - Path with backslash not escaping space - Added security integration test verifying no re-expansion of: - File content containing expansion syntax - Parameter values containing command syntax - Command output containing parameter syntax Co-authored-by: alexec <1142830+alexec@users.noreply.github.com>
Fix Command Injection Vulnerability and Code Review Feedback
Security Issue (Fixed in commit 3f3f3f3)
The previous implementation processed expansions in multiple passes which allowed command injection attacks. Fixed by processing rune-by-rune in a single pass.
Code Review Changes
Expanderstruct private (lowercaseexpander) per @alexec feedback (commit 8d435ad)paramsfield to usemap[string]stringdirectly instead ofParamstype alias per @alexec feedback (commit 8d435ad)Changes
expander.gofile with expansion logic (rune-by-rune processing)expandfunction with params and logger as parameters${param_name})!`command`) - outputs stderr even on failure@path)expander_test.gowith 60+ test cases including edge casesexpandParamsmethod incontext.godocs/reference/file-formats.mddocumenting all three expansion types with examples, security notes, and usage guidelinesTest Coverage
Unit Tests (60+ test cases)
Integration Tests
Test Results
✅ All unit tests pass (60+ test cases)
✅ All integration tests pass including security test
✅ Edge case tests for malformed input
✅ Security tests confirm no re-expansion
✅ Lint clean
Documentation
✅ Added comprehensive "Content Expansion" section to
docs/reference/file-formats.md✅ Documented all three expansion types with examples
✅ Documented security model and injection protection
✅ Included usage guidelines and behavior notes
Original prompt
Original prompt
Original prompt
Original prompt
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.