fix(cmd): correctness + audit hardening across cmd/ + internal/
Addresses audit findings from a tri-partite review (codex + 2 Claude agents).
Multiple distinct fixes here because they touched overlapping files; happy
to split via interactive rebase if a reviewer prefers.
## Correctness bugs (HIGH)
* `--config` is now actually honored. cmd/root.initConfig fed Viper but
every command that mattered loaded config via `internal/config.Load()`
which always read the default path. Added `config.SetExplicitConfigPath`
consulted by `GetConfigPath`; `--config other.yaml auth login` now writes
to other.yaml.
- internal/config/config.go, cmd/root.go
* `--json` now works on `fj run …`, `fj workflow …`, and `fj wiki view`.
cmd/aliases.go registered `--json` as a Bool but the handlers call
`wantJSON()` which does `GetString("json")` and silently ignores the
type-error return. cmd/wiki.go did the inverse (`GetBool("json")` against
a string-registered flag). Both now use `addJSONFlags`/`wantJSON`/
`outputJSON` consistently.
- cmd/aliases.go, cmd/wiki.go
* `fj api` no longer lets endpoints escape the /api/v1 base via
path-traversal. `fj api '/../admin/users'` previously normalized to
`/admin/users` because `http.NewRequest` resolves `..` segments —
silently sending authenticated traffic to non-API routes. Endpoint is
now parsed, `..` segments are rejected, and JoinPath is used.
- cmd/api.go
## Design rework (BREAKING — gets rid of the `--json=fields` quirk)
* `--json` flag rebuilt from a string-with-NoOptDefVal=" " sentinel into a
plain Bool. `--json-fields` keeps comma-separated projection. The two
are mutually exclusive (`MarkFlagsMutuallyExclusive`). `--jq` composes
with either or neither. The previous design produced a `--json string[=" "]`
in --help and required `--json=fields` (with literal "=") because
`--json fields` was parsed as the bare flag plus a positional. Gone.
- cmd/json.go: addJSONFlags / wantJSON / outputJSON
- cmd/api.go: example block reflects the new shape
Migration: `--json=fields` → `--json-fields fields`. Bare `--json` still
means "everything as JSON".
* `fj api` now uses `internal/api.SharedHTTPClient` (30 s timeout, pooled)
instead of constructing a zero-value `&http.Client{}` with no timeout.
A hung Forgejo no longer pins the CLI indefinitely. Response body is
also bounded by `io.LimitReader` at 64 MB to prevent OOM-on-self.
- internal/api/client.go (export SharedHTTPClient), cmd/api.go
* `--hostname` declared as a persistent flag on rootCmd is now the only
declaration. cmd/auth.go re-declared `--hostname` on three subcommands,
shadowing the persistent flag — meaning `fj --hostname=X auth login`
and `fj auth login --hostname=X` went through different code paths
(viper read vs. local flag read). Local declarations removed.
- cmd/auth.go
## Hardening (MEDIUM/LOW)
* `--token` on `auth login` now emits a stderr warning when used, since
it puts the PAT on argv (visible in `ps auxe`/shell history). Flag not
removed — too disruptive — but discoverable now.
- cmd/auth.go
* Error handling no longer regex-matches "401"/"403" against rendered
error strings (would have triggered "auth login" hint for an error
that just mentioned issue #403). Now relies on typed `*api.APIError`.
Hints moved to a separate `Hint` field on `CLIError`, so JSON-error
consumers get clean structure and the human renderer still appends
"\nHint: …".
- cmd/errors.go
* `migrateConfigDir` now opens dst with `O_TRUNC` instead of just
`O_CREATE|O_WRONLY`. Previously a partially-pre-existing dst file
would have legacy contents overwrite a prefix and leave stale tail
bytes — silent YAML/token corruption.
- cmd/root.go (extracted into copyOneConfigFile with proper close handling)
* Config dir created with mode 0700 instead of 0755. `initConfig` warns
on stderr if the resolved config file is world/group readable
(`mode & 0o077 != 0`); doesn't fail-close.
- cmd/root.go
* Network errors (`no such host`, `connection refused`, `i/o timeout`)
now return a structured `CLIError` with code `ErrNetworkError` and a
hint, instead of a fmt.Errorf chain.
- cmd/errors.go
Verified: `go build ./...` and `go test ./...` clean. Live integration
tested against forgejo.zerova.net.
Out of scope, deferred to follow-up commits:
- Pagination unification across `repo list`/`pr list`/`issue list` (only
`release list` walks pages today; others silently truncate).
- `fj api --paginate` to follow pages like `gh api --paginate`.
- De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees.
This commit is contained in:
parent
f75b831a53
commit
0c181df1d1
9 changed files with 196 additions and 94 deletions
60
cmd/root.go
60
cmd/root.go
|
|
@ -8,6 +8,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"forgejo.zerova.net/public/fj/internal/config"
|
||||
"forgejo.zerova.net/public/fj/internal/git"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
|
@ -45,7 +46,12 @@ func init() {
|
|||
|
||||
func initConfig() {
|
||||
if cfgFile != "" {
|
||||
// Tell viper to load this file for env-style overrides AND make
|
||||
// internal/config.Load()/.Save() use it (this is the load-bearing
|
||||
// half — without SetExplicitConfigPath, --config was silently
|
||||
// ignored by every auth-touching command).
|
||||
viper.SetConfigFile(cfgFile)
|
||||
config.SetExplicitConfigPath(cfgFile)
|
||||
} else {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
|
|
@ -66,7 +72,7 @@ func initConfig() {
|
|||
}
|
||||
}
|
||||
|
||||
_ = os.MkdirAll(configDir, 0755)
|
||||
_ = os.MkdirAll(configDir, 0700)
|
||||
|
||||
viper.AddConfigPath(configDir)
|
||||
viper.SetConfigType("yaml")
|
||||
|
|
@ -77,6 +83,14 @@ func initConfig() {
|
|||
viper.SetEnvPrefix("FJ")
|
||||
|
||||
_ = viper.ReadInConfig()
|
||||
|
||||
// If the resolved config exists with overly permissive mode, warn — the
|
||||
// file holds API tokens. Don't fail-close; just nudge the user.
|
||||
if path, err := config.GetConfigPath(); err == nil {
|
||||
if info, statErr := os.Stat(path); statErr == nil && info.Mode()&0o077 != 0 {
|
||||
fmt.Fprintf(ios.ErrOut, "warning: %s mode %o is world/group readable; tokens may leak. chmod 600 it.\n", path, info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseRepo parses the repository string in the format "owner/name".
|
||||
|
|
@ -143,8 +157,11 @@ func parseIssueArg(arg string) (int64, error) {
|
|||
}
|
||||
|
||||
// migrateConfigDir copies all files from src to dst (one level, no subdirs).
|
||||
// Uses O_TRUNC so a partially-pre-existing dst file is fully replaced rather
|
||||
// than having the legacy contents overwrite a prefix and leaving stale tail
|
||||
// bytes — which for a YAML token store would silently corrupt config.
|
||||
func migrateConfigDir(src, dst string) error {
|
||||
if err := os.MkdirAll(dst, 0755); err != nil {
|
||||
if err := os.MkdirAll(dst, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(src)
|
||||
|
|
@ -155,21 +172,34 @@ func migrateConfigDir(src, dst string) error {
|
|||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
in, err := os.Open(filepath.Join(src, e.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(filepath.Join(dst, e.Name()), os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
in.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, in)
|
||||
in.Close()
|
||||
out.Close()
|
||||
if err != nil {
|
||||
if err := copyOneConfigFile(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyOneConfigFile(srcPath, dstPath string) (retErr error) {
|
||||
in, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := in.Close(); retErr == nil {
|
||||
retErr = cerr
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := out.Close(); retErr == nil {
|
||||
retErr = cerr
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue