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
26 changes: 25 additions & 1 deletion core/internal/desktop/mimeapps.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,18 @@ func mergedAssociations() *MimeAssociations {
return merged
}

// isSafeIniField rejects anything that could break out of a single
// "key=value" line in the generated INI-format mimeapps.list: a newline
// could inject a fake [Section] header or extra key=value lines, and a
// bracket could be confused for one even without a newline. mimeType and
// desktopId ultimately come from the local IPC socket with no other
// sanitization (StripMimeParams only trims a trailing ";params" suffix),
// and this file is a shared freedesktop config consumed by other
// applications (GTK, KDE, xdg-open) beyond this daemon.
func isSafeIniField(s string) bool {
return !strings.ContainsAny(s, "\n\r[]")
}

func writeUserMimeapps(update func(*MimeAssociations)) error {
mimeappsWriteMu.Lock()
defer mimeappsWriteMu.Unlock()
Expand All @@ -152,6 +164,7 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
var buf bytes.Buffer
w := bufio.NewWriter(&buf)

var writeErr error
writeSection := func(name string, entries map[string]string) {
fmt.Fprintf(w, "[%s]\n", name)
keys := make([]string, 0, len(entries))
Expand All @@ -160,7 +173,14 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(w, "%s=%s\n", k, entries[k])
v := entries[k]
if !isSafeIniField(k) || !isSafeIniField(v) {
if writeErr == nil {
writeErr = fmt.Errorf("invalid mimeapps.list field %q=%q", k, v)
}
continue
}
fmt.Fprintf(w, "%s=%s\n", k, v)
}
fmt.Fprintln(w)
}
Expand All @@ -177,6 +197,10 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
writeSection(groupAdded, flatten(assoc.Added))
writeSection(groupRemoved, flatten(assoc.Removed))

if writeErr != nil {
return writeErr
}

if err := w.Flush(); err != nil {
return err
}
Expand Down
8 changes: 7 additions & 1 deletion core/internal/geolocation/client_geoclue.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,13 @@ func (c *GeoClueClient) startSignalPump() error {
if err := c.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(c.clientPath),
dbus.WithMatchInterface(dbusGeoClueClientInterface),
dbus.WithMatchSender(dbusGeoClueClientLocationUpdated),
// The member name ("LocationUpdated"), not WithMatchSender: that
// expects a bus name (e.g. "org.freedesktop.GeoClue2"), and
// dbusGeoClueClientLocationUpdated is actually the full
// interface.member string -- no real sender ever matches that, so
// this match rule never matched anything and live location
// updates silently never arrived after the initial value.
dbus.WithMatchMember("LocationUpdated"),
); err != nil {
return err
}
Expand Down
92 changes: 90 additions & 2 deletions core/internal/greeter/session_launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package greeter
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
Expand Down Expand Up @@ -93,18 +94,105 @@ func resolveSessionExecInDirs(sessionID string, dirs []string) (string, error) {
return "", fmt.Errorf("session desktop file %q was not found", id)
}

// tokenizeExecLine splits a Desktop Entry Exec= value into argv, honoring
// basic single/double quoting and backslash escapes, but never invokes a
// shell. Session .desktop files can live in a user-writable directory
// (~/.local/share/wayland-sessions), so shell metacharacters in Exec=
// must never reach an actual shell -- they're just literal argv text here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a security fix since the user is already authenticated at this point, but maybe a spec correctness change

func tokenizeExecLine(execLine string) ([]string, error) {
var tokens []string
var cur strings.Builder
hasCur := false
runes := []rune(execLine)
i := 0
for i < len(runes) {
c := runes[i]
switch {
case c == ' ' || c == '\t':
if hasCur {
tokens = append(tokens, cur.String())
cur.Reset()
hasCur = false
}
i++
case c == '\'':

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe XDG spec dictates single quotes shouldnt be treated as a quote, but a reserved character

hasCur = true
i++
for i < len(runes) && runes[i] != '\'' {
cur.WriteRune(runes[i])
i++
}
if i >= len(runes) {
return nil, fmt.Errorf("unterminated single quote")
}
i++
case c == '"':
hasCur = true
i++
for i < len(runes) && runes[i] != '"' {
if runes[i] == '\\' && i+1 < len(runes) {
if next := runes[i+1]; next == '"' || next == '\\' || next == '$' || next == '`' {
cur.WriteRune(next)
i += 2
continue
}
}
cur.WriteRune(runes[i])
i++
}
if i >= len(runes) {
return nil, fmt.Errorf("unterminated double quote")
}
i++
case c == '\\' && i+1 < len(runes):
hasCur = true
cur.WriteRune(runes[i+1])
i += 2
default:
hasCur = true
cur.WriteRune(c)
i++
}
}
if hasCur {
tokens = append(tokens, cur.String())
}
return tokens, nil
}

