From 6fa56b6b4d5278d41726700f75b37aaedddd7754 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Sat, 9 Aug 2025 08:11:28 +0000 Subject: [PATCH 1/4] feat(http): optional X-Forwarded-Host support for host authorization\n\nAdds --use-x-forwarded-host CLI flag and ServerConfig.UseXForwardedHost.\nWhen enabled, the middleware prefers X-Forwarded-Host (first value, before comma),\nparses and matches hostname ignoring port. Includes tests for enabled/disabled,\nports, IPv6, and comma-separated cases.\n\nCo-authored-by: hugodutka <28019628+hugodutka@users.noreply.github.com> --- cmd/server/server.go | 33 +++++++------- lib/httpapi/server.go | 39 ++++++++++------ lib/httpapi/server_test.go | 91 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 28 deletions(-) diff --git a/cmd/server/server.go b/cmd/server/server.go index 69757b8..68ef7d9 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -171,12 +171,13 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er } port := viper.GetInt(FlagPort) srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ - AgentType: agentType, - Process: process, - Port: port, - ChatBasePath: viper.GetString(FlagChatBasePath), - AllowedHosts: viper.GetStringSlice(FlagAllowedHosts), - AllowedOrigins: viper.GetStringSlice(FlagAllowedOrigins), + AgentType: agentType, + Process: process, + Port: port, + ChatBasePath: viper.GetString(FlagChatBasePath), + AllowedHosts: viper.GetStringSlice(FlagAllowedHosts), + AllowedOrigins: viper.GetStringSlice(FlagAllowedOrigins), + UseXForwardedHost: viper.GetBool(FlagUseXForwardedHost), }) if err != nil { return xerrors.Errorf("failed to create server: %w", err) @@ -230,15 +231,16 @@ type flagSpec struct { } const ( - FlagType = "type" - FlagPort = "port" - FlagPrintOpenAPI = "print-openapi" - FlagChatBasePath = "chat-base-path" - FlagTermWidth = "term-width" - FlagTermHeight = "term-height" - FlagAllowedHosts = "allowed-hosts" - FlagAllowedOrigins = "allowed-origins" - FlagExit = "exit" + FlagType = "type" + FlagPort = "port" + FlagPrintOpenAPI = "print-openapi" + FlagChatBasePath = "chat-base-path" + FlagTermWidth = "term-width" + FlagTermHeight = "term-height" + FlagAllowedHosts = "allowed-hosts" + FlagAllowedOrigins = "allowed-origins" + FlagUseXForwardedHost = "use-x-forwarded-host" + FlagExit = "exit" ) func CreateServerCmd() *cobra.Command { @@ -283,6 +285,7 @@ func CreateServerCmd() *cobra.Command { {FlagAllowedHosts, "a", []string{"localhost"}, "HTTP allowed hosts (hostnames only, no ports). Use '*' for all, comma-separated list via flag, space-separated list via AGENTAPI_ALLOWED_HOSTS env var", "stringSlice"}, // localhost:3284 is the default origin when you open the chat interface in your browser. localhost:3000 and 3001 are used during development. {FlagAllowedOrigins, "o", []string{"http://localhost:3284", "http://localhost:3000", "http://localhost:3001"}, "HTTP allowed origins. Use '*' for all, comma-separated list via flag, space-separated list via AGENTAPI_ALLOWED_ORIGINS env var", "stringSlice"}, + {FlagUseXForwardedHost, "", false, "Use X-Forwarded-Host header for host authorization (behind trusted proxies)", "bool"}, } for _, spec := range flagSpecs { diff --git a/lib/httpapi/server.go b/lib/httpapi/server.go index 29c9332..28c90d6 100644 --- a/lib/httpapi/server.go +++ b/lib/httpapi/server.go @@ -62,12 +62,13 @@ func (s *Server) GetOpenAPI() string { const snapshotInterval = 25 * time.Millisecond type ServerConfig struct { - AgentType mf.AgentType - Process *termexec.Process - Port int - ChatBasePath string - AllowedHosts []string - AllowedOrigins []string + AgentType mf.AgentType + Process *termexec.Process + Port int + ChatBasePath string + AllowedHosts []string + AllowedOrigins []string + UseXForwardedHost bool } func parseAllowedHosts(hosts []string) ([]string, error) { @@ -145,7 +146,7 @@ func NewServer(ctx context.Context, config ServerConfig) (*Server, error) { badHostHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "Invalid host header. Allowed hosts: "+strings.Join(allowedHosts, ", "), http.StatusBadRequest) }) - router.Use(hostAuthorizationMiddleware(allowedHosts, badHostHandler)) + router.Use(hostAuthorizationMiddleware(allowedHosts, config.UseXForwardedHost, badHostHandler)) corsMiddleware := cors.New(cors.Options{ AllowedOrigins: allowedOrigins, @@ -198,8 +199,9 @@ func (s *Server) Handler() http.Handler { // hostAuthorizationMiddleware enforces that the request Host header matches one of the allowed // hosts, ignoring any port in the comparison. If allowedHosts is empty, all hosts are allowed. -// Always uses url.Parse("http://" + r.Host) to robustly extract the hostname (handles IPv6). -func hostAuthorizationMiddleware(allowedHosts []string, badHostHandler http.Handler) func(next http.Handler) http.Handler { +// If useXForwardedHost is true and the X-Forwarded-Host header is present, that header is used +// as the source of host. Hostname is extracted via url.Parse to handle IPv6 and strip ports. +func hostAuthorizationMiddleware(allowedHosts []string, useXForwardedHost bool, badHostHandler http.Handler) func(next http.Handler) http.Handler { // Copy for safety; also build a map for O(1) lookups with case-insensitive keys. allowed := make(map[string]struct{}, len(allowedHosts)) for _, h := range allowedHosts { @@ -211,13 +213,24 @@ func hostAuthorizationMiddleware(allowedHosts []string, badHostHandler http.Hand next.ServeHTTP(w, r) return } - // Extract hostname from the Host header using url.Parse; ignore any port. - hostHeader := r.Host - if hostHeader == "" { + // Choose header source + rawHost := r.Host + if useXForwardedHost { + if xfhs := r.Header.Values("X-Forwarded-Host"); len(xfhs) > 0 { + // Use the first value and trim anything after a comma + h := xfhs[0] + if idx := strings.IndexByte(h, ','); idx >= 0 { + h = h[:idx] + } + rawHost = strings.TrimSpace(h) + } + } + if rawHost == "" { badHostHandler.ServeHTTP(w, r) return } - if u, err := url.Parse("http://" + hostHeader); err == nil { + // Extract hostname via url.Parse; ignore any port. + if u, err := url.Parse("http://" + rawHost); err == nil { hostname := u.Hostname() if _, ok := allowed[strings.ToLower(hostname)]; ok { next.ServeHTTP(w, r) diff --git a/lib/httpapi/server_test.go b/lib/httpapi/server_test.go index 5a80ee9..432952d 100644 --- a/lib/httpapi/server_test.go +++ b/lib/httpapi/server_test.go @@ -241,6 +241,97 @@ func TestServer_AllowedHosts(t *testing.T) { } } +func TestServer_UseXForwardedHost(t *testing.T) { + cases := []struct { + name string + allowedHosts []string + useXForwardedHost bool + hostHeader string + xForwardedHostHeader string + expectedStatusCode int + expectedErrorMsg string + }{ + { + name: "disabled flag ignores X-Forwarded-Host", + allowedHosts: []string{"app.example.com"}, + useXForwardedHost: false, + hostHeader: "malicious.com", + xForwardedHostHeader: "app.example.com", + expectedStatusCode: http.StatusBadRequest, + expectedErrorMsg: "Invalid host header. Allowed hosts: app.example.com", + }, + { + name: "enabled flag uses X-Forwarded-Host", + allowedHosts: []string{"app.example.com"}, + useXForwardedHost: true, + hostHeader: "malicious.com", + xForwardedHostHeader: "app.example.com", + expectedStatusCode: http.StatusOK, + }, + { + name: "enabled with port in X-Forwarded-Host", + allowedHosts: []string{"app.example.com"}, + useXForwardedHost: true, + hostHeader: "malicious.com", + xForwardedHostHeader: "app.example.com:443", + expectedStatusCode: http.StatusOK, + }, + { + name: "enabled with IPv6 literal in X-Forwarded-Host", + allowedHosts: []string{"2001:db8::1"}, + useXForwardedHost: true, + hostHeader: "malicious.com", + xForwardedHostHeader: "[2001:db8::1]:8443", + expectedStatusCode: http.StatusOK, + }, + { + name: "enabled with comma-separated X-Forwarded-Host takes first", + allowedHosts: []string{"first.example.com"}, + useXForwardedHost: true, + hostHeader: "malicious.com", + xForwardedHostHeader: "first.example.com, other.example.com", + expectedStatusCode: http.StatusOK, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil))) + s, err := httpapi.NewServer(ctx, httpapi.ServerConfig{ + AgentType: msgfmt.AgentTypeClaude, + Process: nil, + Port: 0, + ChatBasePath: "/chat", + AllowedHosts: tc.allowedHosts, + AllowedOrigins: []string{"https://example.com"}, // isolate + UseXForwardedHost: tc.useXForwardedHost, + }) + require.NoError(t, err) + tsServer := httptest.NewServer(s.Handler()) + t.Cleanup(tsServer.Close) + + req, err := http.NewRequest("GET", tsServer.URL+"/status", nil) + require.NoError(t, err) + if tc.hostHeader != "" { + req.Host = tc.hostHeader + } + if tc.xForwardedHostHeader != "" { + req.Header.Set("X-Forwarded-Host", tc.xForwardedHostHeader) + } + + resp, err := (&http.Client{}).Do(req) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + require.Equal(t, tc.expectedStatusCode, resp.StatusCode) + if tc.expectedErrorMsg != "" { + b, _ := io.ReadAll(resp.Body) + require.Contains(t, string(b), tc.expectedErrorMsg) + } + }) + } +} + func TestServer_CORSPreflightWithHosts(t *testing.T) { cases := []struct { name string From bbf910248d835dc05e1fd2e801878cc8cf44257a Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Sat, 9 Aug 2025 08:33:53 +0000 Subject: [PATCH 2/4] test(cli): assert default and env/precedence for --use-x-forwarded-host\n\nExtend CLI tests to cover default false, env var AGENTAPI_USE_X_FORWARDED_HOST,\nand CLI overrides env precedence.\n\nCo-authored-by: hugodutka <28019628+hugodutka@users.noreply.github.com> --- cmd/server/server_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cmd/server/server_test.go b/cmd/server/server_test.go index eb339f4..37a0a8e 100644 --- a/cmd/server/server_test.go +++ b/cmd/server/server_test.go @@ -157,6 +157,7 @@ func TestServerCmd_AllArgs_Defaults(t *testing.T) { {"term-height default", FlagTermHeight, uint16(1000), func() any { return viper.GetUint16(FlagTermHeight) }}, {"allowed-hosts default", FlagAllowedHosts, []string{"localhost"}, func() any { return viper.GetStringSlice(FlagAllowedHosts) }}, {"allowed-origins default", FlagAllowedOrigins, []string{"http://localhost:3284", "http://localhost:3000", "http://localhost:3001"}, func() any { return viper.GetStringSlice(FlagAllowedOrigins) }}, + {"use-x-forwarded-host default", FlagUseXForwardedHost, false, func() any { return viper.GetBool(FlagUseXForwardedHost) }}, } for _, tt := range tests { @@ -191,6 +192,7 @@ func TestServerCmd_AllEnvVars(t *testing.T) { {"AGENTAPI_TERM_HEIGHT", "AGENTAPI_TERM_HEIGHT", "500", uint16(500), func() any { return viper.GetUint16(FlagTermHeight) }}, {"AGENTAPI_ALLOWED_HOSTS", "AGENTAPI_ALLOWED_HOSTS", "localhost example.com", []string{"localhost", "example.com"}, func() any { return viper.GetStringSlice(FlagAllowedHosts) }}, {"AGENTAPI_ALLOWED_ORIGINS", "AGENTAPI_ALLOWED_ORIGINS", "https://example.com http://localhost:3000", []string{"https://example.com", "http://localhost:3000"}, func() any { return viper.GetStringSlice(FlagAllowedOrigins) }}, + {"AGENTAPI_USE_X_FORWARDED_HOST", "AGENTAPI_USE_X_FORWARDED_HOST", "true", true, func() any { return viper.GetBool(FlagUseXForwardedHost) }}, } for _, tt := range tests { @@ -268,6 +270,13 @@ func TestServerCmd_ArgsPrecedenceOverEnv(t *testing.T) { []string{"https://cli-example.com"}, func() any { return viper.GetStringSlice(FlagAllowedOrigins) }, }, + { + "use-x-forwarded-host: CLI overrides env", + "AGENTAPI_USE_X_FORWARDED_HOST", "false", + []string{"--use-x-forwarded-host"}, + true, + func() any { return viper.GetBool(FlagUseXForwardedHost) }, + }, } for _, tt := range tests { From 7f9234fab52e1f1ee41e52cad2ef792c8bd27332 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Sat, 9 Aug 2025 08:46:16 +0000 Subject: [PATCH 3/4] docs(readme): document --use-x-forwarded-host and env var\n\nExplain when to enable, behavior (first value, comma-trimming, hostname-only, IPv6),\nand security considerations (trusted proxy).\n\nCo-authored-by: hugodutka <28019628+hugodutka@users.noreply.github.com> --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c4c39bc..af8719d 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ agentapi server --allowed-hosts 'example.com,example.org' -- claude AGENTAPI_ALLOWED_HOSTS='example.com example.org' agentapi server -- claude ``` +If you're running behind a trusted reverse proxy that sets the `X-Forwarded-Host` header, you can opt in to using that header for host authorization with `--use-x-forwarded-host` (or `AGENTAPI_USE_X_FORWARDED_HOST=true`). When enabled, the server prefers the first `X-Forwarded-Host` value (trimming anything after a comma), extracts the hostname (ignoring any port, supports IPv6 bracket literals), and matches it against the allowed host list. Leave this disabled unless your deployment terminates at a trusted proxy. + #### Allowed origins By default, the server allows CORS requests from `http://localhost:3284`, `http://localhost:3000`, and `http://localhost:3001`. If you'd like to change which origins can make cross-origin requests to AgentAPI, you can change this by using the `AGENTAPI_ALLOWED_ORIGINS` environment variable or the `--allowed-origins` flag. From 2213e2b50cd42a3d39b46986d06d9fdec55c9585 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Sat, 9 Aug 2025 10:47:40 +0200 Subject: [PATCH 4/4] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index af8719d..57dad3d 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ agentapi server --allowed-hosts 'example.com,example.org' -- claude AGENTAPI_ALLOWED_HOSTS='example.com example.org' agentapi server -- claude ``` -If you're running behind a trusted reverse proxy that sets the `X-Forwarded-Host` header, you can opt in to using that header for host authorization with `--use-x-forwarded-host` (or `AGENTAPI_USE_X_FORWARDED_HOST=true`). When enabled, the server prefers the first `X-Forwarded-Host` value (trimming anything after a comma), extracts the hostname (ignoring any port, supports IPv6 bracket literals), and matches it against the allowed host list. Leave this disabled unless your deployment terminates at a trusted proxy. +If you're running behind a trusted reverse proxy that sets the `X-Forwarded-Host` header, you can opt in to using that header for host authorization with `--use-x-forwarded-host` (or `AGENTAPI_USE_X_FORWARDED_HOST=true`). When enabled, the server prefers the first `X-Forwarded-Host` value, and matches it against the allowed host list. Leave this disabled unless your deployment terminates at a trusted proxy. #### Allowed origins