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:
sid 2026-05-02 15:41:48 -06:00
parent f75b831a53
commit 0c181df1d1
9 changed files with 196 additions and 94 deletions

View file

@ -6,15 +6,23 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/git"
"github.com/spf13/cobra"
)
// maxAPIResponseBytes caps response bodies for `fj api`. Forgejo responses
// are normally <1 MB; 64 MB is enough for any sane payload while preventing
// a runaway body from OOMing the CLI when combined with the 30 s client
// timeout.
const maxAPIResponseBytes = 64 << 20
var apiCmd = &cobra.Command{
Use: "api <endpoint> [flags]",
Short: "Make an authenticated API request",
@ -40,11 +48,7 @@ If --field is used and no --method is specified, the method defaults to POST.`,
# Filter the response with a jq expression
fj api /repos/{owner}/{repo}/issues --jq '.[].title'
# Project the response down to specific fields (requires "=" because --json
# also accepts an empty value to mean "all fields as JSON")
fj api /repos/{owner}/{repo} --json=full_name,description,private
# Same projection without the "=" quirk
# Project the response down to specific fields
fj api /repos/{owner}/{repo} --json-fields full_name,description,private`,
Args: cobra.ExactArgs(1),
RunE: runAPI,
@ -150,15 +154,28 @@ func runAPI(cmd *cobra.Command, args []string) error {
body = bytes.NewReader(bodyBytes)
}
// Build URL
baseURL := "https://" + host.Hostname + "/api/v1"
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
// Build the request URL safely. Naive concatenation lets endpoints like
// "/../admin/users" escape the /api/v1 base via Go's URL normalization
// of `..` segments — silently sending authenticated traffic to non-API
// paths. Parse the endpoint, reject `..`, then JoinPath onto the base.
endpointURL, err := url.Parse(endpoint)
if err != nil {
return fmt.Errorf("invalid endpoint %q: %w", endpoint, err)
}
url := baseURL + endpoint
if endpointURL.Scheme != "" || endpointURL.Host != "" {
return fmt.Errorf("endpoint must be a path, not a full URL: %s", endpoint)
}
for _, seg := range strings.Split(strings.Trim(endpointURL.Path, "/"), "/") {
if seg == ".." {
return fmt.Errorf("endpoint contains forbidden '..' segment: %s", endpoint)
}
}
base := &url.URL{Scheme: "https", Host: host.Hostname, Path: "/api/v1"}
final := base.JoinPath(endpointURL.Path)
final.RawQuery = endpointURL.RawQuery
// Create HTTP request
req, err := http.NewRequest(method, url, body)
req, err := http.NewRequest(method, final.String(), body)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
@ -181,10 +198,11 @@ func runAPI(cmd *cobra.Command, args []string) error {
req.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
}
// Execute request
// Execute request via the shared client (30 s timeout, pooled
// connections). Previous zero-value http.Client{} had no timeout, which
// pinned the CLI on a hung Forgejo indefinitely.
ios.StartSpinner("Requesting...")
httpClient := &http.Client{}
resp, err := httpClient.Do(req)
resp, err := api.SharedHTTPClient.Do(req)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to perform request: %w", err)
@ -202,11 +220,15 @@ func runAPI(cmd *cobra.Command, args []string) error {
fmt.Fprintln(ios.Out)
}
// Read response body
respBody, err := io.ReadAll(resp.Body)
// Read response body with a hard ceiling so a runaway upstream can't OOM
// the CLI. Read maxAPIResponseBytes+1 to detect overflow.
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes+1))
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if int64(len(respBody)) > maxAPIResponseBytes {
return fmt.Errorf("response body exceeded %d bytes (use a different tool for bulk transfers)", maxAPIResponseBytes)
}
// Handle non-2xx status codes
if resp.StatusCode < 200 || resp.StatusCode >= 300 {