func LaunchSessionByID(sessionID string) error {
execLine, err := ResolveSessionExec(sessionID)
if err != nil {
return err
}
execLine = strings.TrimSpace(stripDesktopExecCodes(execLine))
execLine = strings.TrimSpace(execLine)
if execLine == "" {
return fmt.Errorf("session %q has an empty Exec command", sessionID)
}

tokens, err := tokenizeExecLine(execLine)
if err != nil {
return fmt.Errorf("session %q has an invalid Exec command: %w", sessionID, err)
}

argv := make([]string, 0, len(tokens))
for _, tok := range tokens {
if strings.HasPrefix(tok, "%") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would maybe reference quickshell code DeskopEntries for all of this stuff, to ensure its following the XDG specification

continue // field codes (%f/%U/etc.) -- sessions take no file args
}
argv = append(argv, tok)
}
if len(argv) == 0 {
return fmt.Errorf("session %q has an empty Exec command", sessionID)
}

resolved, err := exec.LookPath(argv[0])
if err != nil {
return fmt.Errorf("session %q command %q not found: %w", sessionID, argv[0], err)
}

env := append(os.Environ(), "XDG_SESSION_TYPE=wayland")
return syscall.Exec("/bin/sh", []string{"sh", "-c", "exec " + execLine}, env)
return syscall.Exec(resolved, argv, env)
}

func LaunchSessionFromMemory(cacheDir, homeDir string) error {
Expand Down
80 changes: 64 additions & 16 deletions core/internal/keybinds/providers/hyprland.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,14 @@ type hyprlandOverrideBind struct {
Options map[string]any
// Unbind: negative override (hl.unbind only, no rebind).
Unbind bool
// RawLuaAction is true when Action was parsed from an existing Lua
// bind override as a hand-written custom hl.* expression that this
// code doesn't have a specific translation for (see
// luaExprToInternalAction). Only in that case is Action re-emitted
// verbatim as Lua source; every other bind (including ones parsed from
// a classic hyprland.conf bind= line, where Action is freeform
// dispatcher+params text, never Lua) always gets safely quoted.
RawLuaAction bool
}

func (h *HyprlandProvider) ensureWritableConfig() error {
Expand Down Expand Up @@ -1046,18 +1054,45 @@ func luaActionStringFromHyprlangAction(action string) string {
if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
return expr
}
return action
// Unrecognized dispatcher (typo, third-party dispatcher with no native
// translation, or -- since Action here is classic freeform
// "dispatcher params" text, never Lua -- a crafted bind line): forward
// it to hyprctl rather than dropping it, but as a properly quoted
// string argument, never as raw unescaped Lua source. Without this, an
// unrecognized dispatcher name/params containing e.g. a stray `"` could
// close the Lua string literal in writeLuaBindLine's hl.bind(...) call
// and inject arbitrary Lua that Hyprland executes on reload.
//
// This function is never used for RawLuaAction binds (see
// writeLuaBindLine): those already are a Lua expression and must be
// emitted verbatim instead, which is exactly the "custom Lua
// dispatcher" escape hatch this deliberately doesn't reach.
return luaHyprctlDispatchFunction(action)
}

func luaExprToInternalAction(expr string) string {
// luaExprToInternalAction converts a parsed Lua bind expression back into
// the classic "dispatcher params" text form used elsewhere in this file. It
// also reports whether expr didn't match any known hl.dsp.*/hl.dispatch/
// hl.exec_cmd shape (luaExprToDispatcherParams's default case returns the
// expression itself unchanged as the "dispatcher") -- i.e. whether this is
// a genuine hand-written custom Lua dispatcher rather than something this
// code knows how to translate. That distinction matters when writing the
// bind back out: a custom Lua expression must be re-emitted verbatim as
// Lua, but anything else (including this same fallback text if it had come
// from a classic hyprland.conf bind= line instead) must always be safely
// quoted -- see writeLuaBindLine.
func luaExprToInternalAction(expr string) (action string, isRawLua bool) {
d, p := luaExprToDispatcherParams(expr)
if d == expr && p == "" {
return expr, true
}
if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
return "exec " + p
return "exec " + p, false
}
if p != "" {
return d + " " + p
return d + " " + p, false
}
return d
return d, false
}

func luaBindOptions(bind *hyprlandOverrideBind) []string {
Expand All @@ -1075,20 +1110,32 @@ func luaBindOptions(bind *hyprlandOverrideBind) []string {
}

func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
key := formatLuaBindKey(bind.Key)
// strconv.Quote, not a bare %s inside a manually-written Lua string
// literal: a parsed bind key containing a stray `"` would otherwise
// close the literal early and let whatever follows execute as Lua.
key := strconv.Quote(formatLuaBindKey(bind.Key))
if bind.Unbind {
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
fmt.Fprintf(sb, `hl.unbind(%s)`, key)
sb.WriteByte('\n')
return
}
expr := luaActionStringFromHyprlangAction(bind.Action)
// RawLuaAction binds already hold an exact Lua expression (round-tripped
// from an existing custom hl.* call this code doesn't translate) and
// must be re-emitted verbatim; anything else is freeform dispatcher
// text that must always go through the safe-quoting path.
var expr string
if bind.RawLuaAction {
expr = bind.Action
} else {
expr = luaActionStringFromHyprlangAction(bind.Action)
}
opts := luaBindOptions(bind)
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
fmt.Fprintf(sb, `hl.unbind(%s)`, key)
sb.WriteByte('\n')
if len(opts) > 0 {
fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", "))
fmt.Fprintf(sb, `hl.bind(%s, %s, { %s })`, key, expr, strings.Join(opts, ", "))
} else {
fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
fmt.Fprintf(sb, `hl.bind(%s, %s)`, key, expr)
}
sb.WriteByte('\n')
}
Expand All @@ -1104,17 +1151,18 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
}
internalKey := luaKeyComboToInternalKey(kbc)

