mirror of
https://github.com/versity/versitygw.git
synced 2026-10-22 06:12:39 +00:00
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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 #
|
||||
##############
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}}))
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 "/".
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -40,4 +40,5 @@ const (
|
||||
ContextKeyRequestID = httpctx.ContextKeyRequestID
|
||||
ContextKeyHostID = httpctx.ContextKeyHostID
|
||||
ContextKeyWebsiteConfig = httpctx.ContextKeyWebsiteConfig
|
||||
ContextKeyClientIP = httpctx.ContextKeyClientIP
|
||||
)
|
||||
|
||||
+1
-1
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user