diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b69152ae..cc3124ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - feat(service/ratelimit): moved the `rate-limit` commands under the `service` command, with an unlisted and deprecated alias of `rate-limit` ([#1632](https://github.com/fastly/cli/pull/1632)) - feat(compute/build): Remove Rust version restriction, allowing 1.93.0 and later versions to be used. ([#1633](https://github.com/fastly/cli/pull/1633)) - feat(service/resourcelink): moved the `resource-link` commands under the `service` command, with an unlisted and deprecated alias of `resource-link` ([#1635](https://github.com/fastly/cli/pull/1635)) +- feat(service/vcl): escape control characters when displaying VCL content for cleaner terminal output ([#1637](https://github.com/fastly/cli/pull/1637)) ### Bug fixes: - fix(compute/serve): ensure hostname has a port nubmer when building pushpin routes ([#1631](https://github.com/fastly/cli/pull/1631)) diff --git a/pkg/commands/service/vcl/custom/describe.go b/pkg/commands/service/vcl/custom/describe.go index b059e7824..db74c49f7 100644 --- a/pkg/commands/service/vcl/custom/describe.go +++ b/pkg/commands/service/vcl/custom/describe.go @@ -10,6 +10,7 @@ import ( "github.com/fastly/cli/pkg/argparser" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" ) // NewDescribeCommand returns a usable command registered under the parent. @@ -117,7 +118,7 @@ func (c *DescribeCommand) print(out io.Writer, v *fastly.VCL) error { fmt.Fprintf(out, "Service Version: %d\n\n", fastly.ToValue(v.ServiceVersion)) fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(v.Name)) fmt.Fprintf(out, "Main: %t\n", fastly.ToValue(v.Main)) - fmt.Fprintf(out, "Content: \n%s\n\n", fastly.ToValue(v.Content)) + fmt.Fprintf(out, "Content: \n%s\n\n", text.SanitizeTerminalOutput(fastly.ToValue(v.Content))) if v.CreatedAt != nil { fmt.Fprintf(out, "Created at: %s\n", v.CreatedAt) } diff --git a/pkg/commands/service/vcl/describe.go b/pkg/commands/service/vcl/describe.go index 87938abe8..4d107925e 100644 --- a/pkg/commands/service/vcl/describe.go +++ b/pkg/commands/service/vcl/describe.go @@ -10,6 +10,7 @@ import ( "github.com/fastly/cli/pkg/argparser" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" ) // NewDescribeCommand returns a usable command registered under the parent. @@ -133,11 +134,11 @@ func (c *DescribeCommand) printVerbose(out io.Writer, serviceVersion int, v *fas fmt.Fprintf(out, "Deleted at: %s\n", v.DeletedAt) } - fmt.Fprintf(out, "Content: \n%s\n", fastly.ToValue(v.Content)) + fmt.Fprintf(out, "Content: \n%s\n", text.SanitizeTerminalOutput(fastly.ToValue(v.Content))) } // print the generated VCL. func (c *DescribeCommand) print(out io.Writer, v *fastly.VCL) error { - fmt.Fprintf(out, "%s\n", fastly.ToValue(v.Content)) + fmt.Fprintf(out, "%s\n", text.SanitizeTerminalOutput(fastly.ToValue(v.Content))) return nil } diff --git a/pkg/commands/service/vcl/snippet/describe.go b/pkg/commands/service/vcl/snippet/describe.go index e529022d4..93bb926ed 100644 --- a/pkg/commands/service/vcl/snippet/describe.go +++ b/pkg/commands/service/vcl/snippet/describe.go @@ -10,6 +10,7 @@ import ( "github.com/fastly/cli/pkg/argparser" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" ) // NewDescribeCommand returns a usable command registered under the parent. @@ -170,7 +171,7 @@ func (c *DescribeCommand) constructInput(serviceID string, serviceVersion int) ( func (c *DescribeCommand) printDynamic(out io.Writer, ds *fastly.DynamicSnippet) error { fmt.Fprintf(out, "\nService ID: %s\n", fastly.ToValue(ds.ServiceID)) fmt.Fprintf(out, "ID: %s\n", fastly.ToValue(ds.SnippetID)) - fmt.Fprintf(out, "Content: \n%s\n", fastly.ToValue(ds.Content)) + fmt.Fprintf(out, "Content: \n%s\n", text.SanitizeTerminalOutput(fastly.ToValue(ds.Content))) if ds.CreatedAt != nil { fmt.Fprintf(out, "Created at: %s\n", ds.CreatedAt) } @@ -191,7 +192,7 @@ func (c *DescribeCommand) print(out io.Writer, s *fastly.Snippet) error { fmt.Fprintf(out, "Priority: %s\n", fastly.ToValue(s.Priority)) fmt.Fprintf(out, "Dynamic: %t\n", argparser.IntToBool(fastly.ToValue(s.Dynamic))) fmt.Fprintf(out, "Type: %s\n", fastly.ToValue(s.Type)) - fmt.Fprintf(out, "Content: \n%s\n", fastly.ToValue(s.Content)) + fmt.Fprintf(out, "Content: \n%s\n", text.SanitizeTerminalOutput(fastly.ToValue(s.Content))) if s.CreatedAt != nil { fmt.Fprintf(out, "Created at: %s\n", s.CreatedAt) } diff --git a/pkg/text/sanitize.go b/pkg/text/sanitize.go new file mode 100644 index 000000000..c04cbdef5 --- /dev/null +++ b/pkg/text/sanitize.go @@ -0,0 +1,24 @@ +package text + +import ( + "fmt" + "strings" +) + +// SanitizeTerminalOutput escapes control characters from untrusted content +// to prevent terminal injection attacks. +func SanitizeTerminalOutput(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch { + case r == '\t', r == '\n', r == '\r': + b.WriteRune(r) + case r < 0x20 || r == 0x7F: + fmt.Fprintf(&b, "\\x%02x", r) + default: + b.WriteRune(r) + } + } + return b.String() +} diff --git a/pkg/text/sanitize_test.go b/pkg/text/sanitize_test.go new file mode 100644 index 000000000..f0de8d8e6 --- /dev/null +++ b/pkg/text/sanitize_test.go @@ -0,0 +1,121 @@ +package text + +import "testing" + +func TestSanitizeTerminalOutput(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "normal text unchanged", + input: "Hello, World!", + expected: "Hello, World!", + }, + { + name: "preserves newlines and tabs", + input: "line1\nline2\ttabbed", + expected: "line1\nline2\ttabbed", + }, + { + name: "escapes color codes", + input: "\x1b[31mRED\x1b[0m", + expected: "\\x1b[31mRED\\x1b[0m", + }, + { + name: "escapes bold and other SGR codes", + input: "\x1b[1mbold\x1b[0m \x1b[4munderline\x1b[0m", + expected: "\\x1b[1mbold\\x1b[0m \\x1b[4munderline\\x1b[0m", + }, + { + name: "escapes cursor movement", + input: "before\x1b[2Aafter", + expected: "before\\x1b[2Aafter", + }, + { + name: "escapes screen clear", + input: "\x1b[2Jcleared", + expected: "\\x1b[2Jcleared", + }, + { + name: "escapes window title manipulation (OSC)", + input: "\x1b]0;malicious title\x07content", + expected: "\\x1b]0;malicious title\\x07content", + }, + { + name: "escapes multiple escape sequences", + input: "\x1b[31m\x1b[1mred bold\x1b[0m normal \x1b[32mgreen\x1b[0m", + expected: "\\x1b[31m\\x1b[1mred bold\\x1b[0m normal \\x1b[32mgreen\\x1b[0m", + }, + { + name: "escapes VCL content with escape sequences", + input: "sub vcl_recv { # \x1b[31mRED\x1b[0m }", + expected: "sub vcl_recv { # \\x1b[31mRED\\x1b[0m }", + }, + { + name: "empty string unchanged", + input: "", + expected: "", + }, + { + name: "escapes cursor position codes", + input: "\x1b[10;20Htext at position", + expected: "\\x1b[10;20Htext at position", + }, + { + name: "escapes erase codes", + input: "\x1b[Kerase line\x1b[Jclear below", + expected: "\\x1b[Kerase line\\x1b[Jclear below", + }, + { + name: "escapes standalone BEL", + input: "before\x07after", + expected: "before\\x07after", + }, + { + name: "escapes backspace", + input: "secret\x08visible", + expected: "secret\\x08visible", + }, + { + name: "escapes NUL character", + input: "before\x00after", + expected: "before\\x00after", + }, + { + name: "escapes form feed", + input: "page1\x0cpage2", + expected: "page1\\x0cpage2", + }, + { + name: "escapes vertical tab", + input: "line1\x0bline2", + expected: "line1\\x0bline2", + }, + { + name: "escapes DEL character", + input: "before\x7fafter", + expected: "before\\x7fafter", + }, + { + name: "preserves tab newline carriage return", + input: "col1\tcol2\nline2\r\nline3", + expected: "col1\tcol2\nline2\r\nline3", + }, + { + name: "escapes mixed control characters", + input: "\x00\x07\x08text\x0b\x0c\x1a\x7f", + expected: "\\x00\\x07\\x08text\\x0b\\x0c\\x1a\\x7f", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeTerminalOutput(tt.input) + if result != tt.expected { + t.Errorf("SanitizeTerminalOutput(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +}