| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "net/url" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | ) |
| 10 | |
| 11 | // normalizeLocalOpenPath validates and normalizes a user-clicked local path |
| 12 | // before it is handed to the OS opener. It accepts either a plain absolute |
| 13 | // path (D:\a\b.md or D:/a/b.md) or a file URL. The native boundary validates |
| 14 | // URLs independently because desktop commands are callable without the frontend. |
| 15 | func normalizeLocalOpenPath(path string) (string, error) { |
| 16 | path = strings.TrimSpace(path) |
| 17 | if path == "" { |
| 18 | return "", os.ErrInvalid |
| 19 | } |
| 20 | if strings.HasPrefix(path, "file://") { |
| 21 | parsed, err := url.Parse(path) |
| 22 | if err != nil || parsed.Scheme != "file" || parsed.Opaque != "" || parsed.User != nil || parsed.Port() != "" || parsed.RawQuery != "" || parsed.Fragment != "" { |
| 23 | return "", fmt.Errorf("invalid local file URL %q", path) |
| 24 | } |
| 25 | decoded, err := url.PathUnescape(parsed.EscapedPath()) |
| 26 | if err != nil { |
| 27 | return "", fmt.Errorf("invalid local file URL %q: %w", path, err) |
| 28 | } |
| 29 | host := parsed.Hostname() |
| 30 | if host == "." || host == "?" { |
| 31 | return "", fmt.Errorf("unsafe local file URL authority %q", host) |
| 32 | } |
| 33 | if strings.EqualFold(host, "localhost") { |
| 34 | host = "" |
| 35 | } |
| 36 | if host != "" { |
| 37 | decoded = "//" + host + decoded |
| 38 | } |
| 39 | if len(decoded) >= 4 && decoded[0] == '/' && isASCIILetter(decoded[1]) && decoded[2] == ':' && decoded[3] == '/' { |
| 40 | decoded = decoded[1:] |
| 41 | } |
| 42 | path = decoded |
| 43 | } |
| 44 | if hasDisallowedWindowsPathSyntax(path) { |
| 45 | return "", fmt.Errorf("unsafe local path syntax %q", path) |
| 46 | } |
| 47 | // Normalize forward slashes (file URLs, slash-form UNC "//nas/share") |
| 48 | // to the platform-native separators the opener expects. |
| 49 | path = filepath.FromSlash(path) |
| 50 | if !filepath.IsAbs(path) { |
| 51 | return "", fmt.Errorf("path is not absolute: %q", path) |
| 52 | } |
| 53 | return path, nil |
| 54 | } |
| 55 | |
| 56 | func isASCIILetter(value byte) bool { |
| 57 | return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' |
| 58 | } |
| 59 | |
| 60 | func hasDisallowedWindowsPathSyntax(path string) bool { |
| 61 | slashPath := strings.ReplaceAll(path, "\\", "/") |
| 62 | if strings.ContainsRune(slashPath, '\x00') { |
| 63 | return true |
| 64 | } |
| 65 | if slashPath == "//." || slashPath == "//?" || strings.HasPrefix(slashPath, "//./") || strings.HasPrefix(slashPath, "//?/") { |
| 66 | return true |
| 67 | } |
| 68 | isDrivePath := len(slashPath) >= 3 && isASCIILetter(slashPath[0]) && slashPath[1] == ':' && slashPath[2] == '/' |
| 69 | isUNCPath := strings.HasPrefix(slashPath, "//") |
| 70 | if !isDrivePath && !isUNCPath { |
| 71 | return false |
| 72 | } |
| 73 | remainder := slashPath[2:] |
| 74 | if strings.Contains(remainder, ":") { |
| 75 | return true |
| 76 | } |
| 77 | for component := range strings.SplitSeq(remainder, "/") { |
| 78 | component = strings.TrimRight(component, " .") |
| 79 | if dot := strings.IndexByte(component, '.'); dot >= 0 { |
| 80 | component = component[:dot] |
| 81 | } |
| 82 | switch strings.ToUpper(component) { |
| 83 | case "CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$", |
| 84 | "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", |
| 85 | "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", |
| 86 | "COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³": |
| 87 | return true |
| 88 | } |
| 89 | } |
| 90 | return false |
| 91 | } |
| 92 | |
| 93 | // openTargetAllowed reports whether a resolved path may be handed to the OS |
| 94 | // "open" verb. Directories and documents open normally; executable targets |
| 95 | // are refused because OpenLocalPath is fed by AI-generated chat content — |
| 96 | // a prompt-injected or hallucinated ".bat" path must not run on click. |
| 97 | // openWorkspacePath remains outside this executable-target guard because its |
| 98 | // callers use it for paths already authorized by the workspace boundary. |
| 99 | var executableOpenSuffixes = map[string]bool{ |
| 100 | ".app": true, |
| 101 | ".bat": true, ".cmd": true, ".com": true, ".exe": true, |
| 102 | ".desktop": true, |
| 103 | ".ps1": true, ".vbs": true, ".jse": true, ".js": true, |
| 104 | ".lnk": true, ".url": true, ".scr": true, ".msi": true, |
| 105 | ".reg": true, ".pif": true, ".hta": true, ".wsf": true, |
| 106 | } |
| 107 | |
| 108 | func openTargetAllowed(path string, isDir bool, mode os.FileMode) bool { |
| 109 | // Windows resolves trailing dots and spaces away before opening a path, and |
| 110 | // filepath.Clean removes a trailing separator from macOS app bundles. |
| 111 | base := strings.TrimRight(filepath.Base(filepath.Clean(path)), " .") |
| 112 | if executableOpenSuffixes[strings.ToLower(filepath.Ext(base))] { |
| 113 | return false |
| 114 | } |
| 115 | return isDir || mode.Perm()&0o111 == 0 |
| 116 | } |
| 117 | |
| 118 | func openTargetPathAllowed(path string, info os.FileInfo) bool { |
| 119 | if !openTargetAllowed(path, info.IsDir(), info.Mode()) { |
| 120 | return false |
| 121 | } |
| 122 | cleanPath := filepath.Clean(path) |
| 123 | linkInfo, err := os.Lstat(cleanPath) |
| 124 | if err != nil { |
| 125 | return false |
| 126 | } |
| 127 | if linkInfo.Mode()&os.ModeSymlink == 0 { |
| 128 | return true |
| 129 | } |
| 130 | resolved, err := filepath.EvalSymlinks(cleanPath) |
| 131 | if err != nil { |
| 132 | return false |
| 133 | } |
| 134 | return openTargetAllowed(resolved, info.IsDir(), info.Mode()) |
| 135 | } |
| 136 | |
| 137 | // OpenLocalPath opens an arbitrary local absolute path (file or directory) |
| 138 | // with the OS default application. It backs clicking a local path rendered in |
| 139 | // chat markdown (issue #7426) — Windows drive paths, UNC paths and file:/// |
| 140 | // URLs included. |
| 141 | func (a *App) OpenLocalPath(path string) error { |
| 142 | path, err := normalizeLocalOpenPath(path) |
| 143 | if err != nil { |
| 144 | return err |
| 145 | } |
| 146 | info, err := os.Stat(path) |
| 147 | if err != nil { |
| 148 | return err |
| 149 | } |
| 150 | if !openTargetPathAllowed(path, info) { |
| 151 | return fmt.Errorf("refusing to open executable target %q", path) |
| 152 | } |
| 153 | return openWorkspacePathWithType(path, info.IsDir()) |
| 154 | } |
| 155 |