Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add template support #285

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion webhookbot/db.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ CREATE TABLE `hooks` (
`id` varchar(100) NOT NULL,
`name` varchar(100) NOT NULL,
`conv_id` varchar(100) NOT NULL,
`template` varchar(10000) NOT NULL,
CONSTRAINT hook UNIQUE (`name`,`conv_id`),
PRIMARY KEY (`id`),
KEY `conv_id` (`conv_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
39 changes: 37 additions & 2 deletions webhookbot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,30 @@ func (s *BotServer) makeAdvertisement() kbchat.Advertisement {
createExtended := fmt.Sprintf(`Create a new webhook for sending messages into the current conversation. You must supply a name as well to identify the webhook. To use a webhook URL, supply a %smsg%s URL parameter, or a JSON POST body with a field %smsg%s.

Example:%s
!webhook create alerts%s`, back, back, back, back, backs, backs)
!webhook create alerts%s

Example (using custom template):%s
!webhook create alerts *{{.title}}*
%s{{.body}}%s%s

Templates:
Information about creating templates, and some examples can be found at %shttps://pkg.go.dev/text/template%s
kf5grd marked this conversation as resolved.
Show resolved Hide resolved
You can test your templates against your JSON data at %shttps://play.golang.org/p/vC06kRCQDfX%s

There are 2 custom variables you can use in your templates (see the default template below for example usage):
- %s$webhookName%s: The name you gave the webhook
- %s$webhookMethod%s: Whether GET or POST was used when the webhook endpoint was fetched

If you don't provide a template, this is the default:%s
[hook: *{{$webhookName}}*]

{{if eq $webhookMethod "POST"}}{{.msg}}{{else}}{{index .msg 0}}{{end}}%s`,
back, back, back, back, backs, backs, backs, back, back, backs, back, back, back, back, back, back, back, back, backs, backs)
updateExtended := fmt.Sprintf(`Update an existing webhook's template. Leave the template field empty to use the default template. For more information about templates, see the help text for %s!webhook create%s.

Example:%s
!webhook update alerts *New Alert: {{.title}}*
%s{{.body}}%s%s`, back, back, backs, back, back, backs)
removeExtended := fmt.Sprintf(`Remove a webhook from the current conversation. You must supply the name of the webhook.

Example:%s
Expand All @@ -57,14 +80,26 @@ func (s *BotServer) makeAdvertisement() kbchat.Advertisement {
cmds := []chat1.UserBotCommandInput{
{
Name: "webhook create",
Usage: "<name> [<template>]",
Description: "Create a new webhook for sending into the current conversation",
ExtendedDescription: &chat1.UserBotExtendedDescription{
Title: `*!webhook create* <name>
Title: `*!webhook create* <name> [<template>]
Create a webhook`,
DesktopBody: createExtended,
MobileBody: createExtended,
},
},
{
Name: "webhook update",
Usage: "<name> [<template>]",
Description: "Update the template of an existing webhook in the current conversation",
ExtendedDescription: &chat1.UserBotExtendedDescription{
Title: `*!webhook update* <name> [<template>]
Update a webhook's template`,
DesktopBody: updateExtended,
MobileBody: updateExtended,
},
},
{
Name: "webhook list",
Description: "List active webhooks in the current conversation",
Expand Down
33 changes: 24 additions & 9 deletions webhookbot/webhookbot/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,39 +35,54 @@ func (d *DB) makeID(name string, convID chat1.ConvIDStr) (string, error) {
return base.URLEncoder().EncodeToString(h.Sum(nil)[:20]), nil
}

func (d *DB) Create(name string, convID chat1.ConvIDStr) (string, error) {
func (d *DB) Create(name, template string, convID chat1.ConvIDStr) (string, error) {
id, err := d.makeID(name, convID)
if err != nil {
return "", err
}
err = d.RunTxn(func(tx *sql.Tx) error {
if _, err := tx.Exec(`
INSERT INTO hooks
(id, name, conv_id)
(id, name, template, conv_id)
VALUES
(?, ?, ?)
`, id, name, convID); err != nil {
(?, ?, ?, ?)
`, id, name, template, convID); err != nil {
return err
}
return nil
})
return id, err
}

func (d *DB) Update(name, template string, convID chat1.ConvIDStr) error {
err := d.RunTxn(func(tx *sql.Tx) error {
if _, err := tx.Exec(`
UPDATE hooks
SET template = ?
WHERE conv_id = ? AND name = ?
`, template, convID, name); err != nil {
return err
}
return nil
})
return err
}

func (d *DB) GetHook(id string) (res webhook, err error) {
row := d.DB.QueryRow(`
SELECT conv_id, name FROM hooks WHERE id = ?
SELECT conv_id, name, template FROM hooks WHERE id = ?
`, id)
if err := row.Scan(&res.convID, &res.name); err != nil {
if err := row.Scan(&res.convID, &res.name, &res.template); err != nil {
return res, err
}
return res, nil
}

type webhook struct {
id string
convID chat1.ConvIDStr
name string
id string
convID chat1.ConvIDStr
name string
template string
}

func (d *DB) List(convID chat1.ConvIDStr) (res []webhook, err error) {
Expand Down
78 changes: 76 additions & 2 deletions webhookbot/webhookbot/handler.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
package webhookbot

import (
"database/sql"
"errors"
"fmt"
"strings"
"text/template"

_ "github.com/go-sql-driver/mysql"
"github.com/keybase/go-keybase-chat-bot/kbchat"
"github.com/keybase/go-keybase-chat-bot/kbchat/types/chat1"
"github.com/keybase/managed-bots/base"
)

const defaultTemplate = "[hook: *{{$webhookName}}*]\n\n{{if eq $webhookMethod \"POST\"}}{{.msg}}{{else}}{{index .msg 0}}{{end}}"

type Handler struct {
*base.DebugOutput

Expand Down Expand Up @@ -109,10 +113,19 @@ func (h *Handler) handleList(cmd string, msg chat1.MsgSummary) (err error) {
return nil
}

func validateTemplate(templateSrc string) error {
tWithVars := injectTemplateVars("testhook1", "POST", templateSrc)
_, err := template.New("").Parse(tWithVars)
if err != nil {
return err
}
return nil
}

func (h *Handler) handleCreate(cmd string, msg chat1.MsgSummary) (err error) {
convID := msg.ConvID
toks := strings.Split(cmd, " ")
if len(toks) != 3 {
if len(toks) < 3 {
h.ChatEcho(convID, "invalid number of arguments, must specify a name")
return nil
}
Expand All @@ -128,7 +141,22 @@ func (h *Handler) handleCreate(cmd string, msg chat1.MsgSummary) (err error) {

h.stats.Count("create")
name := toks[2]
id, err := h.db.Create(name, convID)

// template is whatever remains after removing "!webhook create <name>", and trimming spaces.
// if the template is empty, we'll set a default one
templateSrc := strings.Replace(cmd, "!webhook create", "", 1)
templateSrc = strings.Replace(templateSrc, name, "", 1)
templateSrc = strings.TrimSpace(templateSrc)
if templateSrc == "" {
templateSrc = defaultTemplate
}
err = validateTemplate(templateSrc)
if err != nil {
h.ChatEcho(convID, "failed to parse template: %v", err)
return fmt.Errorf("handleCreate: failed to parse template: %s", err)
}

id, err := h.db.Create(name, templateSrc, convID)
if err != nil {
return fmt.Errorf("handleCreate: failed to create webhook: %s", err)
}
Expand All @@ -139,6 +167,50 @@ func (h *Handler) handleCreate(cmd string, msg chat1.MsgSummary) (err error) {
return nil
}

func (h *Handler) handleUpdate(cmd string, msg chat1.MsgSummary) (err error) {
convID := msg.ConvID
toks := strings.Split(cmd, " ")
if len(toks) < 3 {
h.ChatEcho(convID, "invalid number of arguments, must specify a name")
return nil
}
err = h.checkAllowed(msg)
switch err {
case nil:
case errNotAllowed:
h.ChatEcho(convID, err.Error())
return nil
default:
return err
}
h.stats.Count("update")
name := toks[2]
// template is whatever remains after removing "!webhook update <name>", and trimming spaces.
// if the template is empty, we'll set a default one
templateSrc := strings.Replace(cmd, "!webhook update", "", 1)
templateSrc = strings.Replace(templateSrc, name, "", 1)
templateSrc = strings.TrimSpace(templateSrc)
if templateSrc == "" {
templateSrc = defaultTemplate
}
err = validateTemplate(templateSrc)
if err != nil {
h.ChatEcho(convID, "failed to parse template: %v", err)
return fmt.Errorf("handleCreate: failed to parse template: %s", err)
}

err = h.db.Update(name, templateSrc, convID)
switch err {
case nil:
case sql.ErrNoRows:
default:
h.ChatEcho(convID, "failed to update template: no webhook with that name exists in this conversation. a new webhook can be created with the `!webhook create` command.")
return nil
}
h.ChatEcho(convID, "Success!")
return nil
}

func (h *Handler) HandleNewConv(conv chat1.ConvSummary) error {
welcomeMsg := "I can create generic webhooks into Keybase! Try `!webhook create` to get started."
return base.HandleNewTeam(h.stats, h.DebugOutput, h.kbc, conv, welcomeMsg)
Expand All @@ -156,6 +228,8 @@ func (h *Handler) HandleCommand(msg chat1.MsgSummary) error {
return h.handleList(cmd, msg)
case strings.HasPrefix(cmd, "!webhook remove"):
return h.handleRemove(cmd, msg)
case strings.HasPrefix(cmd, "!webhook update"):
return h.handleUpdate(cmd, msg)
}
return nil
}
69 changes: 50 additions & 19 deletions webhookbot/webhookbot/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"os"
"strings"
"text/template"
"time"

"github.com/gorilla/mux"
Expand All @@ -32,33 +33,59 @@ func NewHTTPSrv(stats *base.StatsRegistry, debugConfig *base.ChatDebugOutputConf
return h
}

type msgPayload struct {
Msg string
func injectTemplateVars(webhookName, webhookMethod, template string) string {
return fmt.Sprintf("{{$webhookName := %q}}{{$webhookMethod := %q}} %s", webhookName, webhookMethod, template)
}

func (h *HTTPSrv) getMessage(r *http.Request) (string, error) {
msg := r.URL.Query().Get("msg")
if len(msg) > 0 {
return msg, nil
func (h *HTTPSrv) getMessage(r *http.Request, hook webhook) (string, error) {
if r.Method == http.MethodGet && len(hook.template) == 0 {
j, err := json.Marshal(r.URL.Query())
if err != nil {
return "", err
}
return string(j), nil
}

if r.Method == http.MethodPost && len(hook.template) == 0 {
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
return "", err
}
return string(body), nil
}

defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
tWithVars := injectTemplateVars(hook.name, r.Method, hook.template)
t, err := template.New("").Parse(tWithVars)
if err != nil {
return "", err
return "`Error: failed to parse template: " + err.Error() + "`", nil
}

var payload msgPayload
decoder := json.NewDecoder(bytes.NewReader(body))
if err := decoder.Decode(&payload); err == nil && len(payload.Msg) > 0 {
return payload.Msg, nil
if r.Method == http.MethodGet {
buf := new(bytes.Buffer)
if err := t.Execute(buf, r.URL.Query()); err == nil {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps the template population should be timeboxed (context.WithTimeout) (and in a separate goroutine) so a malicious template (infinite loop) doesn't halt the bot

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i've spent some time looking into this and thinking about how it would be done and i'm struggling to think of how we would cancel the t.Execute call if it did end up in a situation where there was some kind of infinite loop in a template. we could call it in a goroutine, and we could use context.WithTimeout to know that we're okay to move on after so many seconds of waiting, but i'm not sure how we would actually stop the t.Execute call from continuing to hang?

i also did some tests to see if it's possible to create some kind of malicious template with an infinite loop, and it turns out that if you create a template that ranges over nothing, like {{ range }}{{ end }} it just fails to execute and returns an error, rather than creating an infinite loop the way for{} would. i think overall these templates should be pretty safe, considering the fact that they provide a separate templating package specifically to prevent code injection when dealing with html, which makes me think these templates should be okay to use even if they come from an untrusted source.

if you still think we should timebox this, i'd be interested in your thoughts about how to kill that t.Execute call.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would a recursive template like this be possible? https://play.golang.org/p/uMN1IsGB4xl

It might be useful to look at this issue golang/go#31107

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realized that my playground example might not be super realistic since playground has some pretty strict timing & memory restrictions. It seems like Execute gives up at some depth (100000 from the source).

However, I came up with something that gets killed by the linux OOM killer when writing to a bytes.Buffer if the timeout is too loose. Might be useful to allocate a limited size buffer. I imagine it's still possible to abuse the memory when executing the template though.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would a recursive template like this be possible? https://play.golang.org/p/uMN1IsGB4xl

i tried this on an instance of the bot that i'm currently running, and it did not work. i tried it exactly as you have it here, which takes no input from any fields on the webhook, and i also modified it a bit to take a name field. both ways resulted in the following log line when attempting to hit the webhook:

DummyStatsBackend: Count: webhookbot - HTTPSrv - handle - empty msg

but this was a really cool approach. i'm kind of curious why this works in the playground but not the bot. out of curiosity i also put your example in a file and compiled it and it runs in under a second before exiting, and it prints out a bunch of the Don't you just love recursion? lines.

It might be useful to look at this issue golang/go#31107

thank you for this! i skimmed it a but just now but will have to read through it a bit when i have more time. it looks like there may be some good ideas in there that i can use.

It seems like Execute gives up at some depth (100000 from the source).

perhaps this explains the behavior i mentioned abovened above

However, I came up with something that gets killed by the linux OOM killer when writing to a bytes.Buffer if the timeout is too loose. Might be useful to allocate a limited size buffer. I imagine it's still possible to abuse the memory when executing the template though.

i'll look into this, thanks for the suggestion. i'd be interested to see what you came up with.
also, feel free to ping me on keybase and i can point you to the instance i'm running if you want to poke at it.

return buf.String(), nil
}
}

msg = string(body)
if len(msg) > 0 {
return msg, nil
if r.Method == http.MethodPost {
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
return "", err
}

m := map[string]interface{}{}
err = json.Unmarshal(body, &m)
if err != nil {
return "", err
}
buf := new(bytes.Buffer)
if err := t.Execute(buf, m); err == nil && len(buf.String()) > 0 {
return buf.String(), nil
}
}
return "`Error: no body found. To use a webhook URL, supply a 'msg' URL parameter, or a JSON POST body with a field 'msg'`", nil
return "", nil
}

func (h *HTTPSrv) handleHook(w http.ResponseWriter, r *http.Request) {
Expand All @@ -71,15 +98,19 @@ func (h *HTTPSrv) handleHook(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
return
}
msg, err := h.getMessage(r)
msg, err := h.getMessage(r, hook)
if err != nil {
h.Stats.Count("handle - no message")
h.Errorf("handleHook: failed to find message: %s", err)
w.WriteHeader(http.StatusBadRequest)
return
}
if strings.TrimSpace(msg) == "" {
h.Stats.Count("handle - empty msg")
return
}
h.Stats.Count("handle - success")
if _, err := h.Config().KBC.SendMessageByConvID(hook.convID, "[hook: *%s*]\n\n%s", hook.name, msg); err != nil {
if _, err := h.Config().KBC.SendMessageByConvID(hook.convID, " %s", msg); err != nil {
if base.IsDeletedConvError(err) {
h.Debug("ChatEcho: failed to send echo message: %s", err)
return
Expand Down