mirror of
https://github.com/versity/versitygw.git
synced 2026-10-22 14:22:40 +00:00
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.
153 lines
5.0 KiB
Go
153 lines
5.0 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 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")
|
|
}
|