Files
versitygw/webui/webserver_test.go
Tung Lam e63998fb31 fix: log the client IP from a trusted proxy header
Behind a reverse proxy the request log and the S3, admin, website and WebUI
access logs record the proxy's address instead of the client's. Add a
client-ip-header option that takes the logged address from X-Forwarded-For or
X-Real-Ip. It is off by default, so an existing deployment logs exactly what it
logged before.

The header is only honoured when the connecting peer is listed in the new
trusted-proxies list of IPs and CIDRs. Without that, the value is whatever the
client sent: nginx's standard
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for appends to a
client-supplied header, leaving the client's own address leftmost.
X-Forwarded-For is walked right to left, skipping the listed proxies, so the
first unlisted address is the client and a forged leftmost entry is never
reached; the two-proxy case from #2243 works once both proxies are listed.
Every header line is walked (a proxy may add its own line instead of
appending), ip:port entries are accepted, and a request from an unlisted peer
keeps the socket address. Setting the header with an empty trusted-proxies list
fails startup rather than silently trusting every peer.

X-Real-Ip reads the last header line, so a proxy that adds a line rather than
replacing the client's is the one that speaks. An address carrying an IPv6 zone
is rejected: the zone is free text that the space-delimited access log would
write verbatim, shifting every field after RemoteIP. When every hop in
X-Forwarded-For is a trusted proxy, the leftmost hop is logged, as nginx's
real_ip_recursive does, since the socket peer is then the nearest proxy. The
trusted-proxies list accepts the entry "unix" to trust the peer of a listener
bound to a unix socket path, which fasthttp reports as 0.0.0.0.

The resolver, middleware, log tag and parsers live in internal/clientip, shared
by s3api, iamapi, website and webui; s3api/utils re-exports them for the S3
callers, so the standalone IAM service no longer pulls in the S3 side. On this
branch go list -deps ./iamapi drops from 375 to 299 packages, against 298 on
main.

ctx.IP() is unchanged and still returns the socket peer address, because it
feeds aws:SourceIp in IAM policy conditions and SourceIP in policy evaluation;
only the address written to the logs and S3 event records comes from the
header.

Fixes #2243.
2026-10-16 00:51:53 +00:00

71 lines
2.3 KiB
Go

// 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))
}