package ingress import ( "encoding/json" "flag" "strings" "testing" "time" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" yaml "gopkg.in/yaml.v3" "github.com/cloudflare/cloudflared/config" "github.com/cloudflare/cloudflared/ipaccess" "github.com/stretchr/testify/assert" ) // Ensure that the nullable config from `config` package and the // non-nullable config from `ingress` package have the same number of // fields. // This test ensures that programmers didn't add a new field to // one struct and forget to add it to the other ;) func TestCorrespondingFields(t *testing.T) { require.Equal( t, CountFields(t, config.OriginRequestConfig{}), CountFields(t, OriginRequestConfig{}), ) } func CountFields(t *testing.T, val interface{}) int { b, err := yaml.Marshal(val) require.NoError(t, err) m := make(map[string]interface{}, 0) err = yaml.Unmarshal(b, &m) require.NoError(t, err) return len(m) } func TestUnmarshalRemoteConfigOverridesGlobal(t *testing.T) { rawConfig := []byte(` { "originRequest": { "connectTimeout": 90, "noHappyEyeballs": true }, "ingress": [ { "hostname": "jira.cfops.com", "service": "http://192.16.19.1:80", "originRequest": { "noTLSVerify": true, "connectTimeout": 10 } }, { "service": "http_status:404" } ], "warp-routing": { "enabled": true } } `) var remoteConfig RemoteConfig err := json.Unmarshal(rawConfig, &remoteConfig) require.NoError(t, err) require.True(t, remoteConfig.Ingress.Rules[0].Config.NoTLSVerify) require.True(t, remoteConfig.Ingress.Defaults.NoHappyEyeballs) } func TestOriginRequestConfigOverrides(t *testing.T) { validate := func(ing Ingress) { // Rule 0 didn't override anything, so it inherits the user-specified // root-level configuration. actual0 := ing.Rules[0].Config expected0 := OriginRequestConfig{ ConnectTimeout: config.CustomDuration{Duration: 1 * time.Minute}, TLSTimeout: config.CustomDuration{Duration: 1 * time.Second}, TCPKeepAlive: config.CustomDuration{Duration: 1 * time.Second}, NoHappyEyeballs: true, KeepAliveTimeout: config.CustomDuration{Duration: 1 * time.Second}, KeepAliveConnections: 1, HTTPHostHeader: "abc", OriginServerName: "a1", CAPool: "/tmp/path0", NoTLSVerify: true, DisableChunkedEncoding: true, BastionMode: true, ProxyAddress: "127.1.2.3", ProxyPort: uint(100), ProxyType: "socks5", IPRules: []ipaccess.Rule{ newIPRule(t, "10.0.0.0/8", []int{80, 8080}, false), newIPRule(t, "fc00::/7", []int{443, 4443}, true), }, } require.Equal(t, expected0, actual0) // Rule 1 overrode all the root-level config. actual1 := ing.Rules[1].Config expected1 := OriginRequestConfig{ ConnectTimeout: config.CustomDuration{Duration: 2 * time.Minute}, TLSTimeout: config.CustomDuration{Duration: 2 * time.Second}, TCPKeepAlive: config.CustomDuration{Duration: 2 * time.Second}, NoHappyEyeballs: false, KeepAliveTimeout: config.CustomDuration{Duration: 2 * time.Second}, KeepAliveConnections: 2, HTTPHostHeader: "def", OriginServerName: "b2", CAPool: "/tmp/path1", NoTLSVerify: false, DisableChunkedEncoding: false, BastionMode: false, ProxyAddress: "interface", ProxyPort: uint(200), ProxyType: "", IPRules: []ipaccess.Rule{ newIPRule(t, "10.0.0.0/16", []int{3000, 3030}, false), newIPRule(t, "192.16.0.0/24", []int{5000, 5050}, true), }, } require.Equal(t, expected1, actual1) } rulesYAML := ` originRequest: connectTimeout: 1m tlsTimeout: 1s noHappyEyeballs: true tcpKeepAlive: 1s keepAliveConnections: 1 keepAliveTimeout: 1s httpHostHeader: abc originServerName: a1 caPool: /tmp/path0 noTLSVerify: true disableChunkedEncoding: true bastionMode: True proxyAddress: 127.1.2.3 proxyPort: 100 proxyType: socks5 ipRules: - prefix: "10.0.0.0/8" ports: - 80 - 8080 allow: false - prefix: "fc00::/7" ports: - 443 - 4443 allow: true ingress: - hostname: tun.example.com service: https://localhost:8000 - hostname: "*" service: https://localhost:8001 originRequest: connectTimeout: 2m tlsTimeout: 2s noHappyEyeballs: false tcpKeepAlive: 2s keepAliveConnections: 2 keepAliveTimeout: 2s httpHostHeader: def originServerName: b2 caPool: /tmp/path1 noTLSVerify: false disableChunkedEncoding: false bastionMode: false proxyAddress: interface proxyPort: 200 proxyType: "" ipRules: - prefix: "10.0.0.0/16" ports: - 3000 - 3030 allow: false - prefix: "192.16.0.0/24" ports: - 5000 - 5050 allow: true ` ing, err := ParseIngress(MustReadIngress(rulesYAML)) require.NoError(t, err) validate(ing) rawConfig := []byte(` { "originRequest": { "connectTimeout": 60, "tlsTimeout": 1, "noHappyEyeballs": true, "tcpKeepAlive": 1, "keepAliveConnections": 1, "keepAliveTimeout": 1, "httpHostHeader": "abc", "originServerName": "a1", "caPool": "/tmp/path0", "noTLSVerify": true, "disableChunkedEncoding": true, "bastionMode": true, "proxyAddress": "127.1.2.3", "proxyPort": 100, "proxyType": "socks5", "ipRules": [ { "prefix": "10.0.0.0/8", "ports": [80, 8080], "allow": false }, { "prefix": "fc00::/7", "ports": [443, 4443], "allow": true } ] }, "ingress": [ { "hostname": "tun.example.com", "service": "https://localhost:8000" }, { "hostname": "*", "service": "https://localhost:8001", "originRequest": { "connectTimeout": 120, "tlsTimeout": 2, "noHappyEyeballs": false, "tcpKeepAlive": 2, "keepAliveConnections": 2, "keepAliveTimeout": 2, "httpHostHeader": "def", "originServerName": "b2", "caPool": "/tmp/path1", "noTLSVerify": false, "disableChunkedEncoding": false, "bastionMode": false, "proxyAddress": "interface", "proxyPort": 200, "proxyType": "", "ipRules": [ { "prefix": "10.0.0.0/16", "ports": [3000, 3030], "allow": false }, { "prefix": "192.16.0.0/24", "ports": [5000, 5050], "allow": true } ] } } ], "warp-routing": { "enabled": true } } `) var remoteConfig RemoteConfig err = json.Unmarshal(rawConfig, &remoteConfig) require.NoError(t, err) validate(remoteConfig.Ingress) } func TestOriginRequestConfigDefaults(t *testing.T) { validate := func(ing Ingress) { // Rule 0 didn't override anything, so it inherits the cloudflared defaults actual0 := ing.Rules[0].Config expected0 := OriginRequestConfig{ ConnectTimeout: defaultHTTPConnectTimeout, TLSTimeout: defaultTLSTimeout, TCPKeepAlive: defaultTCPKeepAlive, KeepAliveConnections: defaultKeepAliveConnections, KeepAliveTimeout: defaultKeepAliveTimeout, ProxyAddress: defaultProxyAddress, } require.Equal(t, expected0, actual0) // Rule 1 overrode all defaults. actual1 := ing.Rules[1].Config expected1 := OriginRequestConfig{ ConnectTimeout: config.CustomDuration{Duration: 2 * time.Minute}, TLSTimeout: config.CustomDuration{Duration: 2 * time.Second}, TCPKeepAlive: config.CustomDuration{Duration: 2 * time.Second}, NoHappyEyeballs: false, KeepAliveTimeout: config.CustomDuration{Duration: 2 * time.Second}, KeepAliveConnections: 2, HTTPHostHeader: "def", OriginServerName: "b2", CAPool: "/tmp/path1", NoTLSVerify: false, DisableChunkedEncoding: false, BastionMode: false, ProxyAddress: "interface", ProxyPort: uint(200), ProxyType: "", IPRules: []ipaccess.Rule{ newIPRule(t, "10.0.0.0/16", []int{3000, 3030}, false), newIPRule(t, "192.16.0.0/24", []int{5000, 5050}, true), }, } require.Equal(t, expected1, actual1) } rulesYAML := ` ingress: - hostname: tun.example.com service: https://localhost:8000 - hostname: "*" service: https://localhost:8001 originRequest: connectTimeout: 2m tlsTimeout: 2s noHappyEyeballs: false tcpKeepAlive: 2s keepAliveConnections: 2 keepAliveTimeout: 2s httpHostHeader: def originServerName: b2 caPool: /tmp/path1 noTLSVerify: false disableChunkedEncoding: false bastionMode: false proxyAddress: interface proxyPort: 200 proxyType: "" ipRules: - prefix: "10.0.0.0/16" ports: - 3000 - 3030 allow: false - prefix: "192.16.0.0/24" ports: - 5000 - 5050 allow: true ` ing, err := ParseIngress(MustReadIngress(rulesYAML)) if err != nil { t.Error(err) } validate(ing) rawConfig := []byte(` { "ingress": [ { "hostname": "tun.example.com", "service": "https://localhost:8000" }, { "hostname": "*", "service": "https://localhost:8001", "originRequest": { "connectTimeout": 120, "tlsTimeout": 2, "noHappyEyeballs": false, "tcpKeepAlive": 2, "keepAliveConnections": 2, "keepAliveTimeout": 2, "httpHostHeader": "def", "originServerName": "b2", "caPool": "/tmp/path1", "noTLSVerify": false, "disableChunkedEncoding": false, "bastionMode": false, "proxyAddress": "interface", "proxyPort": 200, "proxyType": "", "ipRules": [ { "prefix": "10.0.0.0/16", "ports": [3000, 3030], "allow": false }, { "prefix": "192.16.0.0/24", "ports": [5000, 5050], "allow": true } ] } } ] } `) var remoteConfig RemoteConfig err = json.Unmarshal(rawConfig, &remoteConfig) require.NoError(t, err) validate(remoteConfig.Ingress) } func TestDefaultConfigFromCLI(t *testing.T) { set := flag.NewFlagSet("contrive", 0) c := cli.NewContext(nil, set, nil) expected := OriginRequestConfig{ ConnectTimeout: defaultHTTPConnectTimeout, TLSTimeout: defaultTLSTimeout, TCPKeepAlive: defaultTCPKeepAlive, KeepAliveConnections: defaultKeepAliveConnections, KeepAliveTimeout: defaultKeepAliveTimeout, ProxyAddress: defaultProxyAddress, } actual := originRequestFromSingleRule(c) require.Equal(t, expected, actual) } func TestOriginRequestConfigHeaders(t *testing.T) { config := OriginRequestConfig{ Headers: map[string]string{ "X-Custom-Header": "custom-value", "Authorization": "Bearer token123", }, RemoveHeaders: []string{"X-Unwanted", "Server"}, } jsonData, err := json.Marshal(config) assert.NoError(t, err) assert.Contains(t, string(jsonData), "X-Custom-Header") assert.Contains(t, string(jsonData), "custom-value") assert.Contains(t, string(jsonData), "X-Unwanted") var unmarshaled OriginRequestConfig err = json.Unmarshal(jsonData, &unmarshaled) assert.NoError(t, err) assert.Equal(t, "custom-value", unmarshaled.Headers["X-Custom-Header"]) assert.Equal(t, "Bearer token123", unmarshaled.Headers["Authorization"]) assert.Contains(t, unmarshaled.RemoveHeaders, "X-Unwanted") assert.Contains(t, unmarshaled.RemoveHeaders, "Server") } func TestParseHeaderFlag(t *testing.T) { name, value, valid := parseHeaderFlag("X-Custom-Header: custom-value") assert.True(t, valid) assert.Equal(t, "X-Custom-Header", name) assert.Equal(t, "custom-value", value) name, value, valid = parseHeaderFlag(" Authorization : Bearer token ") assert.True(t, valid) assert.Equal(t, "Authorization", name) assert.Equal(t, "Bearer token", value) name, value, valid = parseHeaderFlag("X-Header: ") assert.False(t, valid) name, value, valid = parseHeaderFlag(" : value") assert.False(t, valid) _, _, valid = parseHeaderFlag("invalid-format") assert.False(t, valid) _, _, valid = parseHeaderFlag(": value-only") assert.False(t, valid) _, _, valid = parseHeaderFlag("name-only:") assert.False(t, valid) _, _, valid = parseHeaderFlag("") assert.False(t, valid) name, value, valid = parseHeaderFlag("X-Special: value with @#$%^&*()") assert.True(t, valid) assert.Equal(t, "X-Special", name) assert.Equal(t, "value with @#$%^&*()", value) name, value, valid = parseHeaderFlag("X-URL: https://example.com:8080/path") assert.True(t, valid) assert.Equal(t, "X-URL", name) assert.Equal(t, "https://example.com:8080/path", value) } func TestIsValidHeaderName(t *testing.T) { assert.True(t, isValidHeaderName("X-Custom-Header")) assert.True(t, isValidHeaderName("Authorization")) assert.True(t, isValidHeaderName("Content-Type")) assert.True(t, isValidHeaderName("X-API-Key")) assert.True(t, isValidHeaderName("User-Agent")) assert.False(t, isValidHeaderName("")) assert.False(t, isValidHeaderName(" ")) assert.False(t, isValidHeaderName("\t")) assert.False(t, isValidHeaderName("\n")) assert.False(t, isValidHeaderName("\r")) assert.False(t, isValidHeaderName("Header With Space")) assert.False(t, isValidHeaderName("Header\tWith\tTab")) assert.False(t, isValidHeaderName("Header\nWith\nNewline")) assert.False(t, isValidHeaderName("Header\rWith\rCarriageReturn")) assert.False(t, isValidHeaderName(":Header")) assert.False(t, isValidHeaderName("Header:")) assert.False(t, isValidHeaderName("Header::Value")) longHeader := strings.Repeat("A", 257) assert.False(t, isValidHeaderName(longHeader)) boundaryHeader := strings.Repeat("A", 256) assert.True(t, isValidHeaderName(boundaryHeader)) assert.True(t, isValidHeaderName("X")) assert.True(t, isValidHeaderName("a")) assert.True(t, isValidHeaderName("1")) assert.True(t, isValidHeaderName("X-Header")) assert.True(t, isValidHeaderName("X_Header")) assert.True(t, isValidHeaderName("X.Header")) } func TestParseHeadersFromCLI(t *testing.T) { app := cli.NewApp() app.Flags = []cli.Flag{ &cli.StringSliceFlag{ Name: "header", }, } app.Action = func(c *cli.Context) error { headers := parseHeadersFromCLI(c) assert.Equal(t, 3, len(headers)) assert.Equal(t, "test-value", headers["X-Test-Header"]) assert.Equal(t, "static-key-123", headers["X-API-Key"]) assert.Equal(t, "Bearer token", headers["Authorization"]) assert.NotContains(t, headers, "Invalid-Header") assert.NotContains(t, headers, "X-Empty") return nil } err := app.Run([]string{"app", "--header", "X-Test-Header: test-value", "--header", "X-API-Key: static-key-123", "--header", "Authorization: Bearer token", "--header", "Invalid-Header", "--header", "X-Empty: "}) assert.NoError(t, err) } func TestParseRemoveHeadersFromCLI(t *testing.T) { app := cli.NewApp() app.Flags = []cli.Flag{ &cli.StringSliceFlag{ Name: "remove-header", }, } app.Action = func(c *cli.Context) error { removeHeaders := parseRemoveHeadersFromCLI(c) assert.Equal(t, 3, len(removeHeaders)) assert.Contains(t, removeHeaders, "X-Unwanted") assert.Contains(t, removeHeaders, "Server") assert.Contains(t, removeHeaders, "User-Agent") return nil } err := app.Run([]string{"app", "--remove-header", "X-Unwanted", "--remove-header", "Server", "--remove-header", "User-Agent"}) assert.NoError(t, err) } func TestParseHeadersFromCLINotSet(t *testing.T) { app := cli.NewApp() app.Action = func(c *cli.Context) error { headers := parseHeadersFromCLI(c) assert.Equal(t, 0, len(headers)) assert.NotNil(t, headers) return nil } err := app.Run([]string{"app"}) assert.NoError(t, err) } func TestParseRemoveHeadersFromCLINotSet(t *testing.T) { app := cli.NewApp() app.Action = func(c *cli.Context) error { removeHeaders := parseRemoveHeadersFromCLI(c) assert.Nil(t, removeHeaders) return nil } err := app.Run([]string{"app"}) assert.NoError(t, err) } func newIPRule(t *testing.T, prefix string, ports []int, allow bool) ipaccess.Rule { rule, err := ipaccess.NewRuleByCIDR(&prefix, ports, allow) require.NoError(t, err) return rule }