detached aca5d0c

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
3838 // runTokenCmd runs the token.cmd command line and returns its raw standard
3939 // output; nil means running it with the system shell. Tests replace it.
4040 runTokenCmd func(ctx context.Context, cmd string) (string, error)
41+ // tokenTimeout bounds the default runTokenCmd; zero means 60 s.
42+ tokenTimeout time.Duration
4143
4244 // The config file is read at most once and token.cmd run at most once per
4345 // process, and only when a command needs them (see cli_configfile.go).
4446 fileOnce sync.Once
4547 file *cliFile
4648 filePath string
49+ fileTrust error // why token.cmd must not run from this file, if so
4750 fileErr error
4851 tokenOnce sync.Once
4952 token string
@@ -38,12 +38,15 @@ type cliEnv struct {
38 // runTokenCmd runs the token.cmd command line and returns its raw standard38 // 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 per44 // 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.Once46 fileOnce sync.Once
45 file *cliFile47 file *cliFile
46 filePath string48 filePath string
49+ fileTrust error // why token.cmd must not run from this file, if so
47 fileErr error50 fileErr error
48 tokenOnce sync.Once51 tokenOnce sync.Once
49 token string52 token string
M internal/app/cli_configfile.go +79 -28
@@ -8,9 +8,9 @@ import (
88 "errors"
99 "fmt"
1010 "io"
11+ "io/fs"
1112 "os"
1213 "os/exec"
13- "runtime"
1414 "strings"
1515 "time"
1616
@@ -57,7 +57,12 @@ func (e *cliEnv) path() (string, error) {
5757
5858 // readFile reads the config file, once per process. A missing file, or no
5959 // 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.
6166 func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, error) {
6267 e.fileOnce.Do(func() {
6368 p, err := e.path()
@@ -68,11 +73,12 @@ func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, erro
6873 return
6974 }
7075 e.filePath = p
71- f, warnings, err := kvconf.Load[cliFile](p, kvconf.Options{})
76+ f, warnings, trust, err := loadConfigFile(p)
7277 if err != nil {
7378 e.fileErr = fmt.Errorf("config: %w", err)
7479 return
7580 }
81+ e.fileTrust = trust
7682 if warn {
7783 for _, w := range warnings {
7884 warnf(cmd, "config: %s", w)
@@ -83,6 +89,40 @@ func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, erro
8389 return e.file, e.filePath, e.fileErr
8490 }
8591
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+
86126 // resolveURL is ERMITE_URL if set, else the url of the config file, else "".
87127 func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) {
88128 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
112152 // the command fetches belongs to the server of the file, and must not be sent
113153 // to another one.
114154 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)
116156 if err != nil {
117157 return "", err
118158 }
@@ -127,7 +167,7 @@ func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) {
127167 }
128168 return
129169 }
130- e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), path, line)
170+ e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), line)
131171 })
132172 return e.token, e.tokenErr
133173 }
@@ -149,13 +189,13 @@ func (e *cliEnv) urlIsFromFile(f *cliFile) bool {
149189
150190 // fetchToken runs token.cmd. Neither its output nor its error text reach the
151191 // 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
155195 }
156196 run := e.runTokenCmd
157197 if run == nil {
158- run = runShell
198+ run = e.shell
159199 }
160200 out, err := run(ctx, line)
161201 if err != nil {
@@ -171,37 +211,37 @@ func (e *cliEnv) fetchToken(ctx context.Context, path, line string) (string, err
171211 return out, nil
172212 }
173213
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
183219 }
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)
188221 }
189222
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
192226 // 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)
195230 defer cancel()
196231 c := shellCommand(ctx, line)
197232 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
199237 // Do not wait forever for a background process that kept the pipe open.
200238 c.WaitDelay = 2 * time.Second
201239 err := c.Run()
202240 switch {
241+ case parent.Err() != nil:
242+ return "", errors.New("interrupted")
203243 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)
205245 case err != nil:
206246 var exit *exec.ExitError
207247 if errors.As(err, &exit) {
@@ -214,6 +254,17 @@ func runShell(ctx context.Context, line string) (string, error) {
214254 return out.buf.String(), nil
215255 }
216256
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+
217268 // cappedBuffer keeps the first max bytes written and notes an overflow.
218269 type cappedBuffer struct {
219270 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 no58 // 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 warnings59 // 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 return73 return
69 }74 }
70 e.filePath = p75 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 return79 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.fileErr89 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 sent152 // 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 "", err157 return "", err
118 }158 }
@@ -127,7 +167,7 @@ func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) {
127 }167 }
128 return168 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.tokenErr172 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 the190 // 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 "", err194+ return "", e.fileTrust
155 }195 }
156 run := e.runTokenCmd196 run := e.runTokenCmd
157 if run == nil {197 if run == nil {
158- run = runShell198+ 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, nil211 return out, nil
172 }212 }
173 213
174-// requirePrivate refuses a config file other users can write: they could214+// 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 nil218+ 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 the223+// 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 is224+// 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.Stderr233+ 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.Second238 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.ExitError246 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(), nil254 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.Buffer270 buf bytes.Buffer
M internal/app/cli_configfile_test.go +87 -3
@@ -10,6 +10,7 @@ import (
1010 "runtime"
1111 "strings"
1212 "testing"
13+ "time"
1314
1415 "github.com/stretchr/testify/assert"
1516 "github.com/stretchr/testify/require"
@@ -308,17 +309,100 @@ func TestRunShellReportsFailuresWithoutOutput(t *testing.T) {
308309 if runtime.GOOS == "windows" {
309310 t.Skip("uses sh")
310311 }
311- _, err := runShell(t.Context(), "echo s3cret; exit 3")
312+ _, err := runShell(t.Context(), "echo s3cret; exit 3", time.Minute)
312313 require.Error(t, err)
313314 assert.Contains(t, err.Error(), "exit status 3")
314315 assert.NotContains(t, err.Error(), "s3cret")
315316
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)
317318 require.Error(t, err)
318319 assert.Contains(t, err.Error(), "more than 4096 bytes")
319320 assert.NotContains(t, err.Error(), "aaa")
320321
321- out, err := runShell(t.Context(), "echo ermite_ok")
322+ out, err := runShell(t.Context(), "echo ermite_ok", time.Minute)
322323 require.NoError(t, err)
323324 assert.Equal(t, "ermite_ok\n", out)
324325 }
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
66
77 import (
88 "context"
9+ "fmt"
10+ "io/fs"
11+ "os"
912 "os/exec"
13+ "path/filepath"
14+ "syscall"
1015 )
1116
1217 // shellCommand runs line with `sh -c`.
1318 func shellCommand(ctx context.Context, line string) *exec.Cmd {
1419 return exec.CommandContext(ctx, "sh", "-c", line)
1520 }
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
66
77 import (
88 "context"
9+ "io/fs"
10+ "os"
911 "os/exec"
1012 "syscall"
1113 )
@@ -17,3 +19,9 @@ func shellCommand(ctx context.Context, line string) *exec.Cmd {
1719 c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line}
1820 return c
1921 }
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 c20 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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close