From 2556b6fd3d451d8491f9abb932f66a786f23a17d Mon Sep 17 00:00:00 2001 From: Reto Gantenbein Date: Sat, 16 May 2026 19:06:12 +0200 Subject: [PATCH 1/6] cmd: refactor default-args injection to avoid mutating os.Args Replace the injectServeDefault() shim that prepended "serve" to os.Args with a pure defaultArgs() function and cobra.SetArgs(), eliminating global state mutation. Co-Authored-By: Claude Sonnet 4.6 --- cmd/root.go | 16 +++++++----- cmd/root_test.go | 32 +++++++++-------------- openspec/specs/default-subcommand/spec.md | 2 +- 3 files changed, 23 insertions(+), 27 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 2aab159..9c7cab5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -51,12 +51,13 @@ Complete documentation is available at https://github.com/ganto/pkgproxy`, const koDataPathEnvVar = "KO_DATA_PATH" -// injectServeDefault prepends "serve" to os.Args when the binary is called -// with no arguments, making the container image work without an explicit subcommand. -func injectServeDefault() { - if len(os.Args) == 1 { - os.Args = append([]string{os.Args[0], "serve"}, os.Args[1:]...) +// defaultArgs returns the cobra argument list for the current invocation, +// substituting "serve" when the binary was invoked with no user-supplied arguments. +func defaultArgs(osArgs []string) []string { + if len(osArgs) <= 1 { + return []string{"serve"} } + return osArgs[1:] } // resolveConfigPath returns the config file path to use when neither --config @@ -115,8 +116,9 @@ func initConfig() error { // Execute starts the command func Execute() { - injectServeDefault() - if err := NewRootCommand().Execute(); err != nil { + c := NewRootCommand() + c.SetArgs(defaultArgs(os.Args)) + if err := c.Execute(); err != nil { os.Exit(1) } } diff --git a/cmd/root_test.go b/cmd/root_test.go index 6bbbb3a..d3823f3 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -3,53 +3,47 @@ package cmd import ( - "os" "testing" "github.com/stretchr/testify/assert" ) -func TestInjectServeDefault(t *testing.T) { +func TestDefaultArgs(t *testing.T) { tests := []struct { name string - args []string + osArgs []string wantArgs []string }{ { name: "zero args → serve inserted", - args: []string{"pkgproxy"}, - wantArgs: []string{"pkgproxy", "serve"}, + osArgs: []string{"pkgproxy"}, + wantArgs: []string{"serve"}, }, { name: "--help → unchanged", - args: []string{"pkgproxy", "--help"}, - wantArgs: []string{"pkgproxy", "--help"}, + osArgs: []string{"pkgproxy", "--help"}, + wantArgs: []string{"--help"}, }, { name: "version → unchanged", - args: []string{"pkgproxy", "version"}, - wantArgs: []string{"pkgproxy", "version"}, + osArgs: []string{"pkgproxy", "version"}, + wantArgs: []string{"version"}, }, { name: "explicit serve → unchanged", - args: []string{"pkgproxy", "serve"}, - wantArgs: []string{"pkgproxy", "serve"}, + osArgs: []string{"pkgproxy", "serve"}, + wantArgs: []string{"serve"}, }, { name: "bare flag → unchanged", - args: []string{"pkgproxy", "--debug"}, - wantArgs: []string{"pkgproxy", "--debug"}, + osArgs: []string{"pkgproxy", "--debug"}, + wantArgs: []string{"--debug"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - original := os.Args - t.Cleanup(func() { os.Args = original }) - - os.Args = tt.args - injectServeDefault() - assert.Equal(t, tt.wantArgs, os.Args) + assert.Equal(t, tt.wantArgs, defaultArgs(tt.osArgs)) }) } } diff --git a/openspec/specs/default-subcommand/spec.md b/openspec/specs/default-subcommand/spec.md index fd1dd08..0444444 100644 --- a/openspec/specs/default-subcommand/spec.md +++ b/openspec/specs/default-subcommand/spec.md @@ -1,7 +1,7 @@ ## ADDED Requirements ### Requirement: `serve` is dispatched when the binary is invoked with no arguments -The pkgproxy CLI SHALL dispatch the `serve` subcommand when the binary is invoked with no user-supplied arguments (i.e. `len(os.Args) == 1`). The dispatch SHALL be implemented as a pre-Cobra shim that prepends the literal string `"serve"` to `os.Args` before `cobra.Command.Execute()` is called. Any other invocation form SHALL be passed to Cobra unchanged; in particular, invocations that include at least one argument (whether a subcommand, a flag, or a positional value) SHALL retain their current behavior, including the `MinimumNArgs(1)` error when a flag appears without a subcommand. +When the binary is invoked with no user-supplied arguments (i.e. `len(os.Args) == 1`), the CLI SHALL pass the literal argument list `["serve"]` to Cobra so that the `serve` subcommand is dispatched as if it had been typed on the command line. The process-global `os.Args` SHALL NOT be mutated. Any other invocation form SHALL be passed to Cobra unchanged; in particular, invocations that include at least one argument (whether a subcommand, a flag, or a positional value) SHALL retain their current behavior, including the `MinimumNArgs(1)` error when a flag appears without a subcommand. #### Scenario: Container start with no arguments runs serve - **WHEN** the binary is executed with `os.Args == ["pkgproxy"]` (e.g. `podman run ghcr.io/ganto/pkgproxy`) From 8b1ac285f3be6105ee200b206e62bd3819a727b6 Mon Sep 17 00:00:00 2001 From: Reto Gantenbein Date: Sat, 16 May 2026 20:03:35 +0200 Subject: [PATCH 2/6] openspec: Add 'xff-trust-opt-in' change spec --- .../changes/xff-trust-opt-in/.openspec.yaml | 2 + openspec/changes/xff-trust-opt-in/design.md | 72 +++++++++++ openspec/changes/xff-trust-opt-in/proposal.md | 29 +++++ .../specs/trusted-proxies-config/spec.md | 118 ++++++++++++++++++ openspec/changes/xff-trust-opt-in/tasks.md | 56 +++++++++ 5 files changed, 277 insertions(+) create mode 100644 openspec/changes/xff-trust-opt-in/.openspec.yaml create mode 100644 openspec/changes/xff-trust-opt-in/design.md create mode 100644 openspec/changes/xff-trust-opt-in/proposal.md create mode 100644 openspec/changes/xff-trust-opt-in/specs/trusted-proxies-config/spec.md create mode 100644 openspec/changes/xff-trust-opt-in/tasks.md diff --git a/openspec/changes/xff-trust-opt-in/.openspec.yaml b/openspec/changes/xff-trust-opt-in/.openspec.yaml new file mode 100644 index 0000000..ab7f13b --- /dev/null +++ b/openspec/changes/xff-trust-opt-in/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-16 diff --git a/openspec/changes/xff-trust-opt-in/design.md b/openspec/changes/xff-trust-opt-in/design.md new file mode 100644 index 0000000..73264e8 --- /dev/null +++ b/openspec/changes/xff-trust-opt-in/design.md @@ -0,0 +1,72 @@ +## Context + +`cmd/serve.go:93` installs `echo.ExtractIPFromXFFHeader()` with no `TrustOption` arguments. Echo v5.1.1's defaults then trust loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), and all RFC1918/RFC4193 private ranges. In container bridge deployments the direct connecting peer is the bridge gateway (e.g. `172.17.0.1`) — inside the trusted private range — so any client can set `X-Forwarded-For` to an arbitrary IP that `c.RealIP()` at `cmd/serve.go:123` will reflect in the `remote_ip` access-log field. + +`cmd/serve.go` already follows a hand-rolled flag → env-var → default precedence pattern for `--host` and `--public-host`, with no viper dependency. Echo v5.1.1 exports the full API surface needed: `TrustLoopback(bool)`, `TrustPrivateNet(bool)`, `TrustIPRange(*net.IPNet)`, `ExtractIPFromXFFHeader(...TrustOption)`, and `ExtractIPDirect()`. + +## Goals / Non-Goals + +**Goals:** +- Replace implicit echo XFF trust with an explicit, operator-controlled opt-in. +- Default to no XFF trust (`ExtractIPDirect`) — the server's behavior is safe out of the box. +- Provide ergonomic keyword shortcuts (`loopback`, `private`) for the two most common deployment topologies. +- Fail startup fast with a clear, actionable error on misconfiguration. +- Remain consistent with the existing flag/env-var/resolver pattern in `cmd/serve.go`. + +**Non-Goals:** +- `X-Real-IP` header support (separate concern; not requested). +- Automatic trust defaulting based on the listen address value. +- Per-repository or per-path trust configuration. +- A `linklocal` keyword (link-local addresses are not a real HTTP service-connection scenario for pkgproxy). +- Any change to cache, mirror selection, or forwarding logic. + +## Decisions + +### 1. Strict-by-default (no XFF trust unless configured) + +**Chosen:** When `--trust-proxy` is unset or evaluates to empty/`none`, install `echo.ExtractIPDirect()`. Echo's implicit defaults are never applied. + +**Alternatives considered:** +- *Infer trust from `--host`*: loopback trust when `--host=localhost`; private trust for all other bind addresses; no trust for public-IP binds. Rejected — coupling two orthogonal flags makes the contract surprising (changing `--host` silently changes trust), and `0.0.0.0` is ambiguous (binds everything but is the common container default). +- *Keep echo's defaults, add the flag as an override*: existing deployments keep working, new operators learn they can tighten trust. Rejected — the current default is the bug; a non-breaking fix leaves the original problem in place indefinitely. + +### 2. Keyword tokens alongside literal CIDRs/IPs + +**Chosen:** Accept `loopback` (→ `echo.TrustLoopback(true)`) and `private` (→ `echo.TrustPrivateNet(true)`) as convenience keywords. Literal CIDRs and bare IPs (promoted to `/32`/`/128`) are accepted alongside or instead. + +**Rationale:** The two most common reverse-proxy topologies (same-host, LAN) map exactly to `loopback` and `private`. Without keywords, operators must either look up the RFC1918 ranges or supply `127.0.0.0/8,::1/128`. Keywords reduce friction while preserving the option of tighter control via exact CIDRs. `linklocal` is omitted because IPv4 169.254/16 and IPv6 fe80::/10 do not arise in normal service-to-service communication. + +### 3. `StringVar` (comma-separated), not `StringSliceVar` + +**Chosen:** A single `StringVar` flag whose value is comma-separated. + +**Rationale:** Consistent with `--host`, `--public-host`, and other flags in this codebase. `StringSliceVar` introduces subtle cobra behavior (shell splitting, quoting) and its env-var representation must still be a delimited string, so it adds complexity without benefit. The custom parser trims whitespace and handles both `a,b` and `a, b` forms. + +### 4. Package-level `ipExtractor` var set in `PersistentPreRunE` + +**Chosen:** Resolve and parse `--trust-proxy` inside `PersistentPreRunE` (where `listenAddress` and `initConfig` are also resolved), storing the resulting `echo.IPExtractor` in a package-level variable consumed by `startServer`. + +**Rationale:** Matches the pattern used for `listenAddress`. Keeps `startServer` free of resolution logic and makes the resolver and parser individually testable without running the full command. + +### 5. `none` as explicit no-trust keyword; empty value treated as `none` + +**Chosen:** `--trust-proxy=none` installs `ExtractIPDirect`, identical to unset. Empty string (unset flag, empty env var) has the same effect. Mixing `none` with any other entry is a startup error. + +**Rationale:** An explicit `none` lets operators document intent in deployment config rather than relying on an absent flag. Treating empty as `none` prevents accidents from an env var that is set but blank in shell scripts. + +## Risks / Trade-offs + +- **Breaking behavior change** → Mitigated by a prominent `CHANGELOG.md` entry and a startup log line showing the resolved trust mode. Operators behind a reverse proxy need one-time migration (`PKGPROXY_TRUST_PROXY=private` or a specific CIDR). +- **`private` keyword retains the original container-bridge risk** → By design; the operator who sets `private` accepts the lenient posture. The key improvement is that this is now an explicit choice rather than a silent default. +- **Bare IP promotion could surprise users** → `10.0.0.5` becomes `10.0.0.5/32`; trusting only that exact host may be unexpected if the operator meant a subnet. Startup log shows the resolved mode string, which helps diagnose. + +## Migration Plan + +| Deployment topology | Required action | +|---------------------|-----------------| +| No reverse proxy | None — default (no XFF) is correct. | +| Same-host reverse proxy (nginx/caddy on localhost) | Add `PKGPROXY_TRUST_PROXY=loopback`. | +| LAN reverse proxy (different host, private network) | Add `PKGPROXY_TRUST_PROXY=/32` (preferred) or `PKGPROXY_TRUST_PROXY=private` (lenient). | +| Container, no reverse proxy | None — default prevents any XFF spoofing. | + +Rollback: revert the flag definition and IPExtractor wiring in `cmd/serve.go`; echo's defaults re-apply. No data migration involved. diff --git a/openspec/changes/xff-trust-opt-in/proposal.md b/openspec/changes/xff-trust-opt-in/proposal.md new file mode 100644 index 0000000..ba880bd --- /dev/null +++ b/openspec/changes/xff-trust-opt-in/proposal.md @@ -0,0 +1,29 @@ +## Why + +`cmd/serve.go` installs `echo.ExtractIPFromXFFHeader()` with no trust options, so echo trusts loopback, link-local, and all RFC1918/RFC4193 private ranges by default. In a typical container deployment (`podman run -p 8080:8080`), every external request arrives via the bridge gateway in private address space — meaning any client can inject an `X-Forwarded-For` header and have its value reflected in the `remote_ip` access-log field. The impact today is log-integrity only, but the implicit trust should be replaced with an explicit, opt-in mechanism before `RealIP()` ever feeds an authorization decision. + +## What Changes + +- Add a `--trust-proxy` CLI flag on the `serve` subcommand with a `PKGPROXY_TRUST_PROXY` environment variable fallback. +- The flag accepts a comma-separated list whose entries are one of: `none`, `loopback`, `private`, a CIDR (e.g. `10.0.0.0/8`), or a bare IP (auto-promoted to `/32` for IPv4 or `/128` for IPv6). +- When the flag is unset, empty, or set to `none`, the server SHALL install `echo.ExtractIPDirect()` — the `X-Forwarded-For` header is ignored entirely. +- When the flag carries any other valid value, the server SHALL install `echo.ExtractIPFromXFFHeader(...)` configured with **only** the operator-supplied trust options. Echo's implicit defaults (loopback / link-local / private) SHALL NOT apply. +- Mixing `none` with any other entry SHALL be an error at startup. +- Invalid entries (unrecognized keyword, malformed CIDR, unparseable IP) SHALL fail startup with a clear error naming the offending token. +- The resolved trust mode SHALL be logged once at startup so operators can confirm what was applied. +- **BREAKING**: Deployments that today rely on echo's implicit private-net trust to populate `remote_ip` from `X-Forwarded-For` will see that field switch to the direct connecting peer until they set `PKGPROXY_TRUST_PROXY` (commonly `private` for LAN reverse proxies, `loopback` for same-host reverse proxies, or a specific CIDR for tightest control). + +## Capabilities + +### New Capabilities +- `trusted-proxies-config`: Resolution and parsing of the `--trust-proxy` flag / `PKGPROXY_TRUST_PROXY` env var, and selection of the corresponding `echo.IPExtractor` for the HTTP server. + +### Modified Capabilities +- _(none — no existing spec covers XFF/IPExtractor behavior; this is a purely additive capability.)_ + +## Impact + +- **Code**: `cmd/serve.go` (flag registration, resolver, parser, `IPExtractor` wiring, startup log line), `cmd/serve_test.go` (table-driven tests for the resolver and parser). +- **Docs**: `README.md` (new row in the CLI flags table plus a short subsection explaining the keywords and common recipes), `CHANGELOG.md` (Unreleased entry calling out both the new flag and the default-behavior change). +- **Runtime behavior**: Default access-log `remote_ip` field changes for operators behind a reverse proxy until they opt in. No effect on cache, mirror selection, or forwarding logic. +- **Dependencies**: No new third-party dependencies; uses `echo.TrustLoopback`, `echo.TrustPrivateNet`, and `echo.TrustIPRange` already exported by `github.com/labstack/echo/v5` v5.1.1. diff --git a/openspec/changes/xff-trust-opt-in/specs/trusted-proxies-config/spec.md b/openspec/changes/xff-trust-opt-in/specs/trusted-proxies-config/spec.md new file mode 100644 index 0000000..864a234 --- /dev/null +++ b/openspec/changes/xff-trust-opt-in/specs/trusted-proxies-config/spec.md @@ -0,0 +1,118 @@ +## ADDED Requirements + +### Requirement: Trust proxy is resolved from flag, then env var, then empty default +The `serve` subcommand SHALL resolve the trust-proxy value using the following ordered precedence: + +1. The value of `--trust-proxy` when the user explicitly passed the flag on the command line (detected via Cobra's `cmd.Flag("trust-proxy").Changed` returning `true`). +2. The value of the `PKGPROXY_TRUST_PROXY` environment variable when it is set to a non-empty string. +3. The built-in default: empty string (no trust). + +An empty `PKGPROXY_TRUST_PROXY` (set but empty, or unset) SHALL be treated as "no env-var input" and SHALL fall through to step 3. + +#### Scenario: Explicit flag overrides env var +- **WHEN** the binary is started with `serve --trust-proxy=loopback` and `PKGPROXY_TRUST_PROXY=private` is set +- **THEN** only loopback addresses SHALL be trusted for X-Forwarded-For + +#### Scenario: Env var used when flag is absent +- **WHEN** the binary is started with `serve` (no `--trust-proxy`) and `PKGPROXY_TRUST_PROXY=private` is set +- **THEN** RFC1918 and RFC4193 private ranges SHALL be trusted for X-Forwarded-For + +#### Scenario: Empty env var falls through to default +- **WHEN** the binary is started with `serve` (no `--trust-proxy`) and `PKGPROXY_TRUST_PROXY=` (set but empty) +- **THEN** X-Forwarded-For SHALL be ignored (direct IP extraction) + +#### Scenario: Neither flag nor env var produces no-trust default +- **WHEN** the binary is started with `serve` (no `--trust-proxy`) and `PKGPROXY_TRUST_PROXY` is unset +- **THEN** X-Forwarded-For SHALL be ignored (direct IP extraction) + +### Requirement: XFF trust is disabled by default +When `--trust-proxy` resolves to an empty string or the value `none`, the server SHALL install `echo.ExtractIPDirect()` as the IP extractor. The `X-Forwarded-For` header SHALL be ignored entirely; `c.RealIP()` SHALL return the direct network peer's IP address. + +#### Scenario: Default behavior ignores XFF +- **WHEN** `--trust-proxy` is unset and a request arrives with `X-Forwarded-For: 1.2.3.4` from `127.0.0.1` +- **THEN** the access-log `remote_ip` field SHALL be `127.0.0.1` + +#### Scenario: Explicit `none` also disables XFF +- **WHEN** `--trust-proxy=none` and a request arrives with `X-Forwarded-For: 1.2.3.4` from `127.0.0.1` +- **THEN** the access-log `remote_ip` field SHALL be `127.0.0.1` + +### Requirement: `loopback` keyword trusts loopback addresses +When the resolved value contains the entry `loopback`, the server SHALL apply `echo.TrustLoopback(true)` so that `X-Forwarded-For` headers arriving from `127.0.0.0/8` and `::1` are honored. + +#### Scenario: Loopback source with XFF surfaced +- **WHEN** `--trust-proxy=loopback` and a request arrives from `127.0.0.1` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `203.0.113.5` + +#### Scenario: Non-loopback source not trusted for XFF +- **WHEN** `--trust-proxy=loopback` and a request arrives from `10.0.0.1` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `10.0.0.1` (the direct peer, not the XFF value) + +### Requirement: `private` keyword trusts RFC1918 and RFC4193 private ranges +When the resolved value contains the entry `private`, the server SHALL apply `echo.TrustPrivateNet(true)` so that `X-Forwarded-For` headers arriving from RFC1918 (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) and RFC4193 (`fc00::/7`) ranges are honored. Loopback addresses are NOT implied by `private` alone. + +#### Scenario: Private-range source with XFF surfaced +- **WHEN** `--trust-proxy=private` and a request arrives from `10.0.0.1` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `203.0.113.5` + +#### Scenario: Loopback source not trusted with private-only setting +- **WHEN** `--trust-proxy=private` and a request arrives from `127.0.0.1` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `127.0.0.1` (loopback not in private ranges) + +### Requirement: Literal CIDR or bare IP trusts exactly that range +When the resolved value contains a CIDR notation entry (e.g. `10.0.0.5/32`, `192.168.0.0/24`) or a bare IP address (auto-promoted to `/32` for IPv4, `/128` for IPv6), the server SHALL apply `echo.TrustIPRange(...)` for that range only. + +#### Scenario: Exact-host CIDR trusts only that host +- **WHEN** `--trust-proxy=10.0.0.5/32` and a request arrives from `10.0.0.5` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `203.0.113.5` + +#### Scenario: Sibling in the subnet not trusted if not in the CIDR +- **WHEN** `--trust-proxy=10.0.0.5/32` and a request arrives from `10.0.0.6` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `10.0.0.6` + +#### Scenario: Bare IP is promoted to single-host CIDR +- **WHEN** `--trust-proxy=192.168.1.10` (no mask) and a request arrives from `192.168.1.10` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `203.0.113.5` + +#### Scenario: Bare IPv6 is promoted to /128 +- **WHEN** `--trust-proxy=::1` (no mask) and a request arrives from `::1` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `203.0.113.5` + +### Requirement: Multiple entries may be combined (comma-separated) +The value of `--trust-proxy` SHALL be split on commas; whitespace around each entry SHALL be trimmed; each entry is evaluated independently. Keywords and literal CIDRs/IPs may be mixed freely, provided `none` is not present. + +#### Scenario: Keyword and CIDR combined +- **WHEN** `--trust-proxy=loopback,10.0.0.0/8` and a request arrives from `10.5.5.5` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `203.0.113.5` + +#### Scenario: Whitespace around entries is tolerated +- **WHEN** `--trust-proxy= loopback , 10.0.0.0/8 ` (spaces around commas) +- **THEN** the server SHALL start without error + +### Requirement: `none` may not be combined with other entries +When the resolved value contains `none` alongside any other non-empty entry, the server SHALL fail at startup with an error stating that `none` cannot be combined with other trust entries. + +#### Scenario: `none` combined with keyword causes startup failure +- **WHEN** `PKGPROXY_TRUST_PROXY=none,loopback` +- **THEN** the server SHALL exit before accepting connections with a non-zero status and an error message referencing `none` + +### Requirement: Unrecognized or malformed entries cause startup failure +Any entry that is not a recognized keyword (`none`, `loopback`, `private`), a valid CIDR, or a parseable IP address SHALL cause the server to fail at startup. The error message SHALL name the offending token. + +#### Scenario: Unrecognized keyword causes startup failure +- **WHEN** `--trust-proxy=garbage` +- **THEN** the server SHALL exit with a non-zero status and an error message containing `garbage` + +#### Scenario: Malformed CIDR causes startup failure +- **WHEN** `--trust-proxy=10.0.0.0/8,not-an-ip` +- **THEN** the server SHALL exit with a non-zero status and an error message containing `not-an-ip` + +### Requirement: Resolved trust mode is logged at startup +The server SHALL emit a structured log line at `INFO` level during startup that records the effective trust-proxy setting (the raw resolved string, or `none` when unset/empty). This line SHALL appear before the server begins accepting connections. + +#### Scenario: Trust mode logged when set +- **WHEN** `--trust-proxy=loopback` and the server starts successfully +- **THEN** a startup log line SHALL contain the key `trust-proxy` with value `loopback` + +#### Scenario: Trust mode logged as `none` by default +- **WHEN** `--trust-proxy` is unset and the server starts successfully +- **THEN** a startup log line SHALL contain the key `trust-proxy` with value `none` diff --git a/openspec/changes/xff-trust-opt-in/tasks.md b/openspec/changes/xff-trust-opt-in/tasks.md new file mode 100644 index 0000000..9de90c9 --- /dev/null +++ b/openspec/changes/xff-trust-opt-in/tasks.md @@ -0,0 +1,56 @@ +## 1. Flag wiring + +- [ ] 1.1 Add `var trustProxy string` and `const trustProxyEnvVar = "PKGPROXY_TRUST_PROXY"` to `cmd/serve.go` +- [ ] 1.2 Register `--trust-proxy` flag in `newServeCommand()` with empty default and a description referencing `none`, `loopback`, `private`, and CIDR/IP forms +- [ ] 1.3 Add `resolveTrustProxy(flagChanged bool, flagValue, envValue string) string` using the same flag→env→default pattern as `resolveListenHost` +- [ ] 1.4 Call `resolveTrustProxy` in `PersistentPreRunE` (after `listenAddress` resolution), pass result to `parseTrustProxy`, store the returned `echo.IPExtractor` in a package-level `var ipExtractor echo.IPExtractor` + +## 2. Parser implementation + +- [ ] 2.1 Implement `parseTrustProxy(value string) (echo.IPExtractor, error)` in `cmd/serve.go`: + - Split on `,`, trim whitespace, lowercase, discard empties + - Empty list → return `echo.ExtractIPDirect()` + - `[none]` → return `echo.ExtractIPDirect()` + - `none` present with other entries → error: "trust-proxy: 'none' cannot be combined with other entries" + - `loopback` entry → append `echo.TrustLoopback(true)` + - `private` entry → append `echo.TrustPrivateNet(true)` + - Otherwise try `net.ParseCIDR`; if it fails try `net.ParseIP` and promote to `/32` (IPv4) or `/128` (IPv6); if both fail → error naming the bad token + - Return `echo.ExtractIPFromXFFHeader(opts...)` + +## 3. Server wiring and logging + +- [ ] 3.1 Replace `cmd/serve.go:93` (`app.IPExtractor = echo.ExtractIPFromXFFHeader()`) with `app.IPExtractor = ipExtractor` +- [ ] 3.2 Update the comment at lines 91–92 to describe the opt-in model +- [ ] 3.3 Add a `slog.Info("trust-proxy", "value", ...)` log line in `startServer` near the existing startup log, showing the raw resolved trust-proxy string (or `"none"` when empty) + +## 4. Tests + +- [ ] 4.1 Add `TestResolveTrustProxy` in `cmd/serve_test.go` (table-driven, mirrors `TestResolveListenHost`): flag-changed wins; env-var used when flag absent; empty env falls through to empty default +- [ ] 4.2 Add `TestParseTrustProxy` in `cmd/serve_test.go` (table-driven), covering: + - Empty string → `ExtractIPDirect` behavior (XFF ignored) + - `"none"` → same as empty + - `"loopback"` → loopback source honored, private source not trusted + - `"private"` → private source honored, loopback source not trusted + - `"10.0.0.0/8"` → matching source honored, non-matching not trusted + - `"192.168.1.10"` (bare IPv4) → promoted to `/32`, only exact host trusted + - `"::1"` (bare IPv6) → promoted to `/128` + - `"loopback,10.0.0.0/8"` → both honored + - `" loopback , 10.0.0.0/8 "` → whitespace tolerance, no error + - `"LOOPBACK"` → case-insensitive, no error + - `"none,loopback"` → error containing "cannot be combined" + - `"garbage"` → error naming "garbage" + - `"10.0.0.0/8,not-an-ip"` → error naming "not-an-ip" + +## 5. Documentation + +- [ ] 5.1 Add `--trust-proxy` row to the CLI flags table in `README.md` (after the `--public-host` row) +- [ ] 5.2 Add a `### Trusting X-Forwarded-For` subsection to `README.md` below the table with: why it's opt-in, common recipes (loopback / specific CIDR / private), and the container-bridge caveat +- [ ] 5.3 Add a combined entry to the `[Unreleased]` section of `CHANGELOG.md` covering the new flag and the breaking default-behavior change + +## 6. Verification + +- [ ] 6.1 Run `make ci-check` and confirm it passes (lint + govulncheck + unit tests) +- [ ] 6.2 Smoke-test default behavior: start server without `--trust-proxy`, `curl -H 'X-Forwarded-For: 1.2.3.4' http://localhost:8080/`, confirm access log shows `remote_ip` as the connecting IP, not `1.2.3.4` +- [ ] 6.3 Smoke-test opt-in: repeat with `PKGPROXY_TRUST_PROXY=loopback`, confirm log shows `remote_ip: 1.2.3.4` +- [ ] 6.4 Smoke-test fast-fail: start with `PKGPROXY_TRUST_PROXY=garbage`, confirm non-zero exit and error message naming "garbage" +- [ ] 6.5 Run `make e2e DISTRO=fedora` (or another distro) to confirm the default behavior change does not break existing e2e tests From 981015880d53237b5ed6af1251fd0b73530b5abf Mon Sep 17 00:00:00 2001 From: Reto Gantenbein Date: Sat, 16 May 2026 23:43:13 +0200 Subject: [PATCH 3/6] serve: add --trust-proxy flag for opt-in X-Forwarded-For trust Replace implicit echo XFF trust (loopback/link-local/private by default) with an explicit opt-in via --trust-proxy / PKGPROXY_TRUST_PROXY. When unset, ExtractIPDirect() is used and XFF is ignored entirely. Accepted values: none, loopback, private, CIDR, or bare IP (promoted to /32 or /128). Invalid or mixed-with-none entries fail startup with a clear error. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 6 +- README.md | 18 ++ cmd/serve.go | 104 ++++++++++- cmd/serve_test.go | 202 +++++++++++++++++++++ openspec/changes/xff-trust-opt-in/tasks.md | 36 ++-- 5 files changed, 340 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed06135..45fde4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- `PKGPROXY_HOST` env var to set the listen address without passing `--host` on the command line - Container image now runs `serve` by default and loads bundled config from `$KO_DATA_PATH` +- `PKGPROXY_TRUST_PROXY` env var (and `--trust-proxy` flag) to opt in to X-Forwarded-For trust +- `PKGPROXY_HOST` env var to set the listen address without passing `--host` on the command line ### Changed +- **Breaking:** `remote_ip` in access logs now reflects the direct connecting peer by default; set `PKGPROXY_TRUST_PROXY` to restore XFF-based IP extraction when running behind a reverse proxy +- Upgraded Echo web framework to v5.1.1 - Config-file errors now list all default paths attempted, not just the last one -- Upgraded Echo web framework to v5.1.0 ## [v0.2.0](https://github.com/ganto/pkgproxy/releases/tag/v0.2.0) - 2026-04-06 diff --git a/README.md b/README.md index 489289e..c0be5b3 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,28 @@ podman run --rm -p 8080:8080 -e PKGPROXY_HOST=0.0.0.0 --volume ./cache:/ko-app/c | `--host` | `PKGPROXY_HOST` | `localhost` | Listen address | | `--port` | | `8080` | Listen port | | `--public-host` | `PKGPROXY_PUBLIC_HOST` | | Public hostname (or `host:port`) shown in landing page config snippets. When set, the listen port is not appended. Useful when running behind a reverse proxy. | +| `--trust-proxy` | `PKGPROXY_TRUST_PROXY` | | Comma-separated list of trusted proxy sources for X-Forwarded-For. Accepted values: `none`, `loopback`, `private`, a CIDR (e.g. `10.0.0.0/8`), or a bare IP (promoted to `/32`/`/128`). Unset or empty means no XFF trust. | | `--debug` | | `false` | Enable debug logging | Any flag with an env variable listed above can be set via the environment instead of passing the flag. +### Trusting X-Forwarded-For + +By default pkgproxy ignores the `X-Forwarded-For` header and uses the direct connecting IP address for the `remote_ip` access-log field. This is the safe behavior when pkgproxy faces the internet directly or runs in a container without a reverse proxy in front of it. + +When pkgproxy runs behind a trusted reverse proxy, set `--trust-proxy` to tell it which source addresses are allowed to supply the client IP via `X-Forwarded-For`. Only the explicitly listed sources are trusted — echo's built-in defaults (loopback/link-local/private) are never applied automatically. + +Common recipes: + +| Topology | Setting | +|----------|---------| +| Same-host reverse proxy (nginx/caddy on localhost) | `PKGPROXY_TRUST_PROXY=loopback` | +| LAN reverse proxy (specific host, tightest control) | `PKGPROXY_TRUST_PROXY=192.168.1.1/32` | +| LAN reverse proxy (any private-range host) | `PKGPROXY_TRUST_PROXY=private` | +| No reverse proxy | Leave unset (default) | + +> **Container-bridge caveat:** In a typical `podman run -p 8080:8080` deployment the direct peer is the bridge gateway (e.g. `172.17.0.1`), which falls inside the private range. Setting `PKGPROXY_TRUST_PROXY=private` in that case means any client can inject an arbitrary `X-Forwarded-For` value. Prefer a specific CIDR or IP for tightest control. + ## Repository Configuration An example repository configuration can be found at [configs/pkgproxy.yaml](configs/pkgproxy.yaml). diff --git a/cmd/serve.go b/cmd/serve.go index da61b66..9a89b55 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -4,11 +4,15 @@ package cmd import ( "context" + "errors" "fmt" "log/slog" + "net" "os" "os/signal" "runtime" + "slices" + "strings" "syscall" "time" @@ -19,9 +23,12 @@ import ( ) var ( - listenAddress string - listenPort uint16 - publicHost string + listenAddress string + listenPort uint16 + publicHost string + trustProxy string + ipExtractor echo.IPExtractor + resolvedTrustProxy string ) const ( @@ -29,6 +36,7 @@ const ( defaultPort = 8080 hostEnvVar = "PKGPROXY_HOST" publicHostEnvVar = "PKGPROXY_PUBLIC_HOST" + trustProxyEnvVar = "PKGPROXY_TRUST_PROXY" ) func newServeCommand() *cobra.Command { @@ -38,6 +46,12 @@ func newServeCommand() *cobra.Command { Short: "Start forward proxy", PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { listenAddress = resolveListenHost(cmd.Flag("host").Changed, listenAddress, os.Getenv(hostEnvVar)) + resolvedTrustProxy = resolveTrustProxy(cmd.Flag("trust-proxy").Changed, trustProxy, os.Getenv(trustProxyEnvVar)) + var err error + ipExtractor, err = parseTrustProxy(resolvedTrustProxy) + if err != nil { + return err + } return initConfig() }, RunE: startServer, @@ -46,6 +60,7 @@ func newServeCommand() *cobra.Command { c.PersistentFlags().StringVar(&listenAddress, "host", defaultAddress, "listen address of the pkgproxy.") c.PersistentFlags().Uint16Var(&listenPort, "port", defaultPort, "listen port of the pkgproxy.") c.PersistentFlags().StringVar(&publicHost, "public-host", "", "public hostname (or host:port) shown in landing page config snippets; overrides PKGPROXY_PUBLIC_HOST.") + c.PersistentFlags().StringVar(&trustProxy, "trust-proxy", "", "comma-separated list of trusted proxy addresses for X-Forwarded-For: none, loopback, private, CIDR, or IP; overrides PKGPROXY_TRUST_PROXY.") return c } @@ -74,6 +89,78 @@ func resolvePublicAddr(flagValue string, listenAddr string, port uint16) string return fmt.Sprintf("%s:%d", listenAddr, port) } +// resolveTrustProxy determines the trust-proxy value using flag → env var → default precedence. +func resolveTrustProxy(flagChanged bool, flagValue, envValue string) string { + if flagChanged { + return flagValue + } + if envValue != "" { + return envValue + } + return "" +} + +// parseTrustProxy converts the resolved trust-proxy string into an echo.IPExtractor. +// Empty or "none" installs ExtractIPDirect (XFF ignored). Other values install +// ExtractIPFromXFFHeader with only the operator-specified trust options; echo's +// implicit defaults (loopback/link-local/private) are never applied automatically. +func parseTrustProxy(value string) (echo.IPExtractor, error) { + var entries []string + for p := range strings.SplitSeq(value, ",") { + p = strings.TrimSpace(strings.ToLower(p)) + if p != "" { + entries = append(entries, p) + } + } + if len(entries) == 0 { + return echo.ExtractIPDirect(), nil + } + if len(entries) == 1 && entries[0] == "none" { + return echo.ExtractIPDirect(), nil + } + if slices.Contains(entries, "none") { + return nil, errors.New("trust-proxy: 'none' cannot be combined with other entries") + } + + var ( + trustLoopback bool + trustPrivate bool + extraRanges []*net.IPNet + ) + for _, e := range entries { + switch e { + case "loopback": + trustLoopback = true + case "private": + trustPrivate = true + default: + _, ipNet, err := net.ParseCIDR(e) + if err != nil { + ip := net.ParseIP(e) + if ip == nil { + return nil, fmt.Errorf("trust-proxy: unrecognized entry %q", e) + } + bits := 128 + if ip.To4() != nil { + bits = 32 + } + _, ipNet, _ = net.ParseCIDR(fmt.Sprintf("%s/%d", ip.String(), bits)) + } + extraRanges = append(extraRanges, ipNet) + } + } + + opts := []echo.TrustOption{ + echo.TrustLoopback(trustLoopback), + echo.TrustLinkLocal(false), + echo.TrustPrivateNet(trustPrivate), + } + for _, ipNet := range extraRanges { + opts = append(opts, echo.TrustIPRange(ipNet)) + } + return echo.ExtractIPFromXFFHeader(opts...), nil +} + func startServer(_ *cobra.Command, _ []string) error { logLevel := slog.LevelInfo if enableDebug { @@ -86,11 +173,16 @@ func startServer(_ *cobra.Command, _ []string) error { "goVersion", runtime.Version(), "buildDate", buildDate(), ) + trustProxyLog := resolvedTrustProxy + if trustProxyLog == "" { + trustProxyLog = "none" + } + slog.Info("trust-proxy", "value", trustProxyLog) app := echo.New() - // Extract client IP from X-Forwarded-For when running behind a reverse proxy. - // Defaults trust loopback/link-local/private-net, which cover typical nginx/caddy deployments. - app.IPExtractor = echo.ExtractIPFromXFFHeader() + // Extract client IP from X-Forwarded-For only when a trusted proxy is explicitly configured + // via --trust-proxy. By default, XFF is ignored and the direct connecting IP is used. + app.IPExtractor = ipExtractor app.Use(middleware.RequestID()) app.Use(func(next echo.HandlerFunc) echo.HandlerFunc { diff --git a/cmd/serve_test.go b/cmd/serve_test.go index 21ae48c..0974048 100644 --- a/cmd/serve_test.go +++ b/cmd/serve_test.go @@ -3,6 +3,7 @@ package cmd import ( + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -60,6 +61,207 @@ func TestResolveListenHost(t *testing.T) { } } +func TestResolveTrustProxy(t *testing.T) { + tests := []struct { + name string + flagChanged bool + flagValue string + envValue string + want string + }{ + { + name: "flag changed wins over env var", + flagChanged: true, + flagValue: "loopback", + envValue: "private", + want: "loopback", + }, + { + name: "flag changed wins even when value is empty", + flagChanged: true, + flagValue: "", + envValue: "private", + want: "", + }, + { + name: "env var used when flag unchanged", + flagChanged: false, + flagValue: "", + envValue: "private", + want: "private", + }, + { + name: "empty env var falls through to empty default", + flagChanged: false, + flagValue: "", + envValue: "", + want: "", + }, + { + name: "neither set returns empty default", + flagChanged: false, + flagValue: "", + envValue: "", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveTrustProxy(tt.flagChanged, tt.flagValue, tt.envValue) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseTrustProxy(t *testing.T) { + tests := []struct { + name string + value string + remoteAddr string + xff string + wantIP string + wantErr string + }{ + { + name: "empty string ignores XFF", + value: "", + remoteAddr: "127.0.0.1:1234", + xff: "1.2.3.4", + wantIP: "127.0.0.1", + }, + { + name: "none ignores XFF", + value: "none", + remoteAddr: "127.0.0.1:1234", + xff: "1.2.3.4", + wantIP: "127.0.0.1", + }, + { + name: "loopback honors XFF from loopback source", + value: "loopback", + remoteAddr: "127.0.0.1:1234", + xff: "203.0.113.5", + wantIP: "203.0.113.5", + }, + { + name: "loopback does not trust XFF from private source", + value: "loopback", + remoteAddr: "10.0.0.1:1234", + xff: "203.0.113.5", + wantIP: "10.0.0.1", + }, + { + name: "private honors XFF from private source", + value: "private", + remoteAddr: "10.0.0.1:1234", + xff: "203.0.113.5", + wantIP: "203.0.113.5", + }, + { + name: "private does not trust XFF from loopback source", + value: "private", + remoteAddr: "127.0.0.1:1234", + xff: "203.0.113.5", + wantIP: "127.0.0.1", + }, + { + name: "CIDR trusts matching source", + value: "10.0.0.0/8", + remoteAddr: "10.5.5.5:1234", + xff: "203.0.113.5", + wantIP: "203.0.113.5", + }, + { + name: "CIDR does not trust non-matching source", + value: "10.0.0.0/8", + remoteAddr: "192.168.1.1:1234", + xff: "203.0.113.5", + wantIP: "192.168.1.1", + }, + { + name: "bare IPv4 trusted as /32", + value: "192.168.1.10", + remoteAddr: "192.168.1.10:1234", + xff: "203.0.113.5", + wantIP: "203.0.113.5", + }, + { + name: "bare IPv4 does not trust sibling in same subnet", + value: "192.168.1.10", + remoteAddr: "192.168.1.11:1234", + xff: "203.0.113.5", + wantIP: "192.168.1.11", + }, + { + name: "bare IPv6 trusted as /128", + value: "::1", + remoteAddr: "[::1]:1234", + xff: "203.0.113.5", + wantIP: "203.0.113.5", + }, + { + name: "combined loopback and CIDR trusts loopback", + value: "loopback,10.0.0.0/8", + remoteAddr: "127.0.0.1:1234", + xff: "203.0.113.5", + wantIP: "203.0.113.5", + }, + { + name: "combined loopback and CIDR trusts CIDR", + value: "loopback,10.0.0.0/8", + remoteAddr: "10.5.5.5:1234", + xff: "203.0.113.5", + wantIP: "203.0.113.5", + }, + { + name: "whitespace around entries is tolerated", + value: " loopback , 10.0.0.0/8 ", + remoteAddr: "127.0.0.1:1234", + xff: "203.0.113.5", + wantIP: "203.0.113.5", + }, + { + name: "LOOPBACK keyword is case-insensitive", + value: "LOOPBACK", + remoteAddr: "127.0.0.1:1234", + xff: "203.0.113.5", + wantIP: "203.0.113.5", + }, + { + name: "none combined with other keyword causes error", + value: "none,loopback", + wantErr: "cannot be combined", + }, + { + name: "unrecognized entry causes error naming the token", + value: "garbage", + wantErr: "garbage", + }, + { + name: "malformed entry causes error naming the token", + value: "10.0.0.0/8,not-an-ip", + wantErr: "not-an-ip", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + extractor, err := parseTrustProxy(tt.value) + if tt.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + assert.NoError(t, err) + req, _ := http.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = tt.remoteAddr + if tt.xff != "" { + req.Header.Set("X-Forwarded-For", tt.xff) + } + assert.Equal(t, tt.wantIP, extractor(req)) + }) + } +} + func TestResolvePublicAddr(t *testing.T) { tests := []struct { name string diff --git a/openspec/changes/xff-trust-opt-in/tasks.md b/openspec/changes/xff-trust-opt-in/tasks.md index 9de90c9..142b81c 100644 --- a/openspec/changes/xff-trust-opt-in/tasks.md +++ b/openspec/changes/xff-trust-opt-in/tasks.md @@ -1,13 +1,13 @@ ## 1. Flag wiring -- [ ] 1.1 Add `var trustProxy string` and `const trustProxyEnvVar = "PKGPROXY_TRUST_PROXY"` to `cmd/serve.go` -- [ ] 1.2 Register `--trust-proxy` flag in `newServeCommand()` with empty default and a description referencing `none`, `loopback`, `private`, and CIDR/IP forms -- [ ] 1.3 Add `resolveTrustProxy(flagChanged bool, flagValue, envValue string) string` using the same flag→env→default pattern as `resolveListenHost` -- [ ] 1.4 Call `resolveTrustProxy` in `PersistentPreRunE` (after `listenAddress` resolution), pass result to `parseTrustProxy`, store the returned `echo.IPExtractor` in a package-level `var ipExtractor echo.IPExtractor` +- [x] 1.1 Add `var trustProxy string` and `const trustProxyEnvVar = "PKGPROXY_TRUST_PROXY"` to `cmd/serve.go` +- [x] 1.2 Register `--trust-proxy` flag in `newServeCommand()` with empty default and a description referencing `none`, `loopback`, `private`, and CIDR/IP forms +- [x] 1.3 Add `resolveTrustProxy(flagChanged bool, flagValue, envValue string) string` using the same flag→env→default pattern as `resolveListenHost` +- [x] 1.4 Call `resolveTrustProxy` in `PersistentPreRunE` (after `listenAddress` resolution), pass result to `parseTrustProxy`, store the returned `echo.IPExtractor` in a package-level `var ipExtractor echo.IPExtractor` ## 2. Parser implementation -- [ ] 2.1 Implement `parseTrustProxy(value string) (echo.IPExtractor, error)` in `cmd/serve.go`: +- [x] 2.1 Implement `parseTrustProxy(value string) (echo.IPExtractor, error)` in `cmd/serve.go`: - Split on `,`, trim whitespace, lowercase, discard empties - Empty list → return `echo.ExtractIPDirect()` - `[none]` → return `echo.ExtractIPDirect()` @@ -19,14 +19,14 @@ ## 3. Server wiring and logging -- [ ] 3.1 Replace `cmd/serve.go:93` (`app.IPExtractor = echo.ExtractIPFromXFFHeader()`) with `app.IPExtractor = ipExtractor` -- [ ] 3.2 Update the comment at lines 91–92 to describe the opt-in model -- [ ] 3.3 Add a `slog.Info("trust-proxy", "value", ...)` log line in `startServer` near the existing startup log, showing the raw resolved trust-proxy string (or `"none"` when empty) +- [x] 3.1 Replace `cmd/serve.go:93` (`app.IPExtractor = echo.ExtractIPFromXFFHeader()`) with `app.IPExtractor = ipExtractor` +- [x] 3.2 Update the comment at lines 91–92 to describe the opt-in model +- [x] 3.3 Add a `slog.Info("trust-proxy", "value", ...)` log line in `startServer` near the existing startup log, showing the raw resolved trust-proxy string (or `"none"` when empty) ## 4. Tests -- [ ] 4.1 Add `TestResolveTrustProxy` in `cmd/serve_test.go` (table-driven, mirrors `TestResolveListenHost`): flag-changed wins; env-var used when flag absent; empty env falls through to empty default -- [ ] 4.2 Add `TestParseTrustProxy` in `cmd/serve_test.go` (table-driven), covering: +- [x] 4.1 Add `TestResolveTrustProxy` in `cmd/serve_test.go` (table-driven, mirrors `TestResolveListenHost`): flag-changed wins; env-var used when flag absent; empty env falls through to empty default +- [x] 4.2 Add `TestParseTrustProxy` in `cmd/serve_test.go` (table-driven), covering: - Empty string → `ExtractIPDirect` behavior (XFF ignored) - `"none"` → same as empty - `"loopback"` → loopback source honored, private source not trusted @@ -43,14 +43,14 @@ ## 5. Documentation -- [ ] 5.1 Add `--trust-proxy` row to the CLI flags table in `README.md` (after the `--public-host` row) -- [ ] 5.2 Add a `### Trusting X-Forwarded-For` subsection to `README.md` below the table with: why it's opt-in, common recipes (loopback / specific CIDR / private), and the container-bridge caveat -- [ ] 5.3 Add a combined entry to the `[Unreleased]` section of `CHANGELOG.md` covering the new flag and the breaking default-behavior change +- [x] 5.1 Add `--trust-proxy` row to the CLI flags table in `README.md` (after the `--public-host` row) +- [x] 5.2 Add a `### Trusting X-Forwarded-For` subsection to `README.md` below the table with: why it's opt-in, common recipes (loopback / specific CIDR / private), and the container-bridge caveat +- [x] 5.3 Add a combined entry to the `[Unreleased]` section of `CHANGELOG.md` covering the new flag and the breaking default-behavior change ## 6. Verification -- [ ] 6.1 Run `make ci-check` and confirm it passes (lint + govulncheck + unit tests) -- [ ] 6.2 Smoke-test default behavior: start server without `--trust-proxy`, `curl -H 'X-Forwarded-For: 1.2.3.4' http://localhost:8080/`, confirm access log shows `remote_ip` as the connecting IP, not `1.2.3.4` -- [ ] 6.3 Smoke-test opt-in: repeat with `PKGPROXY_TRUST_PROXY=loopback`, confirm log shows `remote_ip: 1.2.3.4` -- [ ] 6.4 Smoke-test fast-fail: start with `PKGPROXY_TRUST_PROXY=garbage`, confirm non-zero exit and error message naming "garbage" -- [ ] 6.5 Run `make e2e DISTRO=fedora` (or another distro) to confirm the default behavior change does not break existing e2e tests +- [x] 6.1 Run `make ci-check` and confirm it passes (lint + govulncheck + unit tests) +- [x] 6.2 Smoke-test default behavior: start server without `--trust-proxy`, `curl -H 'X-Forwarded-For: 1.2.3.4' http://localhost:8080/`, confirm access log shows `remote_ip` as the connecting IP, not `1.2.3.4` +- [x] 6.3 Smoke-test opt-in: repeat with `PKGPROXY_TRUST_PROXY=loopback`, confirm log shows `remote_ip: 1.2.3.4` +- [x] 6.4 Smoke-test fast-fail: start with `PKGPROXY_TRUST_PROXY=garbage`, confirm non-zero exit and error message naming "garbage" +- [x] 6.5 Run `make e2e DISTRO=fedora` (or another distro) to confirm the default behavior change does not break existing e2e tests From 566033a4b7b4b77580bbf9146a101e265be4412f Mon Sep 17 00:00:00 2001 From: Reto Gantenbein Date: Sat, 16 May 2026 23:56:35 +0200 Subject: [PATCH 4/6] serve: deduplicate trust-proxy entries before validation Prevents duplicate entries (e.g. 'none,none' or a repeated IP) from triggering a spurious 'cannot be combined' error. Adds test cases for both duplicate 'none' and duplicate IP inputs. Co-Authored-By: Claude Sonnet 4.6 --- cmd/serve.go | 2 ++ cmd/serve_test.go | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/cmd/serve.go b/cmd/serve.go index 9a89b55..dc3c0d7 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -112,6 +112,8 @@ func parseTrustProxy(value string) (echo.IPExtractor, error) { entries = append(entries, p) } } + slices.Sort(entries) + entries = slices.Compact(entries) if len(entries) == 0 { return echo.ExtractIPDirect(), nil } diff --git a/cmd/serve_test.go b/cmd/serve_test.go index 0974048..a5e3d3d 100644 --- a/cmd/serve_test.go +++ b/cmd/serve_test.go @@ -227,6 +227,20 @@ func TestParseTrustProxy(t *testing.T) { xff: "203.0.113.5", wantIP: "203.0.113.5", }, + { + name: "duplicate none is treated as none", + value: "none,none", + remoteAddr: "127.0.0.1:1234", + xff: "1.2.3.4", + wantIP: "127.0.0.1", + }, + { + name: "duplicate IP entries are deduplicated", + value: "192.168.1.10,192.168.1.10", + remoteAddr: "192.168.1.10:1234", + xff: "203.0.113.5", + wantIP: "203.0.113.5", + }, { name: "none combined with other keyword causes error", value: "none,loopback", From b8d524af650cc28d1fcd44f4e5ab9e204d5cbb1e Mon Sep 17 00:00:00 2001 From: Reto Gantenbein Date: Sun, 17 May 2026 00:00:21 +0200 Subject: [PATCH 5/6] openspec: Archive 'xff-trust-opt-in' change spec --- .../.openspec.yaml | 0 .../2026-05-16-xff-trust-opt-in}/design.md | 0 .../2026-05-16-xff-trust-opt-in}/proposal.md | 0 .../specs/trusted-proxies-config/spec.md | 0 .../2026-05-16-xff-trust-opt-in}/tasks.md | 0 openspec/specs/trusted-proxies-config/spec.md | 118 ++++++++++++++++++ 6 files changed, 118 insertions(+) rename openspec/changes/{xff-trust-opt-in => archive/2026-05-16-xff-trust-opt-in}/.openspec.yaml (100%) rename openspec/changes/{xff-trust-opt-in => archive/2026-05-16-xff-trust-opt-in}/design.md (100%) rename openspec/changes/{xff-trust-opt-in => archive/2026-05-16-xff-trust-opt-in}/proposal.md (100%) rename openspec/changes/{xff-trust-opt-in => archive/2026-05-16-xff-trust-opt-in}/specs/trusted-proxies-config/spec.md (100%) rename openspec/changes/{xff-trust-opt-in => archive/2026-05-16-xff-trust-opt-in}/tasks.md (100%) create mode 100644 openspec/specs/trusted-proxies-config/spec.md diff --git a/openspec/changes/xff-trust-opt-in/.openspec.yaml b/openspec/changes/archive/2026-05-16-xff-trust-opt-in/.openspec.yaml similarity index 100% rename from openspec/changes/xff-trust-opt-in/.openspec.yaml rename to openspec/changes/archive/2026-05-16-xff-trust-opt-in/.openspec.yaml diff --git a/openspec/changes/xff-trust-opt-in/design.md b/openspec/changes/archive/2026-05-16-xff-trust-opt-in/design.md similarity index 100% rename from openspec/changes/xff-trust-opt-in/design.md rename to openspec/changes/archive/2026-05-16-xff-trust-opt-in/design.md diff --git a/openspec/changes/xff-trust-opt-in/proposal.md b/openspec/changes/archive/2026-05-16-xff-trust-opt-in/proposal.md similarity index 100% rename from openspec/changes/xff-trust-opt-in/proposal.md rename to openspec/changes/archive/2026-05-16-xff-trust-opt-in/proposal.md diff --git a/openspec/changes/xff-trust-opt-in/specs/trusted-proxies-config/spec.md b/openspec/changes/archive/2026-05-16-xff-trust-opt-in/specs/trusted-proxies-config/spec.md similarity index 100% rename from openspec/changes/xff-trust-opt-in/specs/trusted-proxies-config/spec.md rename to openspec/changes/archive/2026-05-16-xff-trust-opt-in/specs/trusted-proxies-config/spec.md diff --git a/openspec/changes/xff-trust-opt-in/tasks.md b/openspec/changes/archive/2026-05-16-xff-trust-opt-in/tasks.md similarity index 100% rename from openspec/changes/xff-trust-opt-in/tasks.md rename to openspec/changes/archive/2026-05-16-xff-trust-opt-in/tasks.md diff --git a/openspec/specs/trusted-proxies-config/spec.md b/openspec/specs/trusted-proxies-config/spec.md new file mode 100644 index 0000000..864a234 --- /dev/null +++ b/openspec/specs/trusted-proxies-config/spec.md @@ -0,0 +1,118 @@ +## ADDED Requirements + +### Requirement: Trust proxy is resolved from flag, then env var, then empty default +The `serve` subcommand SHALL resolve the trust-proxy value using the following ordered precedence: + +1. The value of `--trust-proxy` when the user explicitly passed the flag on the command line (detected via Cobra's `cmd.Flag("trust-proxy").Changed` returning `true`). +2. The value of the `PKGPROXY_TRUST_PROXY` environment variable when it is set to a non-empty string. +3. The built-in default: empty string (no trust). + +An empty `PKGPROXY_TRUST_PROXY` (set but empty, or unset) SHALL be treated as "no env-var input" and SHALL fall through to step 3. + +#### Scenario: Explicit flag overrides env var +- **WHEN** the binary is started with `serve --trust-proxy=loopback` and `PKGPROXY_TRUST_PROXY=private` is set +- **THEN** only loopback addresses SHALL be trusted for X-Forwarded-For + +#### Scenario: Env var used when flag is absent +- **WHEN** the binary is started with `serve` (no `--trust-proxy`) and `PKGPROXY_TRUST_PROXY=private` is set +- **THEN** RFC1918 and RFC4193 private ranges SHALL be trusted for X-Forwarded-For + +#### Scenario: Empty env var falls through to default +- **WHEN** the binary is started with `serve` (no `--trust-proxy`) and `PKGPROXY_TRUST_PROXY=` (set but empty) +- **THEN** X-Forwarded-For SHALL be ignored (direct IP extraction) + +#### Scenario: Neither flag nor env var produces no-trust default +- **WHEN** the binary is started with `serve` (no `--trust-proxy`) and `PKGPROXY_TRUST_PROXY` is unset +- **THEN** X-Forwarded-For SHALL be ignored (direct IP extraction) + +### Requirement: XFF trust is disabled by default +When `--trust-proxy` resolves to an empty string or the value `none`, the server SHALL install `echo.ExtractIPDirect()` as the IP extractor. The `X-Forwarded-For` header SHALL be ignored entirely; `c.RealIP()` SHALL return the direct network peer's IP address. + +#### Scenario: Default behavior ignores XFF +- **WHEN** `--trust-proxy` is unset and a request arrives with `X-Forwarded-For: 1.2.3.4` from `127.0.0.1` +- **THEN** the access-log `remote_ip` field SHALL be `127.0.0.1` + +#### Scenario: Explicit `none` also disables XFF +- **WHEN** `--trust-proxy=none` and a request arrives with `X-Forwarded-For: 1.2.3.4` from `127.0.0.1` +- **THEN** the access-log `remote_ip` field SHALL be `127.0.0.1` + +### Requirement: `loopback` keyword trusts loopback addresses +When the resolved value contains the entry `loopback`, the server SHALL apply `echo.TrustLoopback(true)` so that `X-Forwarded-For` headers arriving from `127.0.0.0/8` and `::1` are honored. + +#### Scenario: Loopback source with XFF surfaced +- **WHEN** `--trust-proxy=loopback` and a request arrives from `127.0.0.1` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `203.0.113.5` + +#### Scenario: Non-loopback source not trusted for XFF +- **WHEN** `--trust-proxy=loopback` and a request arrives from `10.0.0.1` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `10.0.0.1` (the direct peer, not the XFF value) + +### Requirement: `private` keyword trusts RFC1918 and RFC4193 private ranges +When the resolved value contains the entry `private`, the server SHALL apply `echo.TrustPrivateNet(true)` so that `X-Forwarded-For` headers arriving from RFC1918 (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) and RFC4193 (`fc00::/7`) ranges are honored. Loopback addresses are NOT implied by `private` alone. + +#### Scenario: Private-range source with XFF surfaced +- **WHEN** `--trust-proxy=private` and a request arrives from `10.0.0.1` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `203.0.113.5` + +#### Scenario: Loopback source not trusted with private-only setting +- **WHEN** `--trust-proxy=private` and a request arrives from `127.0.0.1` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `127.0.0.1` (loopback not in private ranges) + +### Requirement: Literal CIDR or bare IP trusts exactly that range +When the resolved value contains a CIDR notation entry (e.g. `10.0.0.5/32`, `192.168.0.0/24`) or a bare IP address (auto-promoted to `/32` for IPv4, `/128` for IPv6), the server SHALL apply `echo.TrustIPRange(...)` for that range only. + +#### Scenario: Exact-host CIDR trusts only that host +- **WHEN** `--trust-proxy=10.0.0.5/32` and a request arrives from `10.0.0.5` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `203.0.113.5` + +#### Scenario: Sibling in the subnet not trusted if not in the CIDR +- **WHEN** `--trust-proxy=10.0.0.5/32` and a request arrives from `10.0.0.6` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `10.0.0.6` + +#### Scenario: Bare IP is promoted to single-host CIDR +- **WHEN** `--trust-proxy=192.168.1.10` (no mask) and a request arrives from `192.168.1.10` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `203.0.113.5` + +#### Scenario: Bare IPv6 is promoted to /128 +- **WHEN** `--trust-proxy=::1` (no mask) and a request arrives from `::1` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `203.0.113.5` + +### Requirement: Multiple entries may be combined (comma-separated) +The value of `--trust-proxy` SHALL be split on commas; whitespace around each entry SHALL be trimmed; each entry is evaluated independently. Keywords and literal CIDRs/IPs may be mixed freely, provided `none` is not present. + +#### Scenario: Keyword and CIDR combined +- **WHEN** `--trust-proxy=loopback,10.0.0.0/8` and a request arrives from `10.5.5.5` with `X-Forwarded-For: 203.0.113.5` +- **THEN** the access-log `remote_ip` field SHALL be `203.0.113.5` + +#### Scenario: Whitespace around entries is tolerated +- **WHEN** `--trust-proxy= loopback , 10.0.0.0/8 ` (spaces around commas) +- **THEN** the server SHALL start without error + +### Requirement: `none` may not be combined with other entries +When the resolved value contains `none` alongside any other non-empty entry, the server SHALL fail at startup with an error stating that `none` cannot be combined with other trust entries. + +#### Scenario: `none` combined with keyword causes startup failure +- **WHEN** `PKGPROXY_TRUST_PROXY=none,loopback` +- **THEN** the server SHALL exit before accepting connections with a non-zero status and an error message referencing `none` + +### Requirement: Unrecognized or malformed entries cause startup failure +Any entry that is not a recognized keyword (`none`, `loopback`, `private`), a valid CIDR, or a parseable IP address SHALL cause the server to fail at startup. The error message SHALL name the offending token. + +#### Scenario: Unrecognized keyword causes startup failure +- **WHEN** `--trust-proxy=garbage` +- **THEN** the server SHALL exit with a non-zero status and an error message containing `garbage` + +#### Scenario: Malformed CIDR causes startup failure +- **WHEN** `--trust-proxy=10.0.0.0/8,not-an-ip` +- **THEN** the server SHALL exit with a non-zero status and an error message containing `not-an-ip` + +### Requirement: Resolved trust mode is logged at startup +The server SHALL emit a structured log line at `INFO` level during startup that records the effective trust-proxy setting (the raw resolved string, or `none` when unset/empty). This line SHALL appear before the server begins accepting connections. + +#### Scenario: Trust mode logged when set +- **WHEN** `--trust-proxy=loopback` and the server starts successfully +- **THEN** a startup log line SHALL contain the key `trust-proxy` with value `loopback` + +#### Scenario: Trust mode logged as `none` by default +- **WHEN** `--trust-proxy` is unset and the server starts successfully +- **THEN** a startup log line SHALL contain the key `trust-proxy` with value `none` From 7ee40e38ab49abe12a3c7204356f131ec2311d41 Mon Sep 17 00:00:00 2001 From: Reto Gantenbein Date: Sun, 17 May 2026 00:07:05 +0200 Subject: [PATCH 6/6] codespell: Fix spelling issue --- .../changes/archive/2026-05-16-xff-trust-opt-in/proposal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/archive/2026-05-16-xff-trust-opt-in/proposal.md b/openspec/changes/archive/2026-05-16-xff-trust-opt-in/proposal.md index ba880bd..46278f8 100644 --- a/openspec/changes/archive/2026-05-16-xff-trust-opt-in/proposal.md +++ b/openspec/changes/archive/2026-05-16-xff-trust-opt-in/proposal.md @@ -9,7 +9,7 @@ - When the flag is unset, empty, or set to `none`, the server SHALL install `echo.ExtractIPDirect()` — the `X-Forwarded-For` header is ignored entirely. - When the flag carries any other valid value, the server SHALL install `echo.ExtractIPFromXFFHeader(...)` configured with **only** the operator-supplied trust options. Echo's implicit defaults (loopback / link-local / private) SHALL NOT apply. - Mixing `none` with any other entry SHALL be an error at startup. -- Invalid entries (unrecognized keyword, malformed CIDR, unparseable IP) SHALL fail startup with a clear error naming the offending token. +- Invalid entries (unrecognized keyword, malformed CIDR, unparsable IP) SHALL fail startup with a clear error naming the offending token. - The resolved trust mode SHALL be logged once at startup so operators can confirm what was applied. - **BREAKING**: Deployments that today rely on echo's implicit private-net trust to populate `remote_ip` from `X-Forwarded-For` will see that field switch to the direct connecting peer until they set `PKGPROXY_TRUST_PROXY` (commonly `private` for LAN reverse proxies, `loopback` for same-host reverse proxies, or a specific CIDR for tightest control).