From 6364d48657c2d7caeaa506d81db7bea8e6ee04f9 Mon Sep 17 00:00:00 2001 From: Jim Ryan Date: Tue, 25 Jun 2024 12:32:55 +0100 Subject: [PATCH] fix api key policy undefined routes (#5838) * fix api key policy undefined routes * add template test * update docs --- examples/custom-resources/api-key/README.md | 12 +- .../__snapshots__/templates_test.snap | 440 ++++++++++++++++++ .../version2/nginx-plus.virtualserver.tmpl | 1 + .../configs/version2/nginx.virtualserver.tmpl | 1 + internal/configs/version2/templates_test.go | 25 + tests/suite/test_apikey_auth_policies.py | 45 ++ 6 files changed, 518 insertions(+), 6 deletions(-) diff --git a/examples/custom-resources/api-key/README.md b/examples/custom-resources/api-key/README.md index c57b9c4d62..4a206afd02 100644 --- a/examples/custom-resources/api-key/README.md +++ b/examples/custom-resources/api-key/README.md @@ -17,7 +17,7 @@ a web application, configure load balancing for it via a VirtualServer, and appl 1. Save the HTTP port of the Ingress Controller into a shell variable: ```console - IC_HTTP_PORT= + IC_HTTPS_PORT= ``` ## Step 1 - Deploy a Web Application @@ -25,7 +25,7 @@ a web application, configure load balancing for it via a VirtualServer, and appl Create the application deployment and service: ```console -kubectl apply -f cafe.yaml +kubectl apply -f cafe.yaml -f cafe-secret.yaml ``` ## Step 2 - Deploy the API Key Auth Secret @@ -62,7 +62,7 @@ Note that the VirtualServer references the policy `api-key-policy` created in St If you attempt to access the application without providing a valid API Key in a expected header or query param for that VirtualServer: ```console -curl --resolve cafe.example.com:$IC_HTTP_PORT:$IC_IP http://cafe.example.com:$IC_HTTP_PORT/ +curl -k --resolve cafe.example.com:$IC_HTTPS_PORT:$IC_IP https://cafe.example.com:$IC_HTTPS_PORT/ ``` ```text @@ -78,7 +78,7 @@ curl --resolve cafe.example.com:$IC_HTTP_PORT:$IC_IP http://cafe.example.com:$IC If you attempt to access the application providing an incorrect API Key in an expected header or query param for that VirtualServer: ```console -curl --resolve cafe.example.com:$IC_HTTP_PORT:$IC_IP -H "X-header-name: wrongpassword" http://cafe.example.com:$IC_HTTP_PORT/coffee +curl -k --resolve cafe.example.com:$IC_HTTPS_PORT:$IC_IP -H "X-header-name: wrongpassword" https://cafe.example.com:$IC_HTTPS_PORT/coffee ``` ```text @@ -94,7 +94,7 @@ curl --resolve cafe.example.com:$IC_HTTP_PORT:$IC_IP -H "X-header-name: wrongpas If you provide a valid API Key in an a header or query defined in the policy, your request will succeed: ```console -curl --resolve cafe.example.com:$IC_HTTPS_PORT:$IC_IP -H "X-header-name: password" https://cafe.example.com:$IC_HTTPS_PORT/coffee +curl -k --resolve cafe.example.com:$IC_HTTPS_PORT:$IC_IP -H "X-header-name: password" https://cafe.example.com:$IC_HTTPS_PORT/coffee ``` ```text @@ -108,7 +108,7 @@ Request ID: 4feedb3265a0430a1f58831d016e846d If you attempt to access the /tea path, the request will be allowed without an API Key, because the auth_request directive is turned off for that path with a location snippet: ```console -curl --resolve cafe.example.com:$IC_HTTP_PORT:$IC_IP http://cafe.example.com:$IC_HTTP_PORT/tea +curl -k --resolve cafe.example.com:$IC_HTTPS_PORT:$IC_IP https://cafe.example.com:$IC_HTTPS_PORT/tea ``` ```text diff --git a/internal/configs/version2/__snapshots__/templates_test.snap b/internal/configs/version2/__snapshots__/templates_test.snap index ff2b738552..60c9946a63 100644 --- a/internal/configs/version2/__snapshots__/templates_test.snap +++ b/internal/configs/version2/__snapshots__/templates_test.snap @@ -80,6 +80,446 @@ server { --- +[TestExecuteVirtualServerTemplateWithAPIKeyPolicyNGINXPlus - 1] + +upstream test-upstream { + zone test-upstream 256k; + random; + server 10.0.0.20:8001 max_fails=4 fail_timeout=10s slow_start=10s max_conns=31; + keepalive 32; + queue 10 timeout=60s; + sticky cookie test expires=25s path=/tea; + + ntlm; +} + +upstream coffee-v1 { + zone coffee-v1 256k; + server 10.0.0.31:8001 max_fails=8 fail_timeout=15s max_conns=2; + + +} + +upstream coffee-v2 { + zone coffee-v2 256k; + server 10.0.0.32:8001 max_fails=12 fail_timeout=20s max_conns=4; + + +} + +split_clients $request_id $split_0 { + 50% @loc0; + 50% @loc1; +} +map $match_0_0 $match { + ~^1 @match_loc_0; + default @match_loc_default; +} +map $http_x_version $match_0_0 { + v2 1; + default 0; +} +# HTTP snippet +limit_req_zone $url zone=pol_rl_test_test_test:10m rate=10r/s; + +server { + listen 80 proxy_protocol; + listen [::]:80 proxy_protocol; + + + server_name example.com; + status_zone example.com; + set $resource_type "virtualserver"; + set $resource_name ""; + set $resource_namespace ""; + listen 443 ssl proxy_protocol; + listen [::]:443 ssl proxy_protocol; + + http2 on; + ssl_certificate cafe-secret.pem; + ssl_certificate_key cafe-secret.pem; + ssl_client_certificate ingress-mtls-secret; + ssl_verify_client on; + ssl_verify_depth 2; + if ($scheme = 'http') { + return 301 https://$host$request_uri; + } + + server_tokens "off"; + set_real_ip_from 0.0.0.0/0; + real_ip_header X-Real-IP; + real_ip_recursive on; + allow 127.0.0.1; + deny all; + deny 127.0.0.1; + allow all; + limit_req_log_level error; + limit_req_status 503; + limit_req zone=pol_rl_test_test_test burst=5 + delay=10; + auth_jwt "My Api"; + auth_jwt_key_file jwk-secret; + js_var $header_query_value "${http_x_header_name}${http_other_header}${arg_myQuery}${arg_myOtherQuery}"; + js_var $apikey_auth_local_map "vs-default-cafe-apikey-policy"; + js_var $apikey_auth_token $apikey_auth_hash; + auth_request /_validate_apikey_njs; + app_protect_enable on; + + app_protect_policy_file /etc/nginx/waf/nac-policies/default-dataguard-alarm; + + + + + + app_protect_security_log_enable on; + + app_protect_security_log /etc/nginx/waf/nac-logconfs/default-logconf; + + + + # server snippet + location /split { + rewrite ^ @split_0 last; + } + location /coffee { + rewrite ^ @match last; + } + location @hc-coffee { + + proxy_connect_timeout ; + proxy_read_timeout ; + proxy_send_timeout ; + proxy_pass http://coffee-v2; + health_check uri=/ port=50 interval=5s jitter=0s + fails=1 passes=1 + mandatory persistent + keepalive_time=; + } + location @hc-tea { + + grpc_connect_timeout ; + grpc_read_timeout ; + grpc_send_timeout ; + grpc_pass grpc://tea-v3; + health_check port=50 interval=5s jitter=0s + fails=1 passes=1 + + type=grpc grpc_status=12 + grpc_service=tea-servicev2 keepalive_time=; + } + location @vs_cafe_cafe_vsr_tea_tea_tea__tea_error_page_0 { + + default_type "application/json"; + + + # status code is ignored here, using 0 + return 0 "Hello World"; + } + + location @vs_cafe_cafe_vsr_tea_tea_tea__tea_error_page_1 { + + + add_header Set-Cookie "cookie1=test" always; + + add_header Set-Cookie "cookie2=test; Secure" always; + + # status code is ignored here, using 0 + return 0 "Hello World"; + } + + + + location @return_0 { + default_type "text/html"; + + # status code is ignored here, using 0 + return 0 "Hello!"; + } + + + + location / { + set $service ""; + status_zone ""; + internal; + # location snippet + allow 127.0.0.1; + deny all; + deny 127.0.0.1; + allow all; + limit_req zone=loc_pol_rl_test_test_test + ; + + + proxy_ssl_certificate egress-mtls-secret.pem; + proxy_ssl_certificate_key egress-mtls-secret.pem; + + proxy_ssl_trusted_certificate trusted-cert.pem; + proxy_ssl_verify on; + proxy_ssl_verify_depth 1; + proxy_ssl_protocols TLSv1.3; + proxy_ssl_ciphers DEFAULT; + proxy_ssl_session_reuse on; + proxy_ssl_server_name on; + proxy_ssl_name ; + set $header_query_value "${http_x_header_name}${http_other_header}${arg_myQuery}${arg_myOtherQuery}"; + set $default_connection_header close; + rewrite $request_uri $request_uri; + rewrite $request_uri $request_uri; + proxy_connect_timeout 30s; + proxy_read_timeout 31s; + proxy_send_timeout 32s; + client_max_body_size 1m; + proxy_max_temp_file_size 1024m; + + proxy_buffering on; + proxy_buffers 8 4k; + proxy_buffer_size 4k; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $vs_connection_header; + proxy_pass_request_headers off; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_hide_header Header; + proxy_pass_header Host; + proxy_ignore_headers Cache; + add_header Header-Name "Header Value" always; + proxy_pass http://test-upstream$request_uri; + proxy_next_upstream error timeout; + proxy_next_upstream_timeout 5s; + proxy_next_upstream_tries 0; + } + location @loc0 { + set $service ""; + status_zone ""; + + + set $header_query_value "${http_x_header_name}${http_other_header}${arg_myQuery}${arg_myOtherQuery}"; + error_page 400 500 =200 "@error_page_1"; + error_page 500 "@error_page_2"; + proxy_intercept_errors on; + set $default_connection_header close; + proxy_connect_timeout 30s; + proxy_read_timeout 31s; + proxy_send_timeout 32s; + client_max_body_size 1m; + + proxy_buffering off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $vs_connection_header; + proxy_pass_request_headers off; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://coffee-v1; + proxy_next_upstream error timeout; + proxy_next_upstream_timeout 5s; + proxy_next_upstream_tries 0; + } + location @loc1 { + set $service ""; + status_zone ""; + + + set $header_query_value "${http_x_header_name}${http_other_header}${arg_myQuery}${arg_myOtherQuery}"; + set $default_connection_header close; + proxy_connect_timeout 30s; + proxy_read_timeout 31s; + proxy_send_timeout 32s; + client_max_body_size 1m; + + proxy_buffering off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $vs_connection_header; + proxy_pass_request_headers off; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://coffee-v2; + proxy_next_upstream error timeout; + proxy_next_upstream_timeout 5s; + proxy_next_upstream_tries 0; + } + location @loc2 { + set $service ""; + status_zone ""; + + + set $header_query_value "${http_x_header_name}${http_other_header}${arg_myQuery}${arg_myOtherQuery}"; + error_page 400 = @grpc_internal; + error_page 401 = @grpc_unauthenticated; + error_page 403 = @grpc_permission_denied; + error_page 404 = @grpc_unimplemented; + error_page 429 = @grpc_unavailable; + error_page 502 = @grpc_unavailable; + error_page 503 = @grpc_unavailable; + error_page 504 = @grpc_unavailable; + error_page 405 = @grpc_internal; + error_page 408 = @grpc_deadline_exceeded; + error_page 413 = @grpc_resource_exhausted; + error_page 414 = @grpc_resource_exhausted; + error_page 415 = @grpc_internal; + error_page 426 = @grpc_internal; + error_page 495 = @grpc_unauthenticated; + error_page 496 = @grpc_unauthenticated; + error_page 497 = @grpc_internal; + error_page 500 = @grpc_internal; + error_page 501 = @grpc_internal; + set $default_connection_header close; + grpc_connect_timeout 30s; + grpc_read_timeout 31s; + grpc_send_timeout 32s; + client_max_body_size 1m; + + proxy_buffering off; + grpc_set_header X-Real-IP $remote_addr; + grpc_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + grpc_set_header X-Forwarded-Host $host; + grpc_set_header X-Forwarded-Port $server_port; + grpc_set_header X-Forwarded-Proto $scheme; + grpc_pass grpc://coffee-v3; + grpc_next_upstream ; + grpc_next_upstream_timeout ; + grpc_next_upstream_tries 0; + } + location @match_loc_0 { + set $service ""; + status_zone ""; + + + set $header_query_value "${http_x_header_name}${http_other_header}${arg_myQuery}${arg_myOtherQuery}"; + set $default_connection_header close; + proxy_connect_timeout 30s; + proxy_read_timeout 31s; + proxy_send_timeout 32s; + client_max_body_size 1m; + + proxy_buffering off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $vs_connection_header; + proxy_pass_request_headers off; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://coffee-v2; + proxy_next_upstream error timeout; + proxy_next_upstream_timeout 5s; + proxy_next_upstream_tries 0; + } + location @match_loc_default { + set $service ""; + status_zone ""; + + + set $header_query_value "${http_x_header_name}${http_other_header}${arg_myQuery}${arg_myOtherQuery}"; + set $default_connection_header close; + proxy_connect_timeout 30s; + proxy_read_timeout 31s; + proxy_send_timeout 32s; + client_max_body_size 1m; + + proxy_buffering off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $vs_connection_header; + proxy_pass_request_headers off; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://coffee-v1; + proxy_next_upstream error timeout; + proxy_next_upstream_timeout 5s; + proxy_next_upstream_tries 0; + } + location /return { + set $service ""; + status_zone ""; + + + set $header_query_value "${http_x_header_name}${http_other_header}${arg_myQuery}${arg_myOtherQuery}"; + error_page 418 =200 "@return_0"; + proxy_intercept_errors on; + proxy_pass http://unix:/var/lib/nginx/nginx-418-server.sock; + set $default_connection_header close; + } + + location @grpc_deadline_exceeded { + default_type application/grpc; + add_header content-type application/grpc; + add_header grpc-status 4; + add_header grpc-message 'deadline exceeded'; + return 204; + } + + location @grpc_permission_denied { + default_type application/grpc; + add_header content-type application/grpc; + add_header grpc-status 7; + add_header grpc-message 'permission denied'; + return 204; + } + + location @grpc_resource_exhausted { + default_type application/grpc; + add_header content-type application/grpc; + add_header grpc-status 8; + add_header grpc-message 'resource exhausted'; + return 204; + } + + location @grpc_unimplemented { + default_type application/grpc; + add_header content-type application/grpc; + add_header grpc-status 12; + add_header grpc-message unimplemented; + return 204; + } + + location @grpc_internal { + default_type application/grpc; + add_header content-type application/grpc; + add_header grpc-status 13; + add_header grpc-message 'internal error'; + return 204; + } + + location @grpc_unavailable { + default_type application/grpc; + add_header content-type application/grpc; + add_header grpc-status 14; + add_header grpc-message unavailable; + return 204; + } + + location @grpc_unauthenticated { + default_type application/grpc; + add_header content-type application/grpc; + add_header grpc-status 16; + add_header grpc-message unauthenticated; + return 204; + } + + + +} + +--- + [TestExecuteVirtualServerTemplateWithBackupServerNGINXPlus - 1] upstream test-upstream { diff --git a/internal/configs/version2/nginx-plus.virtualserver.tmpl b/internal/configs/version2/nginx-plus.virtualserver.tmpl index 0715915457..cdd8eea60e 100644 --- a/internal/configs/version2/nginx-plus.virtualserver.tmpl +++ b/internal/configs/version2/nginx-plus.virtualserver.tmpl @@ -253,6 +253,7 @@ server { {{- end }} {{- with $s.APIKey}} + js_var $header_query_value {{ makeHeaderQueryValue $s.APIKey | printf }}; js_var $apikey_auth_local_map "{{ .MapName}}"; js_var $apikey_auth_token $apikey_auth_hash; auth_request /_validate_apikey_njs; diff --git a/internal/configs/version2/nginx.virtualserver.tmpl b/internal/configs/version2/nginx.virtualserver.tmpl index 3baf6eaec9..d1153170db 100644 --- a/internal/configs/version2/nginx.virtualserver.tmpl +++ b/internal/configs/version2/nginx.virtualserver.tmpl @@ -156,6 +156,7 @@ server { {{- end }} {{- with $s.APIKey}} + js_var $header_query_value {{ makeHeaderQueryValue $s.APIKey | printf }}; js_var $apikey_auth_local_map "{{ .MapName}}"; js_var $apikey_auth_token $apikey_auth_hash; auth_request /_validate_apikey_njs; diff --git a/internal/configs/version2/templates_test.go b/internal/configs/version2/templates_test.go index b9860be9be..adadc78cca 100644 --- a/internal/configs/version2/templates_test.go +++ b/internal/configs/version2/templates_test.go @@ -526,6 +526,31 @@ func TestExecuteVirtualServerTemplateWithBackupServerNGINXPlus(t *testing.T) { t.Log(string(got)) } +func TestExecuteVirtualServerTemplateWithAPIKeyPolicyNGINXPlus(t *testing.T) { + t.Parallel() + + vscfg := vsConfig() + vscfg.Server.APIKey = &APIKey{ + Header: []string{"X-header-name", "other-header"}, + Query: []string{"myQuery", "myOtherQuery"}, + MapName: "vs-default-cafe-apikey-policy", + } + + e := newTmplExecutorNGINXPlus(t) + got, err := e.ExecuteVirtualServerTemplate(&vscfg) + if err != nil { + t.Error(err) + } + + want := "js_var $header_query_value \"${http_x_header_name}${http_other_header}${arg_myQuery}${arg_myOtherQuery}\";" + + if !bytes.Contains(got, []byte(want)) { + t.Errorf("want %q in generated template", want) + } + snaps.MatchSnapshot(t, string(got)) + t.Log(string(got)) +} + func vsConfig() VirtualServerConfig { return VirtualServerConfig{ LimitReqZones: []LimitReqZone{ diff --git a/tests/suite/test_apikey_auth_policies.py b/tests/suite/test_apikey_auth_policies.py index cd84e56634..d8def87914 100644 --- a/tests/suite/test_apikey_auth_policies.py +++ b/tests/suite/test_apikey_auth_policies.py @@ -112,6 +112,18 @@ def test_apikey_auth_policy_vs(self, kube_apis, crd_ingress_controller, virtual_ wait_until_all_pods_are_ready(kube_apis.v1, test_namespace) wait_before_test() + # /undefined path (is not a route defined in the VirtualServer) + undefined_without_auth_headers = {"host": host} + undefined_with_wrong_auth_header = {"host": host, apikey_policy_details.headers[0]: "wrongpassword"} + undefined_with_auth_headers = {"host": host, apikey_policy_details.headers[0]: apikey_policy_details.apikeys[0]} + undefined_path = ( + f"http://{virtual_server_setup.public_endpoint.public_ip}" + f":{virtual_server_setup.public_endpoint.port}/undefined" + ) + undefined_resp_no_auth_header = requests.get(undefined_path, headers=undefined_without_auth_headers) + undefined_resp_with_wrong_auth_header = requests.get(undefined_path, headers=undefined_with_wrong_auth_header) + undefined_resp_with_auth_header = requests.get(undefined_path, headers=undefined_with_auth_headers) + # /no-auth path no_auth_headers = {"host": host} no_auth_path = ( @@ -221,6 +233,15 @@ def test_apikey_auth_policy_vs(self, kube_apis, crd_ingress_controller, virtual_ virtual_server_setup.namespace, ) + # /undefined (without an auth header) + assert undefined_resp_no_auth_header.status_code == 401 + + # /undefined (with wrong password in header) + assert undefined_resp_with_wrong_auth_header.status_code == 403 + + # /undefined (with an auth header) + assert undefined_resp_with_auth_header.status_code == 404 + # /no-auth (snippet to turn off auth_request on this route) assert no_auth_resp.status_code == 200 @@ -302,6 +323,21 @@ def test_apikey_auth_policy_vs_and_vsr( wait_until_all_pods_are_ready(kube_apis.v1, test_namespace) wait_before_test(5) + # /undefined path (is not a route defined in the VirtualServer) + undefined_without_auth_headers = {"host": host} + undefined_with_wrong_auth_header = {"host": host, apikey_policy_details_server.headers[0]: "wrongpassword"} + undefined_with_auth_headers = { + "host": host, + apikey_policy_details_server.headers[0]: apikey_policy_details_server.apikeys[0], + } + undefined_path = ( + f"http://{virtual_server_setup.public_endpoint.public_ip}" + f":{virtual_server_setup.public_endpoint.port}/undefined" + ) + undefined_resp_no_auth_header = requests.get(undefined_path, headers=undefined_without_auth_headers) + undefined_resp_with_wrong_auth_header = requests.get(undefined_path, headers=undefined_with_wrong_auth_header) + undefined_resp_with_auth_header = requests.get(undefined_path, headers=undefined_with_auth_headers) + # /no-auth path no_auth_path_server = ( f"http://{virtual_server_setup.public_endpoint.public_ip}" @@ -409,6 +445,15 @@ def test_apikey_auth_policy_vs_and_vsr( virtual_server_setup.namespace, ) + # /undefined (without an auth header) + assert undefined_resp_no_auth_header.status_code == 401 + + # /undefined (with wrong password in header) + assert undefined_resp_with_wrong_auth_header.status_code == 403 + + # /undefined (with an auth header) + assert undefined_resp_with_auth_header.status_code == 404 + # /no-auth (snippet to turn off auth_request on this route) assert no_auth_server_resp.status_code == 200