-
Notifications
You must be signed in to change notification settings - Fork 191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: get client IP from the request headers #233
Changes from all commits
b2aa859
c0c0e9f
8d95309
51d5c88
422542b
c10176f
004c6c3
a91d55b
1d7200b
4b8eeae
1e89e85
b4e7dbb
63c2cff
1eeb4d6
08e8bba
34a01d7
dd5f439
e769585
033ba9d
87a9d23
50feaa2
c0e942b
6eb828e
831371a
db09289
b7a4a3c
f80294c
36d4b27
f5d9ac3
e0547f2
21cae17
e210fb0
3c1277c
0c45bad
3dc12d1
afb9cd1
5d2acc6
7cd0f1f
078032c
2b3f746
2f2268b
bfe4b35
f37f23a
08de4c3
418c0e4
98988b0
fffb6a2
b9c0286
38e2eae
d14ea20
eef630a
3241298
1c462b1
ed4f4de
0918050
78af4be
2cf398c
1055cb1
f8ab81a
e6ef2f3
2939f8a
5b14062
ed980ad
23a03e9
aa130f0
19fb5f7
c656b36
31cec7c
0642698
a16c1b3
77983fc
f3d63ae
4c4072c
413a1aa
db3d0a3
5f685bb
1dbfa99
23050ef
47222f6
8e9af05
5704718
0cbcd61
bcac22b
220d1d7
d81f128
96de2a6
09e471f
64c48ce
589abba
b12e0bb
354d8a8
d37e358
6ee3084
f2fda60
8fca003
d7e43f7
775692d
c076337
b00ee41
cc3eb8c
7ddab63
aeef1f2
7b2dfe7
d8a8b08
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
// Copyright 2025 The Outline Authors | ||
// | ||
// 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 net | ||
|
||
import ( | ||
"errors" | ||
"net" | ||
"net/http" | ||
"strings" | ||
) | ||
|
||
// GetClientIPFromRequest retrieves the client's IP address from the request. | ||
// This checks common headers that forward the client IP, falling back to the | ||
// request's `RemoteAddr`. | ||
func GetClientIPFromRequest(r *http.Request) (net.IP, error) { | ||
clientIP, err := func() (string, error) { | ||
// `Forwarded` (RFC 7239). | ||
forwardedHeader := r.Header.Get("Forwarded") | ||
if forwardedHeader != "" { | ||
parts := strings.Split(forwardedHeader, ",") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is acceptable for this PR, but you need to update it later to use proper libraries to parse this format. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I think the gorilla library actually has some basic function for this as well: https://github.com/gorilla/handlers/blob/9c61bd81e701cf500437e1b516b675cdd3b73ca7/proxy_headers.go#L21 I'll likely revisit this when moving to that. |
||
firstPart := strings.TrimSpace(parts[0]) | ||
subParts := strings.Split(firstPart, ";") | ||
for _, part := range subParts { | ||
normalisedPart := strings.ToLower(strings.TrimSpace(part)) | ||
if strings.HasPrefix(normalisedPart, "for=") { | ||
return normalisedPart[4:], nil | ||
} | ||
} | ||
} | ||
|
||
// `X-Forwarded-For`` is potentially a list of addresses separated with ",". | ||
// The first item represents the original client. | ||
xForwardedForHeader := r.Header.Get("X-Forwarded-For") | ||
if xForwardedForHeader != "" { | ||
parts := strings.Split(xForwardedForHeader, ",") | ||
firstIP := strings.TrimSpace(parts[0]) | ||
return firstIP, nil | ||
} | ||
|
||
// `X-Real-IP`. | ||
xRealIpHeader := r.Header.Get("X-Real-IP") | ||
if xRealIpHeader != "" { | ||
return xRealIpHeader, nil | ||
} | ||
|
||
// Fallback to the request's `RemoteAddr`, but be aware this is the last | ||
// proxy's IP, not the client's. | ||
ip, _, err := net.SplitHostPort(r.RemoteAddr) | ||
return ip, err | ||
}() | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
parsedIP := net.ParseIP(clientIP) | ||
if parsedIP != nil { | ||
return parsedIP, nil | ||
} | ||
return nil, errors.New("no client IP found") | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
// Copyright 2025 The Outline Authors | ||
// | ||
// 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 net | ||
|
||
import ( | ||
"net" | ||
"net/http" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
func TestGetClientIPFromRequest(t *testing.T) { | ||
tests := []struct { | ||
name string | ||
headers map[string]string | ||
remoteAddr string | ||
wantIP string | ||
wantErr bool | ||
}{ | ||
{ | ||
name: "X-Forwarded-For (Single IP)", | ||
headers: map[string]string{"X-Forwarded-For": "10.0.0.1"}, | ||
wantIP: "10.0.0.1", | ||
}, | ||
{ | ||
name: "X-Forwarded-For (Multiple IPs)", | ||
headers: map[string]string{"X-Forwarded-For": "10.0.0.1, 172.16.0.1"}, | ||
wantIP: "10.0.0.1", | ||
}, | ||
{ | ||
name: "X-Real-IP", | ||
headers: map[string]string{"X-Real-IP": "192.168.2.200"}, | ||
wantIP: "192.168.2.200", | ||
}, | ||
{ | ||
name: "Forwarded", | ||
headers: map[string]string{"Forwarded": "for=192.168.3.100"}, | ||
wantIP: "192.168.3.100", | ||
}, | ||
{ | ||
name: "RemoteAddr (host:port)", | ||
remoteAddr: "172.17.0.1:12345", | ||
wantIP: "172.17.0.1", | ||
}, | ||
{ | ||
name: "RemoteAddr (IP only)", | ||
remoteAddr: "172.17.0.1", | ||
wantErr: true, | ||
}, | ||
{ | ||
name: "No Headers, No RemoteAddr", | ||
wantErr: true, | ||
}, | ||
{ | ||
name: "Invalid IP in header", | ||
headers: map[string]string{"X-Forwarded-For": "invalid-ip"}, | ||
wantErr: true, | ||
}, | ||
{ | ||
name: "Invalid RemoteAddr", | ||
remoteAddr: "invalid-ip:port", | ||
wantErr: true, | ||
}, | ||
} | ||
|
||
for _, tt := range tests { | ||
t.Run(tt.name, func(t *testing.T) { | ||
r := &http.Request{ | ||
Header: make(http.Header), | ||
RemoteAddr: tt.remoteAddr, | ||
} | ||
for h, v := range tt.headers { | ||
r.Header.Set(h, v) | ||
} | ||
|
||
gotIP, err := GetClientIPFromRequest(r) | ||
if !tt.wantErr { | ||
require.NoError(t, err) | ||
return | ||
} | ||
|
||
wantIP := net.ParseIP(tt.wantIP) | ||
if !gotIP.Equal(wantIP) { | ||
t.Errorf("err = %v, want %v", gotIP, wantIP) | ||
} | ||
}) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pass a header instead
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I need the request to fallback to the RemoteAddr. I could move that out of this function, but I think I'll leave this right now and revisit in a refactor that will likely do away with this function altogether.