fix(cli): harden how token.cmd is run
Vet the open config file handle instead of the path: refuse a file owned by another user and a directory that others can write (sticky directories and root-owned ones excepted). Give the command a stdin only when it is a terminal. Make the timeout injectable and report the deadline that fired.
- commit
- aca5d0cc7ec800c33cadf44f891fbede1fc98cdd
- author
- brice · · 2026-10-12 15:12 UTC
- parent
- fff3337
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +218 -31
M internal/app/cli.go +3 -0
| @@ -38,12 +38,15 @@ type cliEnv struct { | |||
| 38 | 38 | // runTokenCmd runs the token.cmd command line and returns its raw standard | |
| 39 | 39 | // output; nil means running it with the system shell. Tests replace it. | |
| 40 | 40 | runTokenCmd func(ctx context.Context, cmd string) (string, error) | |
| 41 | + | // tokenTimeout bounds the default runTokenCmd; zero means 60 s. | |
| 42 | + | tokenTimeout time.Duration | |
| 41 | 43 | ||
| 42 | 44 | // The config file is read at most once and token.cmd run at most once per | |
| 43 | 45 | // process, and only when a command needs them (see cli_configfile.go). | |
| 44 | 46 | fileOnce sync.Once | |
| 45 | 47 | file *cliFile | |
| 46 | 48 | filePath string | |
| 49 | + | fileTrust error // why token.cmd must not run from this file, if so | |
| 47 | 50 | fileErr error | |
| 48 | 51 | tokenOnce sync.Once | |
| 49 | 52 | token string | |
| @@ -38,12 +38,15 @@ type cliEnv struct { | |||||
| 38 | // runTokenCmd runs the token.cmd command line and returns its raw standard | 38 | // runTokenCmd runs the token.cmd command line and returns its raw standard | ||
| 39 | // output; nil means running it with the system shell. Tests replace it. | 39 | // output; nil means running it with the system shell. Tests replace it. | ||
| 40 | runTokenCmd func(ctx context.Context, cmd string) (string, error) | 40 | runTokenCmd func(ctx context.Context, cmd string) (string, error) | ||
| 41 | + | // tokenTimeout bounds the default runTokenCmd; zero means 60 s. | |||
| 42 | + | tokenTimeout time.Duration | |||
| 41 | 43 | ||||
| 42 | // The config file is read at most once and token.cmd run at most once per | 44 | // The config file is read at most once and token.cmd run at most once per | ||
| 43 | // process, and only when a command needs them (see cli_configfile.go). | 45 | // process, and only when a command needs them (see cli_configfile.go). | ||
| 44 | fileOnce sync.Once | 46 | fileOnce sync.Once | ||
| 45 | file *cliFile | 47 | file *cliFile | ||
| 46 | filePath string | 48 | filePath string | ||
| 49 | + | fileTrust error // why token.cmd must not run from this file, if so | |||
| 47 | fileErr error | 50 | fileErr error | ||
| 48 | tokenOnce sync.Once | 51 | tokenOnce sync.Once | ||
| 49 | token string | 52 | token string | ||
M internal/app/cli_configfile.go +79 -28
| @@ -8,9 +8,9 @@ import ( | |||
| 8 | 8 | "errors" | |
| 9 | 9 | "fmt" | |
| 10 | 10 | "io" | |
| 11 | + | "io/fs" | |
| 11 | 12 | "os" | |
| 12 | 13 | "os/exec" | |
| 13 | - | "runtime" | |
| 14 | 14 | "strings" | |
| 15 | 15 | "time" | |
| 16 | 16 | ||
| @@ -57,7 +57,12 @@ func (e *cliEnv) path() (string, error) { | |||
| 57 | 57 | ||
| 58 | 58 | // readFile reads the config file, once per process. A missing file, or no | |
| 59 | 59 | // place to look for it, gives an empty one. Problems in the file are warnings | |
| 60 | - | // on stderr, printed once, and only when warn is set. | |
| 60 | + | // on stderr, printed once, and only when warn is set. The path is returned | |
| 61 | + | // even when reading fails. | |
| 62 | + | // | |
| 63 | + | // The file is opened once: what is parsed and what requirePrivate vets (see | |
| 64 | + | // fetchToken) are the same file, so token.cmd cannot come from a file that was | |
| 65 | + | // swapped after the check. | |
| 61 | 66 | func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, error) { | |
| 62 | 67 | e.fileOnce.Do(func() { | |
| 63 | 68 | p, err := e.path() | |
| @@ -68,11 +73,12 @@ func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, erro | |||
| 68 | 73 | return | |
| 69 | 74 | } | |
| 70 | 75 | e.filePath = p | |
| 71 | - | f, warnings, err := kvconf.Load[cliFile](p, kvconf.Options{}) | |
| 76 | + | f, warnings, trust, err := loadConfigFile(p) | |
| 72 | 77 | if err != nil { | |
| 73 | 78 | e.fileErr = fmt.Errorf("config: %w", err) | |
| 74 | 79 | return | |
| 75 | 80 | } | |
| 81 | + | e.fileTrust = trust | |
| 76 | 82 | if warn { | |
| 77 | 83 | for _, w := range warnings { | |
| 78 | 84 | warnf(cmd, "config: %s", w) | |
| @@ -83,6 +89,40 @@ func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, erro | |||
| 83 | 89 | return e.file, e.filePath, e.fileErr | |
| 84 | 90 | } | |
| 85 | 91 | ||
| 92 | + | // loadConfigFile parses the file at path (missing: empty). trust is nil when | |
| 93 | + | // token.cmd may be run from this file, else the reason why not. | |
| 94 | + | func loadConfigFile(path string) (f *cliFile, warnings []kvconf.Warning, trust, err error) { | |
| 95 | + | h, err := openConfig(path) | |
| 96 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 97 | + | return &cliFile{}, nil, nil, nil | |
| 98 | + | } | |
| 99 | + | if err != nil { | |
| 100 | + | return nil, nil, nil, err | |
| 101 | + | } | |
| 102 | + | defer h.Close() //nolint:errcheck // read only | |
| 103 | + | info, err := h.Stat() | |
| 104 | + | if err != nil { | |
| 105 | + | return nil, nil, nil, err | |
| 106 | + | } | |
| 107 | + | if !info.Mode().IsRegular() { | |
| 108 | + | return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrNotRegular) | |
| 109 | + | } | |
| 110 | + | trust = checkPrivate(path, info) | |
| 111 | + | data, err := io.ReadAll(io.LimitReader(h, kvconf.DefaultMaxBytes+1)) | |
| 112 | + | if err != nil { | |
| 113 | + | return nil, nil, nil, err | |
| 114 | + | } | |
| 115 | + | if len(data) > kvconf.DefaultMaxBytes { | |
| 116 | + | return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrTooLarge) | |
| 117 | + | } | |
| 118 | + | f = &cliFile{} | |
| 119 | + | warnings, err = kvconf.Unmarshal(data, f, kvconf.Options{}) | |
| 120 | + | if err != nil { | |
| 121 | + | return nil, nil, nil, fmt.Errorf("%s: %w", path, err) | |
| 122 | + | } | |
| 123 | + | return f, warnings, trust, nil | |
| 124 | + | } | |
| 125 | + | ||
| 86 | 126 | // resolveURL is ERMITE_URL if set, else the url of the config file, else "". | |
| 87 | 127 | func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) { | |
| 88 | 128 | if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" { | |
| @@ -112,7 +152,7 @@ func (e *cliEnv) resolveToken(cmd *cobra.Command, warn, runCmd bool) (string, er | |||
| 112 | 152 | // the command fetches belongs to the server of the file, and must not be sent | |
| 113 | 153 | // to another one. | |
| 114 | 154 | func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) { | |
| 115 | - | f, path, err := e.readFile(cmd, warn) | |
| 155 | + | f, _, err := e.readFile(cmd, warn) | |
| 116 | 156 | if err != nil { | |
| 117 | 157 | return "", err | |
| 118 | 158 | } | |
| @@ -127,7 +167,7 @@ func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) { | |||
| 127 | 167 | } | |
| 128 | 168 | return | |
| 129 | 169 | } | |
| 130 | - | e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), path, line) | |
| 170 | + | e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), line) | |
| 131 | 171 | }) | |
| 132 | 172 | return e.token, e.tokenErr | |
| 133 | 173 | } | |
| @@ -149,13 +189,13 @@ func (e *cliEnv) urlIsFromFile(f *cliFile) bool { | |||
| 149 | 189 | ||
| 150 | 190 | // fetchToken runs token.cmd. Neither its output nor its error text reach the | |
| 151 | 191 | // caller's messages: the output is the secret. | |
| 152 | - | func (e *cliEnv) fetchToken(ctx context.Context, path, line string) (string, error) { | |
| 153 | - | if err := requirePrivate(path); err != nil { | |
| 154 | - | return "", err | |
| 192 | + | func (e *cliEnv) fetchToken(ctx context.Context, line string) (string, error) { | |
| 193 | + | if e.fileTrust != nil { | |
| 194 | + | return "", e.fileTrust | |
| 155 | 195 | } | |
| 156 | 196 | run := e.runTokenCmd | |
| 157 | 197 | if run == nil { | |
| 158 | - | run = runShell | |
| 198 | + | run = e.shell | |
| 159 | 199 | } | |
| 160 | 200 | out, err := run(ctx, line) | |
| 161 | 201 | if err != nil { | |
| @@ -171,37 +211,37 @@ func (e *cliEnv) fetchToken(ctx context.Context, path, line string) (string, err | |||
| 171 | 211 | return out, nil | |
| 172 | 212 | } | |
| 173 | 213 | ||
| 174 | - | // requirePrivate refuses a config file other users can write: they could | |
| 175 | - | // change token.cmd into any command. Windows has no such mode bits. | |
| 176 | - | func requirePrivate(path string) error { | |
| 177 | - | if runtime.GOOS == "windows" { | |
| 178 | - | return nil | |
| 179 | - | } | |
| 180 | - | info, err := os.Stat(path) | |
| 181 | - | if err != nil { | |
| 182 | - | return fmt.Errorf("config: %w", err) | |
| 214 | + | // shell runs a token.cmd command line, with the timeout of the environment. | |
| 215 | + | func (e *cliEnv) shell(ctx context.Context, line string) (string, error) { | |
| 216 | + | timeout := e.tokenTimeout | |
| 217 | + | if timeout <= 0 { | |
| 218 | + | timeout = tokenCmdTimeout | |
| 183 | 219 | } | |
| 184 | - | if info.Mode().Perm()&0o022 != 0 { | |
| 185 | - | return fmt.Errorf("config file %s is writable by others: token.cmd not run", path) | |
| 186 | - | } | |
| 187 | - | return nil | |
| 220 | + | return runShell(ctx, line, timeout) | |
| 188 | 221 | } | |
| 189 | 222 | ||
| 190 | - | // runShell runs line with the system shell. Standard input and error are the | |
| 191 | - | // terminal's, so a pinentry or a biometric prompt works; standard output is | |
| 223 | + | // runShell runs line with the system shell. Standard error is the terminal's, | |
| 224 | + | // and so is standard input when it is a terminal, so a pinentry or a biometric | |
| 225 | + | // prompt works; a script or an agent gets no input. Standard output is | |
| 192 | 226 | // captured, up to tokenCmdMaxOutput bytes. The environment is the process's. | |
| 193 | - | func runShell(ctx context.Context, line string) (string, error) { | |
| 194 | - | ctx, cancel := context.WithTimeout(ctx, tokenCmdTimeout) | |
| 227 | + | func runShell(ctx context.Context, line string, timeout time.Duration) (string, error) { | |
| 228 | + | parent := ctx | |
| 229 | + | ctx, cancel := context.WithTimeout(parent, timeout) | |
| 195 | 230 | defer cancel() | |
| 196 | 231 | c := shellCommand(ctx, line) | |
| 197 | 232 | out := &cappedBuffer{max: tokenCmdMaxOutput} | |
| 198 | - | c.Stdin, c.Stdout, c.Stderr = os.Stdin, out, os.Stderr | |
| 233 | + | c.Stdout, c.Stderr = out, os.Stderr | |
| 234 | + | if stdinIsTerminal() { | |
| 235 | + | c.Stdin = os.Stdin | |
| 236 | + | } // else nil: the command reads os.DevNull | |
| 199 | 237 | // Do not wait forever for a background process that kept the pipe open. | |
| 200 | 238 | c.WaitDelay = 2 * time.Second | |
| 201 | 239 | err := c.Run() | |
| 202 | 240 | switch { | |
| 241 | + | case parent.Err() != nil: | |
| 242 | + | return "", errors.New("interrupted") | |
| 203 | 243 | case errors.Is(ctx.Err(), context.DeadlineExceeded): | |
| 204 | - | return "", fmt.Errorf("timed out after %s", tokenCmdTimeout) | |
| 244 | + | return "", fmt.Errorf("timed out after %s", timeout) | |
| 205 | 245 | case err != nil: | |
| 206 | 246 | var exit *exec.ExitError | |
| 207 | 247 | if errors.As(err, &exit) { | |
| @@ -214,6 +254,17 @@ func runShell(ctx context.Context, line string) (string, error) { | |||
| 214 | 254 | return out.buf.String(), nil | |
| 215 | 255 | } | |
| 216 | 256 | ||
| 257 | + | // stdinIsTerminal reports whether standard input is a terminal: a character | |
| 258 | + | // device other than the null device. | |
| 259 | + | func stdinIsTerminal() bool { | |
| 260 | + | fi, err := os.Stdin.Stat() | |
| 261 | + | if err != nil || fi.Mode()&os.ModeCharDevice == 0 { | |
| 262 | + | return false | |
| 263 | + | } | |
| 264 | + | null, err := os.Stat(os.DevNull) | |
| 265 | + | return err != nil || !os.SameFile(fi, null) | |
| 266 | + | } | |
| 267 | + | ||
| 217 | 268 | // cappedBuffer keeps the first max bytes written and notes an overflow. | |
| 218 | 269 | type cappedBuffer struct { | |
| 219 | 270 | buf bytes.Buffer | |
| @@ -8,9 +8,9 @@ import ( | |||||
| 8 | "errors" | 8 | "errors" | ||
| 9 | "fmt" | 9 | "fmt" | ||
| 10 | "io" | 10 | "io" | ||
| 11 | + | "io/fs" | |||
| 11 | "os" | 12 | "os" | ||
| 12 | "os/exec" | 13 | "os/exec" | ||
| 13 | - | "runtime" | |||
| 14 | "strings" | 14 | "strings" | ||
| 15 | "time" | 15 | "time" | ||
| 16 | 16 | ||||
| @@ -57,7 +57,12 @@ func (e *cliEnv) path() (string, error) { | |||||
| 57 | 57 | ||||
| 58 | // readFile reads the config file, once per process. A missing file, or no | 58 | // readFile reads the config file, once per process. A missing file, or no | ||
| 59 | // place to look for it, gives an empty one. Problems in the file are warnings | 59 | // place to look for it, gives an empty one. Problems in the file are warnings | ||
| 60 | - | // on stderr, printed once, and only when warn is set. | 60 | + | // on stderr, printed once, and only when warn is set. The path is returned |
| 61 | + | // even when reading fails. | |||
| 62 | + | // | |||
| 63 | + | // The file is opened once: what is parsed and what requirePrivate vets (see | |||
| 64 | + | // fetchToken) are the same file, so token.cmd cannot come from a file that was | |||
| 65 | + | // swapped after the check. | |||
| 61 | func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, error) { | 66 | func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, error) { | ||
| 62 | e.fileOnce.Do(func() { | 67 | e.fileOnce.Do(func() { | ||
| 63 | p, err := e.path() | 68 | p, err := e.path() | ||
| @@ -68,11 +73,12 @@ func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, erro | |||||
| 68 | return | 73 | return | ||
| 69 | } | 74 | } | ||
| 70 | e.filePath = p | 75 | e.filePath = p | ||
| 71 | - | f, warnings, err := kvconf.Load[cliFile](p, kvconf.Options{}) | 76 | + | f, warnings, trust, err := loadConfigFile(p) |
| 72 | if err != nil { | 77 | if err != nil { | ||
| 73 | e.fileErr = fmt.Errorf("config: %w", err) | 78 | e.fileErr = fmt.Errorf("config: %w", err) | ||
| 74 | return | 79 | return | ||
| 75 | } | 80 | } | ||
| 81 | + | e.fileTrust = trust | |||
| 76 | if warn { | 82 | if warn { | ||
| 77 | for _, w := range warnings { | 83 | for _, w := range warnings { | ||
| 78 | warnf(cmd, "config: %s", w) | 84 | warnf(cmd, "config: %s", w) | ||
| @@ -83,6 +89,40 @@ func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, erro | |||||
| 83 | return e.file, e.filePath, e.fileErr | 89 | return e.file, e.filePath, e.fileErr | ||
| 84 | } | 90 | } | ||
| 85 | 91 | ||||
| 92 | + | // loadConfigFile parses the file at path (missing: empty). trust is nil when | |||
| 93 | + | // token.cmd may be run from this file, else the reason why not. | |||
| 94 | + | func loadConfigFile(path string) (f *cliFile, warnings []kvconf.Warning, trust, err error) { | |||
| 95 | + | h, err := openConfig(path) | |||
| 96 | + | if errors.Is(err, fs.ErrNotExist) { | |||
| 97 | + | return &cliFile{}, nil, nil, nil | |||
| 98 | + | } | |||
| 99 | + | if err != nil { | |||
| 100 | + | return nil, nil, nil, err | |||
| 101 | + | } | |||
| 102 | + | defer h.Close() //nolint:errcheck // read only | |||
| 103 | + | info, err := h.Stat() | |||
| 104 | + | if err != nil { | |||
| 105 | + | return nil, nil, nil, err | |||
| 106 | + | } | |||
| 107 | + | if !info.Mode().IsRegular() { | |||
| 108 | + | return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrNotRegular) | |||
| 109 | + | } | |||
| 110 | + | trust = checkPrivate(path, info) | |||
| 111 | + | data, err := io.ReadAll(io.LimitReader(h, kvconf.DefaultMaxBytes+1)) | |||
| 112 | + | if err != nil { | |||
| 113 | + | return nil, nil, nil, err | |||
| 114 | + | } | |||
| 115 | + | if len(data) > kvconf.DefaultMaxBytes { | |||
| 116 | + | return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrTooLarge) | |||
| 117 | + | } | |||
| 118 | + | f = &cliFile{} | |||
| 119 | + | warnings, err = kvconf.Unmarshal(data, f, kvconf.Options{}) | |||
| 120 | + | if err != nil { | |||
| 121 | + | return nil, nil, nil, fmt.Errorf("%s: %w", path, err) | |||
| 122 | + | } | |||
| 123 | + | return f, warnings, trust, nil | |||
| 124 | + | } | |||
| 125 | + | ||||
| 86 | // resolveURL is ERMITE_URL if set, else the url of the config file, else "". | 126 | // resolveURL is ERMITE_URL if set, else the url of the config file, else "". | ||
| 87 | func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) { | 127 | func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) { | ||
| 88 | if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" { | 128 | if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" { | ||
| @@ -112,7 +152,7 @@ func (e *cliEnv) resolveToken(cmd *cobra.Command, warn, runCmd bool) (string, er | |||||
| 112 | // the command fetches belongs to the server of the file, and must not be sent | 152 | // the command fetches belongs to the server of the file, and must not be sent | ||
| 113 | // to another one. | 153 | // to another one. | ||
| 114 | func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) { | 154 | func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) { | ||
| 115 | - | f, path, err := e.readFile(cmd, warn) | 155 | + | f, _, err := e.readFile(cmd, warn) |
| 116 | if err != nil { | 156 | if err != nil { | ||
| 117 | return "", err | 157 | return "", err | ||
| 118 | } | 158 | } | ||
| @@ -127,7 +167,7 @@ func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) { | |||||
| 127 | } | 167 | } | ||
| 128 | return | 168 | return | ||
| 129 | } | 169 | } | ||
| 130 | - | e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), path, line) | 170 | + | e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), line) |
| 131 | }) | 171 | }) | ||
| 132 | return e.token, e.tokenErr | 172 | return e.token, e.tokenErr | ||
| 133 | } | 173 | } | ||
| @@ -149,13 +189,13 @@ func (e *cliEnv) urlIsFromFile(f *cliFile) bool { | |||||
| 149 | 189 | ||||
| 150 | // fetchToken runs token.cmd. Neither its output nor its error text reach the | 190 | // fetchToken runs token.cmd. Neither its output nor its error text reach the | ||
| 151 | // caller's messages: the output is the secret. | 191 | // caller's messages: the output is the secret. | ||
| 152 | - | func (e *cliEnv) fetchToken(ctx context.Context, path, line string) (string, error) { | 192 | + | func (e *cliEnv) fetchToken(ctx context.Context, line string) (string, error) { |
| 153 | - | if err := requirePrivate(path); err != nil { | 193 | + | if e.fileTrust != nil { |
| 154 | - | return "", err | 194 | + | return "", e.fileTrust |
| 155 | } | 195 | } | ||
| 156 | run := e.runTokenCmd | 196 | run := e.runTokenCmd | ||
| 157 | if run == nil { | 197 | if run == nil { | ||
| 158 | - | run = runShell | 198 | + | run = e.shell |
| 159 | } | 199 | } | ||
| 160 | out, err := run(ctx, line) | 200 | out, err := run(ctx, line) | ||
| 161 | if err != nil { | 201 | if err != nil { | ||
| @@ -171,37 +211,37 @@ func (e *cliEnv) fetchToken(ctx context.Context, path, line string) (string, err | |||||
| 171 | return out, nil | 211 | return out, nil | ||
| 172 | } | 212 | } | ||
| 173 | 213 | ||||
| 174 | - | // requirePrivate refuses a config file other users can write: they could | 214 | + | // shell runs a token.cmd command line, with the timeout of the environment. |
| 175 | - | // change token.cmd into any command. Windows has no such mode bits. | 215 | + | func (e *cliEnv) shell(ctx context.Context, line string) (string, error) { |
| 176 | - | func requirePrivate(path string) error { | 216 | + | timeout := e.tokenTimeout |
| 177 | - | if runtime.GOOS == "windows" { | 217 | + | if timeout <= 0 { |
| 178 | - | return nil | 218 | + | timeout = tokenCmdTimeout |
| 179 | - | } | |||
| 180 | - | info, err := os.Stat(path) | |||
| 181 | - | if err != nil { | |||
| 182 | - | return fmt.Errorf("config: %w", err) | |||
| 183 | } | 219 | } | ||
| 184 | - | if info.Mode().Perm()&0o022 != 0 { | 220 | + | return runShell(ctx, line, timeout) |
| 185 | - | return fmt.Errorf("config file %s is writable by others: token.cmd not run", path) | |||
| 186 | - | } | |||
| 187 | - | return nil | |||
| 188 | } | 221 | } | ||
| 189 | 222 | ||||
| 190 | - | // runShell runs line with the system shell. Standard input and error are the | 223 | + | // runShell runs line with the system shell. Standard error is the terminal's, |
| 191 | - | // terminal's, so a pinentry or a biometric prompt works; standard output is | 224 | + | // and so is standard input when it is a terminal, so a pinentry or a biometric |
| 225 | + | // prompt works; a script or an agent gets no input. Standard output is | |||
| 192 | // captured, up to tokenCmdMaxOutput bytes. The environment is the process's. | 226 | // captured, up to tokenCmdMaxOutput bytes. The environment is the process's. | ||
| 193 | - | func runShell(ctx context.Context, line string) (string, error) { | 227 | + | func runShell(ctx context.Context, line string, timeout time.Duration) (string, error) { |
| 194 | - | ctx, cancel := context.WithTimeout(ctx, tokenCmdTimeout) | 228 | + | parent := ctx |
| 229 | + | ctx, cancel := context.WithTimeout(parent, timeout) | |||
| 195 | defer cancel() | 230 | defer cancel() | ||
| 196 | c := shellCommand(ctx, line) | 231 | c := shellCommand(ctx, line) | ||
| 197 | out := &cappedBuffer{max: tokenCmdMaxOutput} | 232 | out := &cappedBuffer{max: tokenCmdMaxOutput} | ||
| 198 | - | c.Stdin, c.Stdout, c.Stderr = os.Stdin, out, os.Stderr | 233 | + | c.Stdout, c.Stderr = out, os.Stderr |
| 234 | + | if stdinIsTerminal() { | |||
| 235 | + | c.Stdin = os.Stdin | |||
| 236 | + | } // else nil: the command reads os.DevNull | |||
| 199 | // Do not wait forever for a background process that kept the pipe open. | 237 | // Do not wait forever for a background process that kept the pipe open. | ||
| 200 | c.WaitDelay = 2 * time.Second | 238 | c.WaitDelay = 2 * time.Second | ||
| 201 | err := c.Run() | 239 | err := c.Run() | ||
| 202 | switch { | 240 | switch { | ||
| 241 | + | case parent.Err() != nil: | |||
| 242 | + | return "", errors.New("interrupted") | |||
| 203 | case errors.Is(ctx.Err(), context.DeadlineExceeded): | 243 | case errors.Is(ctx.Err(), context.DeadlineExceeded): | ||
| 204 | - | return "", fmt.Errorf("timed out after %s", tokenCmdTimeout) | 244 | + | return "", fmt.Errorf("timed out after %s", timeout) |
| 205 | case err != nil: | 245 | case err != nil: | ||
| 206 | var exit *exec.ExitError | 246 | var exit *exec.ExitError | ||
| 207 | if errors.As(err, &exit) { | 247 | if errors.As(err, &exit) { | ||
| @@ -214,6 +254,17 @@ func runShell(ctx context.Context, line string) (string, error) { | |||||
| 214 | return out.buf.String(), nil | 254 | return out.buf.String(), nil | ||
| 215 | } | 255 | } | ||
| 216 | 256 | ||||
| 257 | + | // stdinIsTerminal reports whether standard input is a terminal: a character | |||
| 258 | + | // device other than the null device. | |||
| 259 | + | func stdinIsTerminal() bool { | |||
| 260 | + | fi, err := os.Stdin.Stat() | |||
| 261 | + | if err != nil || fi.Mode()&os.ModeCharDevice == 0 { | |||
| 262 | + | return false | |||
| 263 | + | } | |||
| 264 | + | null, err := os.Stat(os.DevNull) | |||
| 265 | + | return err != nil || !os.SameFile(fi, null) | |||
| 266 | + | } | |||
| 267 | + | ||||
| 217 | // cappedBuffer keeps the first max bytes written and notes an overflow. | 268 | // cappedBuffer keeps the first max bytes written and notes an overflow. | ||
| 218 | type cappedBuffer struct { | 269 | type cappedBuffer struct { | ||
| 219 | buf bytes.Buffer | 270 | buf bytes.Buffer | ||
M internal/app/cli_configfile_test.go +87 -3
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "runtime" | |
| 11 | 11 | "strings" | |
| 12 | 12 | "testing" | |
| 13 | + | "time" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/stretchr/testify/assert" | |
| 15 | 16 | "github.com/stretchr/testify/require" | |
| @@ -308,17 +309,100 @@ func TestRunShellReportsFailuresWithoutOutput(t *testing.T) { | |||
| 308 | 309 | if runtime.GOOS == "windows" { | |
| 309 | 310 | t.Skip("uses sh") | |
| 310 | 311 | } | |
| 311 | - | _, err := runShell(t.Context(), "echo s3cret; exit 3") | |
| 312 | + | _, err := runShell(t.Context(), "echo s3cret; exit 3", time.Minute) | |
| 312 | 313 | require.Error(t, err) | |
| 313 | 314 | assert.Contains(t, err.Error(), "exit status 3") | |
| 314 | 315 | assert.NotContains(t, err.Error(), "s3cret") | |
| 315 | 316 | ||
| 316 | - | _, err = runShell(t.Context(), "head -c 5000 /dev/zero | tr '\\0' a") | |
| 317 | + | _, err = runShell(t.Context(), "head -c 5000 /dev/zero | tr '\\0' a", time.Minute) | |
| 317 | 318 | require.Error(t, err) | |
| 318 | 319 | assert.Contains(t, err.Error(), "more than 4096 bytes") | |
| 319 | 320 | assert.NotContains(t, err.Error(), "aaa") | |
| 320 | 321 | ||
| 321 | - | out, err := runShell(t.Context(), "echo ermite_ok") | |
| 322 | + | out, err := runShell(t.Context(), "echo ermite_ok", time.Minute) | |
| 322 | 323 | require.NoError(t, err) | |
| 323 | 324 | assert.Equal(t, "ermite_ok\n", out) | |
| 324 | 325 | } | |
| 326 | + | ||
| 327 | + | func TestTokenCmdTimesOut(t *testing.T) { | |
| 328 | + | if runtime.GOOS == "windows" { | |
| 329 | + | t.Skip("uses sh") | |
| 330 | + | } | |
| 331 | + | f := listFake() | |
| 332 | + | env := testEnv(t, nil) | |
| 333 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = exec sleep 30\n", 0o600) | |
| 334 | + | env.runTokenCmd = nil | |
| 335 | + | env.tokenTimeout = 100 * time.Millisecond | |
| 336 | + | ||
| 337 | + | start := time.Now() | |
| 338 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 339 | + | require.Error(t, res.err) | |
| 340 | + | assert.Equal(t, "token.cmd failed: timed out after 100ms", res.err.Error()) | |
| 341 | + | assert.Less(t, time.Since(start), 10*time.Second) | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func TestTokenCmdGetsNoInputWithoutATerminal(t *testing.T) { | |
| 345 | + | if runtime.GOOS == "windows" { | |
| 346 | + | t.Skip("uses sh") | |
| 347 | + | } | |
| 348 | + | // under `go test` stdin is the null device or a pipe, never a terminal | |
| 349 | + | require.False(t, stdinIsTerminal()) | |
| 350 | + | out, err := runShell(t.Context(), "cat; echo done", time.Minute) | |
| 351 | + | require.NoError(t, err) | |
| 352 | + | assert.Equal(t, "done\n", out, "the command read nothing") | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | func TestTokenCmdRefusedWhenDirectoryIsWritableByOthers(t *testing.T) { | |
| 356 | + | if runtime.GOOS == "windows" { | |
| 357 | + | t.Skip("no Unix permission bits") | |
| 358 | + | } | |
| 359 | + | env := testEnv(t, nil) | |
| 360 | + | path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600) | |
| 361 | + | dir := filepath.Dir(path) | |
| 362 | + | require.NoError(t, os.Chmod(dir, 0o777)) | |
| 363 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 364 | + | ||
| 365 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 366 | + | require.Error(t, res.err) | |
| 367 | + | assert.Equal(t, "config directory "+dir+" is writable by others: token.cmd not run", res.err.Error()) | |
| 368 | + | assert.Empty(t, *ran) | |
| 369 | + | ||
| 370 | + | t.Run("sticky directory is fine", func(t *testing.T) { | |
| 371 | + | require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky)) | |
| 372 | + | f := listFake() | |
| 373 | + | env := testEnvAt(t, path) | |
| 374 | + | require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\ntoken.cmd = get-token\n"), 0o600)) | |
| 375 | + | tokenCmdStub(env, "ermite_cmd", nil) | |
| 376 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 377 | + | }) | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | func TestTokenCmdRefusedWhenFileIsOwnedByAnotherUser(t *testing.T) { | |
| 381 | + | if runtime.GOOS == "windows" || os.Geteuid() != 0 { | |
| 382 | + | t.Skip("needs root to give a file away") | |
| 383 | + | } | |
| 384 | + | env := testEnv(t, nil) | |
| 385 | + | path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600) | |
| 386 | + | require.NoError(t, os.Chown(path, 65534, -1)) | |
| 387 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 388 | + | ||
| 389 | + | res := runCLIWith(t, env, "", "repo", "list") | |
| 390 | + | require.Error(t, res.err) | |
| 391 | + | assert.Equal(t, "config file "+path+" is owned by another user: token.cmd not run", res.err.Error()) | |
| 392 | + | assert.Empty(t, *ran) | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func TestTokenCmdFollowsASymlinkedConfigFile(t *testing.T) { | |
| 396 | + | if runtime.GOOS == "windows" { | |
| 397 | + | t.Skip("symlinks") | |
| 398 | + | } | |
| 399 | + | f := listFake() | |
| 400 | + | env := testEnv(t, nil) | |
| 401 | + | real := writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |
| 402 | + | link := filepath.Join(t.TempDir(), "dotfile-link") | |
| 403 | + | require.NoError(t, os.Symlink(real, link)) | |
| 404 | + | tokenCmdStub(env, "ermite_cmd", nil) | |
| 405 | + | env.configPath = func() (string, error) { return link, nil } | |
| 406 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 407 | + | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | |
| 408 | + | } | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "runtime" | 10 | "runtime" | ||
| 11 | "strings" | 11 | "strings" | ||
| 12 | "testing" | 12 | "testing" | ||
| 13 | + | "time" | |||
| 13 | 14 | ||||
| 14 | "github.com/stretchr/testify/assert" | 15 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/require" | 16 | "github.com/stretchr/testify/require" | ||
| @@ -308,17 +309,100 @@ func TestRunShellReportsFailuresWithoutOutput(t *testing.T) { | |||||
| 308 | if runtime.GOOS == "windows" { | 309 | if runtime.GOOS == "windows" { | ||
| 309 | t.Skip("uses sh") | 310 | t.Skip("uses sh") | ||
| 310 | } | 311 | } | ||
| 311 | - | _, err := runShell(t.Context(), "echo s3cret; exit 3") | 312 | + | _, err := runShell(t.Context(), "echo s3cret; exit 3", time.Minute) |
| 312 | require.Error(t, err) | 313 | require.Error(t, err) | ||
| 313 | assert.Contains(t, err.Error(), "exit status 3") | 314 | assert.Contains(t, err.Error(), "exit status 3") | ||
| 314 | assert.NotContains(t, err.Error(), "s3cret") | 315 | assert.NotContains(t, err.Error(), "s3cret") | ||
| 315 | 316 | ||||
| 316 | - | _, err = runShell(t.Context(), "head -c 5000 /dev/zero | tr '\\0' a") | 317 | + | _, err = runShell(t.Context(), "head -c 5000 /dev/zero | tr '\\0' a", time.Minute) |
| 317 | require.Error(t, err) | 318 | require.Error(t, err) | ||
| 318 | assert.Contains(t, err.Error(), "more than 4096 bytes") | 319 | assert.Contains(t, err.Error(), "more than 4096 bytes") | ||
| 319 | assert.NotContains(t, err.Error(), "aaa") | 320 | assert.NotContains(t, err.Error(), "aaa") | ||
| 320 | 321 | ||||
| 321 | - | out, err := runShell(t.Context(), "echo ermite_ok") | 322 | + | out, err := runShell(t.Context(), "echo ermite_ok", time.Minute) |
| 322 | require.NoError(t, err) | 323 | require.NoError(t, err) | ||
| 323 | assert.Equal(t, "ermite_ok\n", out) | 324 | assert.Equal(t, "ermite_ok\n", out) | ||
| 324 | } | 325 | } | ||
| 326 | + | ||||
| 327 | + | func TestTokenCmdTimesOut(t *testing.T) { | |||
| 328 | + | if runtime.GOOS == "windows" { | |||
| 329 | + | t.Skip("uses sh") | |||
| 330 | + | } | |||
| 331 | + | f := listFake() | |||
| 332 | + | env := testEnv(t, nil) | |||
| 333 | + | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = exec sleep 30\n", 0o600) | |||
| 334 | + | env.runTokenCmd = nil | |||
| 335 | + | env.tokenTimeout = 100 * time.Millisecond | |||
| 336 | + | ||||
| 337 | + | start := time.Now() | |||
| 338 | + | res := runCLIWith(t, env, "", "repo", "list") | |||
| 339 | + | require.Error(t, res.err) | |||
| 340 | + | assert.Equal(t, "token.cmd failed: timed out after 100ms", res.err.Error()) | |||
| 341 | + | assert.Less(t, time.Since(start), 10*time.Second) | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func TestTokenCmdGetsNoInputWithoutATerminal(t *testing.T) { | |||
| 345 | + | if runtime.GOOS == "windows" { | |||
| 346 | + | t.Skip("uses sh") | |||
| 347 | + | } | |||
| 348 | + | // under `go test` stdin is the null device or a pipe, never a terminal | |||
| 349 | + | require.False(t, stdinIsTerminal()) | |||
| 350 | + | out, err := runShell(t.Context(), "cat; echo done", time.Minute) | |||
| 351 | + | require.NoError(t, err) | |||
| 352 | + | assert.Equal(t, "done\n", out, "the command read nothing") | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | func TestTokenCmdRefusedWhenDirectoryIsWritableByOthers(t *testing.T) { | |||
| 356 | + | if runtime.GOOS == "windows" { | |||
| 357 | + | t.Skip("no Unix permission bits") | |||
| 358 | + | } | |||
| 359 | + | env := testEnv(t, nil) | |||
| 360 | + | path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600) | |||
| 361 | + | dir := filepath.Dir(path) | |||
| 362 | + | require.NoError(t, os.Chmod(dir, 0o777)) | |||
| 363 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 364 | + | ||||
| 365 | + | res := runCLIWith(t, env, "", "repo", "list") | |||
| 366 | + | require.Error(t, res.err) | |||
| 367 | + | assert.Equal(t, "config directory "+dir+" is writable by others: token.cmd not run", res.err.Error()) | |||
| 368 | + | assert.Empty(t, *ran) | |||
| 369 | + | ||||
| 370 | + | t.Run("sticky directory is fine", func(t *testing.T) { | |||
| 371 | + | require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky)) | |||
| 372 | + | f := listFake() | |||
| 373 | + | env := testEnvAt(t, path) | |||
| 374 | + | require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\ntoken.cmd = get-token\n"), 0o600)) | |||
| 375 | + | tokenCmdStub(env, "ermite_cmd", nil) | |||
| 376 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 377 | + | }) | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | func TestTokenCmdRefusedWhenFileIsOwnedByAnotherUser(t *testing.T) { | |||
| 381 | + | if runtime.GOOS == "windows" || os.Geteuid() != 0 { | |||
| 382 | + | t.Skip("needs root to give a file away") | |||
| 383 | + | } | |||
| 384 | + | env := testEnv(t, nil) | |||
| 385 | + | path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600) | |||
| 386 | + | require.NoError(t, os.Chown(path, 65534, -1)) | |||
| 387 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 388 | + | ||||
| 389 | + | res := runCLIWith(t, env, "", "repo", "list") | |||
| 390 | + | require.Error(t, res.err) | |||
| 391 | + | assert.Equal(t, "config file "+path+" is owned by another user: token.cmd not run", res.err.Error()) | |||
| 392 | + | assert.Empty(t, *ran) | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func TestTokenCmdFollowsASymlinkedConfigFile(t *testing.T) { | |||
| 396 | + | if runtime.GOOS == "windows" { | |||
| 397 | + | t.Skip("symlinks") | |||
| 398 | + | } | |||
| 399 | + | f := listFake() | |||
| 400 | + | env := testEnv(t, nil) | |||
| 401 | + | real := writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |||
| 402 | + | link := filepath.Join(t.TempDir(), "dotfile-link") | |||
| 403 | + | require.NoError(t, os.Symlink(real, link)) | |||
| 404 | + | tokenCmdStub(env, "ermite_cmd", nil) | |||
| 405 | + | env.configPath = func() (string, error) { return link, nil } | |||
| 406 | + | runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 407 | + | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | |||
| 408 | + | } | |||
M internal/app/cli_shell_unix.go +41 -0
| @@ -6,10 +6,51 @@ package app | |||
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | 8 | "context" | |
| 9 | + | "fmt" | |
| 10 | + | "io/fs" | |
| 11 | + | "os" | |
| 9 | 12 | "os/exec" | |
| 13 | + | "path/filepath" | |
| 14 | + | "syscall" | |
| 10 | 15 | ) | |
| 11 | 16 | ||
| 12 | 17 | // shellCommand runs line with `sh -c`. | |
| 13 | 18 | func shellCommand(ctx context.Context, line string) *exec.Cmd { | |
| 14 | 19 | return exec.CommandContext(ctx, "sh", "-c", line) | |
| 15 | 20 | } | |
| 21 | + | ||
| 22 | + | // openConfig opens the config file without blocking on a FIFO swapped in for it. | |
| 23 | + | func openConfig(path string) (*os.File, error) { | |
| 24 | + | return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // checkPrivate says whether token.cmd may be run from the config file at | |
| 28 | + | // path, whose open handle reported info: whoever can change the file can make | |
| 29 | + | // token.cmd any command. It returns nil when the file belongs to the current | |
| 30 | + | // user and cannot be written by group or others, and when its directory | |
| 31 | + | // belongs to the current user or root and cannot be written by group or | |
| 32 | + | // others (a sticky directory excepted), as they could replace the file. | |
| 33 | + | func checkPrivate(path string, info fs.FileInfo) error { | |
| 34 | + | euid := uint32(os.Geteuid()) | |
| 35 | + | if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Uid != euid { | |
| 36 | + | return fmt.Errorf("config file %s is owned by another user: token.cmd not run", path) | |
| 37 | + | } | |
| 38 | + | if info.Mode().Perm()&0o022 != 0 { | |
| 39 | + | return fmt.Errorf("config file %s is writable by others: token.cmd not run", path) | |
| 40 | + | } | |
| 41 | + | dir := filepath.Dir(path) | |
| 42 | + | if resolved, err := filepath.EvalSymlinks(path); err == nil { | |
| 43 | + | dir = filepath.Dir(resolved) // a symlinked dotfile: the directory of the target | |
| 44 | + | } | |
| 45 | + | di, err := os.Stat(dir) | |
| 46 | + | if err != nil { | |
| 47 | + | return fmt.Errorf("config directory %s: %w: token.cmd not run", dir, err) | |
| 48 | + | } | |
| 49 | + | if st, ok := di.Sys().(*syscall.Stat_t); ok && st.Uid != euid && st.Uid != 0 { | |
| 50 | + | return fmt.Errorf("config directory %s is owned by another user: token.cmd not run", dir) | |
| 51 | + | } | |
| 52 | + | if di.Mode().Perm()&0o022 != 0 && di.Mode()&fs.ModeSticky == 0 { | |
| 53 | + | return fmt.Errorf("config directory %s is writable by others: token.cmd not run", dir) | |
| 54 | + | } | |
| 55 | + | return nil | |
| 56 | + | } | |
| @@ -6,10 +6,51 @@ package app | |||||
| 6 | 6 | ||||
| 7 | import ( | 7 | import ( | ||
| 8 | "context" | 8 | "context" | ||
| 9 | + | "fmt" | |||
| 10 | + | "io/fs" | |||
| 11 | + | "os" | |||
| 9 | "os/exec" | 12 | "os/exec" | ||
| 13 | + | "path/filepath" | |||
| 14 | + | "syscall" | |||
| 10 | ) | 15 | ) | ||
| 11 | 16 | ||||
| 12 | // shellCommand runs line with `sh -c`. | 17 | // shellCommand runs line with `sh -c`. | ||
| 13 | func shellCommand(ctx context.Context, line string) *exec.Cmd { | 18 | func shellCommand(ctx context.Context, line string) *exec.Cmd { | ||
| 14 | return exec.CommandContext(ctx, "sh", "-c", line) | 19 | return exec.CommandContext(ctx, "sh", "-c", line) | ||
| 15 | } | 20 | } | ||
| 21 | + | ||||
| 22 | + | // openConfig opens the config file without blocking on a FIFO swapped in for it. | |||
| 23 | + | func openConfig(path string) (*os.File, error) { | |||
| 24 | + | return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // checkPrivate says whether token.cmd may be run from the config file at | |||
| 28 | + | // path, whose open handle reported info: whoever can change the file can make | |||
| 29 | + | // token.cmd any command. It returns nil when the file belongs to the current | |||
| 30 | + | // user and cannot be written by group or others, and when its directory | |||
| 31 | + | // belongs to the current user or root and cannot be written by group or | |||
| 32 | + | // others (a sticky directory excepted), as they could replace the file. | |||
| 33 | + | func checkPrivate(path string, info fs.FileInfo) error { | |||
| 34 | + | euid := uint32(os.Geteuid()) | |||
| 35 | + | if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Uid != euid { | |||
| 36 | + | return fmt.Errorf("config file %s is owned by another user: token.cmd not run", path) | |||
| 37 | + | } | |||
| 38 | + | if info.Mode().Perm()&0o022 != 0 { | |||
| 39 | + | return fmt.Errorf("config file %s is writable by others: token.cmd not run", path) | |||
| 40 | + | } | |||
| 41 | + | dir := filepath.Dir(path) | |||
| 42 | + | if resolved, err := filepath.EvalSymlinks(path); err == nil { | |||
| 43 | + | dir = filepath.Dir(resolved) // a symlinked dotfile: the directory of the target | |||
| 44 | + | } | |||
| 45 | + | di, err := os.Stat(dir) | |||
| 46 | + | if err != nil { | |||
| 47 | + | return fmt.Errorf("config directory %s: %w: token.cmd not run", dir, err) | |||
| 48 | + | } | |||
| 49 | + | if st, ok := di.Sys().(*syscall.Stat_t); ok && st.Uid != euid && st.Uid != 0 { | |||
| 50 | + | return fmt.Errorf("config directory %s is owned by another user: token.cmd not run", dir) | |||
| 51 | + | } | |||
| 52 | + | if di.Mode().Perm()&0o022 != 0 && di.Mode()&fs.ModeSticky == 0 { | |||
| 53 | + | return fmt.Errorf("config directory %s is writable by others: token.cmd not run", dir) | |||
| 54 | + | } | |||
| 55 | + | return nil | |||
| 56 | + | } | |||
M internal/app/cli_shell_windows.go +8 -0
| @@ -6,6 +6,8 @@ package app | |||
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | 8 | "context" | |
| 9 | + | "io/fs" | |
| 10 | + | "os" | |
| 9 | 11 | "os/exec" | |
| 10 | 12 | "syscall" | |
| 11 | 13 | ) | |
| @@ -17,3 +19,9 @@ func shellCommand(ctx context.Context, line string) *exec.Cmd { | |||
| 17 | 19 | c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line} | |
| 18 | 20 | return c | |
| 19 | 21 | } | |
| 22 | + | ||
| 23 | + | // openConfig opens the config file. | |
| 24 | + | func openConfig(path string) (*os.File, error) { return os.Open(path) } | |
| 25 | + | ||
| 26 | + | // checkPrivate: Windows has no Unix mode bits to check. | |
| 27 | + | func checkPrivate(string, fs.FileInfo) error { return nil } | |
| @@ -6,6 +6,8 @@ package app | |||||
| 6 | 6 | ||||
| 7 | import ( | 7 | import ( | ||
| 8 | "context" | 8 | "context" | ||
| 9 | + | "io/fs" | |||
| 10 | + | "os" | |||
| 9 | "os/exec" | 11 | "os/exec" | ||
| 10 | "syscall" | 12 | "syscall" | ||
| 11 | ) | 13 | ) | ||
| @@ -17,3 +19,9 @@ func shellCommand(ctx context.Context, line string) *exec.Cmd { | |||||
| 17 | c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line} | 19 | c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line} | ||
| 18 | return c | 20 | return c | ||
| 19 | } | 21 | } | ||
| 22 | + | ||||
| 23 | + | // openConfig opens the config file. | |||
| 24 | + | func openConfig(path string) (*os.File, error) { return os.Open(path) } | |||
| 25 | + | ||||
| 26 | + | // checkPrivate: Windows has no Unix mode bits to check. | |||
| 27 | + | func checkPrivate(string, fs.FileInfo) error { return nil } | |||