Merge pull request #380 from moorereason/feature/http-methods

Feature/http methods
This commit is contained in:
Adnan Hajdarević 2019-12-28 14:17:39 +01:00 committed by GitHub
commit aa03daeff8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 255 additions and 187 deletions

View file

@ -10,6 +10,7 @@ Hooks are defined as JSON objects. Please note that in order to be considered va
* `response-headers` - specifies the list of headers in format `{"name": "X-Example-Header", "value": "it works"}` that will be returned in HTTP response for the hook
* `success-http-response-code` - specifies the HTTP status code to be returned upon success
* `incoming-payload-content-type` - sets the `Content-Type` of the incoming HTTP request (ie. `application/json`); useful when the request lacks a `Content-Type` or sends an erroneous value
* `http-methods` - a list of allowed HTTP methods, such as `POST` and `GET`
* `include-command-output-in-response` - boolean whether webhook should wait for the command to finish and return the raw output as a response to the hook initiator. If the command fails to execute or encounters any errors while executing the response will result in 500 Internal Server Error HTTP status code, otherwise the 200 OK status code will be returned.
* `include-command-output-in-response-on-error` - boolean whether webhook should include command stdout & stderror as a response in failed executions. It only works if `include-command-output-in-response` is set to `true`.
* `parse-parameters-as-json` - specifies the list of arguments that contain JSON strings. These parameters will be decoded by webhook and you can access them like regular objects in rules and `pass-arguments-to-command`.

View file

@ -13,6 +13,8 @@ Usage of webhook:
path to the json file containing defined hooks the webhook should serve, use multiple times to load from different files
-hotreload
watch hooks file for changes and reload them automatically
-http-methods string
globally restrict allowed HTTP methods; separate methods with comma
-ip string
ip the webhook should serve hooks on (default "0.0.0.0")
-key string

View file

@ -460,6 +460,7 @@ type Hook struct {
TriggerRuleMismatchHttpResponseCode int `json:"trigger-rule-mismatch-http-response-code,omitempty"`
IncomingPayloadContentType string `json:"incoming-payload-content-type,omitempty"`
SuccessHttpResponseCode int `json:"success-http-response-code,omitempty"`
HTTPMethods []string `json:"http-methods"`
}
// ParseJSONParameters decodes specified arguments to JSON objects and replaces the
@ -648,8 +649,7 @@ func (h *Hooks) LoadFromFile(path string, asTemplate bool) error {
file = buf.Bytes()
}
e = yaml.Unmarshal(file, h)
return e
return yaml.Unmarshal(file, h)
}
// Append appends hooks unless the new hooks contain a hook with an ID that already exists

View file

@ -3,6 +3,7 @@
"id": "github",
"execute-command": "{{ .Hookecho }}",
"command-working-directory": "/",
"http-methods": ["Post "],
"include-command-output-in-response": true,
"trigger-rule-mismatch-http-response-code": 400,
"pass-environment-to-command":

View file

@ -1,4 +1,6 @@
- id: github
http-methods:
- "Post "
trigger-rule:
and:
- match:

View file

