Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion internal/token/gat_claims.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
clement0010 marked this conversation as resolved.
case ResourceTypeSSH:
return false
}

return false
}

type User struct {
Expand Down Expand Up @@ -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.
Expand Down
61 changes: 51 additions & 10 deletions internal/token/gat_claims_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,31 +20,44 @@ 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",
resourceType: ResourceTypeKubernetes,
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())
})
Expand Down Expand Up @@ -339,19 +352,47 @@ 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
json string
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,
},
Expand Down
11 changes: 11 additions & 0 deletions internal/webapphandler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
58 changes: 58 additions & 0 deletions internal/webapphandler/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
})
}
}
12 changes: 11 additions & 1 deletion test/fake/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type Client struct {

resourceHostname string
downstreamPort int
downstreamTLS bool
upstreamPort int
resourceType token.ResourceType
requestHeaderRewrites map[string]string
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
Expand Down
2 changes: 2 additions & 0 deletions test/integration/web_app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
clement0010 marked this conversation as resolved.
}

for header, expected := range expectedHeaders {
Expand Down
51 changes: 36 additions & 15 deletions tools/local/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,23 +142,38 @@ 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,
token.ResourceTypeWebApp,
)
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 {
Expand All @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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}}"
Expand Down