diff --git a/chart/Chart.yaml b/chart/Chart.yaml index c26e1fec..0d5afad7 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: versitygw description: A Helm chart for deploying the Versity S3 Gateway on Kubernetes type: application -version: 0.4.5 +version: 0.4.6 sources: - https://github.com/versity/versitygw icon: https://raw.githubusercontent.com/versity/versitygw/main/webui/web/assets/images/Versity-logo-blue-horizontal.png diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 13da15e9..a91bb7ec 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -17,6 +17,9 @@ {{- $versioningDir := .Values.gateway.backend.versioningDir -}} {{- $adminPathPrefix := .Values.admin.pathPrefix | default "" -}} {{- $webuiS3Prefix := .Values.webui.s3Prefix | default "" -}} +{{- if and .Values.gateway.clientIPHeader (not .Values.gateway.trustedProxies) }} + {{- fail "gateway.trustedProxies is required when gateway.clientIPHeader is set" }} +{{- end }} {{- /* Safety check: multiple replicas with local state must have persistence enabled */}} {{- if and (gt (int .Values.replicaCount) 1) .Values.iam.enabled (eq .Values.iam.type "internal") }} {{- fail "replicaCount > 1 cannot use iam.type=internal because the file store does not coordinate concurrent writers; use standalone or another external IAM backend" }} @@ -222,6 +225,14 @@ spec: - name: VGW_CORS_ALLOW_ORIGIN value: {{ .Values.gateway.corsAllowOrigin | quote }} {{- end }} + {{- if .Values.gateway.clientIPHeader }} + - name: VGW_CLIENT_IP_HEADER + value: {{ .Values.gateway.clientIPHeader | quote }} + {{- end }} + {{- if .Values.gateway.trustedProxies }} + - name: VGW_TRUSTED_PROXIES + value: {{ .Values.gateway.trustedProxies | quote }} + {{- end }} {{- if .Values.gateway.disableStrictBucketNames }} - name: VGW_DISABLE_STRICT_BUCKET_NAMES value: "true" diff --git a/chart/templates/iam-deployment.yaml b/chart/templates/iam-deployment.yaml index 8ecfaae8..c5544bd8 100644 --- a/chart/templates/iam-deployment.yaml +++ b/chart/templates/iam-deployment.yaml @@ -2,6 +2,9 @@ {{- if ($iamServer.enabled | default false) }} {{- $iamServerAuth := .Values.iamServer.auth | default dict -}} {{- $iamServerOidc := .Values.iamServer.oidc | default dict -}} +{{- if and .Values.iamServer.clientIPHeader (not .Values.iamServer.trustedProxies) }} + {{- fail "iamServer.trustedProxies is required when iamServer.clientIPHeader is set" }} +{{- end }} {{- $iamPrivateCertVolume := dig "private" "certificate" "volumeName" "" $iamServer -}} {{- $iamTLSCertVolume := dig "tls" "volumeName" "" $iamServer -}} {{- if not (or (eq .Values.iamServer.storage.type "internal") (eq .Values.iamServer.storage.type "vault")) }} @@ -125,6 +128,14 @@ spec: - name: VGW_LOG_LEVEL value: {{ .Values.iamServer.logLevel | quote }} {{- end }} + {{- if .Values.iamServer.clientIPHeader }} + - name: VGW_CLIENT_IP_HEADER + value: {{ .Values.iamServer.clientIPHeader | quote }} + {{- end }} + {{- if .Values.iamServer.trustedProxies }} + - name: VGW_TRUSTED_PROXIES + value: {{ .Values.iamServer.trustedProxies | quote }} + {{- end }} {{- if or $iamServerOidc.disableThumbprintAutofetch .Values.iamServer.disableOidcThumbprintAutofetch }} - name: VGW_IAM_DISABLE_OIDC_THUMBPRINT_AUTOFETCH value: "true" diff --git a/chart/values.yaml b/chart/values.yaml index c02f7e9e..ebe70737 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -178,6 +178,15 @@ gateway: debug: false # Access log file path for S3 requests (AWS log format). Empty = disabled. accessLog: "" + # Proxy header to take the client IP address from for logs: "X-Forwarded-For" + # or "X-Real-Ip". Empty = log the connection peer address. Requires + # trustedProxies. + clientIPHeader: "" + # Comma-separated IP addresses and CIDR prefixes of the proxies in front of + # the gateway (e.g. "10.0.0.1, 10.0.0.0/24, 2001:db8::/32"); the entry + # "unix" trusts a peer connected over a unix socket. The client IP header is + # only honoured from these peers, and is required when clientIPHeader is set. + trustedProxies: "" # Default CORS allowed origin. Empty = CORS disabled. # Must include the scheme prefix, e.g. "https://example.com" or "http://example.com". corsAllowOrigin: "" @@ -446,6 +455,15 @@ iamServer: maxRequests: 100000 # Silence stdout request logging. quiet: false + # Proxy header to take the client IP address from for logs: "X-Forwarded-For" + # or "X-Real-Ip". Empty = log the connection peer address. Requires + # trustedProxies. + clientIPHeader: "" + # Comma-separated IP addresses and CIDR prefixes of the proxies in front of + # the IAM API (e.g. "10.0.0.1, 10.0.0.0/24, 2001:db8::/32"); the entry + # "unix" trusts a peer connected over a unix socket. The client IP header is + # only honoured from these peers, and is required when clientIPHeader is set. + trustedProxies: "" # Debug logger verbosity: "silent" (default), "debug", or "unsafe" -- see # gateway.logLevel for details. logLevel: silent diff --git a/cmd/versitygw/iam.go b/cmd/versitygw/iam.go index 43022007..671e66ff 100644 --- a/cmd/versitygw/iam.go +++ b/cmd/versitygw/iam.go @@ -51,6 +51,8 @@ func runIAM(ctx *cli.Context) error { KeepAlive: keepAlive, HealthPath: healthPath, SocketPerm: socketPerm, + ClientIPHeader: clientIPHeader, + TrustedProxies: trustedProxies, PrivatePorts: ctx.StringSlice("private-ports"), PrivateCertFile: ctx.String("private-cert"), PrivateKeyFile: ctx.String("private-cert-key"), diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index 5ed9db43..dcbc0551 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -48,6 +48,8 @@ var ( eventConfigFilePath string logWebhookURL, accessLog string adminLogFile string + clientIPHeader string + trustedProxies string healthPath string virtualDomain string logLevel string @@ -469,6 +471,18 @@ func initFlags() []cli.Flag { EnvVars: []string{"LOGFILE", "VGW_ADMIN_ACCESS_LOG"}, Destination: &adminLogFile, }, + &cli.StringFlag{ + Name: "client-ip-header", + Usage: `header to take the client IP for logs from: "X-Forwarded-For" or "X-Real-Ip"; disabled by default; requires --trusted-proxies`, + EnvVars: []string{"VGW_CLIENT_IP_HEADER"}, + Destination: &clientIPHeader, + }, + &cli.StringFlag{ + Name: "trusted-proxies", + Usage: `comma-separated IP addresses and CIDR prefixes of the proxies in front of the gateway, or "unix" to trust a peer connected over a unix socket; the client IP header is only honoured from these peers, and is required when --client-ip-header is set`, + EnvVars: []string{"VGW_TRUSTED_PROXIES"}, + Destination: &trustedProxies, + }, &cli.StringFlag{ Name: "log-webhook-url", Usage: "webhook url to send the audit logs", @@ -1026,6 +1040,8 @@ func runGateway(ctx context.Context, be backend.Backend) error { AccessLog: accessLog, LogWebhookURL: logWebhookURL, AdminLogFile: adminLogFile, + ClientIPHeader: clientIPHeader, + TrustedProxies: trustedProxies, MetricsService: metricsService, StatsdServers: statsdServers, DogstatsServers: dogstatsServers, diff --git a/embedgw/embedgw.go b/embedgw/embedgw.go index 86d2e8b3..4819afbf 100644 --- a/embedgw/embedgw.go +++ b/embedgw/embedgw.go @@ -372,6 +372,20 @@ type Config struct { // AdminLogFile is the file path or stream name (stdout, stderr, or - for // stdout) for admin API request logs. AdminLogFile string + // ClientIPHeader records the client address from this proxy header in the + // request log and the S3 and admin access logs. Accepted values are + // "X-Forwarded-For" and "X-Real-Ip". Empty (the default) keeps the socket + // peer address. The header is only honoured from a peer in + // TrustedProxies, which is required when this is set. IAM aws:SourceIp + // conditions are unaffected and keep evaluating the socket address. + ClientIPHeader string + // TrustedProxies lists the comma-separated IP addresses and CIDR prefixes + // of the proxies in front of the gateway. The entry "unix" trusts a peer + // connected over a unix domain socket, whose address fasthttp reports as + // 0.0.0.0. Required when ClientIPHeader is set: the header is ignored + // from any other peer, and startup fails when the header is set with no + // trusted proxies. + TrustedProxies string // Metrics // @@ -660,6 +674,18 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { return fmt.Errorf("mp-max-parts must be positive") } + clientIPHeader, err := utils.NormalizeClientIPHeader(cfg.ClientIPHeader) + if err != nil { + return err + } + trustedProxies, err := utils.ParseTrustedProxies(cfg.TrustedProxies) + if err != nil { + return err + } + if clientIPHeader != "" && trustedProxies.IsEmpty() { + return fmt.Errorf("client-ip-header is set but trusted-proxies is empty: the header would be trusted from every peer") + } + if len(cfg.Ports) == 0 { return fmt.Errorf("no ports specified") } @@ -798,6 +824,10 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if cfg.DisableSSECTLS { opts = append(opts, s3api.WithDisableSSECTLS()) } + if clientIPHeader != "" { + opts = append(opts, s3api.WithClientIPHeader(clientIPHeader)) + opts = append(opts, s3api.WithTrustedProxies(trustedProxies)) + } if len(cfg.S3Options) > 0 { opts = append(opts, cfg.S3Options...) } @@ -968,6 +998,10 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if cfg.AdminPathPrefix != "" { admOpts = append(admOpts, s3api.WithAdminPathPrefix(cfg.AdminPathPrefix)) } + if clientIPHeader != "" { + admOpts = append(admOpts, s3api.WithAdminClientIPHeader(clientIPHeader)) + admOpts = append(admOpts, s3api.WithAdminTrustedProxies(trustedProxies)) + } admSrv = s3api.NewAdminServer(be, middlewares.RootUserConfig{Access: cfg.RootUserAccess, Secret: cfg.RootUserSecret}, cfg.Region, iam, loggers.AdminLogger, srv.Router.Ctrl, admOpts...) } @@ -1063,6 +1097,10 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if cfg.SocketPerm != "" { webOpts = append(webOpts, webui.WithSocketPerm(parsedSocketPerm)) } + if clientIPHeader != "" { + webOpts = append(webOpts, webui.WithClientIPHeader(clientIPHeader)) + webOpts = append(webOpts, webui.WithTrustedProxies(trustedProxies)) + } webSrv, err = webui.NewServer(&webui.ServerConfig{ Gateways: gateways, @@ -1125,6 +1163,10 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if cfg.SocketPerm != "" { wsOpts = append(wsOpts, website.WithSocketPerm(parsedSocketPerm)) } + if clientIPHeader != "" { + wsOpts = append(wsOpts, website.WithClientIPHeader(clientIPHeader)) + wsOpts = append(wsOpts, website.WithTrustedProxies(trustedProxies)) + } wsSrv = website.NewServer(be, cfg.WebsiteDomain, wsOpts...) } diff --git a/embedgw/embedgw_test.go b/embedgw/embedgw_test.go index e8967a29..1ab298c8 100644 --- a/embedgw/embedgw_test.go +++ b/embedgw/embedgw_test.go @@ -257,3 +257,70 @@ func TestRunVersityGWValidatesAdminPathPrefix(t *testing.T) { }) } } + +func TestRunVersityGWValidatesClientIPHeader(t *testing.T) { + cfg := Config{ + RootUserAccess: "root", + RootUserSecret: "secret", + Ports: []string{"127.0.0.1:0"}, + MaxConnections: 1, + MaxRequests: 1, + MultipartMaxParts: 1, + Quiet: true, + ClientIPHeader: "Forwarded", + } + + err := RunVersityGW(context.Background(), backend.BackendUnsupported{}, &cfg) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "invalid client IP header") { + t.Fatalf("error = %q, want substring %q", err, "invalid client IP header") + } +} + +// TestRunVersityGWRequiresTrustedProxies pins the review requirement that a +// configured client IP header cannot silently degrade into trusting every +// peer. +func TestRunVersityGWRequiresTrustedProxies(t *testing.T) { + cfg := Config{ + RootUserAccess: "root", + RootUserSecret: "secret", + Ports: []string{"127.0.0.1:0"}, + MaxConnections: 1, + MaxRequests: 1, + MultipartMaxParts: 1, + Quiet: true, + ClientIPHeader: "X-Forwarded-For", + } + + err := RunVersityGW(context.Background(), backend.BackendUnsupported{}, &cfg) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "trusted-proxies is empty") { + t.Fatalf("error = %q, want substring %q", err, "trusted-proxies is empty") + } +} + +func TestRunVersityGWRejectsInvalidTrustedProxies(t *testing.T) { + cfg := Config{ + RootUserAccess: "root", + RootUserSecret: "secret", + Ports: []string{"127.0.0.1:0"}, + MaxConnections: 1, + MaxRequests: 1, + MultipartMaxParts: 1, + Quiet: true, + ClientIPHeader: "X-Forwarded-For", + TrustedProxies: "10.0.0.0/8, nope", + } + + err := RunVersityGW(context.Background(), backend.BackendUnsupported{}, &cfg) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "invalid trusted proxy") { + t.Fatalf("error = %q, want substring %q", err, "invalid trusted proxy") + } +} diff --git a/embedgw/iam.go b/embedgw/iam.go index 4f7d2a6f..026f6c16 100644 --- a/embedgw/iam.go +++ b/embedgw/iam.go @@ -29,6 +29,7 @@ import ( "github.com/versity/versitygw/iamapi/private" "github.com/versity/versitygw/iamapi/storage" "github.com/versity/versitygw/internal/netutil" + "github.com/versity/versitygw/s3api/utils" "github.com/versity/versitygw/webui" ) @@ -81,6 +82,20 @@ type IAMConfig struct { // abstract namespace sockets. SocketPerm string + // ClientIPHeader records the client address from this proxy header in the + // IAM API and WebUI request logs. Accepted values are "X-Forwarded-For" + // and "X-Real-Ip". Empty (the default) keeps the socket peer address. The + // header is only honoured from a peer in TrustedProxies, which is + // required when this is set. + ClientIPHeader string + // TrustedProxies lists the comma-separated IP addresses and CIDR prefixes + // of the proxies in front of the IAM API. The entry "unix" trusts a peer + // connected over a unix domain socket, whose address fasthttp reports as + // 0.0.0.0. Required when ClientIPHeader is set: the header is ignored + // from any other peer, and startup fails when the header is set with no + // trusted proxies. + TrustedProxies string + // PrivatePorts is the list of listening addresses for the standalone // IAM service's private endpoints (derive-signing-key, evaluate-policy, resolve-identity) // — see private.PrivateAPI. Each address must be a unix socket, or a TCP @@ -320,8 +335,11 @@ func iamWebUITLSFiles(cfg *IAMConfig) (certFile, keyFile string) { } // newIAMWebUI builds the WebUI server hosted by the IAM service process. It -// returns nil when no WebuiPorts are configured. -func newIAMWebUI(cfg *IAMConfig) (*webui.Server, error) { +// returns nil when no WebuiPorts are configured. clientIPHeader and +// trustedProxies are the already-parsed client-ip-header settings, so the +// standalone WebUI request log records the client address like the other +// servers do. +func newIAMWebUI(cfg *IAMConfig, clientIPHeader string, trustedProxies utils.TrustedProxies) (*webui.Server, error) { if len(cfg.WebuiPorts) == 0 { return nil, nil } @@ -371,6 +389,10 @@ func newIAMWebUI(cfg *IAMConfig) (*webui.Server, error) { } webOpts = append(webOpts, webui.WithSocketPerm(os.FileMode(perm))) } + if clientIPHeader != "" { + webOpts = append(webOpts, webui.WithClientIPHeader(clientIPHeader)) + webOpts = append(webOpts, webui.WithTrustedProxies(trustedProxies)) + } return webui.NewServer(&webui.ServerConfig{ Gateways: gateways, @@ -418,6 +440,18 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { return fmt.Errorf("root secret key is required for IAM API authentication") } + clientIPHeader, err := utils.NormalizeClientIPHeader(cfg.ClientIPHeader) + if err != nil { + return err + } + trustedProxies, err := utils.ParseTrustedProxies(cfg.TrustedProxies) + if err != nil { + return err + } + if clientIPHeader != "" && trustedProxies.IsEmpty() { + return fmt.Errorf("client-ip-header is set but trusted-proxies is empty: the header would be trusted from every peer") + } + store, err := storage.New(storage.Config{ Dir: cfg.IAMDir, Vault: storage.VaultConfig{ @@ -452,6 +486,10 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { if cfg.Quiet { opts = append(opts, iamapi.WithQuiet()) } + if clientIPHeader != "" { + opts = append(opts, iamapi.WithClientIPHeader(clientIPHeader)) + opts = append(opts, iamapi.WithTrustedProxies(trustedProxies)) + } if cfg.DisableOIDCThumbprintAutoFetch { opts = append(opts, iamapi.WithOIDCThumbprintAutoFetchDisabled()) } @@ -512,7 +550,7 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { } } - webSrv, err := newIAMWebUI(cfg) + webSrv, err := newIAMWebUI(cfg, clientIPHeader, trustedProxies) if err != nil { return fmt.Errorf("init webui: %w", err) } diff --git a/extra/example.conf b/extra/example.conf index 637b435c..463a5ea9 100644 --- a/extra/example.conf +++ b/extra/example.conf @@ -229,6 +229,27 @@ ROOT_SECRET_ACCESS_KEY= # sent to the webhook. #VGW_LOG_WEBHOOK_URL= +# The VGW_CLIENT_IP_HEADER option when set will specify the proxy header to +# take the client IP address from for the request logs and the S3, admin, +# website and WebUI access logs. Set it to X-Forwarded-For or X-Real-Ip. The +# header is only honoured from a peer listed in VGW_TRUSTED_PROXIES, which is +# required when this option is set. X-Forwarded-For is walked from the right, +# skipping the trusted proxies, so a forged leftmost entry is never logged and +# a request through two listed proxies still logs the original client. When +# every hop is a trusted proxy, the leftmost hop is logged, as nginx's +# real_ip_recursive does. This option is optional, and defaults to logging the +# connection peer address. +#VGW_CLIENT_IP_HEADER= + +# The VGW_TRUSTED_PROXIES option is a comma-separated list of the IP addresses +# and CIDR prefixes of the proxies in front of the gateway, for example +# "10.0.0.1, 10.0.0.0/24, 2001:db8::/32". The entry "unix" trusts a peer +# connected over a unix domain socket, for a listener bound to a socket path +# that nginx reaches with "proxy_pass http://unix:/run/versitygw.sock". A +# request from any other peer has its VGW_CLIENT_IP_HEADER ignored, and startup +# fails if VGW_CLIENT_IP_HEADER is set while this list is empty. +#VGW_TRUSTED_PROXIES= + ############## # Event Logs # ############## diff --git a/iamapi/server.go b/iamapi/server.go index 575c923b..2106b0be 100644 --- a/iamapi/server.go +++ b/iamapi/server.go @@ -30,6 +30,7 @@ import ( "github.com/versity/versitygw/iamapi/internal/iammiddleware" "github.com/versity/versitygw/iamapi/internal/iamutil" "github.com/versity/versitygw/iamapi/storage" + "github.com/versity/versitygw/internal/clientip" "github.com/versity/versitygw/internal/netutil" ) @@ -66,6 +67,10 @@ type IAMApiServer struct { oidc OIDCConfig // corsAllowOrigin is the single origin browsers may call this API from corsAllowOrigin string + // clientIPHeader and trustedProxies control the address recorded in the + // request log; see WithClientIPHeader and WithTrustedProxies. + clientIPHeader string + trustedProxies clientip.TrustedProxies } // OIDCConfig groups the settings that govern how this API treats OIDC @@ -174,6 +179,16 @@ func New(store storage.Storer, root RootCredentials, opts ...Option) (*IAMApiSer return nil, err } + // validate the header once here, so the request path can compare it + // directly instead of re-normalizing on every request + if server.clientIPHeader != "" { + header, err := clientip.NormalizeClientIPHeader(server.clientIPHeader) + if err != nil { + return nil, err + } + server.clientIPHeader = header + } + app := fiber.New(fiber.Config{ AppName: "versitygw-iam", ServerHeader: "VERSITYGW", @@ -199,6 +214,7 @@ func New(store storage.Storer, root RootCredentials, opts ...Option) (*IAMApiSer Format: "${time} | vgw-iam | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n", CustomTags: map[string]logger.LogFunc{ logger.TagQueryStringParams: debuglogger.RedactedQueryParamsTag, + logger.TagIP: clientip.Tag, }, })) } @@ -209,6 +225,12 @@ func New(store storage.Storer, root RootCredentials, opts ...Option) (*IAMApiSer app.Use("*", iammiddleware.RequestIDs()) + // resolve the logged client address from a trusted proxy header, when + // one is configured + if server.clientIPHeader != "" { + app.Use("*", clientip.Middleware(server.clientIPHeader, server.trustedProxies)) + } + if server.health != "" { app.Get(server.health, func(ctx fiber.Ctx) error { return ctx.SendStatus(http.StatusOK) @@ -257,6 +279,23 @@ func WithSocketPerm(perm os.FileMode) Option { return func(s *IAMApiServer) { s.socketPerm = perm } } +// WithClientIPHeader records the client address from the named proxy header in +// the request log. The accepted values are clientip.ClientIPHeaderForwardedFor +// and clientip.ClientIPHeaderRealIP; an empty value (the default) keeps the +// socket peer address. The header is only honoured from a peer listed in +// WithTrustedProxies. ctx.IP() is not changed, so SourceIP in policy +// evaluation keeps using the socket address. +func WithClientIPHeader(header string) Option { + return func(s *IAMApiServer) { s.clientIPHeader = header } +} + +// WithTrustedProxies sets the peers whose client IP header is trusted, as +// parsed by clientip.ParseTrustedProxies. It only has an effect together with +// WithClientIPHeader. +func WithTrustedProxies(trusted clientip.TrustedProxies) Option { + return func(s *IAMApiServer) { s.trustedProxies = trusted } +} + // WithCORSAllowOrigin sets the Access-Control-Allow-Origin value returned to // browsers, and enables preflight handling. Required for the WebUI, which // never shares a port with the IAM API. Empty (the default) skips the CORS diff --git a/internal/clientip/client_ip.go b/internal/clientip/client_ip.go new file mode 100644 index 00000000..66394b74 --- /dev/null +++ b/internal/clientip/client_ip.go @@ -0,0 +1,228 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Package clientip resolves the client address recorded in the request logs +// and access logs. It lives here rather than in s3api/utils so the standalone +// IAM and WebUI servers can use it without importing the S3 side. +package clientip + +import ( + "fmt" + "net" + "net/netip" + "strings" + + "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/internal/httpctx" +) + +// Proxy headers the client address can be read from when the operator opts in +// with the client-ip-header option. +const ( + ClientIPHeaderForwardedFor = "X-Forwarded-For" + ClientIPHeaderRealIP = "X-Real-Ip" +) + +// trustUnixEntry is the trusted-proxies entry that trusts the peer of a +// listener bound to a unix socket path. +const trustUnixEntry = "unix" + +// TrustedProxies is the set of peers whose forwarding headers are honoured. +type TrustedProxies struct { + // Prefixes holds the trusted IP addresses and CIDR prefixes. + Prefixes []netip.Prefix + // Unix trusts a peer that connected over a unix domain socket. fasthttp + // reports such a peer as 0.0.0.0, which only a prefix covering 0.0.0.0 + // (0.0.0.0/32, 0.0.0.0/0) matches, so without this entry a proxy on a + // unix socket listener is not trusted. + Unix bool +} + +// IsEmpty reports whether no peer is trusted. +func (t TrustedProxies) IsEmpty() bool { + return len(t.Prefixes) == 0 && !t.Unix +} + +// NormalizeClientIPHeader validates a configured client IP header and returns +// its canonical spelling. An empty value means the option is disabled. +func NormalizeClientIPHeader(header string) (string, error) { + trimmed := strings.TrimSpace(header) + switch { + case trimmed == "": + return "", nil + case strings.EqualFold(trimmed, ClientIPHeaderForwardedFor): + return ClientIPHeaderForwardedFor, nil + case strings.EqualFold(trimmed, ClientIPHeaderRealIP): + return ClientIPHeaderRealIP, nil + default: + return "", fmt.Errorf("invalid client IP header %q: must be %q or %q", + header, ClientIPHeaderForwardedFor, ClientIPHeaderRealIP) + } +} + +// ParseTrustedProxies parses a comma-separated list of IP addresses, CIDR +// prefixes and the literal "unix" into the set of peers whose forwarding +// headers are trusted. The "unix" entry trusts a peer connected over a unix +// domain socket; an empty list yields an empty set. +func ParseTrustedProxies(list string) (TrustedProxies, error) { + var trusted TrustedProxies + for _, entry := range strings.Split(list, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if strings.EqualFold(entry, trustUnixEntry) { + trusted.Unix = true + continue + } + if prefix, err := netip.ParsePrefix(entry); err == nil { + trusted.Prefixes = append(trusted.Prefixes, normalizePrefix(prefix)) + continue + } + addr, err := netip.ParseAddr(entry) + if err != nil { + return TrustedProxies{}, fmt.Errorf("invalid trusted proxy %q: must be an IP address, a CIDR prefix, or %q", entry, trustUnixEntry) + } + addr = addr.Unmap() + trusted.Prefixes = append(trusted.Prefixes, netip.PrefixFrom(addr, addr.BitLen())) + } + return trusted, nil +} + +// normalizePrefix unmaps an IPv4-mapped IPv6 prefix so it matches the unmapped +// addresses the trusted set compares. +func normalizePrefix(prefix netip.Prefix) netip.Prefix { + addr := prefix.Addr() + if addr.Is4In6() && prefix.Bits() >= 96 { + return netip.PrefixFrom(addr.Unmap(), prefix.Bits()-96) + } + return prefix +} + +// ClientIP returns the address to record for a request. The client IP +// middleware stores the address resolved from the configured proxy header; +// without it this is the socket peer address from ctx.IP(). +func ClientIP(ctx fiber.Ctx) string { + if ip, ok := httpctx.ContextKeyClientIP.Get(ctx).(string); ok && ip != "" { + return ip + } + return ctx.IP() +} + +// ClientIPFromHeader resolves the client address from header. The header is +// only honoured when the connection peer is one of the configured trusted +// proxies; from any other peer it is ignored and the socket peer address is +// returned, so a client cannot choose the address that gets logged. +// +// X-Forwarded-For is a comma-separated chain, oldest hop first. It is walked +// right to left, skipping the trusted proxies that appended to it; the first +// address that is not a trusted proxy is the client. A proxy may also add its +// own header line instead of appending to the existing one, so every line is +// walked, last line first. Some proxies write "ip:port" entries, which are +// accepted too. X-Real-Ip carries a single address; when a proxy adds a line +// rather than replacing the client's, the last line is the proxy's, so the +// last line is the one read. +// +// When every hop is a trusted proxy — a deployment whose trusted-proxies is +// the ingress pod CIDR, say — the leftmost hop is returned, as nginx's +// real_ip_recursive does. The socket peer would otherwise be the nearest +// proxy, which is never the client. It is no less safe than stopping at an +// untrusted hop: a trusted peer can already choose the logged address by +// putting any untrusted address on the left. +// +// Any unusable entry falls back to the socket peer address rather than +// returning a value from further up the chain. +func ClientIPFromHeader(ctx fiber.Ctx, header string, trusted TrustedProxies) string { + peer := ctx.IP() + if !trusted.matchesPeer(ctx, peer) { + return peer + } + + if header == ClientIPHeaderRealIP { + // a proxy that adds its own line rather than replacing the client's + // leaves the client's line first, so take the last one + lines := ctx.Request().Header.PeekAll(header) + if len(lines) == 0 { + return peer + } + if addr, ok := parseAddr(string(lines[len(lines)-1])); ok { + return addr.String() + } + return peer + } + + lines := ctx.Request().Header.PeekAll(header) + var leftmost netip.Addr + haveLeftmost := false + for i := len(lines) - 1; i >= 0; i-- { + hops := strings.Split(string(lines[i]), ",") + for j := len(hops) - 1; j >= 0; j-- { + addr, ok := parseAddr(strings.TrimSpace(hops[j])) + if !ok { + return peer + } + if trusted.contains(addr) { + leftmost, haveLeftmost = addr, true + continue + } + return addr.String() + } + } + if haveLeftmost { + return leftmost.String() + } + return peer +} + +// matchesPeer reports whether the connection peer may speak for the client. +func (t TrustedProxies) matchesPeer(ctx fiber.Ctx, peer string) bool { + if t.Unix { + if _, ok := ctx.RequestCtx().RemoteAddr().(*net.UnixAddr); ok { + return true + } + } + if addr, ok := parseAddr(peer); ok { + return t.contains(addr) + } + return false +} + +// contains reports whether addr is covered by one of the trusted prefixes. +func (t TrustedProxies) contains(addr netip.Addr) bool { + for _, prefix := range t.Prefixes { + if prefix.Contains(addr) { + return true + } + } + return false +} + +// parseAddr parses an address, accepting the "ip:port" form some proxies write +// in a forwarding header, and returns it unmapped from IPv4-in-IPv6 form so +// every spelling of one client compares equal. An address with an IPv6 zone is +// rejected: the zone is free text and would be written to the logs verbatim. +func parseAddr(s string) (netip.Addr, bool) { + addr, err := netip.ParseAddr(s) + if err != nil { + addrPort, err := netip.ParseAddrPort(s) + if err != nil { + return netip.Addr{}, false + } + addr = addrPort.Addr() + } + if addr.Zone() != "" { + return netip.Addr{}, false + } + return addr.Unmap(), true +} diff --git a/internal/clientip/client_ip_middleware.go b/internal/clientip/client_ip_middleware.go new file mode 100644 index 00000000..20bab0af --- /dev/null +++ b/internal/clientip/client_ip_middleware.go @@ -0,0 +1,40 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package clientip + +import ( + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/logger" + "github.com/versity/versitygw/internal/httpctx" +) + +// Middleware resolves the client address for logging from the given proxy +// header, honouring it only when the connection peer is a trusted proxy, and +// stores it in the request context. Requests from an untrusted peer, or with +// no usable header value, keep the socket peer address. +func Middleware(header string, trusted TrustedProxies) fiber.Handler { + return func(ctx fiber.Ctx) error { + httpctx.ContextKeyClientIP.Set(ctx, ClientIPFromHeader(ctx, header, trusted)) + return ctx.Next() + } +} + +// Tag writes the address resolved by Middleware, falling back to the socket +// peer address when no proxy header is configured. Register it as the +// logger.TagIP custom tag so the request log uses the same address as the +// access logs. +func Tag(output logger.Buffer, ctx fiber.Ctx, _ *logger.Data, _ string) (int, error) { + return output.WriteString(ClientIP(ctx)) +} diff --git a/internal/clientip/client_ip_middleware_test.go b/internal/clientip/client_ip_middleware_test.go new file mode 100644 index 00000000..15e6095e --- /dev/null +++ b/internal/clientip/client_ip_middleware_test.go @@ -0,0 +1,351 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package clientip + +import ( + "io" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The socket peer in every case below; the trusted lists decide whether it is +// allowed to speak for the client. +const clientIPSocketAddr = "198.51.100.9" + +var trustedSocketPeer = netip.MustParsePrefix("198.51.100.9/32") + +// clientIPRequest runs one request through a fiber app whose peer address is +// pinned, with the client IP middleware configured for trustedHeader, and +// returns the address the loggers would record. +func clientIPRequest(t *testing.T, trustedHeader string, trusted []netip.Prefix, headers func(*http.Request)) string { + t.Helper() + + app := fiber.New() + // app.Test serves over a placeholder connection; pin the peer address + // before the client IP middleware reads it. + app.Use("*", func(ctx fiber.Ctx) error { + ctx.RequestCtx().SetRemoteAddr(&net.TCPAddr{IP: net.ParseIP(clientIPSocketAddr), Port: 12345}) + return ctx.Next() + }) + if trustedHeader != "" { + app.Use("*", Middleware(trustedHeader, TrustedProxies{Prefixes: trusted})) + } + app.Get("/", func(ctx fiber.Ctx) error { + return ctx.SendString(ClientIP(ctx)) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + if headers != nil { + headers(req) + } + + resp, err := app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return string(body) +} + +// header sets one header value on the request. +func header(name, value string) func(*http.Request) { + return func(req *http.Request) { req.Header.Set(name, value) } +} + +// headerLines adds the values as separate header lines, as a proxy that adds +// its own X-Forwarded-For line rather than appending to the existing one does. +func headerLines(name string, values ...string) func(*http.Request) { + return func(req *http.Request) { + for _, value := range values { + req.Header.Add(name, value) + } + } +} + +func TestClientIPMiddleware(t *testing.T) { + tests := []struct { + name string + trustedHeader string + trusted []netip.Prefix + headers func(*http.Request) + want string + }{ + { + name: "x-real-ip from a trusted proxy", + trustedHeader: "X-Real-Ip", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Real-Ip", "203.0.113.7"), + want: "203.0.113.7", + }, + { + name: "x-real-ip ipv6 is normalized", + trustedHeader: "X-Real-Ip", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Real-Ip", "2001:DB8::1"), + want: "2001:db8::1", + }, + { + name: "x-forwarded-for single hop from a trusted proxy", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Forwarded-For", "203.0.113.7"), + want: "203.0.113.7", + }, + { + // The chain is walked right to left, skipping the trusted + // proxies that appended to it, so two proxies listed in + // trusted-proxies still resolve the original client. + name: "two trusted proxies resolve the client", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{ + trustedSocketPeer, + netip.MustParsePrefix("203.0.113.9/32"), + }, + headers: header("X-Forwarded-For", "203.0.113.7, 203.0.113.9"), + want: "203.0.113.7", + }, + { + name: "two trusted proxies with cidr", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{ + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + }, + headers: header("X-Forwarded-For", "192.0.2.7, 203.0.113.9"), + want: "192.0.2.7", + }, + { + // The blocking review: the client wrote 6.6.6.6 and nginx + // appended the real client with + // proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for. + name: "a forged leftmost entry is never reached", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Forwarded-For", "6.6.6.6, 203.0.113.7"), + want: "203.0.113.7", + }, + { + // Azure Application Gateway writes ip:port entries. + name: "ip:port entries are accepted", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Forwarded-For", "203.0.113.7:1234, 198.51.100.9"), + want: "203.0.113.7", + }, + { + name: "bracketed ipv6 with a port is accepted", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Forwarded-For", "[2001:db8::1]:1234, 198.51.100.9"), + want: "2001:db8::1", + }, + { + name: "ipv6 client behind a trusted proxy", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Forwarded-For", "2001:db8::1, 198.51.100.9"), + want: "2001:db8::1", + }, + { + // A proxy that adds its own line instead of appending: the + // client's line is first, the proxy's appended line last. + name: "separate header lines are walked last first", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: headerLines("X-Forwarded-For", "6.6.6.6", "203.0.113.7"), + want: "203.0.113.7", + }, + { + // An untrusted peer cannot choose the logged address. + name: "header from an unlisted peer is ignored", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + headers: header("X-Forwarded-For", "6.6.6.6"), + want: clientIPSocketAddr, + }, + { + name: "x-real-ip from an unlisted peer is ignored", + trustedHeader: "X-Real-Ip", + trusted: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + headers: header("X-Real-Ip", "6.6.6.6"), + want: clientIPSocketAddr, + }, + { + name: "no trusted proxies ignores the header", + trustedHeader: "X-Forwarded-For", + headers: header("X-Forwarded-For", "203.0.113.7"), + want: clientIPSocketAddr, + }, + { + name: "the header that is not trusted is ignored", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Real-Ip", "203.0.113.7"), + want: clientIPSocketAddr, + }, + { + name: "x-forwarded-for absent falls back to the socket address", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{trustedSocketPeer}, + want: clientIPSocketAddr, + }, + { + name: "x-forwarded-for empty falls back to the socket address", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Forwarded-For", ""), + want: clientIPSocketAddr, + }, + { + name: "x-forwarded-for malformed falls back to the socket address", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Forwarded-For", "unknown"), + want: clientIPSocketAddr, + }, + { + name: "a malformed hop stops the walk", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Forwarded-For", "unknown, 198.51.100.9"), + want: clientIPSocketAddr, + }, + { + name: "x-real-ip absent falls back to the socket address", + trustedHeader: "X-Real-Ip", + trusted: []netip.Prefix{trustedSocketPeer}, + want: clientIPSocketAddr, + }, + { + name: "x-real-ip empty falls back to the socket address", + trustedHeader: "X-Real-Ip", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Real-Ip", ""), + want: clientIPSocketAddr, + }, + { + name: "x-real-ip malformed falls back to the socket address", + trustedHeader: "X-Real-Ip", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Real-Ip", "unknown"), + want: clientIPSocketAddr, + }, + { + // A zone is free text and the access log is space-delimited, so a + // zoned address would shift every field after RemoteIP. + name: "an ipv6 zone is rejected", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Forwarded-For", "::1%a b, 198.51.100.9"), + want: clientIPSocketAddr, + }, + { + name: "an ipv6 zone in a bracketed address is rejected", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Forwarded-For", "[fe80::1%a b]:80, 198.51.100.9"), + want: clientIPSocketAddr, + }, + { + name: "an ipv6 zone in x-real-ip is rejected", + trustedHeader: "X-Real-Ip", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: header("X-Real-Ip", "::1%x"), + want: clientIPSocketAddr, + }, + { + // A proxy that adds its own line rather than replacing the + // client's leaves the client's line first, so the last line is + // the one the trusted proxy wrote. + name: "x-real-ip takes the last header line", + trustedHeader: "X-Real-Ip", + trusted: []netip.Prefix{trustedSocketPeer}, + headers: headerLines("X-Real-Ip", "6.6.6.6", "203.0.113.7"), + want: "203.0.113.7", + }, + { + // With every hop listed as a trusted proxy there is no untrusted + // address to stop at, so the leftmost hop is logged, as nginx's + // real_ip_recursive does. + name: "all hops trusted returns the leftmost hop", + trustedHeader: "X-Forwarded-For", + trusted: []netip.Prefix{ + trustedSocketPeer, + netip.MustParsePrefix("203.0.113.9/32"), + netip.MustParsePrefix("203.0.113.10/32"), + }, + headers: header("X-Forwarded-For", "203.0.113.10, 203.0.113.9"), + want: "203.0.113.10", + }, + { + name: "no header configured keeps the socket address", + headers: header("X-Real-Ip", "203.0.113.7"), + want: clientIPSocketAddr, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := clientIPRequest(t, tt.trustedHeader, tt.trusted, tt.headers) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestClientIPMiddlewareUnixPeer covers a listener bound to a unix socket path: +// fasthttp reports the peer as 0.0.0.0, so no listed proxy address matches it +// and the peer is trusted only through the explicit "unix" trusted-proxies entry. +func TestClientIPMiddlewareUnixPeer(t *testing.T) { + request := func(t *testing.T, trusted TrustedProxies) string { + t.Helper() + + app := fiber.New() + app.Use("*", func(ctx fiber.Ctx) error { + ctx.RequestCtx().SetRemoteAddr(&net.UnixAddr{Name: "/run/versitygw.sock", Net: "unix"}) + return ctx.Next() + }) + app.Use("*", Middleware("X-Forwarded-For", trusted)) + app.Get("/", func(ctx fiber.Ctx) error { + return ctx.SendString(ClientIP(ctx)) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Forwarded-For", "203.0.113.7") + + resp, err := app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return string(body) + } + + t.Run("unix entry trusts the peer", func(t *testing.T) { + assert.Equal(t, "203.0.113.7", request(t, TrustedProxies{Unix: true})) + }) + t.Run("without the unix entry the header is ignored", func(t *testing.T) { + // ctx.IP() is 0.0.0.0 for a non-TCP peer; that is what gets logged. + assert.Equal(t, "0.0.0.0", request(t, TrustedProxies{Prefixes: []netip.Prefix{trustedSocketPeer}})) + }) +} diff --git a/internal/clientip/client_ip_test.go b/internal/clientip/client_ip_test.go new file mode 100644 index 00000000..99947320 --- /dev/null +++ b/internal/clientip/client_ip_test.go @@ -0,0 +1,127 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package clientip + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeClientIPHeader(t *testing.T) { + tests := []struct { + name string + header string + want string + wantErr bool + }{ + {name: "disabled", header: "", want: ""}, + {name: "blank is disabled", header: " ", want: ""}, + {name: "x-forwarded-for", header: "X-Forwarded-For", want: ClientIPHeaderForwardedFor}, + {name: "x-forwarded-for lowercase", header: "x-forwarded-for", want: ClientIPHeaderForwardedFor}, + {name: "x-real-ip", header: "X-Real-Ip", want: ClientIPHeaderRealIP}, + {name: "x-real-ip uppercase", header: "X-REAL-IP", want: ClientIPHeaderRealIP}, + {name: "surrounding spaces", header: " X-Real-Ip ", want: ClientIPHeaderRealIP}, + {name: "unknown header", header: "Forwarded", wantErr: true}, + {name: "typo", header: "X-Forward-For", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeClientIPHeader(tt.header) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client IP header") + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseTrustedProxies(t *testing.T) { + tests := []struct { + name string + list string + want TrustedProxies + wantErr bool + }{ + {name: "empty", list: "", want: TrustedProxies{}}, + {name: "blank entries are skipped", list: " , ", want: TrustedProxies{}}, + { + name: "single address becomes a full-length prefix", + list: "192.0.2.1", + want: TrustedProxies{Prefixes: []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}}, + }, + { + name: "comma separated mixed forms", + list: " 10.0.0.0/8 , 2001:db8::1 , 198.51.100.0/24", + want: TrustedProxies{Prefixes: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("2001:db8::1/128"), + netip.MustParsePrefix("198.51.100.0/24"), + }}, + }, + { + name: "ipv4 mapped address is unmapped", + list: "::ffff:192.0.2.1", + want: TrustedProxies{Prefixes: []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}}, + }, + { + name: "unix entry trusts a socket peer", + list: "unix", + want: TrustedProxies{Unix: true}, + }, + { + name: "unix entry alongside addresses", + list: "unix, 10.0.0.0/8", + want: TrustedProxies{Unix: true, Prefixes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}}, + }, + { + name: "unix entry is case-insensitive", + list: "UNIX", + want: TrustedProxies{Unix: true}, + }, + {name: "garbage", list: "not-an-ip", wantErr: true}, + {name: "bad prefix", list: "10.0.0.0/33", wantErr: true}, + { + name: "one bad entry fails the whole list", + list: "10.0.0.0/8, nope", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseTrustedProxies(tt.list) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid trusted proxy") + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestTrustedProxiesIsEmpty(t *testing.T) { + assert.True(t, TrustedProxies{}.IsEmpty()) + assert.False(t, TrustedProxies{Unix: true}.IsEmpty()) + assert.False(t, TrustedProxies{Prefixes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}}.IsEmpty()) +} diff --git a/internal/httpctx/context_keys.go b/internal/httpctx/context_keys.go index 13845ae7..012f7463 100644 --- a/internal/httpctx/context_keys.go +++ b/internal/httpctx/context_keys.go @@ -42,6 +42,7 @@ const ( ContextKeyWebsiteConfig ContextKey = "website-config" ContextKeyCallerIdentity ContextKey = "iam-caller-identity" ContextKeyOriginalURIPath ContextKey = "original-uri-path" + ContextKeyClientIP ContextKey = "client-ip" ) func (ck ContextKey) Set(ctx fiber.Ctx, val any) { diff --git a/s3api/admin-server.go b/s3api/admin-server.go index ebd0420d..323f8c6b 100644 --- a/s3api/admin-server.go +++ b/s3api/admin-server.go @@ -28,6 +28,7 @@ import ( "github.com/versity/versitygw/internal/netutil" "github.com/versity/versitygw/s3api/controllers" "github.com/versity/versitygw/s3api/middlewares" + "github.com/versity/versitygw/s3api/utils" "github.com/versity/versitygw/s3log" ) @@ -43,7 +44,13 @@ type S3AdminServer struct { maxRequests int socketPerm os.FileMode pathPrefix string - extraRoutes []adminRouteMount + clientIPHeader string + trustedProxies utils.TrustedProxies + // clientIPHeaderErr holds an invalid WithAdminClientIPHeader value; the + // admin server has no error-returning constructor, so ServeMultiPort + // reports it at startup rather than logging the socket address silently. + clientIPHeaderErr error + extraRoutes []adminRouteMount } // adminRouteMount is a route registered on the admin app after the @@ -66,6 +73,18 @@ func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, region opt(server) } + // validate the header once here, so the request path can compare it + // directly instead of re-normalizing on every request + if server.clientIPHeader != "" { + header, err := utils.NormalizeClientIPHeader(server.clientIPHeader) + if err != nil { + server.clientIPHeaderErr = err + server.clientIPHeader = "" + } else { + server.clientIPHeader = header + } + } + app := fiber.New(fiber.Config{ AppName: "versitygw", ServerHeader: "VERSITYGW", @@ -87,12 +106,19 @@ func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, region Format: "${time} | adm | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n", CustomTags: map[string]logger.LogFunc{ logger.TagQueryStringParams: debuglogger.RedactedQueryParamsTag, + logger.TagIP: utils.ClientIPTag, }, })) } // initialize requestId middleware app.Use("*", middlewares.RequestIDs()) + // resolve the logged client address from a trusted proxy header, when + // one is configured + if server.clientIPHeader != "" { + app.Use("*", utils.ClientIPMiddleware(server.clientIPHeader, server.trustedProxies)) + } + // initialize total requests cap limiter middleware app.Use("*", middlewares.RateLimiter(server.maxRequests, nil, l)) @@ -139,6 +165,19 @@ func WithAdminCORSAllowOrigin(origin string) AdminOpt { return func(s *S3AdminServer) { s.corsAllowOrigin = origin } } +// WithAdminClientIPHeader is WithClientIPHeader for the standalone admin +// server. +func WithAdminClientIPHeader(header string) AdminOpt { + return func(s *S3AdminServer) { s.clientIPHeader = header } +} + +// WithAdminTrustedProxies sets the peers whose client IP header is trusted for +// the standalone admin server. It only has an effect together with +// WithAdminClientIPHeader. +func WithAdminTrustedProxies(trusted utils.TrustedProxies) AdminOpt { + return func(s *S3AdminServer) { s.trustedProxies = trusted } +} + // WithAdminConcurrencyLimiter sets the admin standalone server's maximum // connection limit and the hard limit for in-flight requests. func WithAdminConcurrencyLimiter(maxConnections, maxRequests int) AdminOpt { @@ -180,6 +219,9 @@ func WithAdminRoute(method, path string, handlers ...fiber.Handler) AdminOpt { // on all of them simultaneously. This supports listening on multiple ports and/or // addresses (e.g., [":8080", "localhost:8081"]). func (sa *S3AdminServer) ServeMultiPort(ports []string) error { + if sa.clientIPHeaderErr != nil { + return sa.clientIPHeaderErr + } if len(ports) == 0 { return fmt.Errorf("no ports specified") } diff --git a/s3api/client_ip_test.go b/s3api/client_ip_test.go new file mode 100644 index 00000000..b6ccd5e1 --- /dev/null +++ b/s3api/client_ip_test.go @@ -0,0 +1,152 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package s3api + +import ( + "io" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/versity/versitygw/backend" + "github.com/versity/versitygw/s3api/controllers" + "github.com/versity/versitygw/s3api/utils" +) + +// testConnPeer is the peer address fiber's app.Test serves every request from. +var testConnPeer = netip.MustParsePrefix("0.0.0.0/32") + +// reportClientIP pins the peer address and returns the address the loggers +// would record for the request. +func reportClientIP(ctx fiber.Ctx) error { + ctx.RequestCtx().SetRemoteAddr(&net.TCPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345}) + return ctx.SendString(utils.ClientIP(ctx)) +} + +func TestS3ApiServerClientIPHeader(t *testing.T) { + server, err := newTestS3ApiServer( + WithQuiet(), + WithClientIPHeader(utils.ClientIPHeaderRealIP), + WithTrustedProxies(utils.TrustedProxies{Prefixes: []netip.Prefix{testConnPeer}}), + WithRoute(http.MethodGet, "/client-ip", reportClientIP), + ) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/client-ip", nil) + req.Header.Set(utils.ClientIPHeaderRealIP, "203.0.113.7") + + resp, err := server.app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "203.0.113.7", string(body)) +} + +func TestS3ApiServerClientIPHeaderDisabled(t *testing.T) { + server, err := newTestS3ApiServer( + WithQuiet(), + WithRoute(http.MethodGet, "/client-ip", reportClientIP), + ) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/client-ip", nil) + req.Header.Set(utils.ClientIPHeaderRealIP, "203.0.113.7") + + resp, err := server.app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "192.0.2.1", string(body)) +} + +// TestS3ApiServerClientIPHeaderUnlistedPeer checks that a peer outside +// trusted-proxies cannot choose the logged address. +func TestS3ApiServerClientIPHeaderUnlistedPeer(t *testing.T) { + server, err := newTestS3ApiServer( + WithQuiet(), + WithClientIPHeader(utils.ClientIPHeaderForwardedFor), + WithTrustedProxies(utils.TrustedProxies{Prefixes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}}), + WithRoute(http.MethodGet, "/client-ip", reportClientIP), + ) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/client-ip", nil) + req.Header.Set(utils.ClientIPHeaderForwardedFor, "6.6.6.6") + + resp, err := server.app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "0.0.0.0", string(body)) +} + +func TestS3ApiServerRejectsUnknownClientIPHeader(t *testing.T) { + _, err := newTestS3ApiServer( + WithQuiet(), + WithClientIPHeader("Forwarded"), + WithTrustedProxies(utils.TrustedProxies{Prefixes: []netip.Prefix{testConnPeer}}), + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client IP header") +} + +func TestS3AdminServerClientIPHeader(t *testing.T) { + server := NewAdminServer(backend.BackendUnsupported{}, testAdminRoot, "us-east-1", testAdminIAM(), nil, controllers.S3ApiController{}, + WithAdminConcurrencyLimiter(10, 10), + WithAdminQuiet(), + WithAdminClientIPHeader(utils.ClientIPHeaderForwardedFor), + WithAdminTrustedProxies(utils.TrustedProxies{Prefixes: []netip.Prefix{ + testConnPeer, + netip.MustParsePrefix("198.51.100.9/32"), + }}), + WithAdminRoute(http.MethodGet, "/client-ip", reportClientIP), + ) + + req := httptest.NewRequest(http.MethodGet, "/client-ip", nil) + req.Header.Set(utils.ClientIPHeaderForwardedFor, "203.0.113.7, 198.51.100.9") + + resp, err := server.app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "203.0.113.7", string(body)) +} + +// TestS3AdminServerRejectsUnknownClientIPHeader has no error-returning +// constructor to report the invalid value, so it must surface at startup. +func TestS3AdminServerRejectsUnknownClientIPHeader(t *testing.T) { + server := NewAdminServer(backend.BackendUnsupported{}, testAdminRoot, "us-east-1", testAdminIAM(), nil, controllers.S3ApiController{}, + WithAdminConcurrencyLimiter(10, 10), + WithAdminQuiet(), + WithAdminClientIPHeader("Forwarded"), + ) + + err := server.ServeMultiPort([]string{"127.0.0.1:0"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client IP header") +} diff --git a/s3api/server.go b/s3api/server.go index f70d66c3..f0bc1895 100644 --- a/s3api/server.go +++ b/s3api/server.go @@ -57,6 +57,8 @@ type S3ApiServer struct { maxRequests int webuiMountPrefix string webuiSrvCfg *webui.ServerConfig + clientIPHeader string + trustedProxies utils.TrustedProxies routes []routeMount middlewares []middlewareMount socketPerm os.FileMode @@ -104,6 +106,16 @@ func New( opt(server) } + // validate the header once here, so the request path can compare it + // directly instead of re-normalizing on every request + if server.clientIPHeader != "" { + header, err := utils.NormalizeClientIPHeader(server.clientIPHeader) + if err != nil { + return nil, err + } + server.clientIPHeader = header + } + app := fiber.New(fiber.Config{ AppName: "versitygw", ServerHeader: "VERSITYGW", @@ -138,6 +150,7 @@ func New( Format: "${time} | vgw | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n", CustomTags: map[string]logger.LogFunc{ logger.TagQueryStringParams: debuglogger.RedactedQueryParamsTag, + logger.TagIP: utils.ClientIPTag, }, })) } @@ -145,6 +158,12 @@ func New( // initialize requestId middleware app.Use("*", middlewares.RequestIDs()) + // resolve the logged client address from a trusted proxy header, when + // one is configured + if server.clientIPHeader != "" { + app.Use("*", utils.ClientIPMiddleware(server.clientIPHeader, server.trustedProxies)) + } + // Set up health endpoint if specified if server.health != "" { app.Get(server.health, func(ctx fiber.Ctx) error { @@ -301,6 +320,23 @@ func WithCORSAllowOrigin(origin string) Option { return func(s *S3ApiServer) { s.Router.corsAllowOrigin = origin } } +// WithClientIPHeader records the client address from the named proxy header in +// the request log and the S3 access logs. The accepted values are +// utils.ClientIPHeaderForwardedFor and utils.ClientIPHeaderRealIP; an empty +// value (the default) keeps the socket peer address. The header is only +// honoured from a peer listed in WithTrustedProxies. ctx.IP() is not changed, +// so IAM aws:SourceIp conditions keep evaluating the socket address. +func WithClientIPHeader(header string) Option { + return func(s *S3ApiServer) { s.clientIPHeader = header } +} + +// WithTrustedProxies sets the peers whose client IP header is trusted, as +// parsed by utils.ParseTrustedProxies. It only has an effect together with +// WithClientIPHeader. +func WithTrustedProxies(trusted utils.TrustedProxies) Option { + return func(s *S3ApiServer) { s.trustedProxies = trusted } +} + // WithWebUI mounts the WebUI on the S3 server's Fiber app at the given path prefix, // before S3 routes are registered. The prefix must start with "/" and must not be // empty or just "/". diff --git a/s3api/server_test.go b/s3api/server_test.go index 0c06c17f..ac88036d 100644 --- a/s3api/server_test.go +++ b/s3api/server_test.go @@ -15,6 +15,7 @@ package s3api import ( + "io" "net" "net/http" "net/http/httptest" @@ -287,3 +288,41 @@ func TestCustomMountValidation(t *testing.T) { }) } } + +// TestRequestAddressIgnoresProxyHeaders pins the peer address reported by +// ctx.IP(). The gateway deliberately configures neither fiber's ProxyHeader nor +// TrustProxy, because ctx.IP() feeds aws:SourceIp in IAM policy conditions and +// a client-supplied header must not be able to influence that. The logging +// address is resolved separately from an opt-in header. +func TestRequestAddressIgnoresProxyHeaders(t *testing.T) { + server, err := newTestS3ApiServer( + WithQuiet(), + WithRoute(http.MethodGet, "/addr", func(ctx fiber.Ctx) error { + // app.Test() serves over a placeholder connection; pin the peer + // address so the fallback is deterministic. + ctx.RequestCtx().SetRemoteAddr(&net.TCPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345}) + return ctx.SendString(ctx.IP()) + }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/addr", nil) + req.Header.Set("X-Forwarded-For", "203.0.113.7") + req.Header.Set("X-Real-Ip", "203.0.113.7") + + resp, err := server.app.Test(req) + if err != nil { + t.Fatalf("app.Test() error = %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if got := string(body); got != "192.0.2.1" { + t.Fatalf("ctx.IP() = %q, want the socket peer address %q", got, "192.0.2.1") + } +} diff --git a/s3api/utils/client_ip.go b/s3api/utils/client_ip.go new file mode 100644 index 00000000..f0a2a210 --- /dev/null +++ b/s3api/utils/client_ip.go @@ -0,0 +1,50 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package utils + +import ( + "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/internal/clientip" +) + +// Re-exports for the S3 callers, which have always reached these through +// s3api/utils. The implementation is in internal/clientip so the standalone +// IAM and WebUI packages can use it without importing the S3 side. +type TrustedProxies = clientip.TrustedProxies + +const ( + ClientIPHeaderForwardedFor = clientip.ClientIPHeaderForwardedFor + ClientIPHeaderRealIP = clientip.ClientIPHeaderRealIP +) + +// ClientIPTag is clientip.Tag for the S3 loggers. +var ClientIPTag = clientip.Tag + +func NormalizeClientIPHeader(header string) (string, error) { + return clientip.NormalizeClientIPHeader(header) +} + +func ParseTrustedProxies(list string) (TrustedProxies, error) { + return clientip.ParseTrustedProxies(list) +} + +func ClientIP(ctx fiber.Ctx) string { + return clientip.ClientIP(ctx) +} + +// ClientIPMiddleware is clientip.Middleware for the S3 and website servers. +func ClientIPMiddleware(header string, trusted TrustedProxies) fiber.Handler { + return clientip.Middleware(header, trusted) +} diff --git a/s3api/utils/context-keys.go b/s3api/utils/context-keys.go index 54612b77..4b822c68 100644 --- a/s3api/utils/context-keys.go +++ b/s3api/utils/context-keys.go @@ -40,4 +40,5 @@ const ( ContextKeyRequestID = httpctx.ContextKeyRequestID ContextKeyHostID = httpctx.ContextKeyHostID ContextKeyWebsiteConfig = httpctx.ContextKeyWebsiteConfig + ContextKeyClientIP = httpctx.ContextKeyClientIP ) diff --git a/s3event/event.go b/s3event/event.go index c3d69731..34311600 100644 --- a/s3event/event.go +++ b/s3event/event.go @@ -169,7 +169,7 @@ func createEventSchema(ctx fiber.Ctx, meta EventMeta, configId ConfigurationId) PrincipalId: acc.Access, }, RequestParameters: EventRequestParams{ - SourceIPAddress: ctx.IP(), + SourceIPAddress: utils.ClientIP(ctx), }, ResponseElements: EventResponseElements{ RequestId: ctx.Get("X-Amz-Request-Id"), diff --git a/s3log/client_ip_test.go b/s3log/client_ip_test.go new file mode 100644 index 00000000..6161383e --- /dev/null +++ b/s3log/client_ip_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package s3log + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/versity/versitygw/s3api/utils" +) + +// TestFileLoggerRemoteIP verifies that the access log records the address +// resolved by the client IP middleware rather than the socket peer address. +func TestFileLoggerRemoteIP(t *testing.T) { + logfile := filepath.Join(t.TempDir(), "access.log") + logger, err := InitFileLogger(logfile) + require.NoError(t, err) + defer logger.Shutdown() + + app := fiber.New() + app.Get("/", func(ctx fiber.Ctx) error { + utils.ContextKeyClientIP.Set(ctx, "203.0.113.7") + logger.Log(ctx, nil, nil, LogMeta{}) + return ctx.SendStatus(http.StatusNoContent) + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/", nil)) + require.NoError(t, err) + resp.Body.Close() + + content, err := os.ReadFile(logfile) + require.NoError(t, err) + assert.Contains(t, string(content), " 203.0.113.7 ") +} diff --git a/s3log/file.go b/s3log/file.go index 1a2bda92..10d4c745 100644 --- a/s3log/file.go +++ b/s3log/file.go @@ -122,7 +122,7 @@ func (f *FileLogger) Log(ctx fiber.Ctx, err error, body []byte, meta LogMeta) { lf.BucketOwner = meta.BucketOwner lf.Bucket = bucket lf.Time = time.Now() - lf.RemoteIP = ctx.IP() + lf.RemoteIP = utils.ClientIP(ctx) lf.Requester = access lf.Operation = meta.Action lf.Key = object diff --git a/s3log/file_admin.go b/s3log/file_admin.go index a3789b8c..e076b152 100644 --- a/s3log/file_admin.go +++ b/s3log/file_admin.go @@ -78,7 +78,7 @@ func (f *AdminFileLogger) Log(ctx fiber.Ctx, err error, body []byte, meta LogMet } lf.Time = time.Now() - lf.RemoteIP = ctx.IP() + lf.RemoteIP = utils.ClientIP(ctx) lf.Requester = access lf.Operation = meta.Action lf.RequestURI = reqURI diff --git a/s3log/webhook.go b/s3log/webhook.go index 77aa12ba..8ed1f9cd 100644 --- a/s3log/webhook.go +++ b/s3log/webhook.go @@ -101,7 +101,7 @@ func (wl *WebhookLogger) Log(ctx fiber.Ctx, err error, body []byte, meta LogMeta lf.BucketOwner = meta.BucketOwner lf.Bucket = bucket lf.Time = time.Now() - lf.RemoteIP = ctx.IP() + lf.RemoteIP = utils.ClientIP(ctx) lf.Requester = access lf.Operation = meta.Action lf.Key = object diff --git a/website/server.go b/website/server.go index fb8b5a3b..779f3627 100644 --- a/website/server.go +++ b/website/server.go @@ -26,15 +26,22 @@ import ( "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/internal/netutil" "github.com/versity/versitygw/s3api/middlewares" + "github.com/versity/versitygw/s3api/utils" ) // Server is the static website hosting endpoint. type Server struct { - app *fiber.App - CertStorage *netutil.CertStorage - domain string - quiet bool - socketPerm os.FileMode + app *fiber.App + CertStorage *netutil.CertStorage + domain string + quiet bool + socketPerm os.FileMode + clientIPHeader string + trustedProxies utils.TrustedProxies + // clientIPHeaderErr holds an invalid WithClientIPHeader value; NewServer + // has no error return, so ServeMultiPort reports it at startup rather + // than logging the socket address silently. + clientIPHeaderErr error } // Option sets various options for NewServer(). @@ -57,6 +64,22 @@ func WithSocketPerm(perm os.FileMode) Option { return func(s *Server) { s.socketPerm = perm } } +// WithClientIPHeader records the client address from the named proxy header in +// the website request log. The accepted values are +// utils.ClientIPHeaderForwardedFor and utils.ClientIPHeaderRealIP; an empty +// value (the default) keeps the socket peer address. The header is only +// honoured from a peer listed in WithTrustedProxies. +func WithClientIPHeader(header string) Option { + return func(s *Server) { s.clientIPHeader = header } +} + +// WithTrustedProxies sets the peers whose client IP header is trusted, as +// parsed by utils.ParseTrustedProxies. It only has an effect together with +// WithClientIPHeader. +func WithTrustedProxies(trusted utils.TrustedProxies) Option { + return func(s *Server) { s.trustedProxies = trusted } +} + // NewServer creates a new static website hosting server. // The domain parameter is the base domain for virtual-host routing: // - Host "blog." resolves to bucket "blog" @@ -76,6 +99,19 @@ func NewServer(be backend.Backend, domain string, opts ...Option) *Server { opt(server) } + // validate the header here, so the request path can compare it directly + // instead of re-normalizing on every request; an invalid value is + // reported by ServeMultiPort + if server.clientIPHeader != "" { + header, err := utils.NormalizeClientIPHeader(server.clientIPHeader) + if err != nil { + server.clientIPHeaderErr = err + server.clientIPHeader = "" + } else { + server.clientIPHeader = header + } + } + domainInfo := "catch-all" if domain != "" { domainInfo = "domain: " + domain @@ -89,9 +125,18 @@ func NewServer(be backend.Backend, domain string, opts ...Option) *Server { fmt.Printf("initializing website endpoint (%s)\n", domainInfo) app.Use("*", logger.New(logger.Config{ Format: "${time} | website | ${status} | ${latency} | ${ip} | ${method} | ${path}\n", + CustomTags: map[string]logger.LogFunc{ + logger.TagIP: utils.ClientIPTag, + }, })) } + // resolve the logged client address from a trusted proxy header, when + // one is configured + if server.clientIPHeader != "" { + app.Use("*", utils.ClientIPMiddleware(server.clientIPHeader, server.trustedProxies)) + } + // initialize the debug logger in debug mode if debuglogger.IsDebugEnabled() { app.Use("*", middlewares.DebugLogger()) @@ -105,6 +150,9 @@ func NewServer(be backend.Backend, domain string, opts ...Option) *Server { // ServeMultiPort creates listeners for multiple address specifications and serves // on all of them simultaneously. func (s *Server) ServeMultiPort(ports []string) error { + if s.clientIPHeaderErr != nil { + return s.clientIPHeaderErr + } if len(ports) == 0 { return fmt.Errorf("no addresses specified") } diff --git a/website/server_test.go b/website/server_test.go new file mode 100644 index 00000000..a82d7301 --- /dev/null +++ b/website/server_test.go @@ -0,0 +1,34 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package website + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/versity/versitygw/backend" +) + +// TestServerRejectsUnknownClientIPHeader has no error-returning constructor to +// report the invalid value, so ServeMultiPort must surface it at startup +// instead of silently logging the socket address. +func TestServerRejectsUnknownClientIPHeader(t *testing.T) { + server := NewServer(backend.BackendUnsupported{}, "", WithQuiet(), WithClientIPHeader("Forwarded")) + + err := server.ServeMultiPort([]string{"127.0.0.1:0"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client IP header") +} diff --git a/webui/webserver.go b/webui/webserver.go index 02a7dfcb..7a77824d 100644 --- a/webui/webserver.go +++ b/webui/webserver.go @@ -27,6 +27,7 @@ import ( "github.com/gofiber/fiber/v3/middleware/logger" "github.com/gofiber/fiber/v3/middleware/recover" "github.com/gofiber/fiber/v3/middleware/static" + "github.com/versity/versitygw/internal/clientip" "github.com/versity/versitygw/internal/netutil" ) @@ -51,6 +52,11 @@ type Server struct { pathPrefix string quiet bool socketPerm os.FileMode + // clientIPHeader and trustedProxies control the address recorded in the + // standalone server's request log; see WithClientIPHeader and + // WithTrustedProxies. + clientIPHeader string + trustedProxies clientip.TrustedProxies } // Option sets various options for NewServer() @@ -78,6 +84,23 @@ func WithSocketPerm(perm os.FileMode) Option { return func(s *Server) { s.socketPerm = perm } } +// WithClientIPHeader records the client address from the named proxy header in +// the standalone WebUI request log. The accepted values are +// clientip.ClientIPHeaderForwardedFor and clientip.ClientIPHeaderRealIP; an +// empty value (the default) keeps the socket peer address. The header is only +// honoured from a peer listed in WithTrustedProxies. When the WebUI is mounted +// on another server's port (MountOn), that server's settings apply instead. +func WithClientIPHeader(header string) Option { + return func(s *Server) { s.clientIPHeader = header } +} + +// WithTrustedProxies sets the peers whose client IP header is trusted, as +// parsed by clientip.ParseTrustedProxies. It only has an effect together with +// WithClientIPHeader. +func WithTrustedProxies(trusted clientip.TrustedProxies) Option { + return func(s *Server) { s.trustedProxies = trusted } +} + // NewServer creates a new GUI server instance func NewServer(cfg *ServerConfig, opts ...Option) (*Server, error) { app := fiber.New(fiber.Config{ @@ -94,6 +117,16 @@ func NewServer(cfg *ServerConfig, opts ...Option) (*Server, error) { opt(server) } + // validate the header once here, so the request path can compare it + // directly instead of re-normalizing on every request + if server.clientIPHeader != "" { + header, err := clientip.NormalizeClientIPHeader(server.clientIPHeader) + if err != nil { + return nil, err + } + server.clientIPHeader = header + } + fmt.Printf("initializing web dashboard\n") server.setupMiddleware() @@ -113,8 +146,17 @@ func (s *Server) setupMiddleware() { if !s.quiet { s.app.Use("*", logger.New(logger.Config{ Format: "${time} | web | ${status} | ${latency} | ${ip} | ${method} | ${path}\n", + CustomTags: map[string]logger.LogFunc{ + logger.TagIP: clientip.Tag, + }, })) } + + // resolve the logged client address from a trusted proxy header, when + // one is configured + if s.clientIPHeader != "" { + s.app.Use("*", clientip.Middleware(s.clientIPHeader, s.trustedProxies)) + } } // setupRoutes configures all routes diff --git a/webui/webserver_test.go b/webui/webserver_test.go new file mode 100644 index 00000000..b1ea7594 --- /dev/null +++ b/webui/webserver_test.go @@ -0,0 +1,70 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package webui + +import ( + "io" + "net/http" + "net/http/httptest" + "net/netip" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/versity/versitygw/internal/clientip" +) + +func TestNewServerRejectsUnknownClientIPHeader(t *testing.T) { + _, err := NewServer(&ServerConfig{}, WithQuiet(), WithClientIPHeader("Forwarded")) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client IP header") +} + +func TestNewServerAcceptsKnownClientIPHeader(t *testing.T) { + _, err := NewServer(&ServerConfig{}, + WithQuiet(), + WithClientIPHeader(clientip.ClientIPHeaderRealIP), + WithTrustedProxies(clientip.TrustedProxies{Prefixes: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/32")}}), + ) + require.NoError(t, err) +} + +// TestSetupMiddlewareResolvesClientIP checks that the standalone WebUI server +// installs the client IP middleware, so its request log records the address +// from the trusted proxy header rather than the socket peer. +func TestSetupMiddlewareResolvesClientIP(t *testing.T) { + server := &Server{ + app: fiber.New(), + quiet: true, + clientIPHeader: clientip.ClientIPHeaderForwardedFor, + trustedProxies: clientip.TrustedProxies{Prefixes: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/32")}}, + } + server.setupMiddleware() + server.app.Get("/client-ip", func(ctx fiber.Ctx) error { + return ctx.SendString(clientip.ClientIP(ctx)) + }) + + req := httptest.NewRequest(http.MethodGet, "/client-ip", nil) + req.Header.Set(clientip.ClientIPHeaderForwardedFor, "203.0.113.7") + + resp, err := server.app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "203.0.113.7", string(body)) +}