lua-resty-waf controller (#2304)

This commit is contained in:
Elvin Efendi 2018-04-08 16:37:13 -04:00 committed by Manuel Alejandro de Brito Fontes
parent b17ed7b6fd
commit a6fe800a47
15 changed files with 455 additions and 37 deletions

View file

@ -50,7 +50,7 @@ IMAGE = $(REGISTRY)/$(IMGNAME)
MULTI_ARCH_IMG = $(IMAGE)-$(ARCH)
# Set default base image dynamically for each arch
BASEIMAGE?=quay.io/kubernetes-ingress-controller/nginx-$(ARCH):0.38
BASEIMAGE?=quay.io/kubernetes-ingress-controller/nginx-$(ARCH):0.40
ifeq ($(ARCH),arm)
QEMUARCH=arm

View file

@ -66,6 +66,8 @@ The following annotations are supported:
|[nginx.ingress.kubernetes.io/ssl-ciphers](#ssl-ciphers)|string|
|[nginx.ingress.kubernetes.io/connection-proxy-header](#connection-proxy-header)|string|
|[nginx.ingress.kubernetes.io/enable-access-log](#enable-access-log)|"true" or "false"|
|[nginx.ingress.kubernetes.io/lua-resty-waf](#lua-resty-waf)|"true" or "false"|
|[nginx.ingress.kubernetes.io/lua-resty-waf-debug](#lua-resty-waf)|"true" or "false"|
**Note:** all the values must be a string. In case of booleans or number it must be quoted.
@ -463,3 +465,14 @@ In some scenarios could be required to disable NGINX access logs. To enable this
```yaml
nginx.ingress.kubernetes.io/enable-access-log: "false"
```
### Lua Resty WAF
Using `lua-resty-waf-*` annotations we can enable and control [lua-resty-waf](https://github.com/p0pr0ck5/lua-resty-waf) per location.
Following configuration will enable WAF for the paths defined in the corresponding ingress:
```yaml
nginx.ingress.kubernetes.io/lua-resty-waf: "true"
```
In order to run it in debugging mode you can set `nginx.ingress.kubernetes.io/lua-resty-waf-debug` to `"true"` in addition to the above configuration.

File diff suppressed because one or more lines are too long

View file

@ -37,6 +37,7 @@ import (
"k8s.io/ingress-nginx/internal/ingress/annotations/ipwhitelist"
"k8s.io/ingress-nginx/internal/ingress/annotations/loadbalancing"
"k8s.io/ingress-nginx/internal/ingress/annotations/log"
"k8s.io/ingress-nginx/internal/ingress/annotations/luarestywaf"
"k8s.io/ingress-nginx/internal/ingress/annotations/parser"
"k8s.io/ingress-nginx/internal/ingress/annotations/portinredirect"
"k8s.io/ingress-nginx/internal/ingress/annotations/proxy"
@ -93,6 +94,7 @@ type Ingress struct {
SSLCiphers string
Logs log.Config
GRPC bool
LuaRestyWAF luarestywaf.Config
}
// Extractor defines the annotation parsers to be used in the extraction of annotations
@ -133,6 +135,7 @@ func NewAnnotationExtractor(cfg resolver.Resolver) Extractor {
"SSLCiphers": sslcipher.NewParser(cfg),
"Logs": log.NewParser(cfg),
"GRPC": grpc.NewParser(cfg),
"LuaRestyWAF": luarestywaf.NewParser(cfg),
},
}
}

View file

@ -0,0 +1,74 @@
/*
Copyright 2016 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package luarestywaf
import (
extensions "k8s.io/api/extensions/v1beta1"
"k8s.io/ingress-nginx/internal/ingress/annotations/parser"
"k8s.io/ingress-nginx/internal/ingress/resolver"
)
// Config returns lua-resty-waf configuration for an Ingress rule
type Config struct {
Enabled bool `json:"enabled"`
Debug bool `json:"debug"`
}
// Equal tests for equality between two Config types
func (e1 *Config) Equal(e2 *Config) bool {
if e1 == e2 {
return true
}
if e1 == nil || e2 == nil {
return false
}
if e1.Enabled != e2.Enabled {
return false
}
if e1.Debug != e2.Debug {
return false
}
return true
}
type luarestywaf struct {
r resolver.Resolver
}
// NewParser creates a new CORS annotation parser
func NewParser(r resolver.Resolver) parser.IngressAnnotation {
return luarestywaf{r}
}
// Parse parses the annotations contained in the ingress rule
// used to indicate if the location/s contains a fragment of
// configuration to be included inside the paths of the rules
func (a luarestywaf) Parse(ing *extensions.Ingress) (interface{}, error) {
enabled, err := parser.GetBoolAnnotation("lua-resty-waf", ing)
if err != nil {
return &Config{}, err
}
debug, _ := parser.GetBoolAnnotation("lua-resty-waf-debug", ing)
return &Config{
Enabled: enabled,
Debug: debug,
}, nil
}

View file

@ -0,0 +1,69 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package luarestywaf
import (
"testing"
api "k8s.io/api/core/v1"
extensions "k8s.io/api/extensions/v1beta1"
meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/ingress-nginx/internal/ingress/annotations/parser"
"k8s.io/ingress-nginx/internal/ingress/resolver"
)
func TestParse(t *testing.T) {
luaRestyWAFAnnotation := parser.GetAnnotationWithPrefix("lua-resty-waf")
luaRestyWAFDebugAnnotation := parser.GetAnnotationWithPrefix("lua-resty-waf-debug")
ap := NewParser(&resolver.Mock{})
if ap == nil {
t.Fatalf("expected a parser.IngressAnnotation but returned nil")
}
testCases := []struct {
annotations map[string]string
expected *Config
}{
{nil, &Config{}},
{map[string]string{}, &Config{}},
{map[string]string{luaRestyWAFAnnotation: "true"}, &Config{Enabled: true, Debug: false}},
{map[string]string{luaRestyWAFDebugAnnotation: "true"}, &Config{Enabled: false, Debug: false}},
{map[string]string{luaRestyWAFAnnotation: "true", luaRestyWAFDebugAnnotation: "true"}, &Config{Enabled: true, Debug: true}},
{map[string]string{luaRestyWAFAnnotation: "true", luaRestyWAFDebugAnnotation: "false"}, &Config{Enabled: true, Debug: false}},
{map[string]string{luaRestyWAFAnnotation: "false", luaRestyWAFDebugAnnotation: "true"}, &Config{Enabled: false, Debug: true}},
}
ing := &extensions.Ingress{
ObjectMeta: meta_v1.ObjectMeta{
Name: "foo",
Namespace: api.NamespaceDefault,
},
Spec: extensions.IngressSpec{},
}
for _, testCase := range testCases {
ing.SetAnnotations(testCase.annotations)
result, _ := ap.Parse(ing)
config := result.(*Config)
if !config.Equal(testCase.expected) {
t.Errorf("expected %v but returned %v, annotations: %s", testCase.expected, result, testCase.annotations)
}
}
}

View file

@ -503,6 +503,10 @@ type Configuration struct {
// NoAuthLocations is a comma-separated list of locations that
// should not get authenticated
NoAuthLocations string `json:"no-auth-locations"`
// DisableLuaRestyWAF disables lua-resty-waf globally regardless
// of whether there's an ingress that has enabled the WAF using annotation
DisableLuaRestyWAF bool `json:"disable-lua-resty-waf"`
}
// NewDefault returns the default nginx configuration

View file

@ -457,6 +457,7 @@ func (n *NGINXController) getBackendServers(ingresses []*extensions.Ingress) ([]
loc.Connection = anns.Connection
loc.Logs = anns.Logs
loc.GRPC = anns.GRPC
loc.LuaRestyWAF = anns.LuaRestyWAF
if loc.Redirect.FromToWWW {
server.RedirectFromToWWW = true
@ -492,6 +493,7 @@ func (n *NGINXController) getBackendServers(ingresses []*extensions.Ingress) ([]
Connection: anns.Connection,
Logs: anns.Logs,
GRPC: anns.GRPC,
LuaRestyWAF: anns.LuaRestyWAF,
}
if loc.Redirect.FromToWWW {
@ -928,6 +930,7 @@ func (n *NGINXController) createServers(data []*extensions.Ingress,
defLoc.Whitelist = anns.Whitelist
defLoc.Denied = anns.Denied
defLoc.GRPC = anns.GRPC
defLoc.LuaRestyWAF = anns.LuaRestyWAF
}
}
}

View file

@ -119,29 +119,30 @@ var (
}
return true
},
"buildLocation": buildLocation,
"buildAuthLocation": buildAuthLocation,
"buildAuthResponseHeaders": buildAuthResponseHeaders,
"buildLoadBalancingConfig": buildLoadBalancingConfig,
"buildProxyPass": buildProxyPass,
"filterRateLimits": filterRateLimits,
"buildRateLimitZones": buildRateLimitZones,
"buildRateLimit": buildRateLimit,
"buildResolvers": buildResolvers,
"buildUpstreamName": buildUpstreamName,
"isLocationInLocationList": isLocationInLocationList,
"isLocationAllowed": isLocationAllowed,
"buildLogFormatUpstream": buildLogFormatUpstream,
"buildDenyVariable": buildDenyVariable,
"getenv": os.Getenv,
"contains": strings.Contains,
"hasPrefix": strings.HasPrefix,
"hasSuffix": strings.HasSuffix,
"toUpper": strings.ToUpper,
"toLower": strings.ToLower,
"formatIP": formatIP,
"buildNextUpstream": buildNextUpstream,
"getIngressInformation": getIngressInformation,
"buildLuaSharedDictionaries": buildLuaSharedDictionaries,
"buildLocation": buildLocation,
"buildAuthLocation": buildAuthLocation,
"buildAuthResponseHeaders": buildAuthResponseHeaders,
"buildLoadBalancingConfig": buildLoadBalancingConfig,
"buildProxyPass": buildProxyPass,
"filterRateLimits": filterRateLimits,
"buildRateLimitZones": buildRateLimitZones,
"buildRateLimit": buildRateLimit,
"buildResolvers": buildResolvers,
"buildUpstreamName": buildUpstreamName,
"isLocationInLocationList": isLocationInLocationList,
"isLocationAllowed": isLocationAllowed,
"buildLogFormatUpstream": buildLogFormatUpstream,
"buildDenyVariable": buildDenyVariable,
"getenv": os.Getenv,
"contains": strings.Contains,
"hasPrefix": strings.HasPrefix,
"hasSuffix": strings.HasSuffix,
"toUpper": strings.ToUpper,
"toLower": strings.ToLower,
"formatIP": formatIP,
"buildNextUpstream": buildNextUpstream,
"getIngressInformation": getIngressInformation,
"serverConfig": func(all config.TemplateConfig, server *ingress.Server) interface{} {
return struct{ First, Second interface{} }{all, server}
},
@ -167,6 +168,47 @@ func formatIP(input string) string {
return fmt.Sprintf("[%s]", input)
}
func buildLuaSharedDictionaries(s interface{}, dynamicConfigurationEnabled bool, disableLuaRestyWAF bool) string {
servers, ok := s.([]*ingress.Server)
if !ok {
glog.Errorf("expected an '[]*ingress.Server' type but %T was returned", s)
return ""
}
out := []string{}
if dynamicConfigurationEnabled {
out = append(out,
"lua_shared_dict configuration_data 5M",
"lua_shared_dict round_robin_state 1M",
"lua_shared_dict locks 512k",
"lua_shared_dict balancer_ewma 1M",
"lua_shared_dict balancer_ewma_last_touched_at 1M",
)
}
if !disableLuaRestyWAF {
luaRestyWAFEnabled := func() bool {
for _, server := range servers {
for _, location := range server.Locations {
if location.LuaRestyWAF.Enabled {
return true
}
}
}
return false
}()
if luaRestyWAFEnabled {
out = append(out, "lua_shared_dict waf_storage 64M")
}
}
if len(out) == 0 {
return ""
}
return strings.Join(out, ";\n\r") + ";"
}
// buildResolvers returns the resolvers reading the /etc/resolv.conf file
func buildResolvers(res interface{}, disableIpv6 interface{}) string {
// NGINX need IPV6 addresses to be surrounded by brackets

View file

@ -32,6 +32,7 @@ import (
"k8s.io/ingress-nginx/internal/file"
"k8s.io/ingress-nginx/internal/ingress"
"k8s.io/ingress-nginx/internal/ingress/annotations/authreq"
"k8s.io/ingress-nginx/internal/ingress/annotations/luarestywaf"
"k8s.io/ingress-nginx/internal/ingress/annotations/rewrite"
"k8s.io/ingress-nginx/internal/ingress/controller/config"
)
@ -296,6 +297,46 @@ var (
}
)
func TestBuildLuaSharedDictionaries(t *testing.T) {
servers := []*ingress.Server{
{
Hostname: "foo.bar",
Locations: []*ingress.Location{{Path: "/", LuaRestyWAF: luarestywaf.Config{}}},
},
{
Hostname: "another.host",
Locations: []*ingress.Location{{Path: "/", LuaRestyWAF: luarestywaf.Config{}}},
},
}
config := buildLuaSharedDictionaries(servers, false, false)
if config != "" {
t.Errorf("expected to not configure any lua shared dictionary, but generated %s", config)
}
config = buildLuaSharedDictionaries(servers, true, false)
if !strings.Contains(config, "lua_shared_dict configuration_data") {
t.Errorf("expected to include 'configuration_data' but got %s", config)
}
if strings.Contains(config, "waf_storage") {
t.Errorf("expected to not include 'waf_storage' but got %s", config)
}
servers[1].Locations[0].LuaRestyWAF = luarestywaf.Config{Enabled: true}
config = buildLuaSharedDictionaries(servers, false, false)
if !strings.Contains(config, "lua_shared_dict waf_storage") {
t.Errorf("expected to configure 'waf_storage', but got %s", config)
}
config = buildLuaSharedDictionaries(servers, true, false)
if !strings.Contains(config, "lua_shared_dict waf_storage") {
t.Errorf("expected to configure 'waf_storage', but got %s", config)
}
config = buildLuaSharedDictionaries(servers, false, true)
if config != "" {
t.Errorf("expected to not configure any lua shared dictionary, but generated %s", config)
}
}
func TestFormatIP(t *testing.T) {
cases := map[string]struct {
Input, Output string

View file

@ -30,6 +30,7 @@ import (
"k8s.io/ingress-nginx/internal/ingress/annotations/cors"
"k8s.io/ingress-nginx/internal/ingress/annotations/ipwhitelist"
"k8s.io/ingress-nginx/internal/ingress/annotations/log"
"k8s.io/ingress-nginx/internal/ingress/annotations/luarestywaf"
"k8s.io/ingress-nginx/internal/ingress/annotations/proxy"
"k8s.io/ingress-nginx/internal/ingress/annotations/ratelimit"
"k8s.io/ingress-nginx/internal/ingress/annotations/redirect"
@ -268,6 +269,8 @@ type Location struct {
// GRPC indicates if the kubernetes service exposes a gRPC interface
// By default this is false
GRPC bool `json:"grpc"`
// LuaRestyWAF contains parameters to configure lua-resty-waf
LuaRestyWAF luarestywaf.Config `json:"luaRestyWAF"`
}
// SSLPassthroughBackend describes a SSL upstream server configured

View file

@ -385,6 +385,9 @@ func (l1 *Location) Equal(l2 *Location) bool {
if l1.GRPC != l2.GRPC {
return false
}
if !(&l1.LuaRestyWAF).Equal(&l2.LuaRestyWAF) {
return false
}
return true
}

View file

@ -38,6 +38,10 @@ end
local function get_current_lb_alg()
local backend = get_current_backend()
if not backend then
return nil
end
return backend["load-balance"] or DEFAULT_LB_ALG
end

View file

@ -36,20 +36,19 @@ events {
}
http {
{{ if $all.DynamicConfigurationEnabled }}
lua_package_cpath "/usr/local/lib/lua/?.so;/usr/lib/x86_64-linux-gnu/lua/5.1/?.so;;";
lua_package_path "/etc/nginx/lua/?.lua;/etc/nginx/lua/vendor/?.lua;/usr/local/lib/lua/?.lua;;";
lua_shared_dict configuration_data 5M;
lua_shared_dict round_robin_state 1M;
lua_shared_dict locks 512k;
lua_shared_dict balancer_ewma 1M;
lua_shared_dict balancer_ewma_last_touched_at 1M;
{{ buildLuaSharedDictionaries $servers $all.DynamicConfigurationEnabled $all.Cfg.DisableLuaRestyWAF }}
init_by_lua_block {
require("resty.core")
collectgarbage("collect")
local lua_resty_waf = require("resty.waf")
lua_resty_waf.init()
{{ if $all.DynamicConfigurationEnabled }}
-- init modules
local ok, res
@ -66,8 +65,10 @@ http {
else
balancer = res
end
{{ end }}
}
{{ if $all.DynamicConfigurationEnabled }}
init_worker_by_lua_block {
balancer.init_worker()
}
@ -815,6 +816,48 @@ stream {
{{ end }}
location {{ $path }} {
{{ if (and (not $all.Cfg.DisableLuaRestyWAF) $location.LuaRestyWAF.Enabled) }}
access_by_lua_block {
local lua_resty_waf = require("resty.waf")
local waf = lua_resty_waf:new()
waf:set_option("mode", "ACTIVE")
waf:set_option("storage_zone", "waf_storage")
waf:set_option("allowed_content_types", { "text/html", "text/json", "application/json" })
waf:set_option("event_log_level", ngx.WARN)
{{ if $location.LuaRestyWAF.Debug }}
waf:set_option("debug", true)
waf:set_option("event_log_request_arguments", true)
waf:set_option("event_log_request_body", true)
waf:set_option("event_log_request_headers", true)
waf:set_option("req_tid_header", true)
waf:set_option("res_tid_header", true)
{{ end }}
waf:exec()
}
header_filter_by_lua_block {
local lua_resty_waf = require "resty.waf"
local waf = lua_resty_waf:new()
waf:exec()
}
body_filter_by_lua_block {
local lua_resty_waf = require "resty.waf"
local waf = lua_resty_waf:new()
waf:exec()
}
{{ end }}
log_by_lua_block {
{{ if (and (not $all.Cfg.DisableLuaRestyWAF) $location.LuaRestyWAF.Enabled) }}
local lua_resty_waf = require "resty.waf"
local waf = lua_resty_waf:new()
waf:exec()
{{ end }}
{{ if $all.DynamicConfigurationEnabled}}
balancer.call()
{{ end }}
}
{{ if (and (not (empty $server.SSLCertificate)) $all.Cfg.HSTS) }}
if ($scheme = https) {
more_set_headers "Strict-Transport-Security: max-age={{ $all.Cfg.HSTSMaxAge }}{{ if $all.Cfg.HSTSIncludeSubdomains }}; includeSubDomains{{ end }}{{ if $all.Cfg.HSTSPreload }}; preload{{ end }}";
@ -1009,11 +1052,6 @@ stream {
{{ end }}
{{ if not (empty $location.Backend) }}
{{ if $all.DynamicConfigurationEnabled}}
log_by_lua_block {
balancer.call()
}
{{ end }}
{{ buildProxyPass $server.Hostname $all.Backends $location $all.DynamicConfigurationEnabled }}
{{ if (or (eq $location.Proxy.ProxyRedirectFrom "default") (eq $location.Proxy.ProxyRedirectFrom "off")) }}
proxy_redirect {{ $location.Proxy.ProxyRedirectFrom }};

View file

@ -0,0 +1,121 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package annotations
import (
"fmt"
"net/http"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"github.com/parnurzeal/gorequest"
v1beta1 "k8s.io/api/extensions/v1beta1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/ingress-nginx/test/e2e/framework"
)
var _ = framework.IngressNginxDescribe("Annotations - lua-resty-waf", func() {
f := framework.NewDefaultFramework("luarestywaf")
BeforeEach(func() {
err := f.NewEchoDeployment()
Expect(err).NotTo(HaveOccurred())
})
Context("when lua-resty-waf is enabled", func() {
It("should return 403 for a malicious request that matches a default WAF rule and 200 for other requests", func() {
host := "foo"
createIngress(f, host, map[string]string{"nginx.ingress.kubernetes.io/lua-resty-waf": "true"})
url := fmt.Sprintf("%s?msg=<A href=\"http://mysite.com/\">XSS</A>", f.NginxHTTPURL)
resp, _, errs := gorequest.New().
Get(url).
Set("Host", host).
End()
Expect(len(errs)).Should(Equal(0))
Expect(resp.StatusCode).Should(Equal(http.StatusForbidden))
})
})
Context("when lua-resty-waf is not enabled", func() {
It("should return 200 even for a malicious request", func() {
host := "foo"
createIngress(f, host, map[string]string{})
url := fmt.Sprintf("%s?msg=<A href=\"http://mysite.com/\">XSS</A>", f.NginxHTTPURL)
resp, _, errs := gorequest.New().
Get(url).
Set("Host", host).
End()
Expect(len(errs)).Should(Equal(0))
Expect(resp.StatusCode).Should(Equal(http.StatusOK))
})
})
})
func createIngress(f *framework.Framework, host string, annotations map[string]string) {
ing, err := f.EnsureIngress(&v1beta1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: host,
Namespace: f.Namespace.Name,
Annotations: annotations,
},
Spec: v1beta1.IngressSpec{
Rules: []v1beta1.IngressRule{
{
Host: host,
IngressRuleValue: v1beta1.IngressRuleValue{
HTTP: &v1beta1.HTTPIngressRuleValue{
Paths: []v1beta1.HTTPIngressPath{
{
Path: "/",
Backend: v1beta1.IngressBackend{
ServiceName: "http-svc",
ServicePort: intstr.FromInt(80),
},
},
},
},
},
},
},
},
})
Expect(err).NotTo(HaveOccurred())
Expect(ing).NotTo(BeNil())
err = f.WaitForNginxServer(host,
func(server string) bool {
return Expect(server).Should(ContainSubstring("server_name foo")) &&
Expect(server).ShouldNot(ContainSubstring("return 503"))
})
Expect(err).NotTo(HaveOccurred())
resp, body, errs := gorequest.New().
Get(f.NginxHTTPURL).
Set("Host", host).
End()
Expect(len(errs)).Should(Equal(0))
Expect(resp.StatusCode).Should(Equal(http.StatusOK))
Expect(body).Should(ContainSubstring(fmt.Sprintf("host=%v", host)))
}