diff --git a/README.md b/README.md
index b961578..2a899a0 100644
--- a/README.md
+++ b/README.md
@@ -208,7 +208,7 @@ While a thread or message is selected, letter keys act on it. Any other key goes
| `/logout` | delete the stored token and quit |
| `/quit` | quit |
-Saved files get the macOS quarantine attribute, so Gatekeeper checks them before they run. `/open` only follows http and https links.
+Saved files get the macOS quarantine attribute, so Gatekeeper checks them before they run. Leading dots are stripped from their names, so a file can't land as a hidden dotfile. `/save` creates `~/Downloads` when it's missing. `/open` downloads into mutter's directory in the user cache dir. `/open` only follows http and https links, and only http and https links in cards are clickable.
### Permissions
diff --git a/actions.go b/actions.go
index 8746725..6bd2842 100644
--- a/actions.go
+++ b/actions.go
@@ -5,8 +5,6 @@ import (
"context"
"fmt"
"net/url"
- "os"
- "path/filepath"
"strings"
tea "charm.land/bubbletea/v2"
@@ -206,8 +204,8 @@ func (m *model) openFile(f file) (string, error) {
}
return "opened " + f.label + " in the browser", nil
}
- dir := filepath.Join(os.TempDir(), "mutter")
- if err := os.MkdirAll(dir, 0o700); err != nil {
+ dir, err := openDir()
+ if err != nil {
return "", err
}
path, err := m.c.saveFile(m.ctx, f, dir)
@@ -227,7 +225,11 @@ func (m *model) saveToDownloads(f file) (string, error) {
}
return "opened " + f.label + " in the browser, Drive files and GIFs can't be saved from here", nil
}
- path, err := m.c.saveFile(m.ctx, f, downloadsDir())
+ dir, err := downloadsDir()
+ if err != nil {
+ return "", err
+ }
+ path, err := m.c.saveFile(m.ctx, f, dir)
if err != nil {
return "", err
}
@@ -238,14 +240,18 @@ func (m *model) saveToDownloads(f file) (string, error) {
// messages, and open hands custom schemes to local apps, so only http(s)
// passes.
func openURL(f file) error {
- u, err := url.Parse(f.url)
- if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
+ if !webLink(f.url) {
return fmt.Errorf("won't open %s, its link isn't http(s)", f.label)
}
openBrowser(f.url)
return nil
}
+func webLink(s string) bool {
+ u, err := url.Parse(s)
+ return err == nil && (u.Scheme == "https" || u.Scheme == "http")
+}
+
// unreadFrom marks the open space unread from at onward. It holds off
// marking the space read again until it's reopened.
func (m *model) unreadFrom(at string) tea.Cmd {
diff --git a/attach.go b/attach.go
index 7e03051..0ce6c9f 100644
--- a/attach.go
+++ b/attach.go
@@ -52,21 +52,26 @@ func threadFiles(t *thread) []file {
return out
}
-// downloadsDir is where /save puts files.
-func downloadsDir() string {
+// downloadsDir is where /save puts files. It's never the home directory
+// itself, where a file named like a dotfile would run at the next login.
+func downloadsDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
- return os.TempDir()
- }
- if d := filepath.Join(home, "Downloads"); isDir(d) {
- return d
+ return "", err
}
- return home
+ d := filepath.Join(home, "Downloads")
+ return d, os.MkdirAll(d, 0o700)
}
-func isDir(p string) bool {
- fi, err := os.Stat(p)
- return err == nil && fi.IsDir()
+// openDir is where /open puts files before opening them. It's per user,
+// because in a shared temp dir another local user could swap the file.
+func openDir() (string, error) {
+ cache, err := os.UserCacheDir()
+ if err != nil {
+ return "", err
+ }
+ d := filepath.Join(cache, "mutter", "open")
+ return d, os.MkdirAll(d, 0o700)
}
// saveFile downloads an uploaded file into dir and returns its path.
@@ -95,10 +100,11 @@ func (c *client) saveFile(ctx context.Context, f file, dir string) (string, erro
return path, nil
}
-// safeName keeps a sender-chosen name from escaping the target directory.
+// safeName keeps a sender-chosen name from escaping the target directory or
+// landing as a hidden dotfile.
func safeName(name string) string {
- name = filepath.Base(strings.ReplaceAll(name, `\`, "/"))
- if name == "." || name == ".." || name == "/" || name == "" {
+ name = strings.TrimLeft(filepath.Base(strings.ReplaceAll(name, `\`, "/")), ".")
+ if name == "/" || name == "" {
return "attachment"
}
return name
diff --git a/attach_test.go b/attach_test.go
index ba323fa..4f731b3 100644
--- a/attach_test.go
+++ b/attach_test.go
@@ -12,6 +12,9 @@ func TestSafeName(t *testing.T) {
"../../.ssh/config": "config",
`..\..\evil.exe`: "evil.exe",
"..": "attachment",
+ "...": "attachment",
+ ".zshenv": "zshenv",
+ "x/..bash_login": "bash_login",
"": "attachment",
"dir/": "dir",
} {
diff --git a/format.go b/format.go
index bf30441..86625fc 100644
--- a/format.go
+++ b/format.go
@@ -6,6 +6,7 @@ import (
"html"
"regexp"
"strings"
+ "unicode"
"charm.land/lipgloss/v2"
"google.golang.org/api/chat/v1"
@@ -35,8 +36,32 @@ var inline = []struct {
{marker("~"), strikeStyle},
}
+// clean drops control characters other than newline and tab from untrusted
+// text, so it can't smuggle escape sequences into the terminal.
+func clean(s string) string {
+ return strings.Map(func(r rune) rune {
+ if unicode.IsControl(r) && r != '\n' && r != '\t' {
+ return -1
+ }
+ return r
+ }, s)
+}
+
+// stripC1 drops C1 controls from a whole frame. The renderer filters 7-bit
+// sequences but passes C1 through, and names, titles and notices reach the
+// frame uncleaned. The styles only emit 7-bit escapes, so none are lost.
+func stripC1(s string) string {
+ return strings.Map(func(r rune) rune {
+ if r >= 0x80 && r <= 0x9f {
+ return -1
+ }
+ return r
+ }, s)
+}
+
// formatText renders Google Chat markup for the terminal.
func formatText(s string) string {
+ s = clean(s)
var b strings.Builder
for i, block := range strings.Split(s, "```") {
if i%2 == 1 {
@@ -82,7 +107,7 @@ func messageBody(m *chat.Message, img func(ref string) string, num *int) string
}
}
if len(parts) == 0 && m.FallbackText != "" {
- parts = append(parts, m.FallbackText)
+ parts = append(parts, clean(m.FallbackText))
}
for _, a := range m.Attachment {
*num++
@@ -132,10 +157,10 @@ func cardText(c *chat.GoogleAppsCardV1Card) string {
var lines []string
if h := c.Header; h != nil {
if h.Title != "" {
- lines = append(lines, boldStyle.Render(h.Title))
+ lines = append(lines, boldStyle.Render(clean(h.Title)))
}
if h.Subtitle != "" {
- lines = append(lines, dimStyle.Render(h.Subtitle))
+ lines = append(lines, dimStyle.Render(clean(h.Subtitle)))
}
}
for _, sec := range c.Sections {
@@ -166,8 +191,8 @@ func widgetLines(w *chat.GoogleAppsCardV1Widget) []string {
if b := w.ButtonList; b != nil {
buttons := make([]string, 0, len(b.Buttons))
for _, btn := range b.Buttons {
- label := "[ " + btn.Text + " ]"
- if btn.OnClick != nil && btn.OnClick.OpenLink != nil {
+ label := "[ " + clean(btn.Text) + " ]"
+ if btn.OnClick != nil && btn.OnClick.OpenLink != nil && webLink(btn.OnClick.OpenLink.Url) {
label = linkStyle.Hyperlink(btn.OnClick.OpenLink.Url).Render(label)
}
buttons = append(buttons, label)
@@ -175,7 +200,7 @@ func widgetLines(w *chat.GoogleAppsCardV1Widget) []string {
lines = append(lines, strings.Join(buttons, " "))
}
if i := w.Image; i != nil {
- lines = append(lines, dimStyle.Render("[image: "+cmp.Or(i.AltText, i.ImageUrl)+"]"))
+ lines = append(lines, dimStyle.Render("[image: "+clean(cmp.Or(i.AltText, i.ImageUrl))+"]"))
}
if w.Divider != nil {
lines = append(lines, dimStyle.Render("───"))
@@ -202,17 +227,28 @@ var (
iTag = regexp.MustCompile(`(?is)(.*?)`)
aTag = regexp.MustCompile(`(?is)]*href="([^"]*)"[^>]*>(.*?)`)
anyTag = regexp.MustCompile(`<[^>]+>`)
+ entity = regexp.MustCompile(`?[0-9A-Za-z]+;?`)
)
// cardHTML renders the HTML subset that card text fields accept. Tags other
-// than b, i, a and br are dropped and their text kept.
+// than b, i, a and br are dropped and their text kept. Only http(s) links
+// become clickable, because terminals hand other schemes to local apps.
func cardHTML(s string) string {
- s = brTag.ReplaceAllString(s, "\n")
+ s = brTag.ReplaceAllString(clean(s), "\n")
s = aTag.ReplaceAllStringFunc(s, func(m string) string {
sub := aTag.FindStringSubmatch(m)
- return linkStyle.Hyperlink(html.UnescapeString(sub[1])).Render(sub[2])
+ href := html.UnescapeString(sub[1])
+ if !webLink(href) {
+ return sub[2]
+ }
+ return linkStyle.Hyperlink(href).Render(sub[2])
})
s = bTag.ReplaceAllStringFunc(s, func(m string) string { return boldStyle.Render(bTag.FindStringSubmatch(m)[1]) })
s = iTag.ReplaceAllStringFunc(s, func(m string) string { return italicStyle.Render(iTag.FindStringSubmatch(m)[1]) })
- return html.UnescapeString(anyTag.ReplaceAllString(s, ""))
+
+ // Entities unescape one at a time, so can't become a raw ESC
+ // among the styling escapes.
+ return entity.ReplaceAllStringFunc(anyTag.ReplaceAllString(s, ""), func(e string) string {
+ return clean(html.UnescapeString(e))
+ })
}
diff --git a/image_test.go b/image_test.go
index 8b22608..ea391a6 100644
--- a/image_test.go
+++ b/image_test.go
@@ -32,9 +32,24 @@ func TestKittyPlaceholderWidth(t *testing.T) {
}
func TestCardHTML(t *testing.T) {
- got := cardHTML(`a
b & c`)
- if got != "a\nb & c" {
- t.Errorf("cardHTML = %q", got)
+ for in, want := range map[string]string{
+ `a
b & c`: "a\nb & c",
+ `x`: "x",
+ `x ]8;;y`: "x ]8;;y›",
+ "a\x1b]52;c;aGk=\x07b\u009b2J": "a]52;c;aGk=b2J",
+ } {
+ if got := cardHTML(in); got != want {
+ t.Errorf("cardHTML(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestFormatTextDropsControls(t *testing.T) {
+ if got := formatText("a\x1b]8;;file:///x\x07b\u009dc\n\td"); got != "a]8;;file:///xbc\n\td" {
+ t.Errorf("formatText = %q", got)
+ }
+ if got := stripC1("a\u009b2J\x1b[1mb"); got != "a2J\x1b[1mb" {
+ t.Errorf("stripC1 = %q", got)
}
}
diff --git a/notify.go b/notify.go
index ee0a7d3..1a630af 100644
--- a/notify.go
+++ b/notify.go
@@ -7,6 +7,7 @@ import (
"strings"
"sync"
"time"
+ "unicode"
"golang.org/x/sync/errgroup"
"google.golang.org/api/chat/v1"
@@ -186,7 +187,7 @@ func shouldNotify(s *chat.SpaceNotificationSetting, dm, mentioned, newThread boo
func osc777(title, body string) string {
clean := func(s string) string {
return strings.Map(func(r rune) rune {
- if r == ';' || r < 0x20 || r == 0x7f {
+ if r == ';' || unicode.IsControl(r) {
return ' '
}
return r
diff --git a/notify_test.go b/notify_test.go
index 8719611..e74ddfd 100644
--- a/notify_test.go
+++ b/notify_test.go
@@ -48,7 +48,7 @@ func TestMentionsAndReadState(t *testing.T) {
if got := readStateSpace("users/1/spaces/AAA/spaceReadState"); got != "spaces/AAA" {
t.Errorf("readStateSpace = %s", got)
}
- if got := osc777("a;b", "c\nd"); got != "\x1b]777;notify;a b;c d\x1b\\" {
+ if got := osc777("a;b", "c\nd\u009ce"); got != "\x1b]777;notify;a b;c d e\x1b\\" {
t.Errorf("osc777 = %q", got)
}
}
diff --git a/ui.go b/ui.go
index 3e4588d..57596fc 100644
--- a/ui.go
+++ b/ui.go
@@ -998,12 +998,12 @@ func (m model) View() tea.View {
if status == "" {
status = dimStyle.Render(hint)
}
- v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
+ v := tea.NewView(stripC1(lipgloss.JoinVertical(lipgloss.Left,
headerStyle.Render(title),
body,
inputStyle.Render(m.ta.View()),
status,
- ))
+ )))
v.AltScreen = true
v.ReportFocus = true
return v