// A complete Kesvara webhook receiver, in Go. // // Copy this file, set KESVARA_SIGNING_KEY, run it, point your workspace's // notification destination at http://your-host/kesvara. // // export KESVARA_SIGNING_KEY=... # from your Kesvara settings page // go run go_receiver.go # no dependencies; standard library only // // This file is not an illustration. Kesvara's own test suite starts this exact // program, sends it messages signed by the same code that signs yours, and then // sends it a battery of forgeries and replays and asserts it refuses every one. // If you change it, the part most worth re-testing is `verify` -- everything // below it is ordinary plumbing. // // The two mistakes this file exists to stop you making: // // 1. json.NewDecoder(r.Body).Decode(&msg). It reads the body to parse it, and // the raw bytes are gone afterwards -- so the natural next step is to // re-encode the struct and sign that, which produces a DIFFERENT byte // string. Go's encoder escapes `<`, `>` and `&` as < and friends by // default and orders struct fields by declaration, so the MAC disagrees on // ordinary traffic. The obvious way out of a check that fails on genuine // messages is to stop checking. Read the bytes first, verify, parse after. // // 2. Reading r.Body without a limit. This handler runs BEFORE anything has // been authenticated -- that is unavoidable, since the signature covers the // body you have not read yet -- so io.ReadAll on a raw r.Body will read as // much as a stranger cares to send you, and the first thing anyone finds // when they discover this endpoint is that it accepts a 10GB POST. Go gives // you no default limit here; Express does (100kb) and Flask does not. // http.MaxBytesReader is the fix and it is one line. package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "os" "strconv" "strings" "time" ) const ( sigHeader = "X-Kesvara-Signature" tsHeader = "X-Kesvara-Timestamp" version = "v1" // How much clock skew you will tolerate, in seconds. Kesvara cannot // enforce this for you -- it is your end of the exchange. Five minutes is a // normal choice: long enough to survive an unsynchronised clock, short // enough that a captured message is not replayable tomorrow. toleranceSeconds = 300 // See mistake 2 above. Kesvara's messages are a few kilobytes; this is // generous by two orders of magnitude and still refuses a flood. maxBodyBytes = 1 << 20 ) // verify returns "" if the message is genuine, or a reason to refuse it. // // A reason rather than a bool because the reason belongs in your log. "Refused // a webhook" at 4am with no reason is a support ticket. // // `now` is unix seconds, taken as a parameter so a test can move the clock // without waiting for it. func verify(secret, ts, sig string, body []byte, now int64) string { if sig == "" || ts == "" { return "missing signature headers" } // An explicit version check. If Kesvara ever signs a different shape of // string it will say v2, and a receiver written against v1 must refuse it // rather than compute a v1 signature over it and quietly disagree. if !strings.HasPrefix(sig, version+"=") { return "unknown signature version" } if !isInteger(ts) { return "malformed timestamp" } // The timestamp is signed as the exact string that arrived, not as a number // that has been through strconv and back. Those differ for "0123", and a // signature scheme with two spellings of one input has a hole in it. This // is why the []byte below is built from `ts` and not from a parsed int -- // see tests/bad_receivers/normalised_timestamp_php.php for the receiver // that gets this wrong and what it then accepts. signed := append([]byte(version+":"+ts+":"), body...) mac := hmac.New(sha256.New, []byte(secret)) mac.Write(signed) expected := version + "=" + hex.EncodeToString(mac.Sum(nil)) // hmac.Equal, not ==. A byte-by-byte comparison that returns early leaks // the correct signature to anyone who can time it. if !hmac.Equal([]byte(expected), []byte(sig)) { return "signature mismatch" } // Freshness is checked AFTER the signature, deliberately. Before it, you // would be making a decision about a header that nothing has authenticated // yet. After it, the timestamp is known to be the one Kesvara signed -- // which is what makes rejecting an old one a real defence against a // replayed message rather than a formality. It also keeps the refusal // reason from telling a forger which of the two checks they got past. stamp, err := strconv.ParseInt(ts, 10, 64) if err != nil { // Unreachable: isInteger already passed. Handled rather than ignored // because a 20-digit timestamp overflows int64, and the alternative // spelling of this line is a panic in a handler. return "malformed timestamp" } age := now - stamp if age > toleranceSeconds { return "timestamp too old" } if age < -toleranceSeconds { return "timestamp too far in the future" } return "" } // isInteger reports whether s is an optionally-negative run of ASCII digits. // // Written out rather than reached for via strconv, because ParseInt accepts // things this must not: "+5", "0x1f", and underscores in a Go source literal // spelling like "1_600_000_000". A timestamp with several spellings is the // same hole the raw-string signing above exists to close. func isInteger(s string) bool { s = strings.TrimPrefix(s, "-") if s == "" { return false } for _, c := range s { if c < '0' || c > '9' { return false } } return true } // Message is the payload Kesvara sends. Only the fields a receiver acts on are // declared; the rest are ignored by the decoder, which is what you want -- a // new field added by Kesvara must not stop your receiver working. type Message struct { Event string `json:"event"` Text string `json:"text"` Incident *Incident `json:"incident"` } type Incident struct { ID int64 `json:"id"` Title string `json:"title"` Service string `json:"service"` AlertCount int `json:"alert_count"` URL string `json:"url"` Similar *Similar `json:"similar"` } // Similar is null when Kesvara has nothing in your history close enough, which // is the common case on a new workspace and is said in `text` too. A pointer // rather than a value so that "no citation" is distinguishable from "a citation // whose fields happen to be empty". type Similar struct { IncidentID int64 `json:"incident_id"` Date string `json:"date"` Title string `json:"title"` ActionItem string `json:"action_item"` URL string `json:"url"` // Kesvara watched this one happen, or your team imported it as history. // Worth carrying into whatever you build: "as written at the time" is a // claim Kesvara cannot make about a row somebody pasted in. ObservedByKesvara bool `json:"observed_by_kesvara"` // False when the link came from a vocabulary entry somebody on your team // wrote, rather than from wording the two incidents share. MatchedBySharedWording bool `json:"matched_by_shared_wording"` } func handler(secret string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") refuse(w, http.StatusMethodNotAllowed, "POST only") return } // The limit goes on BEFORE the read, not as a length check after it. r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) body, err := io.ReadAll(r.Body) // RAW bytes. Not a JSON decoder. if err != nil { refuse(w, http.StatusRequestEntityTooLarge, "body too large or unreadable") return } if why := verify(secret, r.Header.Get(tsHeader), r.Header.Get(sigHeader), body, time.Now().Unix()); why != "" { // Log the reason, never the key and never the expected signature -- // printing what you expected hands a forger the answer, and the // same string usually ends up in a log a whole team can read. log.Printf("refused a Kesvara webhook: %s", why) refuse(w, http.StatusUnauthorized, why) return } var msg Message if err := json.Unmarshal(body, &msg); err != nil { // Only reachable from something holding your signing key, so this // is a bug report rather than an attack. refuse(w, http.StatusBadRequest, "signed body was not JSON") return } if msg.Event == "incident.opened" && msg.Incident != nil { // Kesvara retries a failed delivery, and a retry re-sends the SAME // bytes with the same timestamp and the same signature. If this // does something you would not want done twice -- paging someone, // opening a ticket -- key it on Incident.ID, which is stable across // retries. Do not key it on the signature, which is stable too and // will therefore also suppress a genuine second delivery after a // key rotation. handleIncident(msg.Incident) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]bool{"ok": true}) } } func refuse(w http.ResponseWriter, status int, why string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(map[string]string{"refused": why}) } // handleIncident is yours to write. This is what the payload contains. func handleIncident(inc *Incident) { fmt.Printf("incident %d: %s (%d alerts)\n", inc.ID, inc.Title, inc.AlertCount) if inc.Similar == nil { fmt.Println(" no similar resolved incident in this workspace's history") return } // Kesvara found an older incident of yours that looks like this one. // ActionItem is a sentence one of your team typed at the time. It is not a // diagnosis and Kesvara does not claim it is one. fmt.Printf(" similar to %s: %s\n", inc.Similar.Date, inc.Similar.ActionItem) if !inc.Similar.ObservedByKesvara { fmt.Println(" (from history your team imported, not one Kesvara saw)") } if !inc.Similar.MatchedBySharedWording { fmt.Println(" (linked by a vocabulary entry somebody on your team wrote)") } } func main() { // Read from the environment, never pasted into the file. This is the one // value that lets anyone forge a message your receiver will believe. secret := os.Getenv("KESVARA_SIGNING_KEY") if secret == "" { log.Fatal("KESVARA_SIGNING_KEY is not set") } port := os.Getenv("PORT") if port == "" { port = "8080" } mux := http.NewServeMux() mux.HandleFunc("/kesvara", handler(secret)) // Timeouts set explicitly. http.ListenAndServe has none, so one client that // opens a connection and never finishes the request holds a goroutine // forever -- and this endpoint is reachable by anyone who learns the URL. srv := &http.Server{ Addr: "127.0.0.1:" + port, Handler: mux, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, } log.Printf("listening on %s", srv.Addr) log.Fatal(srv.ListenAndServe()) }