diff --git a/internal/token/gat_claims.go b/internal/token/gat_claims.go index 0c013071..90b179ba 100644 --- a/internal/token/gat_claims.go +++ b/internal/token/gat_claims.go @@ -91,7 +91,16 @@ func validatePort(port int, fieldName string) error { } func (p GATClaims) ShouldUpgradeTLS() bool { - return p.Resource.Type == ResourceTypeKubernetes + switch p.Resource.Type { + case ResourceTypeKubernetes: + return true + case ResourceTypeWebApp: + return p.Resource.GatewayMetadata.Downstream.TLS + case ResourceTypeSSH: + return false + } + + return false } type User struct { @@ -153,6 +162,8 @@ type GatewayMetadata struct { type Downstream struct { // Port is the port that the protocol client connects to. Port int `json:"port"` + // TLS indicates whether the Gateway should enforce TLS for the protocol client. + TLS bool `json:"tls"` } // Upstream describes the connection between the Gateway and the upstream resource. diff --git a/internal/token/gat_claims_test.go b/internal/token/gat_claims_test.go index 1f86d832..43d3fcaa 100644 --- a/internal/token/gat_claims_test.go +++ b/internal/token/gat_claims_test.go @@ -20,9 +20,10 @@ import ( func TestGATClaims_ShouldUpgradeTLS(t *testing.T) { tests := []struct { - name string - resourceType ResourceType - expected bool + name string + resourceType ResourceType + downstreamTLS bool + expected bool }{ { name: "Kubernetes should upgrade TLS", @@ -30,21 +31,33 @@ func TestGATClaims_ShouldUpgradeTLS(t *testing.T) { expected: true, }, { - name: "SSH should not upgrade TLS", - resourceType: ResourceTypeSSH, - expected: false, + name: "SSH should not upgrade TLS", + resourceType: ResourceTypeSSH, + downstreamTLS: true, + expected: false, }, { - name: "Web app should not upgrade TLS", + name: "Web app without downstream TLS should not upgrade TLS", resourceType: ResourceTypeWebApp, expected: false, }, + { + name: "Web app with downstream TLS should upgrade TLS", + resourceType: ResourceTypeWebApp, + downstreamTLS: true, + expected: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { claims := &GATClaims{ - Resource: Resource{Type: tt.resourceType}, + Resource: Resource{ + Type: tt.resourceType, + GatewayMetadata: GatewayMetadata{ + Downstream: Downstream{TLS: tt.downstreamTLS}, + }, + }, } assert.Equal(t, tt.expected, claims.ShouldUpgradeTLS()) }) @@ -339,6 +352,34 @@ func TestPublicKey_UnmarshalJSON(t *testing.T) { } } +func TestGatewayMetadata_UnmarshalDownstreamTLS(t *testing.T) { + tests := []struct { + name string + json string + wantTLS bool + }{ + { + name: "downstream TLS true", + json: `{"downstream": {"port": 443, "tls": true}}`, + wantTLS: true, + }, + { + name: "TLS absent defaults to false", + json: `{"downstream": {"port": 443}}`, + wantTLS: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var metadata GatewayMetadata + + require.NoError(t, json.Unmarshal([]byte(tt.json), &metadata)) + assert.Equal(t, tt.wantTLS, metadata.Downstream.TLS) + }) + } +} + func TestGatewayMetadata_UnmarshalUpstreamTLS(t *testing.T) { tests := []struct { name string @@ -346,12 +387,12 @@ func TestGatewayMetadata_UnmarshalUpstreamTLS(t *testing.T) { wantTLS bool }{ { - name: "upstream tls true", + name: "upstream TLS true", json: `{"upstream": {"port": 5000, "tls": true}}`, wantTLS: true, }, { - name: "tls absent defaults to false", + name: "TLS absent defaults to false", json: `{"upstream": {"port": 5000}}`, wantTLS: false, }, diff --git a/internal/webapphandler/handler.go b/internal/webapphandler/handler.go index e74271e6..56d0ce88 100644 --- a/internal/webapphandler/handler.go +++ b/internal/webapphandler/handler.go @@ -82,6 +82,15 @@ func buildVariables(conn *connect.ProxyConn) map[string]string { // these are the identity headers it leaves in place. var clientIdentityHeaders = []string{"X-Real-IP", "X-Forwarded-Port", "X-Forwarded-Server"} +// downstreamScheme reports the scheme the protocol client used to reach the Gateway. +func downstreamScheme(conn *connect.ProxyConn) string { + if conn.GATClaims().ShouldUpgradeTLS() { + return "https" + } + + return "http" +} + func rewrite(r *httputil.ProxyRequest, conn *connect.ProxyConn, headers map[string]*template.Template) error { scheme := "http" if conn.GATClaims().Resource.GatewayMetadata.Upstream.TLS { @@ -99,6 +108,8 @@ func rewrite(r *httputil.ProxyRequest, conn *connect.ProxyConn, headers map[stri r.Out.Header.Del(headerName) } + r.Out.Header.Set("X-Forwarded-Proto", downstreamScheme(conn)) + variables := buildVariables(conn) for headerName, tmpl := range headers { diff --git a/internal/webapphandler/handler_test.go b/internal/webapphandler/handler_test.go index e8d37073..b6f60644 100644 --- a/internal/webapphandler/handler_test.go +++ b/internal/webapphandler/handler_test.go @@ -351,3 +351,61 @@ func TestBuildVariables_CoversAllowedKeys(t *testing.T) { assert.Equal(t, want, got) } + +func TestRewrite_SetsXForwardedProto(t *testing.T) { + tests := []struct { + name string + downstreamTLS bool + clientSuppliedXFP string + want string + }{ + { + name: "HTTPS when the Gateway terminates TLS downstream", + downstreamTLS: true, + want: "https", + }, + { + name: "HTTP when the protocol client connects in plaintext", + downstreamTLS: false, + want: "http", + }, + { + name: "client-supplied value is overwritten", + downstreamTLS: false, + clientSuppliedXFP: "https", + want: "http", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + connMetrics := connect.CreateProxyConnMetrics(prometheus.NewRegistry()) + conn := connect.NewProxyConn(nil, nil, nil, zap.NewNop(), connMetrics) + conn.UpstreamHost = "admin.example.int" + conn.Claims = &token.GATClaims{ + Resource: token.Resource{ + Type: token.ResourceTypeWebApp, + GatewayMetadata: token.GatewayMetadata{ + Downstream: token.Downstream{Port: 443, TLS: tt.downstreamTLS}, + Upstream: token.Upstream{Port: 80}, + }, + }, + } + + outReq := httptest.NewRequest(http.MethodGet, "http://admin.example.int/path", nil) + if tt.clientSuppliedXFP != "" { + outReq.Header.Set("X-Forwarded-Proto", tt.clientSuppliedXFP) + } + + proxyReq := &httputil.ProxyRequest{ + In: httptest.NewRequest(http.MethodGet, "http://admin.example.int/path", nil), + Out: outReq, + } + + err := rewrite(proxyReq, conn, nil) + require.NoError(t, err) + + assert.Equal(t, tt.want, proxyReq.Out.Header.Get("X-Forwarded-Proto")) + }) + } +} diff --git a/test/fake/client.go b/test/fake/client.go index 51400b0e..7ee09640 100644 --- a/test/fake/client.go +++ b/test/fake/client.go @@ -46,6 +46,7 @@ type Client struct { resourceHostname string downstreamPort int + downstreamTLS bool upstreamPort int resourceType token.ResourceType requestHeaderRewrites map[string]string @@ -73,6 +74,15 @@ func WithRequestHeaderRewrites(rewrites map[string]string) Option { } } +// WithDownstreamTLS marks the resource as TLS-enforced on downstream in the GAT +// and switches the client-facing port to the HTTPS port. +func WithDownstreamTLS() Option { + return func(c *Client) { + c.downstreamTLS = true + c.downstreamPort = 443 + } +} + // NewClient creates a new Client. upstreamAddress must include both the host and the port that // the backend actually listens on. The client-facing downstream port used in the CONNECT request // is derived from resourceType. The Gateway rewrites it to the upstream port before forwarding @@ -278,7 +288,7 @@ func (c *Client) fetchGAT() (string, error) { Type: c.resourceType, Address: c.resourceHostname, GatewayMetadata: token.GatewayMetadata{ - Downstream: token.Downstream{Port: c.downstreamPort}, + Downstream: token.Downstream{Port: c.downstreamPort, TLS: c.downstreamTLS}, Upstream: token.Upstream{Port: c.upstreamPort}, RequestHeaderRewrites: c.requestHeaderRewrites, }, diff --git a/test/integration/web_app_test.go b/test/integration/web_app_test.go index d0dccd20..45f29e92 100644 --- a/test/integration/web_app_test.go +++ b/test/integration/web_app_test.go @@ -118,6 +118,8 @@ func TestWebApp(t *testing.T) { "X-Twingate-Client-Geo-Country": "US", // From GAT Token "X-Twingate-Username": "alex@acme.com", + // Downstream scheme + "X-Forwarded-Proto": "http", } for header, expected := range expectedHeaders { diff --git a/tools/local/main.go b/tools/local/main.go index a01835e2..e87e13d3 100644 --- a/tools/local/main.go +++ b/tools/local/main.go @@ -142,15 +142,17 @@ func main() { } }() + webAppGeo := token.GeoIPLocation{ + Lat: 37.5, + Lon: -122.4, + Country: "US", + Region: "CA", + City: "San Mateo", + } + webAppClient := fake.NewClient( user, - token.GeoIPLocation{ - Lat: 37.5, - Lon: -122.4, - Country: "US", - Region: "CA", - City: "San Mateo", - }, + webAppGeo, fmt.Sprintf("%s:%d", gatewayHost, gatewayPort), controller.URL, echoServer.address, @@ -158,7 +160,20 @@ func main() { ) defer webAppClient.Close() - logger.Info("Web app fake Twingate client is serving at", zap.String("address", webAppClient.Address)) + logger.Info("Web app HTTP fake Twingate client is serving at", zap.String("address", webAppClient.Address)) + + webAppTLSClient := fake.NewClient( + user, + webAppGeo, + fmt.Sprintf("%s:%d", gatewayHost, gatewayPort), + controller.URL, + echoServer.address, + token.ResourceTypeWebApp, + fake.WithDownstreamTLS(), + ) + defer webAppTLSClient.Close() + + logger.Info("Web app HTTPS fake Twingate client is serving at", zap.String("address", webAppTLSClient.Address)) err = createLocalGatewayConfig(kindBearerToken) if err != nil { @@ -176,11 +191,12 @@ func main() { Twingate local dev environment running! ===================================================== - Controller: %s - User: %s - Client (Kubernetes): %s - Client (SSH): %s - Client (Web App): %s + Controller: %s + User: %s + Client (Kubernetes): %s + Client (SSH): %s + Client (Web App HTTP): %s + Client (Web App HTTPS): %s ----------------------------------------------------- 1. Start the Gateway (in a separate terminal): @@ -206,17 +222,22 @@ Twingate local dev environment running! ----------------------------------------------------- 4. Test Web App header forwarding: + Over HTTP: curl http://%s + Over HTTPS: + curl --cacert ./test/data/proxy/tls.crt https://%s + ----------------------------------------------------- Press Ctrl+C to stop ===================================================== -`, controller.URL, user.Username, kubernetesClient.Address, sshClient.Address, webAppClient.Address, +`, controller.URL, user.Username, kubernetesClient.Address, sshClient.Address, webAppClient.Address, webAppTLSClient.Address, gatewayRunCmd, kubeConfigFile, kubeConfigFile, kindClusterName, sshClientPort, sshKnownHostFile, webAppClient.Address, + webAppTLSClient.Address, ) //nolint:forbidigo @@ -253,7 +274,7 @@ ssh: manual: privateKeyFile: ./test/data/ssh/ca/ca webApp: - headers: + requestHeaders: Authorization: "Bearer {{jwt}}" X-Twingate-User: "{{username}}" X-Twingate-Groups: "{{groups}}"