From 97abfeb3da0bed09e395bff2c5bcf35b6435cb5f Mon Sep 17 00:00:00 2001 From: Scott Nichols Date: Tue, 19 May 2020 10:29:22 -0700 Subject: [PATCH] Abuse Protection for webhooks (#491) * First pass at supporting receiver based webhook abuse protection * Adding client test for new status codes. * adding options to set the GET and OPTIONS function handlers. * adding docs for abuse protection * don't drop encoding checking * fix unit tests for unknown encoding * allow nil event to be delivered if receiver wants Signed-off-by: Scott Nichols --- v2/alias.go | 13 +- .../samples/http/receiver-protected/main.go | 39 ++++++ v2/cmd/tools/http/raw/main.go | 34 +++++ v2/go.mod | 6 +- v2/go.sum | 22 ++++ v2/protocol/http/abuse_protection.go | 121 ++++++++++++++++++ v2/protocol/http/options.go | 43 +++++++ v2/protocol/http/options_test.go | 84 ++++++++++++ v2/protocol/http/protocol.go | 38 ++++++ v2/protocol/http/protocol_test.go | 6 +- v2/test/integration/http/receiver.go | 1 + v2/test/integration/http/receiver_v1_test.go | 38 +++++- 12 files changed, 435 insertions(+), 10 deletions(-) create mode 100644 v2/cmd/samples/http/receiver-protected/main.go create mode 100644 v2/cmd/tools/http/raw/main.go create mode 100644 v2/protocol/http/abuse_protection.go diff --git a/v2/alias.go b/v2/alias.go index 92fee454f..3ba8999c9 100644 --- a/v2/alias.go +++ b/v2/alias.go @@ -161,9 +161,12 @@ var ( WithShutdownTimeout = http.WithShutdownTimeout //WithEncoding = http.WithEncoding //WithStructuredEncoding = http.WithStructuredEncoding // TODO: expose new way - WithPort = http.WithPort - WithPath = http.WithPath - WithMiddleware = http.WithMiddleware - WithListener = http.WithListener - WithRoundTripper = http.WithRoundTripper + WithPort = http.WithPort + WithPath = http.WithPath + WithMiddleware = http.WithMiddleware + WithListener = http.WithListener + WithRoundTripper = http.WithRoundTripper + WithGetHandlerFunc = http.WithGetHandlerFunc + WithOptionsHandlerFunc = http.WithOptionsHandlerFunc + WithDefaultOptionsHandlerFunc = http.WithDefaultOptionsHandlerFunc ) diff --git a/v2/cmd/samples/http/receiver-protected/main.go b/v2/cmd/samples/http/receiver-protected/main.go new file mode 100644 index 000000000..034c7b214 --- /dev/null +++ b/v2/cmd/samples/http/receiver-protected/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "fmt" + "log" + + cloudevents "github.com/cloudevents/sdk-go/v2" +) + +func main() { + ctx := context.Background() + p, err := cloudevents.NewHTTP( + cloudevents.WithDefaultOptionsHandlerFunc([]string{"POST", "OPTIONS"}, 100, []string{"http://localhost:8181"}, true), + ) + if err != nil { + log.Fatalf("failed to create protocol: %s", err.Error()) + } + + c, err := cloudevents.NewClient(p) + if err != nil { + log.Fatalf("failed to create client, %v", err) + } + + log.Printf("will listen on :8080\n") + log.Fatalf("failed to start receiver: %s", c.StartReceiver(ctx, receive)) +} + +func receive(ctx context.Context, event cloudevents.Event) { + fmt.Printf("%s", event) +} + +// +// Testing with: +// +// PORT=8181 go run ./cmd/tools/http/raw/ +// +// curl http://localhost:8080 -v -X OPTIONS -H "Origin: http://example.com" -H "WebHook-Request-Origin: http://example.com" -H "WebHook-Request-Callback: http://localhost:8181/do-this?now=true" +// diff --git a/v2/cmd/tools/http/raw/main.go b/v2/cmd/tools/http/raw/main.go new file mode 100644 index 000000000..457f9a9eb --- /dev/null +++ b/v2/cmd/tools/http/raw/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "net/http/httputil" + + "github.com/kelseyhightower/envconfig" +) + +type RawHTTP struct { + Port int `envconfig:"PORT" default:"8080"` +} + +func (raw *RawHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if reqBytes, err := httputil.DumpRequest(r, true); err == nil { + log.Printf("Raw HTTP Request:\n%+v", string(reqBytes)) + _, _ = w.Write(reqBytes) + } else { + log.Printf("Failed to call DumpRequest: %s", err) + } + fmt.Println("------------------------------") +} + +func main() { + var env RawHTTP + if err := envconfig.Process("", &env); err != nil { + log.Fatalf("Failed to process env var: %s", err) + } + log.Printf("Starting listening on :%d\n", env.Port) + log.Println(http.ListenAndServe(fmt.Sprintf(":%d", env.Port), &env)) +} diff --git a/v2/go.mod b/v2/go.mod index be311f192..2410dabbe 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -10,13 +10,17 @@ require ( github.com/cucumber/messages-go/v10 v10.0.3 github.com/google/go-cmp v0.4.0 github.com/google/uuid v1.1.1 - github.com/gorilla/mux v1.6.2 + github.com/gorilla/mux v1.7.3 + github.com/hashicorp/golang-lru v0.5.3 // indirect github.com/kelseyhightower/envconfig v1.4.0 github.com/lightstep/tracecontext.go v0.0.0-20181129014701-1757c391b1ac github.com/nats-io/nats-streaming-server v0.17.0 // indirect github.com/nats-io/nats.go v1.9.1 github.com/nats-io/stan.go v0.6.0 + github.com/onsi/ginkgo v1.10.2 // indirect + github.com/onsi/gomega v1.7.0 // indirect github.com/pkg/errors v0.9.1 + github.com/prometheus/client_golang v1.0.0 // indirect github.com/stretchr/testify v1.5.1 github.com/valyala/bytebufferpool v1.0.0 go.opencensus.io v0.22.0 diff --git a/v2/go.sum b/v2/go.sum index ccb15d75a..08bbfdc09 100644 --- a/v2/go.sum +++ b/v2/go.sum @@ -42,6 +42,8 @@ github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQh github.com/aslakhellesoy/gox v1.0.100/go.mod h1:AJl542QsKKG96COVsv0N74HHzVQgDIQPceVUh1aeU2M= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= github.com/census-instrumentation/opencensus-proto v0.2.0 h1:LzQXZOgg4CQfE6bFvXGM30YZL1WW/M337pXml+GrcZ4= github.com/census-instrumentation/opencensus-proto v0.2.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -77,6 +79,7 @@ github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8 github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= @@ -121,6 +124,8 @@ github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8 github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/grpc-ecosystem/grpc-gateway v1.8.5 h1:2+KSC78XiO6Qy0hIjfc1OD9H+hsaJdJlb8Kqsd41CTE= github.com/grpc-ecosystem/grpc-gateway v1.8.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= @@ -137,11 +142,14 @@ github.com/hashicorp/go-version v1.0.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.3 h1:YPkqC67at8FYaadspW/6uE0COsBxS2656RLEr8Bppgk= +github.com/hashicorp/golang-lru v0.5.3/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/raft v1.1.1 h1:HJr7UE1x/JrJSc9Oy6aDBHtNHUUBHjcQjTgvUVihoZs= github.com/hashicorp/raft v1.1.1/go.mod h1:vPAJM8Asw6u8LxC3eJCUZmRP/E4QmUGE1R7g7k8sG/8= github.com/hashicorp/raft-boltdb v0.0.0-20171010151810-6e5ba93211ea/go.mod h1:pNv7Wc3ycL6F5oOWn+tPGo2gWD4a5X+yp/ntwdKLjRk= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= @@ -167,6 +175,10 @@ github.com/lightstep/tracecontext.go v0.0.0-20181129014701-1757c391b1ac/go.mod h github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= github.com/nats-io/jwt v0.3.2 h1:+RB5hMpXUUA2dfxuhBTEkMOrYmM+gKIZYS1KjSostMI= @@ -191,8 +203,12 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2 h1:uqH7bpe+ERSiDa34FDOF7RikN6RzXgduUF8yarlZp94= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= @@ -210,21 +226,27 @@ github.com/prometheus/client_golang v0.9.2 h1:awm861/B8OKDd2I/6o1dy3ra4BamzKhYOi github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829 h1:D+CiwcpGTW6pL6bv6KI3KbyEyCKyS+1JWS2h8PNDnGA= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= +github.com/prometheus/client_golang v1.0.0 h1:vrDKnkGzuGvhNAL56c7DBz29ZL+KxnoR0x7enabFceM= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f h1:BVwpUVJDADN2ufcGik7W992pyps0wZ888b/y9GXcLTU= github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.0.0-20181126121408-4724e9255275 h1:PnBWHBf+6L0jOqq0gIVUe6Yk0/QMZ640k6NvkxcBf+8= github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.2.0 h1:kUZDBDTdBVBYBj5Tmh2NZLlF60mfjA27rM34b+cVwNU= github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1 h1:K0MGApIoQvMw27RTdJkPbr3JZ7DNbtxQNyi5STVM6Kw= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a h1:9a8MnZMP0X2nLJdBg+pBmGgkJlSaKC2KaQmTCk1XDtE= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1 h1:/K3IL0Z1quvmJ7X0A1AwNEK7CRkVK3YwfOU/QAL4WGg= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8 h1:+fpWZdT24pJBiqJdAwYBjPSk+5YmQzYNPYzQsdzLkt8= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a h1:9ZKAASQSHhDYGoxY8uLVpewe1GDZ2vu2Tr/vTdVAkFQ= diff --git a/v2/protocol/http/abuse_protection.go b/v2/protocol/http/abuse_protection.go new file mode 100644 index 000000000..eb004101f --- /dev/null +++ b/v2/protocol/http/abuse_protection.go @@ -0,0 +1,121 @@ +package http + +import ( + "context" + cecontext "github.com/cloudevents/sdk-go/v2/context" + "go.uber.org/zap" + "net/http" + "strconv" + "strings" +) + +type WebhookConfig struct { + AllowedMethods []string // defaults to POST + AllowedRate *int + AutoACKCallback bool + AllowedOrigins []string +} + +const ( + DefaultAllowedRate = 1000 +) + +// TODO: implement rate limiting. +// Throttling is indicated by requests being rejected using HTTP status code 429 Too Many Requests. +// TODO: use this if Webhook Request Origin has been turned on. +// Inbound requests should be rejected if Allowed Origins is required by SDK. + +func (p *Protocol) OptionsHandler(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodOptions || p.WebhookConfig == nil { + rw.WriteHeader(http.StatusMethodNotAllowed) + return + } + + headers := make(http.Header) + + // The spec does not say we need to validate the origin, just the request origin. + // After the handshake, we will validate the origin. + if origin, ok := p.ValidateRequestOrigin(req); !ok { + rw.WriteHeader(http.StatusBadRequest) + return + } else { + headers.Set("WebHook-Allowed-Origin", origin) + } + + allowedRateRequired := false + if _, ok := req.Header[http.CanonicalHeaderKey("WebHook-Request-Rate")]; ok { + // must send WebHook-Allowed-Rate + allowedRateRequired = true + } + + if p.WebhookConfig.AllowedRate != nil { + headers.Set("WebHook-Allowed-Rate", strconv.Itoa(*p.WebhookConfig.AllowedRate)) + } else if allowedRateRequired { + headers.Set("WebHook-Allowed-Rate", strconv.Itoa(DefaultAllowedRate)) + } + + if len(p.WebhookConfig.AllowedMethods) > 0 { + headers.Set("Allow", strings.Join(p.WebhookConfig.AllowedMethods, ", ")) + } else { + headers.Set("Allow", http.MethodPost) + } + + cb := req.Header.Get("WebHook-Request-Callback") + if cb != "" { + if p.WebhookConfig.AutoACKCallback { + go func() { + reqAck, err := http.NewRequest(http.MethodPost, cb, nil) + if err != nil { + cecontext.LoggerFrom(req.Context()).Errorw("OPTIONS handler failed to create http request attempting to ack callback.", zap.Error(err), zap.String("callback", cb)) + return + } + + // Write out the headers. + for k := range headers { + reqAck.Header.Set(k, headers.Get(k)) + } + + _, err = http.DefaultClient.Do(reqAck) + if err != nil { + cecontext.LoggerFrom(req.Context()).Errorw("OPTIONS handler failed to ack callback.", zap.Error(err), zap.String("callback", cb)) + return + } + }() + return + } else { + cecontext.LoggerFrom(req.Context()).Infof("ACTION REQUIRED: Please validate web hook request callback: %q", cb) + // TODO: what to do pending https://github.com/cloudevents/spec/issues/617 + return + } + } + + // Write out the headers. + for k := range headers { + rw.Header().Set(k, headers.Get(k)) + } +} + +func (p *Protocol) ValidateRequestOrigin(req *http.Request) (string, bool) { + return p.validateOrigin(req.Header.Get("WebHook-Request-Origin")) +} + +func (p *Protocol) ValidateOrigin(req *http.Request) (string, bool) { + return p.validateOrigin(req.Header.Get("Origin")) +} + +func (p *Protocol) validateOrigin(ro string) (string, bool) { + cecontext.LoggerFrom(context.TODO()).Infow("Validating origin.", zap.String("origin", ro)) + + for _, ao := range p.WebhookConfig.AllowedOrigins { + if ao == "*" { + return ao, true + } + // TODO: it is not clear what the rules for allowed hosts are. + // Need to find docs for this. For now, test for prefix. + if strings.HasPrefix(ro, ao) { + return ao, true + } + } + + return ro, false +} diff --git a/v2/protocol/http/options.go b/v2/protocol/http/options.go index bf632808c..71f88fcb2 100644 --- a/v2/protocol/http/options.go +++ b/v2/protocol/http/options.go @@ -192,3 +192,46 @@ func WithClient(client nethttp.Client) Option { return nil } } + +// WithGetHandlerFunc sets the http GET handler func +func WithGetHandlerFunc(fn nethttp.HandlerFunc) Option { + return func(p *Protocol) error { + if p == nil { + return fmt.Errorf("http GET handler func can not set nil protocol") + } + p.GetHandlerFn = fn + return nil + } +} + +// WithOptionsHandlerFunc sets the http OPTIONS handler func +func WithOptionsHandlerFunc(fn nethttp.HandlerFunc) Option { + return func(p *Protocol) error { + if p == nil { + return fmt.Errorf("http OPTIONS handler func can not set nil protocol") + } + p.OptionsHandlerFn = fn + return nil + } +} + +// WithDefaultOptionsHandlerFunc sets the options handler to be the built in handler and configures the options. +// methods: the supported methods reported to OPTIONS caller. +// rate: the rate limit reported to OPTIONS caller. +// origins: the prefix of the accepted origins, or "*". +// callback: preform the callback to ACK the OPTIONS request. +func WithDefaultOptionsHandlerFunc(methods []string, rate int, origins []string, callback bool) Option { + return func(p *Protocol) error { + if p == nil { + return fmt.Errorf("http OPTIONS handler func can not set nil protocol") + } + p.OptionsHandlerFn = p.DeleteHandlerFn + p.WebhookConfig = &WebhookConfig{ + AllowedMethods: methods, + AllowedRate: &rate, + AllowedOrigins: origins, + AutoACKCallback: callback, + } + return nil + } +} diff --git a/v2/protocol/http/options_test.go b/v2/protocol/http/options_test.go index 33d301d81..48ef7580c 100644 --- a/v2/protocol/http/options_test.go +++ b/v2/protocol/http/options_test.go @@ -600,3 +600,87 @@ func TestWithRoundTripper(t *testing.T) { }) } } + +func TestWithGetHandlerFunc(t *testing.T) { + testCases := map[string]struct { + t *Protocol + fn http.HandlerFunc + wantErr string + }{ + "nil protocol": { + wantErr: "http GET handler func can not set nil protocol", + }, + "non-nil protocol": { + t: &Protocol{}, + fn: func(http.ResponseWriter, *http.Request) {}, + }, + } + for n, tc := range testCases { + t.Run(n, func(t *testing.T) { + err := tc.t.applyOptions(WithGetHandlerFunc(tc.fn)) + if tc.wantErr != "" { + if err == nil || err.Error() != tc.wantErr { + t.Fatalf("Expected error '%s'. Actual '%v'", tc.wantErr, err) + } + } else if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + }) + } +} + +func TestWithOptionsHandlerFunc(t *testing.T) { + testCases := map[string]struct { + t *Protocol + fn http.HandlerFunc + wantErr string + }{ + "nil protocol": { + wantErr: "http OPTIONS handler func can not set nil protocol", + }, + "non-nil protocol": { + t: &Protocol{}, + fn: func(http.ResponseWriter, *http.Request) {}, + }, + } + for n, tc := range testCases { + t.Run(n, func(t *testing.T) { + err := tc.t.applyOptions(WithOptionsHandlerFunc(tc.fn)) + if tc.wantErr != "" { + if err == nil || err.Error() != tc.wantErr { + t.Fatalf("Expected error '%s'. Actual '%v'", tc.wantErr, err) + } + } else if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + }) + } +} + +func TestWithDefaultOptionsHandlerFunc(t *testing.T) { + testCases := map[string]struct { + t *Protocol + fn http.HandlerFunc + wantErr string + }{ + "nil protocol": { + wantErr: "http OPTIONS handler func can not set nil protocol", + }, + "non-nil protocol": { + t: &Protocol{}, + fn: func(http.ResponseWriter, *http.Request) {}, + }, + } + for n, tc := range testCases { + t.Run(n, func(t *testing.T) { + err := tc.t.applyOptions(WithOptionsHandlerFunc(tc.fn)) + if tc.wantErr != "" { + if err == nil || err.Error() != tc.wantErr { + t.Fatalf("Expected error '%s'. Actual '%v'", tc.wantErr, err) + } + } else if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + }) + } +} diff --git a/v2/protocol/http/protocol.go b/v2/protocol/http/protocol.go index 46a0c4cca..731d69f86 100644 --- a/v2/protocol/http/protocol.go +++ b/v2/protocol/http/protocol.go @@ -35,6 +35,15 @@ type Protocol struct { Client *http.Client incoming chan msgErr + // OptionsHandlerFn handles the OPTIONS method requests and is intended to + // implement the abuse protection spec: + // https://github.com/cloudevents/spec/blob/v1.0/http-webhook.md#4-abuse-protection + OptionsHandlerFn http.HandlerFunc + WebhookConfig *WebhookConfig + + GetHandlerFn http.HandlerFunc + DeleteHandlerFn http.HandlerFunc + // To support Opener: // ShutdownTimeout defines the timeout given to the http.Server when calling Shutdown. @@ -226,9 +235,38 @@ func (p *Protocol) Respond(ctx context.Context) (binding.Message, protocol.Respo // ServeHTTP implements http.Handler. // Blocks until ResponseFn is invoked. func (p *Protocol) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + // Filter the GET style methods: + switch req.Method { + case http.MethodOptions: + if p.OptionsHandlerFn == nil { + rw.WriteHeader(http.StatusMethodNotAllowed) + return + } + p.OptionsHandlerFn(rw, req) + return + + case http.MethodGet: + if p.GetHandlerFn == nil { + rw.WriteHeader(http.StatusMethodNotAllowed) + return + } + p.GetHandlerFn(rw, req) + return + + case http.MethodDelete: + if p.DeleteHandlerFn == nil { + rw.WriteHeader(http.StatusMethodNotAllowed) + return + } + p.DeleteHandlerFn(rw, req) + return + } + m := NewMessageFromHttpRequest(req) if m == nil { + // Should never get here unless ServeHTTP is called directly. p.incoming <- msgErr{msg: nil, err: binding.ErrUnknownEncoding} + rw.WriteHeader(http.StatusBadRequest) return // if there was no message, return. } diff --git a/v2/protocol/http/protocol_test.go b/v2/protocol/http/protocol_test.go index 158841028..866a60739 100644 --- a/v2/protocol/http/protocol_test.go +++ b/v2/protocol/http/protocol_test.go @@ -221,9 +221,9 @@ func TestServeHTTP_Receive(t *testing.T) { want binding.Message wantErr string }{ - "nil": { - rw: httptest.NewRecorder(), - wantErr: "unknown Message encoding", + "non-event": { + rw: httptest.NewRecorder(), + req: httptest.NewRequest("POST", "http://unittest", nil), }, } for n, tc := range testCases { diff --git a/v2/test/integration/http/receiver.go b/v2/test/integration/http/receiver.go index 64e481066..22d5f53fd 100644 --- a/v2/test/integration/http/receiver.go +++ b/v2/test/integration/http/receiver.go @@ -29,6 +29,7 @@ type ReceiverTapTest struct { request func(url string) *http.Request asRecv *TapValidation receiverFuncFactory func(context.CancelFunc) interface{} + opts []client.Option } type ReceiverTapTestCases map[string]ReceiverTapTest diff --git a/v2/test/integration/http/receiver_v1_test.go b/v2/test/integration/http/receiver_v1_test.go index ec91c5ada..16079c76a 100644 --- a/v2/test/integration/http/receiver_v1_test.go +++ b/v2/test/integration/http/receiver_v1_test.go @@ -90,6 +90,42 @@ func TestClientReceiver_Status_Codes(t *testing.T) { Status: fmt.Sprintf("%d %s", http.StatusOK, http.StatusText(http.StatusOK)), ContentLength: 0, }, + receiverFuncFactory: func(cancelFunc context.CancelFunc) interface{} { + return func() *cloudevents.Event { + defer cancelFunc() + return nil // acts as a 200 OK + } + }, + }, + "405 if the receiver is not expecting a GET request": { + now: now, + request: func(url string) *http.Request { + req, _ := http.NewRequest("GET", url, nil) + return req + }, + asRecv: &TapValidation{ + Header: map[string][]string{}, + Status: fmt.Sprintf("%d %s", http.StatusMethodNotAllowed, http.StatusText(http.StatusMethodNotAllowed)), + ContentLength: 0, + }, + receiverFuncFactory: func(cancelFunc context.CancelFunc) interface{} { + return func() *cloudevents.Event { + defer cancelFunc() + return nil + } + }, + }, + "405 if the receiver is not expecting an OPTIONS request": { + now: now, + request: func(url string) *http.Request { + req, _ := http.NewRequest("OPTIONS", url, nil) + return req + }, + asRecv: &TapValidation{ + Header: map[string][]string{}, + Status: fmt.Sprintf("%d %s", http.StatusMethodNotAllowed, http.StatusText(http.StatusMethodNotAllowed)), + ContentLength: 0, + }, receiverFuncFactory: func(cancelFunc context.CancelFunc) interface{} { return func() *cloudevents.Event { defer cancelFunc() @@ -101,7 +137,7 @@ func TestClientReceiver_Status_Codes(t *testing.T) { for n, tc := range testCases { t.Run(n, func(t *testing.T) { - ClientReceiver(t, tc) + ClientReceiver(t, tc, tc.opts...) }) } }