From f10d586350aded05196f9e0544a5c04c1a4fd215 Mon Sep 17 00:00:00 2001 From: hulto <7121375+hulto@users.noreply.github.com> Date: Sat, 20 Dec 2025 04:49:32 +0000 Subject: [PATCH 1/9] part one --- tavern/internal/c2/api_claim_tasks.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tavern/internal/c2/api_claim_tasks.go b/tavern/internal/c2/api_claim_tasks.go index 46a9ffc8f..f98baf54c 100644 --- a/tavern/internal/c2/api_claim_tasks.go +++ b/tavern/internal/c2/api_claim_tasks.go @@ -54,6 +54,9 @@ func getRemoteIP(ctx context.Context) string { func getClientIP(ctx context.Context) string { md, ok := metadata.FromIncomingContext(ctx) if ok { + if redirectedFor, exists := md["x-redirected-for"]; exists && len(redirectedFor) > 0 { + return strings.TrimSpace(redirectedFor[0]) + } if forwardedFor, exists := md["x-forwarded-for"]; exists && len(forwardedFor) > 0 { // X-Forwarded-For is a comma-separated list, the first IP is the original client clientIP := strings.Split(forwardedFor[0], ",")[0] From 938943c67507f12b1b63a9886c6994f3f02c0234 Mon Sep 17 00:00:00 2001 From: hulto <7121375+hulto@users.noreply.github.com> Date: Sun, 21 Dec 2025 04:26:02 +0000 Subject: [PATCH 2/9] Redirector get IP --- tavern/internal/redirectors/grpc/grpc.go | 50 +++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/tavern/internal/redirectors/grpc/grpc.go b/tavern/internal/redirectors/grpc/grpc.go index 6dcba19b7..0f71f75b4 100644 --- a/tavern/internal/redirectors/grpc/grpc.go +++ b/tavern/internal/redirectors/grpc/grpc.go @@ -5,11 +5,13 @@ import ( "fmt" "io" "net" + "strings" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/encoding" "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" "google.golang.org/grpc/status" "realm.pub/tavern/internal/redirectors" ) @@ -41,6 +43,38 @@ func (r *Redirector) Redirect(ctx context.Context, listenOn string, upstream *gr return s.Serve(lis) } +// Copy pasted from api_claim_tasks.go should this just be shared public function? +func getRemoteIP(ctx context.Context) string { + p, ok := peer.FromContext(ctx) + if !ok { + return "unknown" + } + + host, _, err := net.SplitHostPort(p.Addr.String()) + if err != nil { + return "unknown" + } + + return host +} + +func getClientIP(ctx context.Context) string { + md, ok := metadata.FromIncomingContext(ctx) + if ok { + if redirectedFor, exists := md["x-redirected-for"]; exists && len(redirectedFor) > 0 { + return strings.TrimSpace(redirectedFor[0]) + } + if forwardedFor, exists := md["x-forwarded-for"]; exists && len(forwardedFor) > 0 { + // X-Forwarded-For is a comma-separated list, the first IP is the original client + clientIP := strings.Split(forwardedFor[0], ",")[0] + return strings.TrimSpace(clientIP) + } + } + + // Fallback to peer address + return getRemoteIP(ctx) +} + func (r *Redirector) handler(upstream *grpc.ClientConn) grpc.StreamHandler { return func(srv any, ss grpc.ServerStream) error { fullMethodName, ok := grpc.MethodFromServerStream(ss) @@ -49,10 +83,24 @@ func (r *Redirector) handler(upstream *grpc.ClientConn) grpc.StreamHandler { } ctx := ss.Context() + // Get the client's remote IP address + clientIP := getClientIP(ctx) + md, ok := metadata.FromIncomingContext(ctx) if ok { - ctx = metadata.NewOutgoingContext(ctx, md) + ctx = metadata.NewOutgoingContext(ctx, md.Copy()) } + + // Set x-redirected-for header with the client IP + if clientIP != "" { + outMd, _ := metadata.FromOutgoingContext(ctx) + if outMd == nil { + outMd = metadata.New(nil) + } + outMd.Set("x-redirected-for", clientIP) + ctx = metadata.NewOutgoingContext(ctx, outMd) + } + cs, err := upstream.NewStream(ctx, &grpc.StreamDesc{ StreamName: fullMethodName, ServerStreams: true, From e3b01177a3f3355693be94170c4638c2f5fc9173 Mon Sep 17 00:00:00 2001 From: hulto <7121375+hulto@users.noreply.github.com> Date: Sun, 21 Dec 2025 04:53:24 +0000 Subject: [PATCH 3/9] Cleanup --- tavern/internal/c2/api_claim_tasks.go | 36 +---------------------- tavern/internal/c2/server.go | 37 ++++++++++++++++++++++++ tavern/internal/redirectors/grpc/grpc.go | 37 ++---------------------- 3 files changed, 40 insertions(+), 70 deletions(-) diff --git a/tavern/internal/c2/api_claim_tasks.go b/tavern/internal/c2/api_claim_tasks.go index f98baf54c..6489f4368 100644 --- a/tavern/internal/c2/api_claim_tasks.go +++ b/tavern/internal/c2/api_claim_tasks.go @@ -5,14 +5,11 @@ import ( "encoding/json" "fmt" "log/slog" - "net" "strings" "time" "github.com/prometheus/client_golang/prometheus" "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/peer" "google.golang.org/grpc/status" "realm.pub/tavern/internal/c2/c2pb" "realm.pub/tavern/internal/c2/epb" @@ -37,40 +34,9 @@ func init() { prometheus.MustRegister(metricHostCallbacksTotal) } -func getRemoteIP(ctx context.Context) string { - p, ok := peer.FromContext(ctx) - if !ok { - return "unknown" - } - - host, _, err := net.SplitHostPort(p.Addr.String()) - if err != nil { - return "unknown" - } - - return host -} - -func getClientIP(ctx context.Context) string { - md, ok := metadata.FromIncomingContext(ctx) - if ok { - if redirectedFor, exists := md["x-redirected-for"]; exists && len(redirectedFor) > 0 { - return strings.TrimSpace(redirectedFor[0]) - } - if forwardedFor, exists := md["x-forwarded-for"]; exists && len(forwardedFor) > 0 { - // X-Forwarded-For is a comma-separated list, the first IP is the original client - clientIP := strings.Split(forwardedFor[0], ",")[0] - return strings.TrimSpace(clientIP) - } - } - - // Fallback to peer address - return getRemoteIP(ctx) -} - func (srv *Server) ClaimTasks(ctx context.Context, req *c2pb.ClaimTasksRequest) (*c2pb.ClaimTasksResponse, error) { now := time.Now() - clientIP := getClientIP(ctx) + clientIP := GetClientIP(ctx) // Validate input if req.Beacon == nil { diff --git a/tavern/internal/c2/server.go b/tavern/internal/c2/server.go index 42558857a..aa4c37dc4 100644 --- a/tavern/internal/c2/server.go +++ b/tavern/internal/c2/server.go @@ -1,6 +1,12 @@ package c2 import ( + "context" + "net" + "strings" + + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" "realm.pub/tavern/internal/c2/c2pb" "realm.pub/tavern/internal/ent" "realm.pub/tavern/internal/http/stream" @@ -20,3 +26,34 @@ func New(graph *ent.Client, mux *stream.Mux) *Server { mux: mux, } } + +func getRemoteIP(ctx context.Context) string { + p, ok := peer.FromContext(ctx) + if !ok { + return "unknown" + } + + host, _, err := net.SplitHostPort(p.Addr.String()) + if err != nil { + return "unknown" + } + + return host +} + +func GetClientIP(ctx context.Context) string { + md, ok := metadata.FromIncomingContext(ctx) + if ok { + if redirectedFor, exists := md["x-redirected-for"]; exists && len(redirectedFor) > 0 { + return strings.TrimSpace(redirectedFor[0]) + } + if forwardedFor, exists := md["x-forwarded-for"]; exists && len(forwardedFor) > 0 { + // X-Forwarded-For is a comma-separated list, the first IP is the original client + clientIP := strings.Split(forwardedFor[0], ",")[0] + return strings.TrimSpace(clientIP) + } + } + + // Fallback to peer address + return getRemoteIP(ctx) +} diff --git a/tavern/internal/redirectors/grpc/grpc.go b/tavern/internal/redirectors/grpc/grpc.go index 0f71f75b4..7d31116da 100644 --- a/tavern/internal/redirectors/grpc/grpc.go +++ b/tavern/internal/redirectors/grpc/grpc.go @@ -5,14 +5,13 @@ import ( "fmt" "io" "net" - "strings" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/encoding" "google.golang.org/grpc/metadata" - "google.golang.org/grpc/peer" "google.golang.org/grpc/status" + "realm.pub/tavern/internal/c2" "realm.pub/tavern/internal/redirectors" ) @@ -43,38 +42,6 @@ func (r *Redirector) Redirect(ctx context.Context, listenOn string, upstream *gr return s.Serve(lis) } -// Copy pasted from api_claim_tasks.go should this just be shared public function? -func getRemoteIP(ctx context.Context) string { - p, ok := peer.FromContext(ctx) - if !ok { - return "unknown" - } - - host, _, err := net.SplitHostPort(p.Addr.String()) - if err != nil { - return "unknown" - } - - return host -} - -func getClientIP(ctx context.Context) string { - md, ok := metadata.FromIncomingContext(ctx) - if ok { - if redirectedFor, exists := md["x-redirected-for"]; exists && len(redirectedFor) > 0 { - return strings.TrimSpace(redirectedFor[0]) - } - if forwardedFor, exists := md["x-forwarded-for"]; exists && len(forwardedFor) > 0 { - // X-Forwarded-For is a comma-separated list, the first IP is the original client - clientIP := strings.Split(forwardedFor[0], ",")[0] - return strings.TrimSpace(clientIP) - } - } - - // Fallback to peer address - return getRemoteIP(ctx) -} - func (r *Redirector) handler(upstream *grpc.ClientConn) grpc.StreamHandler { return func(srv any, ss grpc.ServerStream) error { fullMethodName, ok := grpc.MethodFromServerStream(ss) @@ -84,7 +51,7 @@ func (r *Redirector) handler(upstream *grpc.ClientConn) grpc.StreamHandler { ctx := ss.Context() // Get the client's remote IP address - clientIP := getClientIP(ctx) + clientIP := c2.GetClientIP(ctx) md, ok := metadata.FromIncomingContext(ctx) if ok { From fd8db9e07495556daeb9acf7508a2c4f241777a7 Mon Sep 17 00:00:00 2001 From: hulto <7121375+hulto@users.noreply.github.com> Date: Sun, 21 Dec 2025 04:53:29 +0000 Subject: [PATCH 4/9] Add http1 redir --- tavern/internal/redirectors/http1/handlers.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tavern/internal/redirectors/http1/handlers.go b/tavern/internal/redirectors/http1/handlers.go index a6c54ce26..3e7640373 100644 --- a/tavern/internal/redirectors/http1/handlers.go +++ b/tavern/internal/redirectors/http1/handlers.go @@ -7,6 +7,7 @@ import ( "net/http" "google.golang.org/grpc" + "google.golang.org/grpc/metadata" ) func handleFetchAssetStreaming(w http.ResponseWriter, r *http.Request, conn *grpc.ClientConn) { @@ -159,11 +160,20 @@ func handleReportFileStreaming(w http.ResponseWriter, r *http.Request, conn *grp } } +func getClientIP(r *http.Request) string { + if clientIp := r.Header.Get("x-forwarded-for"); len(clientIp) > 0 { + return clientIp + } + return r.RemoteAddr +} + func handleHTTPRequest(w http.ResponseWriter, r *http.Request, conn *grpc.ClientConn) { if !requirePOST(w, r) { return } + clientIp := getClientIP(r) + methodName := r.URL.Path if methodName == "" { http.Error(w, "Method name required in path", http.StatusBadRequest) @@ -180,6 +190,14 @@ func handleHTTPRequest(w http.ResponseWriter, r *http.Request, conn *grpc.Client ctx, cancel := createRequestContext(unaryTimeout) defer cancel() + // Set x-redirected-for header with the client IP + if clientIp != "" { + md := metadata.New(map[string]string{ + "x-redirected-for": clientIp, + }) + ctx = metadata.NewOutgoingContext(ctx, md) + } + var responseBody []byte err := conn.Invoke( ctx, From e6dd9ec439a0b5763a63174086850101e1d334e6 Mon Sep 17 00:00:00 2001 From: hulto <7121375+hulto@users.noreply.github.com> Date: Sun, 21 Dec 2025 05:46:52 +0000 Subject: [PATCH 5/9] Add tests and cleanup --- tavern/app.go | 2 + tavern/internal/c2/server.go | 28 ++- tavern/internal/c2/server_test.go | 160 ++++++++++++++++++ tavern/internal/redirectors/http1/handlers.go | 30 +++- .../redirectors/http1/handlers_test.go | 111 ++++++++++++ 5 files changed, 324 insertions(+), 7 deletions(-) create mode 100644 tavern/internal/c2/server_test.go create mode 100644 tavern/internal/redirectors/http1/handlers_test.go diff --git a/tavern/app.go b/tavern/app.go index a659f2ae5..8e91cf05b 100644 --- a/tavern/app.go +++ b/tavern/app.go @@ -511,6 +511,8 @@ func newGRPCHandler(client *ent.Client, grpcShellMux *stream.Mux) http.Handler { return } + slog.Info("headers: ", r.Header) + if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/grpc") { http.Error(w, "must specify Content-Type application/grpc", http.StatusBadRequest) return diff --git a/tavern/internal/c2/server.go b/tavern/internal/c2/server.go index aa4c37dc4..4ca1a4038 100644 --- a/tavern/internal/c2/server.go +++ b/tavern/internal/c2/server.go @@ -2,6 +2,7 @@ package c2 import ( "context" + "log/slog" "net" "strings" @@ -27,6 +28,10 @@ func New(graph *ent.Client, mux *stream.Mux) *Server { } } +func validateIP(ipaddr string) bool { + return net.ParseIP(ipaddr) != nil || ipaddr == "unknown" +} + func getRemoteIP(ctx context.Context) string { p, ok := peer.FromContext(ctx) if !ok { @@ -45,15 +50,30 @@ func GetClientIP(ctx context.Context) string { md, ok := metadata.FromIncomingContext(ctx) if ok { if redirectedFor, exists := md["x-redirected-for"]; exists && len(redirectedFor) > 0 { - return strings.TrimSpace(redirectedFor[0]) + clientIP := strings.TrimSpace(redirectedFor[0]) + if validateIP(clientIP) { + return clientIP + } else { + slog.Error("bad redirect for ip", "ip", clientIP) + } } if forwardedFor, exists := md["x-forwarded-for"]; exists && len(forwardedFor) > 0 { // X-Forwarded-For is a comma-separated list, the first IP is the original client - clientIP := strings.Split(forwardedFor[0], ",")[0] - return strings.TrimSpace(clientIP) + clientIP := strings.TrimSpace(strings.Split(forwardedFor[0], ",")[0]) + if validateIP(clientIP) { + return clientIP + } else { + slog.Error("bad forwarded for ip", "ip", clientIP) + } } } // Fallback to peer address - return getRemoteIP(ctx) + remoteIp := getRemoteIP(ctx) + if validateIP(remoteIp) { + return remoteIp + } else { + slog.Error("Bad remote IP", "ip", remoteIp) + } + return "unknown" } diff --git a/tavern/internal/c2/server_test.go b/tavern/internal/c2/server_test.go new file mode 100644 index 000000000..78cd38f3c --- /dev/null +++ b/tavern/internal/c2/server_test.go @@ -0,0 +1,160 @@ +package c2 + +import ( + "context" + "net" + "testing" + + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" +) + +func TestGetClientIP(t *testing.T) { + tests := []struct { + name string + setupContext func() context.Context + expectedIP string + }{ + { + name: "X-Forwarded-For_Only", + setupContext: func() context.Context { + ctx := context.Background() + md := metadata.New(map[string]string{ + "x-forwarded-for": "203.0.113.42", + }) + return metadata.NewIncomingContext(ctx, md) + }, + expectedIP: "203.0.113.42", + }, + { + name: "X-Redirected-For_With_X-Forwarded-For", + setupContext: func() context.Context { + ctx := context.Background() + md := metadata.New(map[string]string{ + "x-forwarded-for": "203.0.113.42", + "x-redirected-for": "198.51.100.99", + }) + return metadata.NewIncomingContext(ctx, md) + }, + expectedIP: "198.51.100.99", + }, + { + name: "Neither_Header_Set_Uses_Peer_IP", + setupContext: func() context.Context { + ctx := context.Background() + p := &peer.Peer{ + Addr: &net.TCPAddr{ + IP: net.ParseIP("1.1.1.1"), + Port: 12345, + }, + } + return peer.NewContext(ctx, p) + }, + expectedIP: "1.1.1.1", + }, + { + name: "X-Forwarded-For_With_Multiple_IPs", + setupContext: func() context.Context { + ctx := context.Background() + md := metadata.New(map[string]string{ + "x-forwarded-for": "203.0.113.42, 198.51.100.1, 192.0.2.5", + }) + return metadata.NewIncomingContext(ctx, md) + }, + expectedIP: "203.0.113.42", + }, + { + name: "X-Forwarded-For_With_Whitespace", + setupContext: func() context.Context { + ctx := context.Background() + md := metadata.New(map[string]string{ + "x-forwarded-for": " 203.0.113.42 ", + }) + return metadata.NewIncomingContext(ctx, md) + }, + expectedIP: "203.0.113.42", + }, + { + name: "X-Redirected-For_Precedence_Over_Peer", + setupContext: func() context.Context { + ctx := context.Background() + p := &peer.Peer{ + Addr: &net.TCPAddr{ + IP: net.ParseIP("1.1.1.1"), + Port: 12345, + }, + } + ctx = peer.NewContext(ctx, p) + md := metadata.New(map[string]string{ + "x-redirected-for": "198.51.100.99", + }) + return metadata.NewIncomingContext(ctx, md) + }, + expectedIP: "198.51.100.99", + }, + { + name: "Invalid_X-Forwarded-For_Fallback_To_Peer", + setupContext: func() context.Context { + ctx := context.Background() + p := &peer.Peer{ + Addr: &net.TCPAddr{ + IP: net.ParseIP("1.1.1.1"), + Port: 12345, + }, + } + ctx = peer.NewContext(ctx, p) + md := metadata.New(map[string]string{ + "x-forwarded-for": "invalid-ip-address", + }) + return metadata.NewIncomingContext(ctx, md) + }, + expectedIP: "1.1.1.1", + }, + { + name: "No_Metadata_No_Peer_Returns_Unknown", + setupContext: func() context.Context { + return context.Background() + }, + expectedIP: "unknown", + }, + { + name: "Malformed_X-Redirected-For_Returns_As_Is", + setupContext: func() context.Context { + ctx := context.Background() + p := &peer.Peer{ + Addr: &net.TCPAddr{ + IP: net.ParseIP("1.1.1.1"), + Port: 12345, + }, + } + ctx = peer.NewContext(ctx, p) + md := metadata.New(map[string]string{ + "x-redirected-for": "not-an-ip", + }) + return metadata.NewIncomingContext(ctx, md) + }, + expectedIP: "1.1.1.1", + }, + { + name: "Malformed_X-Forwarded-For_Without_Peer_Returns_Unknown", + setupContext: func() context.Context { + ctx := context.Background() + md := metadata.New(map[string]string{ + "x-forwarded-for": "not-an-ip", + }) + return metadata.NewIncomingContext(ctx, md) + }, + expectedIP: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := tt.setupContext() + result := GetClientIP(ctx) + if result != tt.expectedIP { + t.Errorf("GetClientIP() = %v, want %v", result, tt.expectedIP) + } + }) + } +} diff --git a/tavern/internal/redirectors/http1/handlers.go b/tavern/internal/redirectors/http1/handlers.go index 3e7640373..d64e317f2 100644 --- a/tavern/internal/redirectors/http1/handlers.go +++ b/tavern/internal/redirectors/http1/handlers.go @@ -4,7 +4,9 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" + "strings" "google.golang.org/grpc" "google.golang.org/grpc/metadata" @@ -161,12 +163,34 @@ func handleReportFileStreaming(w http.ResponseWriter, r *http.Request, conn *grp } func getClientIP(r *http.Request) string { - if clientIp := r.Header.Get("x-forwarded-for"); len(clientIp) > 0 { - return clientIp + if forwardedFor := r.Header.Get("x-forwarded-for"); len(forwardedFor) > 0 { + // X-Forwarded-For is a comma-separated list, the first IP is the original client + clientIp := strings.TrimSpace(strings.Split(forwardedFor, ",")[0]) + if validateIP(clientIp) { + return clientIp + } else { + slog.Error("bad forwarded for ip", "ip", clientIp) + } + } + + // Fallback to RemoteAddr + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + slog.Error("failed to parse remote addr", "ip", r.RemoteAddr) } - return r.RemoteAddr + + host = strings.TrimSpace(host) + if validateIP(host) { + return host + } else { + slog.Error("bad remote ip", "ip", host) + } + return "unknown" } +func validateIP(ipaddr string) bool { + return net.ParseIP(ipaddr) != nil || ipaddr == "unknown" +} func handleHTTPRequest(w http.ResponseWriter, r *http.Request, conn *grpc.ClientConn) { if !requirePOST(w, r) { return diff --git a/tavern/internal/redirectors/http1/handlers_test.go b/tavern/internal/redirectors/http1/handlers_test.go new file mode 100644 index 000000000..25a02bf3f --- /dev/null +++ b/tavern/internal/redirectors/http1/handlers_test.go @@ -0,0 +1,111 @@ +package http1 + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetClientIP(t *testing.T) { + tests := []struct { + name string + setupRequest func() *http.Request + expectedIP string + }{ + { + name: "X-Forwarded-For_Set", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, "/test", nil) + req.Header.Set("X-Forwarded-For", "203.0.113.42") + req.RemoteAddr = "192.0.2.1:12345" + return req + }, + expectedIP: "203.0.113.42", + }, + { + name: "X-Forwarded-For_Not_Set_IPv4", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, "/test", nil) + req.RemoteAddr = "1.1.1.1:12345" + return req + }, + expectedIP: "1.1.1.1", + }, + { + name: "X-Forwarded-For_Not_Set_IPv6", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, "/test", nil) + req.RemoteAddr = "[2001:db8::1]:12345" + return req + }, + expectedIP: "2001:db8::1", + }, + { + name: "X-Forwarded-For_Not_Set_IPv6_Localhost", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, "/test", nil) + req.RemoteAddr = "[::1]:5000" + return req + }, + expectedIP: "::1", + }, + { + name: "X-Forwarded-For_Empty_Falls_Back_To_RemoteAddr", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, "/test", nil) + req.Header.Set("X-Forwarded-For", "") + req.RemoteAddr = "1.1.1.1:12345" + return req + }, + expectedIP: "1.1.1.1", + }, + { + name: "X-Forwarded-For_With_Multiple_IPs", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, "/test", nil) + req.Header.Set("X-Forwarded-For", "203.0.113.42, 198.51.100.1, 192.0.2.5") + req.RemoteAddr = "192.0.2.1:12345" + return req + }, + expectedIP: "203.0.113.42", + }, + { + name: "X-Forwarded-For_Malformed_IP", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, "/test", nil) + req.Header.Set("X-Forwarded-For", "not-an-ip") + req.RemoteAddr = "1.1.1.1" + return req + }, + expectedIP: "unknown", + }, + { + name: "RemoteAddr_Without_Port", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, "/test", nil) + req.RemoteAddr = "1.1.1.1" + return req + }, + expectedIP: "unknown", + }, + { + name: "RemoteAddr_Multiple_Colons_IPv6_With_Port", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, "/test", nil) + req.RemoteAddr = "2001:db8::1:5000" + return req + }, + expectedIP: "2001:db8::1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := tt.setupRequest() + result := getClientIP(req) + if result != tt.expectedIP { + t.Errorf("getClientIP() = %v, want %v", result, tt.expectedIP) + } + }) + } +} From 9e16c8e2f83929a977454c52f0227f273f715f0a Mon Sep 17 00:00:00 2001 From: hulto <7121375+hulto@users.noreply.github.com> Date: Sun, 21 Dec 2025 05:48:16 +0000 Subject: [PATCH 6/9] fix ipv6 test --- tavern/internal/redirectors/http1/handlers_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tavern/internal/redirectors/http1/handlers_test.go b/tavern/internal/redirectors/http1/handlers_test.go index 25a02bf3f..7ace4e9ab 100644 --- a/tavern/internal/redirectors/http1/handlers_test.go +++ b/tavern/internal/redirectors/http1/handlers_test.go @@ -92,7 +92,7 @@ func TestGetClientIP(t *testing.T) { name: "RemoteAddr_Multiple_Colons_IPv6_With_Port", setupRequest: func() *http.Request { req := httptest.NewRequest(http.MethodPost, "/test", nil) - req.RemoteAddr = "2001:db8::1:5000" + req.RemoteAddr = "[2001:db8::1]:5000" return req }, expectedIP: "2001:db8::1", From 24a59a61998e10ea5c4cbfa4dfc81e18168ad210 Mon Sep 17 00:00:00 2001 From: hulto <7121375+hulto@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:01:00 +0000 Subject: [PATCH 7/9] remove debug --- tavern/app.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/tavern/app.go b/tavern/app.go index 8e91cf05b..a659f2ae5 100644 --- a/tavern/app.go +++ b/tavern/app.go @@ -511,8 +511,6 @@ func newGRPCHandler(client *ent.Client, grpcShellMux *stream.Mux) http.Handler { return } - slog.Info("headers: ", r.Header) - if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/grpc") { http.Error(w, "must specify Content-Type application/grpc", http.StatusBadRequest) return From 6870869933c1f28c8e49c8ceb5536d63fe5430af Mon Sep 17 00:00:00 2001 From: hulto <7121375+hulto@users.noreply.github.com> Date: Wed, 24 Dec 2025 00:39:47 +0000 Subject: [PATCH 8/9] Fix feedback --- tavern/internal/c2/ip.go | 9 +++++++++ tavern/internal/c2/server.go | 8 ++------ 2 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 tavern/internal/c2/ip.go diff --git a/tavern/internal/c2/ip.go b/tavern/internal/c2/ip.go new file mode 100644 index 000000000..98271235e --- /dev/null +++ b/tavern/internal/c2/ip.go @@ -0,0 +1,9 @@ +package c2 + +import ( + "net" +) + +func validateIP(ipaddr string) bool { + return net.ParseIP(ipaddr) != nil || ipaddr == "unknown" +} diff --git a/tavern/internal/c2/server.go b/tavern/internal/c2/server.go index 4ca1a4038..1db74d1c4 100644 --- a/tavern/internal/c2/server.go +++ b/tavern/internal/c2/server.go @@ -28,10 +28,6 @@ func New(graph *ent.Client, mux *stream.Mux) *Server { } } -func validateIP(ipaddr string) bool { - return net.ParseIP(ipaddr) != nil || ipaddr == "unknown" -} - func getRemoteIP(ctx context.Context) string { p, ok := peer.FromContext(ctx) if !ok { @@ -54,7 +50,7 @@ func GetClientIP(ctx context.Context) string { if validateIP(clientIP) { return clientIP } else { - slog.Error("bad redirect for ip", "ip", clientIP) + slog.Error("bad x-redirected-for ip", "ip", clientIP) } } if forwardedFor, exists := md["x-forwarded-for"]; exists && len(forwardedFor) > 0 { @@ -63,7 +59,7 @@ func GetClientIP(ctx context.Context) string { if validateIP(clientIP) { return clientIP } else { - slog.Error("bad forwarded for ip", "ip", clientIP) + slog.Error("bad x-forwarded-for ip", "ip", clientIP) } } } From e017823144c801d7e82513df6cd2212d58765b55 Mon Sep 17 00:00:00 2001 From: hulto <7121375+hulto@users.noreply.github.com> Date: Wed, 24 Dec 2025 00:54:52 +0000 Subject: [PATCH 9/9] Consolidate SetRedirectedForHeader --- tavern/internal/redirectors/grpc/grpc.go | 9 +------ tavern/internal/redirectors/http1/handlers.go | 9 ++----- tavern/internal/redirectors/metadata.go | 25 +++++++++++++++++++ 3 files changed, 28 insertions(+), 15 deletions(-) create mode 100644 tavern/internal/redirectors/metadata.go diff --git a/tavern/internal/redirectors/grpc/grpc.go b/tavern/internal/redirectors/grpc/grpc.go index 7d31116da..dfb721e3e 100644 --- a/tavern/internal/redirectors/grpc/grpc.go +++ b/tavern/internal/redirectors/grpc/grpc.go @@ -59,14 +59,7 @@ func (r *Redirector) handler(upstream *grpc.ClientConn) grpc.StreamHandler { } // Set x-redirected-for header with the client IP - if clientIP != "" { - outMd, _ := metadata.FromOutgoingContext(ctx) - if outMd == nil { - outMd = metadata.New(nil) - } - outMd.Set("x-redirected-for", clientIP) - ctx = metadata.NewOutgoingContext(ctx, outMd) - } + ctx = redirectors.SetRedirectedForHeader(ctx, clientIP) cs, err := upstream.NewStream(ctx, &grpc.StreamDesc{ StreamName: fullMethodName, diff --git a/tavern/internal/redirectors/http1/handlers.go b/tavern/internal/redirectors/http1/handlers.go index d64e317f2..790ae9972 100644 --- a/tavern/internal/redirectors/http1/handlers.go +++ b/tavern/internal/redirectors/http1/handlers.go @@ -9,7 +9,7 @@ import ( "strings" "google.golang.org/grpc" - "google.golang.org/grpc/metadata" + "realm.pub/tavern/internal/redirectors" ) func handleFetchAssetStreaming(w http.ResponseWriter, r *http.Request, conn *grpc.ClientConn) { @@ -215,12 +215,7 @@ func handleHTTPRequest(w http.ResponseWriter, r *http.Request, conn *grpc.Client defer cancel() // Set x-redirected-for header with the client IP - if clientIp != "" { - md := metadata.New(map[string]string{ - "x-redirected-for": clientIp, - }) - ctx = metadata.NewOutgoingContext(ctx, md) - } + ctx = redirectors.SetRedirectedForHeader(ctx, clientIp) var responseBody []byte err := conn.Invoke( diff --git a/tavern/internal/redirectors/metadata.go b/tavern/internal/redirectors/metadata.go new file mode 100644 index 000000000..fcefef29b --- /dev/null +++ b/tavern/internal/redirectors/metadata.go @@ -0,0 +1,25 @@ +package redirectors + +import ( + "context" + "log/slog" + + "google.golang.org/grpc/metadata" +) + +// SetRedirectedForHeader sets the x-redirected-for header in the outgoing context metadata +// with the provided client IP address. This header is used to track the original client IP +// through the redirector chain. +func SetRedirectedForHeader(ctx context.Context, clientIP string) context.Context { + if clientIP == "" { + return ctx + } + + outMd, _ := metadata.FromOutgoingContext(ctx) + if outMd == nil { + outMd = metadata.New(nil) + } + outMd.Set("x-redirected-for", clientIP) + slog.Info("Setting redirected-for header", "clientIP", clientIP) + return metadata.NewOutgoingContext(ctx, outMd) +}