Merge pull request #2426 from tunglambk/fix/log-client-ip-from-proxy-header

fix: log the client IP from a trusted proxy header when configured
This commit is contained in:
Ben McClelland
2026-10-19 10:39:05 -07:00
committed by GitHub
31 changed files with 1592 additions and 14 deletions
+1 -1
View File
@@ -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
+11
View File
@@ -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"
+11
View File
@@ -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"
+18
View File
@@ -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
+2
View File
@@ -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"),
+16
View File
@@ -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,
+42
View File
@@ -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...)
}
+67
View File
@@ -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")
}
}
+41 -3
View File
@@ -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)
}
+21
View File
@@ -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 #
##############
+39
View File
@@ -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
+228
View File
@@ -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
}
+40
View File
@@ -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))
}
@@ -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}}))
})
}
+127
View File
@@ -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())
}
+1
View File
@@ -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) {
+43 -1
View File
@@ -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")
}
+152
View File
@@ -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")
}
+36
View File
@@ -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 "/".
+39
View File
@@ -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")
}
}
+50
View File
@@ -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)
}
+1
View File
@@ -40,4 +40,5 @@ const (
ContextKeyRequestID = httpctx.ContextKeyRequestID
ContextKeyHostID = httpctx.ContextKeyHostID
ContextKeyWebsiteConfig = httpctx.ContextKeyWebsiteConfig
ContextKeyClientIP = httpctx.ContextKeyClientIP
)
+1 -1
View File
@@ -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"),
+52
View File
@@ -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 ")
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+53 -5
View File
@@ -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.<domain>" 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")
}
+34
View File
@@ -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")
}
+42
View File
@@ -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
+70
View File
@@ -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))
}