Skip to content

Commit b7d1c7a

Browse files
authored
feat: get client IP from the request headers (#233)
* feat: get client IP from the request headers * Fix doc. * More review comments.
1 parent 13bdb23 commit b7d1c7a

File tree

3 files changed

+177
-5
lines changed

3 files changed

+177
-5
lines changed

cmd/outline-ss-server/main.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -346,14 +346,13 @@ func (s *OutlineServer) runConfig(config Config) (func() error, error) {
346346
defer wsConn.Close()
347347
ctx, contextCancel := context.WithCancel(context.Background())
348348
defer contextCancel()
349-
// TODO: Get the forwarded client address.
350-
raddr, err := transport.MakeNetAddr("tcp", r.RemoteAddr)
349+
clientIP, err := onet.GetClientIPFromRequest(r)
351350
if err != nil {
352351
slog.Error("failed to determine client address", "err", err)
353352
w.WriteHeader(http.StatusBadGateway)
354353
return
355354
}
356-
conn := &streamConn{&replaceAddrConn{Conn: wsConn, raddr: raddr}}
355+
conn := &streamConn{&replaceAddrConn{Conn: wsConn, raddr: &net.TCPAddr{IP: clientIP}}}
357356
streamHandler.HandleStream(ctx, conn, s.serviceMetrics.AddOpenTCPConnection(conn))
358357
}
359358
websocket.Handler(handler).ServeHTTP(w, r)
@@ -370,13 +369,13 @@ func (s *OutlineServer) runConfig(config Config) (func() error, error) {
370369
defer wsConn.Close()
371370
ctx, contextCancel := context.WithCancel(context.Background())
372371
defer contextCancel()
373-
raddr, err := transport.MakeNetAddr("udp", r.RemoteAddr)
372+
clientIP, err := onet.GetClientIPFromRequest(r)
374373
if err != nil {
375374
slog.Error("failed to determine client address", "err", err)
376375
w.WriteHeader(http.StatusBadGateway)
377376
return
378377
}
379-
conn := &replaceAddrConn{Conn: wsConn, raddr: raddr}
378+
conn := &replaceAddrConn{Conn: wsConn, raddr: &net.UDPAddr{IP: clientIP}}
380379
associationHandler.HandleAssociation(ctx, conn, s.serviceMetrics.AddOpenUDPAssociation(conn))
381380
}
382381
websocket.Handler(handler).ServeHTTP(w, r)

net/http.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright 2025 The Outline Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package net
16+
17+
import (
18+
"errors"
19+
"net"
20+
"net/http"
21+
"strings"
22+
)
23+
24+
// GetClientIPFromRequest retrieves the client's IP address from the request.
25+
// This checks common headers that forward the client IP, falling back to the
26+
// request's `RemoteAddr`.
27+
func GetClientIPFromRequest(r *http.Request) (net.IP, error) {
28+
clientIP, err := func() (string, error) {
29+
// `Forwarded` (RFC 7239).
30+
forwardedHeader := r.Header.Get("Forwarded")
31+
if forwardedHeader != "" {
32+
parts := strings.Split(forwardedHeader, ",")
33+
firstPart := strings.TrimSpace(parts[0])
34+
subParts := strings.Split(firstPart, ";")
35+
for _, part := range subParts {
36+
normalisedPart := strings.ToLower(strings.TrimSpace(part))
37+
if strings.HasPrefix(normalisedPart, "for=") {
38+
return normalisedPart[4:], nil
39+
}
40+
}
41+
}
42+
43+
// `X-Forwarded-For`` is potentially a list of addresses separated with ",".
44+
// The first item represents the original client.
45+
xForwardedForHeader := r.Header.Get("X-Forwarded-For")
46+
if xForwardedForHeader != "" {
47+
parts := strings.Split(xForwardedForHeader, ",")
48+
firstIP := strings.TrimSpace(parts[0])
49+
return firstIP, nil
50+
}
51+
52+
// `X-Real-IP`.
53+
xRealIpHeader := r.Header.Get("X-Real-IP")
54+
if xRealIpHeader != "" {
55+
return xRealIpHeader, nil
56+
}
57+
58+
// Fallback to the request's `RemoteAddr`, but be aware this is the last
59+
// proxy's IP, not the client's.
60+
ip, _, err := net.SplitHostPort(r.RemoteAddr)
61+
return ip, err
62+
}()
63+
if err != nil {
64+
return nil, err
65+
}
66+
67+
parsedIP := net.ParseIP(clientIP)
68+
if parsedIP != nil {
69+
return parsedIP, nil
70+
}
71+
return nil, errors.New("no client IP found")
72+
}

net/http_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright 2025 The Outline Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package net
16+
17+
import (
18+
"net"
19+
"net/http"
20+
"testing"
21+
22+
"github.com/stretchr/testify/require"
23+
)
24+
25+
func TestGetClientIPFromRequest(t *testing.T) {
26+
tests := []struct {
27+
name string
28+
headers map[string]string
29+
remoteAddr string
30+
wantIP string
31+
wantErr bool
32+
}{
33+
{
34+
name: "X-Forwarded-For (Single IP)",
35+
headers: map[string]string{"X-Forwarded-For": "10.0.0.1"},
36+
wantIP: "10.0.0.1",
37+
},
38+
{
39+
name: "X-Forwarded-For (Multiple IPs)",
40+
headers: map[string]string{"X-Forwarded-For": "10.0.0.1, 172.16.0.1"},
41+
wantIP: "10.0.0.1",
42+
},
43+
{
44+
name: "X-Real-IP",
45+
headers: map[string]string{"X-Real-IP": "192.168.2.200"},
46+
wantIP: "192.168.2.200",
47+
},
48+
{
49+
name: "Forwarded",
50+
headers: map[string]string{"Forwarded": "for=192.168.3.100"},
51+
wantIP: "192.168.3.100",
52+
},
53+
{
54+
name: "RemoteAddr (host:port)",
55+
remoteAddr: "172.17.0.1:12345",
56+
wantIP: "172.17.0.1",
57+
},
58+
{
59+
name: "RemoteAddr (IP only)",
60+
remoteAddr: "172.17.0.1",
61+
wantErr: true,
62+
},
63+
{
64+
name: "No Headers, No RemoteAddr",
65+
wantErr: true,
66+
},
67+
{
68+
name: "Invalid IP in header",
69+
headers: map[string]string{"X-Forwarded-For": "invalid-ip"},
70+
wantErr: true,
71+
},
72+
{
73+
name: "Invalid RemoteAddr",
74+
remoteAddr: "invalid-ip:port",
75+
wantErr: true,
76+
},
77+
}
78+
79+
for _, tt := range tests {
80+
t.Run(tt.name, func(t *testing.T) {
81+
r := &http.Request{
82+
Header: make(http.Header),
83+
RemoteAddr: tt.remoteAddr,
84+
}
85+
for h, v := range tt.headers {
86+
r.Header.Set(h, v)
87+
}
88+
89+
gotIP, err := GetClientIPFromRequest(r)
90+
if !tt.wantErr {
91+
require.NoError(t, err)
92+
return
93+
}
94+
95+
wantIP := net.ParseIP(tt.wantIP)
96+
if !gotIP.Equal(wantIP) {
97+
t.Errorf("err = %v, want %v", gotIP, wantIP)
98+
}
99+
})
100+
}
101+
}

0 commit comments

Comments
 (0)