@ -50,6 +50,7 @@ var (
maxMultipartMem = flag.Int64("max-multipart-mem", 1<<20, "maximum memory in bytes for parsing multipart form data before disk caching")
setGID = flag.Int("setgid", 0, "set group ID after opening listening port; must be used with setuid")
setUID = flag.Int("setuid", 0, "set user ID after opening listening port; must be used with setgid")
httpMethods = flag.String("http-methods", "", `set default allowed HTTP methods (ie. "POST"); separate methods with comma`)
responseHeaders hook.ResponseHeaders
hooksFiles hook.HooksFiles
@ -197,6 +198,9 @@ func main() {
r.Use(middleware.Dumper(log.Writer()))
}
// Clean up input
*httpMethods = strings.ToUpper(strings.ReplaceAll(*httpMethods, " ", ""))
hooksURL := makeURL(hooksURLPrefix)
r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
@ -232,6 +236,7 @@ func main() {
if !*secure {
log.Printf("serving hooks on http://%s%s", addr, hooksURL)
log.Print(svr.Serve(ln))
return
}
@ -251,17 +256,54 @@ func main() {
func hookHandler(w http.ResponseWriter, r *http.Request) {
rid := middleware.GetReqID(r.Context())
log.Printf("[%s] incoming HTTP request from %s\n", rid, r.RemoteAddr)
log.Printf("[%s] incoming HTTP %s request from %s\n", rid, r.Method, r.RemoteAddr)
id := mux.Vars(r)["id"]
matchedHook := matchLoadedHook(id)
if matchedHook == nil {
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, "Hook not found.")
return
}
// Check for allowed methods
var allowedMethod bool
switch {
case len(matchedHook.HTTPMethods) != 0:
for i := range matchedHook.HTTPMethods {
// TODO(moorereason): refactor config loading and reloading to
// sanitize these methods once at load time.
if r.Method == strings.ToUpper(strings.TrimSpace(matchedHook.HTTPMethods[i])) {
allowedMethod = true
break
}
}
case *httpMethods != "":
for _, v := range strings.Split(*httpMethods, ",") {
if r.Method == v {
allowedMethod = true
break
}
}
default:
allowedMethod = true
}
if !allowedMethod {
w.WriteHeader(http.StatusMethodNotAllowed)
log.Printf("[%s] HTTP %s method not allowed for hook %q", rid, r.Method, id)
return
}
log.Printf("[%s] %s got matched\n", rid, id)
for _, responseHeader := range responseHeaders {
w.Header().Set(responseHeader.Name, responseHeader.Value)
}
id := mux.Vars(r)["id"]
if matchedHook := matchLoadedHook(id); matchedHook != nil {
log.Printf("[%s] %s got matched\n", rid, id)
var (
body []byte
err error
@ -461,10 +503,6 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("[%s] %s got matched, but didn't get triggered because the trigger rules were not satisfied\n", rid, matchedHook.ID)
fmt.Fprint(w, "Hook rules were not satisfied.")
} else {
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, "Hook not found.")
}
}
func handleHook(h *hook.Hook, rid string, headers, query, payload *map[string]interface{}, body *[]byte) (string, error) {

View file

@ -79,6 +79,10 @@ func TestWebhook(t *testing.T) {
ip, port := serverAddress(t)
args := []string{fmt.Sprintf("-hooks=%s", configPath), fmt.Sprintf("-ip=%s", ip), fmt.Sprintf("-port=%s", port), "-verbose"}
if len(tt.cliMethods) != 0 {
args = append(args, "-http-methods="+strings.Join(tt.cliMethods, ","))
}
// Setup a buffer for capturing webhook logs for later evaluation
b := &buffer{}
@ -95,7 +99,7 @@ func TestWebhook(t *testing.T) {
url := fmt.Sprintf("http://%s:%s/hooks/%s", ip, port, tt.id)
req, err := http.NewRequest("POST", url, ioutil.NopCloser(strings.NewReader(tt.body)))
req, err := http.NewRequest(tt.method, url, ioutil.NopCloser(strings.NewReader(tt.body)))
if err != nil {
t.Errorf("New request failed: %s", err)
}
@ -289,6 +293,8 @@ func webhookEnv() (env []string) {
var hookHandlerTests = []struct {
desc string
id string
cliMethods []string
method string
headers map[string]string
contentType string
body string
@ -300,6 +306,8 @@ var hookHandlerTests = []struct {
{
"github",
"github",
nil,
"POST",
map[string]string{"X-Hub-Signature": "f68df0375d7b03e3eb29b4cf9f9ec12e08f42ff8"},
"application/json",
`{
@ -456,6 +464,8 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
"bitbucket", // bitbucket sends their payload using uriencoded params.
"bitbucket",
nil,
"POST",
nil,
"application/x-www-form-urlencoded",
`payload={"canon_url": "https://bitbucket.org","commits": [{"author": "marcus","branch": "master","files": [{"file": "somefile.py","type": "modified"}],"message": "Added some more things to somefile.py\n","node": "620ade18607a","parents": ["702c70160afc"],"raw_author": "Marcus Bertrand <marcus@somedomain.com>","raw_node": "620ade18607ac42d872b568bb92acaa9a28620e9","revision": null,"size": -1,"timestamp": "2012-05-30 05:58:56","utctimestamp": "2014-11-07 15:19:02+00:00"}],"repository": {"absolute_url": "/webhook/testing/","fork": false,"is_private": true,"name": "Project X","owner": "marcus","scm": "git","slug": "project-x","website": "https://atlassian.com/"},"user": "marcus"}`,
http.StatusOK,
@ -465,6 +475,8 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
{
"gitlab",
"gitlab",
nil,
"POST",
map[string]string{"X-Gitlab-Event": "Push Hook"},
"application/json",
`{
@ -517,6 +529,8 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
{
"xml",
"xml",
nil,
"POST",
map[string]string{"Content-Type": "application/xml"},
"application/xml",
`<app>
@ -536,6 +550,8 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
"multipart",
"plex",
nil,
"POST",
nil,
"multipart/form-data; boundary=xxx",
`--xxx
Content-Disposition: form-data; name="payload"
@ -566,6 +582,8 @@ binary data
{
"missing-cmd-arg", // missing head_commit.author.email
"github",
nil,
"POST",
map[string]string{"X-Hub-Signature": "ab03955b9377f530aa298b1b6d273ae9a47e1e40"},
"application/json",
`{
@ -607,6 +625,8 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
{
"missing-env-arg", // missing head_commit.timestamp
"github",
nil,
"POST",
map[string]string{"X-Hub-Signature": "2cf8b878cb6b74a25090a140fa4a474be04b97fa"},
"application/json",
`{
@ -643,25 +663,29 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
``,
},
// test with disallowed global HTTP method
{"global disallowed method", "bitbucket", []string{"Post "}, "GET", nil, `{}`, "application/json", http.StatusMethodNotAllowed, ``, ``},
// test with disallowed HTTP method
{"disallowed method", "github", nil, "Get", nil, `{}`, "application/json", http.StatusMethodNotAllowed, ``, ``},
// test with custom return code
{"empty payload", "github", nil, "application/json", `{}`, http.StatusBadRequest, `Hook rules were not satisfied.`, ``},
{"empty payload", "github", nil, "POST", nil, "application/json", `{}`, http.StatusBadRequest, `Hook rules were not satisfied.`, ``},
// test with custom invalid http code, should default to 200 OK
{"empty payload", "bitbucket", nil, "application/json", `{}`, http.StatusOK, `Hook rules were not satisfied.`, ``},
{"empty payload", "bitbucket", nil, "POST", nil, "application/json", `{}`, http.StatusOK, `Hook rules were not satisfied.`, ``},
// test with no configured http return code, should default to 200 OK
{"empty payload", "gitlab", nil, "application/json", `{}`, http.StatusOK, `Hook rules were not satisfied.`, ``},
{"empty payload", "gitlab", nil, "POST", nil, "application/json", `{}`, http.StatusOK, `Hook rules were not satisfied.`, ``},
// test capturing command output
{"don't capture output on success by default", "capture-command-output-on-success-not-by-default", nil, "application/json", `{}`, http.StatusOK, ``, ``},
{"capture output on success with flag set", "capture-command-output-on-success-yes-with-flag", nil, "application/json", `{}`, http.StatusOK, `arg: exit=0
{"don't capture output on success by default", "capture-command-output-on-success-not-by-default", nil, "POST", nil, "application/json", `{}`, http.StatusOK, ``, ``},
{"capture output on success with flag set", "capture-command-output-on-success-yes-with-flag", nil, "POST", nil, "application/json", `{}`, http.StatusOK, `arg: exit=0
`, ``},
{"don't capture output on error by default", "capture-command-output-on-error-not-by-default", nil, "application/json", `{}`, http.StatusInternalServerError, `Error occurred while executing the hook's command. Please check your logs for more details.`, ``},
{"capture output on error with extra flag set", "capture-command-output-on-error-yes-with-extra-flag", nil, "application/json", `{}`, http.StatusInternalServerError, `arg: exit=1
{"don't capture output on error by default", "capture-command-output-on-error-not-by-default", nil, "POST", nil, "application/json", `{}`, http.StatusInternalServerError, `Error occurred while executing the hook's command. Please check your logs for more details.`, ``},
{"capture output on error with extra flag set", "capture-command-output-on-error-yes-with-extra-flag", nil, "POST", nil, "application/json", `{}`, http.StatusInternalServerError, `arg: exit=1
`, ``},
// Check logs
{"static params should pass", "static-params-ok", nil, "application/json", `{}`, http.StatusOK, "arg: passed\n", `(?s)command output: arg: passed`},
{"command with space logs warning", "warn-on-space", nil, "application/json", `{}`, http.StatusInternalServerError, "Error occurred while executing the hook's command. Please check your logs for more details.", `(?s)unable to locate command.*use 'pass[-]arguments[-]to[-]command' to specify args`},
{"unsupported content type error", "github", map[string]string{"Content-Type": "nonexistent/format"}, "application/json", `{}`, http.StatusBadRequest, `Hook rules were not satisfied.`, `(?s)error parsing body payload due to unsupported content type header:`},
{"static params should pass", "static-params-ok", nil, "POST", nil, "application/json", `{}`, http.StatusOK, "arg: passed\n", `(?s)command output: arg: passed`},
{"command with space logs warning", "warn-on-space", nil, "POST", nil, "application/json", `{}`, http.StatusInternalServerError, "Error occurred while executing the hook's command. Please check your logs for more details.", `(?s)unable to locate command.*use 'pass[-]arguments[-]to[-]command' to specify args`},
{"unsupported content type error", "github", nil, "POST", map[string]string{"Content-Type": "nonexistent/format"}, "application/json", `{}`, http.StatusBadRequest, `Hook rules were not satisfied.`, `(?s)error parsing body payload due to unsupported content type header:`},
}
// buffer provides a concurrency-safe bytes.Buffer to tests above.