Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
3 changes: 2 additions & 1 deletion pkg/commands/service/vcl/custom/describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down
5 changes: 3 additions & 2 deletions pkg/commands/service/vcl/describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
5 changes: 3 additions & 2 deletions pkg/commands/service/vcl/snippet/describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
24 changes: 24 additions & 0 deletions pkg/text/sanitize.go
Original file line number Diff line number Diff line change
@@ -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()
}
121 changes: 121 additions & 0 deletions pkg/text/sanitize_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}