istio.io/istio@v0.0.0-20240520182934-d79c90f27776/tests/integration/security/fuzz/fuzz_test.go (about)

     1  //go:build integfuzz
     2  // +build integfuzz
     3  
     4  // Copyright Istio Authors
     5  //
     6  // Licensed under the Apache License, Version 2.0 (the "License");
     7  // you may not use this file except in compliance with the License.
     8  // You may obtain a copy of the License at
     9  //
    10  //     http://www.apache.org/licenses/LICENSE-2.0
    11  //
    12  // Unless required by applicable law or agreed to in writing, software
    13  // distributed under the License is distributed on an "AS IS" BASIS,
    14  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    15  // See the License for the specific language governing permissions and
    16  // limitations under the License.
    17  
    18  package fuzz
    19  
    20  import (
    21  	"context"
    22  	"fmt"
    23  	"strings"
    24  	"testing"
    25  
    26  	"istio.io/istio/pkg/test/framework"
    27  	"istio.io/istio/pkg/test/framework/components/namespace"
    28  	"istio.io/istio/pkg/test/kube"
    29  	"istio.io/istio/tests/common/jwt"
    30  )
    31  
    32  const (
    33  	apacheServer = "apache"
    34  	nginxServer  = "nginx"
    35  	tomcatServer = "tomcat"
    36  
    37  	dotdotpwn = "dotdotpwn"
    38  	wfuzz     = "wfuzz"
    39  
    40  	authzDenyPolicy = `
    41  apiVersion: security.istio.io/v1
    42  kind: AuthorizationPolicy
    43  metadata:
    44    name: policy-deny
    45  spec:
    46    action: DENY
    47    rules:
    48    - to:
    49      - operation:
    50          paths: ["/private/secret.html"]
    51  `
    52  	jwtTool            = "jwttool"
    53  	requestAuthnPolicy = `
    54  apiVersion: security.istio.io/v1
    55  kind: RequestAuthentication
    56  metadata:
    57    name: jwt
    58  spec:
    59    jwtRules:
    60    - issuer: "test-issuer-1@istio.io"
    61      jwksUri: "https://raw.githubusercontent.com/istio/istio/release-1.10/tests/common/jwt/jwks.json"
    62    - issuer: "test-issuer-2@istio.io"
    63      jwksUri: "https://raw.githubusercontent.com/istio/istio/release-1.10/tests/common/jwt/jwks.json"
    64  `
    65  )
    66  
    67  var (
    68  	// Known unsupported path parameter ("/bla;foo") normalization for Tomcat.
    69  	dotdotPwnIgnoreTomcat = []string{
    70  		"/../private/secret.html;index.html <- VULNERABLE!",
    71  		"/../private/secret.html;index.htm <- VULNERABLE!",
    72  		"/..%5Cprivate%5Csecret.html;index.html <- VULNERABLE!",
    73  		"/..%5Cprivate%5Csecret.html;index.htm <- VULNERABLE!",
    74  	}
    75  	// Known unsupported path parameter ("/bla;foo") normalization for Tomcat.
    76  	wfuzzIgnoreTomcat = []string{
    77  		`%2e%2e/%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    78  		`%2e%2e/%2e%2e/%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    79  		`%2e%2e/%2e%2e/%2e%2e/%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    80  		`%2e%2e/%2e%2e/%2e%2e/%2e%2e/%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    81  		`%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    82  		`%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    83  		`%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    84  		`%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    85  		`%2e%2e\\%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    86  		`%2e%2e\\%2e%2e\\%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    87  		`%2e%2e\\%2e%2e\\%2e%2e\\%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    88  		`%2e%2e\\%2e%2e\\%2e%2e\\%2e%2e\\%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    89  		`%2e%2e\\%2e%2e\\%2e%2e\\%2e%2e\\%2e%2e\\%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    90  		`%2e%2e\\%2e%2e\\%2e%2e\\%2e%2e\\%2e%2e\\%2e%2e\\%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    91  		`%2e%2e\\%2e%2e\\%2e%2e\\%2e%2e\\%2e%2e\\%2e%2e\\%2e%2e\\%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    92  		`%2e%2e\\%2e%2e\\%2e%2e\\%2e%2e\\%2e%2e\\%2e%2e\\%2e%2e\\%2e%2e\\%70%72%69%76%61%74%65/%73%65%63%72%65%74%2e%68%74%6d%6c;%69%6e%64%65%78%2e%68%74%6d%6c`,
    93  	}
    94  )
    95  
    96  func deploy(t framework.TestContext, name, ns, yaml string) {
    97  	t.ConfigIstio().File(ns, yaml).ApplyOrFail(t)
    98  	if _, err := kube.WaitUntilPodsAreReady(kube.NewPodFetch(t.Clusters().Default(), ns, "app="+name)); err != nil {
    99  		t.Fatalf("Wait for pod %s failed: %v", name, err)
   100  	}
   101  	t.Logf("deploy %s is ready", name)
   102  }
   103  
   104  func waitService(t framework.TestContext, name, ns string) {
   105  	if _, _, err := kube.WaitUntilServiceEndpointsAreReady(t.Clusters().Default().Kube(), ns, name); err != nil {
   106  		t.Fatalf("Wait for service %s failed: %v", name, err)
   107  	}
   108  	t.Logf("service %s is ready", name)
   109  }
   110  
   111  func ignoreTomcat(t framework.TestContext, line string, ignores []string) bool {
   112  	for _, ignore := range ignores {
   113  		if strings.Contains(line, ignore) {
   114  			t.Logf("ignored known unsupported normalization: %s", ignore)
   115  			return true
   116  		}
   117  	}
   118  	return false
   119  }
   120  
   121  func runFuzzer(t framework.TestContext, fuzzer, ns, server string) {
   122  	pods, err := t.Clusters().Default().PodsForSelector(context.TODO(), ns, "app="+fuzzer)
   123  	if err != nil {
   124  		t.Fatalf("failed to get %s pod: %v", fuzzer, err)
   125  	}
   126  	t.Logf("running %s test against the %s (should normally complete in 60 seconds)...", fuzzer, server)
   127  
   128  	switch fuzzer {
   129  	case dotdotpwn:
   130  		// Run the dotdotpwn fuzz testing against the http server ("-m http") on port 8080 ("-x 8080"), other arguments:
   131  		// "-C": Continue if no data was received from host.
   132  		// "-d 1": depth of traversals.
   133  		// "-t 50": time in milliseconds between each test.
   134  		// "-f private/secret.html": specific filename to fetch after bypassing the authorization policy.
   135  		// "-k secret_data_leaked": text pattern to match in the response.
   136  		// "-r %s.txt": report filename.
   137  		command := fmt.Sprintf(`./run.sh -m http -h %s -x 8080 -C -d 1 -t 50 -f private/secret.html -k secret_data_leaked -r %s.txt`, server, server)
   138  		stdout, stderr, err := t.Clusters().Default().PodExec(pods.Items[0].Name, ns, dotdotpwn, command)
   139  		if err != nil {
   140  			t.Fatalf("failed to run dotdotpwn: %v", err)
   141  		}
   142  		t.Logf("%s\n%s\n", stdout, stderr)
   143  		t.Logf("dotdotpwn fuzz test completed for %s", server)
   144  
   145  		var errLines []string
   146  		for _, line := range strings.Split(stdout, "\n") {
   147  			if strings.Contains(line, "<- VULNERABLE") {
   148  				if server == tomcatServer && ignoreTomcat(t, line, dotdotPwnIgnoreTomcat) {
   149  					continue
   150  				}
   151  				errLines = append(errLines, line)
   152  			}
   153  		}
   154  		if len(errLines) != 0 {
   155  			t.Errorf("found potential policy bypass requests, please read the log for more details:\n- %s", strings.Join(errLines, "\n- "))
   156  		} else {
   157  			t.Logf("no potential policy bypass requests found")
   158  		}
   159  	case wfuzz:
   160  		// Run the wfuzz fuzz with the following parameters:
   161  		// -z file,wordlist/dirTraversal.txt,<...>: Fuzz based on the basic directory traversal patterns with various encodings (see details below).
   162  		// -f %s.out,csv -o csv: Output result in csv format.
   163  		// --ss secret_data_leaked: Show responses with the specified regex within the content.
   164  		// -t 5: Specify the number of concurrent connections.
   165  		// %s:8080/FUZZ: target server.
   166  		command := fmt.Sprintf("wfuzz -z file,wordlist/dirTraversal.txt,"+
   167  			"doble_nibble_hex-"+ // Replaces ALL characters in string using the %%dd%dd escape.
   168  			"double_urlencode-"+ // Applies a double encode to special characters in string using the %25xx escape.
   169  			// Letters, digits, and the characters '_.-' are never quoted.
   170  			"first_nibble_hex-"+ // Replaces ALL characters in string using the %%dd? escape.
   171  			"second_nibble_hex-"+ // Replaces ALL characters in string using the %?%dd escape.
   172  			"uri_double_hex-"+ // Encodes ALL characters using the %25xx escape.
   173  			"uri_hex-"+ // Encodes ALL characters using the %xx escape.
   174  			"uri_triple_hex-"+ // Encodes ALL characters using the %25%xx%xx escape.
   175  			"uri_unicode-"+ // Replaces ALL characters in string using the %u00xx escape.
   176  			"urlencode-"+ // Replace special characters in string using the %xx escape.
   177  			// Letters, digits, and the characters '_.-' are never quoted.
   178  			"utf8 "+ // Replaces ALL characters in string using the \u00xx escape.
   179  			"-f %s.out,csv -o csv -c -v --ss secret_data_leaked -t 5 %s:8080/FUZZ", server, server)
   180  		stdout, stderr, err := t.Clusters().Default().PodExec(pods.Items[0].Name, ns, wfuzz, command)
   181  		if err != nil {
   182  			t.Fatalf("failed to run wfuzz: %v", err)
   183  		}
   184  		t.Logf("%s\n%s\n", stdout, stderr)
   185  		t.Logf("wfuzz test completed for %s", server)
   186  
   187  		var errLines []string
   188  		for _, line := range strings.Split(stdout, "\n") {
   189  			if strings.Contains(line, ",200,") {
   190  				if server == tomcatServer && ignoreTomcat(t, line, wfuzzIgnoreTomcat) {
   191  					continue
   192  				}
   193  				errLines = append(errLines, line)
   194  			}
   195  		}
   196  		if len(errLines) != 0 {
   197  			t.Errorf("found potential policy bypass requests, please read the log for more details:\n- %s", strings.Join(errLines, "\n- "))
   198  		} else {
   199  			t.Logf("no potential policy bypass requests found")
   200  		}
   201  
   202  	default:
   203  		t.Fatalf("unknown fuzzer %s", fuzzer)
   204  	}
   205  }
   206  
   207  func TestFuzzAuthorization(t *testing.T) {
   208  	framework.NewTest(t).
   209  		Run(func(t framework.TestContext) {
   210  			ns := "fuzz-authz"
   211  			namespace.ClaimOrFail(t, t, ns)
   212  
   213  			t.ConfigIstio().YAML(ns, authzDenyPolicy).ApplyOrFail(t)
   214  			t.Logf("authorization policy applied")
   215  
   216  			deploy(t, dotdotpwn, ns, "fuzzers/dotdotpwn/dotdotpwn.yaml")
   217  			t.ConfigIstio().File(ns, "fuzzers/wfuzz/wordlist.yaml").ApplyOrFail(t)
   218  			deploy(t, wfuzz, ns, "fuzzers/wfuzz/wfuzz.yaml")
   219  
   220  			deploy(t, apacheServer, ns, "backends/apache/apache.yaml")
   221  			deploy(t, nginxServer, ns, "backends/nginx/nginx.yaml")
   222  			deploy(t, tomcatServer, ns, "backends/tomcat/tomcat.yaml")
   223  			waitService(t, apacheServer, ns)
   224  			waitService(t, nginxServer, ns)
   225  			waitService(t, tomcatServer, ns)
   226  			for _, fuzzer := range []string{dotdotpwn, wfuzz} {
   227  				t.NewSubTest(fuzzer).Run(func(t framework.TestContext) {
   228  					for _, target := range []string{apacheServer, nginxServer, tomcatServer} {
   229  						t.NewSubTest(target).Run(func(t framework.TestContext) {
   230  							runFuzzer(t, fuzzer, ns, target)
   231  						})
   232  					}
   233  				})
   234  			}
   235  		})
   236  }
   237  
   238  func runJwtToolTest(t framework.TestContext, ns, server, jwtToken string) {
   239  	pods, err := t.Clusters().Default().PodsForSelector(context.TODO(), ns, "app="+jwtTool)
   240  	if err != nil {
   241  		t.Fatalf("failed to get jwttool pod: %v", err)
   242  	}
   243  	t.Logf("running jwttool fuzz test against the %s (should normally complete in 10 seconds)...", server)
   244  
   245  	// Run the jwttool fuzz testing with "--mode at" to run all tests:
   246  	// - JWT Attack Playbook
   247  	// - Fuzz existing claims to force errors
   248  	// - Fuzz common claims
   249  	commands := []string{
   250  		"./run.sh",
   251  		"--targeturl",
   252  		fmt.Sprintf("http://%s:8080/private/secret.html", server),
   253  		"--noproxy",
   254  		"--headers",
   255  		fmt.Sprintf("Authorization: Bearer %s", jwtToken),
   256  		"--mode",
   257  		"at",
   258  	}
   259  	stdout, stderr, err := t.Clusters().Default().PodExecCommands(pods.Items[0].Name, ns, jwtTool, commands)
   260  	if err != nil {
   261  		t.Fatalf("failed to run jwttool: %v", err)
   262  	}
   263  	t.Logf("%s\n%s\n", stdout, stderr)
   264  	t.Logf("jwttool fuzz test completed for %s", server)
   265  
   266  	if !strings.Contains(stdout, "Prescan: original token Response Code: 200") {
   267  		t.Fatalf("could not find prescan check, please make sure the jwt_tool.py completed successfully")
   268  	}
   269  	errCases := []string{}
   270  	scanStarted := false
   271  	for _, line := range strings.Split(stdout, "\n") {
   272  		if scanStarted {
   273  			// First check the response is a valid test case.
   274  			if strings.Contains(line, "jwttool_") && !strings.Contains(line, "(should always be valid)") {
   275  				// Then add it to errCases if the test case has a response code other than 401.
   276  				if !strings.Contains(line, "Response Code: 401") {
   277  					errCases = append(errCases, line)
   278  				}
   279  			}
   280  		} else if strings.Contains(line, "LAUNCHING SCAN") {
   281  			scanStarted = true
   282  		}
   283  	}
   284  	if len(errCases) != 0 {
   285  		t.Errorf("found %d potential policy bypass requests:\n- %s", len(errCases), strings.Join(errCases, "\n- "))
   286  	} else {
   287  		t.Logf("no potential policy bypass requests found")
   288  	}
   289  }
   290  
   291  func TestRequestAuthentication(t *testing.T) {
   292  	framework.NewTest(t).
   293  		Run(func(t framework.TestContext) {
   294  			ns := "fuzz-jwt"
   295  			namespace.ClaimOrFail(t, t, ns)
   296  
   297  			t.ConfigIstio().YAML(ns, requestAuthnPolicy).ApplyOrFail(t)
   298  			t.Logf("request authentication policy applied")
   299  
   300  			// We don't care about the actual backend for JWT test, one backend is good enough.
   301  			deploy(t, apacheServer, ns, "backends/apache/apache.yaml")
   302  			deploy(t, jwtTool, ns, "fuzzers/jwt_tool/jwt_tool.yaml")
   303  			waitService(t, apacheServer, ns)
   304  			testCases := []struct {
   305  				name      string
   306  				baseToken string
   307  			}{
   308  				{"TokenIssuer1", jwt.TokenIssuer1},
   309  				{"TokenIssuer1WithAud", jwt.TokenIssuer1WithAud},
   310  				{"TokenIssuer1WithAzp", jwt.TokenIssuer1WithAzp},
   311  				{"TokenIssuer2", jwt.TokenIssuer2},
   312  				{"TokenIssuer1WithNestedClaims1", jwt.TokenIssuer1WithNestedClaims1},
   313  				{"TokenIssuer1WithNestedClaims2", jwt.TokenIssuer1WithNestedClaims2},
   314  				{"TokenIssuer2WithSpaceDelimitedScope", jwt.TokenIssuer2WithSpaceDelimitedScope},
   315  			}
   316  			for _, tc := range testCases {
   317  				t.NewSubTest(tc.name).Run(func(t framework.TestContext) {
   318  					runJwtToolTest(t, ns, apacheServer, tc.baseToken)
   319  				})
   320  			}
   321  		})
   322  }