action := luaExprToInternalAction(actionExpr)
action, isRawLua := luaExprToInternalAction(actionExpr)
flags := luaBindOptFlags(optSuffix)
description := luaBindOptDescription(optSuffix)
if description == "" {
description = luaLineTrailingComment(line)
}
return &hyprlandOverrideBind{
Key: internalKey,
Action: action,
Description: description,
Flags: flags,
Key: internalKey,
Action: action,
Description: description,
Flags: flags,
RawLuaAction: isRawLua,
}, true
}

Expand Down
51 changes: 43 additions & 8 deletions core/internal/keybinds/providers/hyprland_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,10 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"), { descripti
func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+u",
Action: "hl.dsp.no_op()",
Description: "Custom Lua",
Key: "Super+u",
Action: "hl.dsp.no_op()",
Description: "Custom Lua",
RawLuaAction: true, // set by parseLuaBindOverrideLine for genuine custom Lua round-trips
})

want := `hl.unbind("SUPER + U")
Expand All @@ -159,6 +160,34 @@ hl.bind("SUPER + U", hl.dsp.no_op(), { description = "Custom Lua" })`
}
}

// TestWriteLuaBindLineQuotesUnrecognizedActionWithoutRawLuaFlag verifies
// that a bind carrying Lua-shaped text in Action but *not* marked
// RawLuaAction (i.e. it isn't a genuine round-tripped custom dispatcher --
// this is what a crafted/unrecognized classic hyprland.conf bind= line
// looks like by the time it reaches here) is always safely quoted rather
// than treated as literal Lua source.
func TestWriteLuaBindLineQuotesUnrecognizedActionWithoutRawLuaFlag(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+u",
Action: `customdispatcher "),os.execute("id")--`,
})

got := sb.String()
if !strings.Contains(got, "hl.exec_cmd(") {
t.Fatalf("expected unrecognized action to go through the safe hyprctl-dispatch wrapper, got %q", got)
}
// Every bare (non-backslash-escaped) `"` in the output should be a
// clean open/close pair for some string literal (the key, or the
// hyprctl-dispatch argument). If the payload's `")` had actually broken
// out of its string, it would introduce an extra, unpaired bare quote,
// making this count odd.
withoutEscapedQuotes := strings.ReplaceAll(got, `\"`, "")
if n := strings.Count(withoutEscapedQuotes, `"`); n%2 != 0 {
t.Fatalf("payload broke out of its string literal (found %d unpaired bare quotes): %q", n, got)
}
}

func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) {
tests := []struct {
action string
Expand Down Expand Up @@ -226,15 +255,21 @@ func TestParseLuaBindLineHandlesFunctionDispatcherFallback(t *testing.T) {
}
}

func TestLuaActionStringLeavesCustomLuaDispatcherRaw(t *testing.T) {
// TestLuaActionStringFromHyprlangActionAlwaysQuotesUnrecognizedText:
// luaActionStringFromHyprlangAction is used for Action text that is
// classic freeform "dispatcher params" (never Lua) -- it must always
// safely quote anything it doesn't recognize, precisely because that text
// could be attacker-influenced content from a hyprland.conf bind= line,
// not just an innocuous typo. The "leave custom Lua dispatcher raw"
// behavior now lives only in writeLuaBindLine, gated on the explicit
// RawLuaAction flag set by parseLuaBindOverrideLine -- see
// TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw.
func TestLuaActionStringFromHyprlangActionAlwaysQuotesUnrecognizedText(t *testing.T) {
got := luaActionStringFromHyprlangAction("hl.dsp.no_op()")
want := `hl.dsp.no_op()`
want := `function() hl.exec_cmd("hyprctl dispatch hl.dsp.no_op()") end`
if got != want {
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
}
if strings.Contains(got, "hl.dispatch") || strings.Contains(got, "hyprctl dispatch") {
t.Fatalf("expected custom Lua dispatcher expression to stay raw, got %q", got)
}
}

func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) {
Expand Down
Loading
Loading