Commit 8dcfa45a8a

8dcfa45a8ac03a5ff9c36828274d05acadcf846c

parent: 8ab6240513

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 00:10 UTC

webhook: add reads the signing secret from stdin

--secret - reads it; a value on the command line is refused.

Ref #284

Layout: unified · split

CHANGELOG.org +2
@@ -6,6 +6,8 @@ anything beyond "replace the binary and restart" is needed.
66
77* Unreleased
88
9- ~webhook add~'s ~--secret~ now reads the signing secret from stdin
10 (~--secret -~) instead of taking it as a command-line value (#284).
911- The builds page's status badge section gives an org-mode snippet
1012 beside the Markdown one, for a README.org (#299).
1113- API tokens on the settings page: create with a scope and optional
internal/control/webhook.go +26 −6
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "io"
77 "strconv"
8 "strings"
89
910 "gitbay.org/gitbay/internal/policy"
1011 "gitbay.org/gitbay/internal/protocol"
@@ -15,13 +16,17 @@ import (
1516func init() {
1617 register(Command{Path: []string{"webhook", "add"},
1718 Summary: "add a webhook",
18 Usage: "webhook add <owner/name> <url> [--secret <s>] [--events push,issue.created|*]",
19 Usage: "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]",
1920 Flags: []Flag{
20 {"--secret", "<s>", "signs deliveries so the receiver can verify them", ""},
21 {"--secret", "-", "read the secret that signs deliveries from stdin", ""},
2122 {"--events", "push,issue.created|*", "which events to send", "*"},
2223 },
23 Examples: []string{"webhook add krz/gitbay https://ci.example.org/hook --events push"},
24 Run: runWebhookAdd})
24 Examples: []string{
25 "webhook add krz/gitbay https://ci.example.org/hook --events push",
26 "webhook add krz/gitbay https://ci.example.org/hook --secret - < secret.txt",
27 },
28 ReadsStdin: true,
29 Run: runWebhookAdd})
2530 register(Command{Path: []string{"webhook", "list"},
2631 Summary: "list webhooks",
2732 Usage: "webhook list <owner/name>",
@@ -45,17 +50,21 @@ func init() {
4550}
4651
4752func runWebhookAdd(c *Ctx, args []string) int {
48 f, err := c.parseArgs(args, flagSpec{Values: []string{"--secret", "--events"}, MaxPos: 2, Usage: "webhook add <owner/name> <url> [--secret <s>] [--events push,issue.created|*]"})
53 f, err := c.parseArgs(args, flagSpec{Values: []string{"--secret", "--events"}, MaxPos: 2, Usage: "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]"})
4954 if err != nil {
5055 return c.fail(protocol.ExitUsage, "%v", err)
5156 }
52 path, url, secret, events := f.pos(0), f.pos(1), f.Value("--secret"), "*"
57 path, url, events := f.pos(0), f.pos(1), "*"
5358 if f.Has("--events") {
5459 events = f.Value("--events")
5560 }
5661 if path == "" || url == "" {
5762 return c.usage()
5863 }
64 // Secrets travel on stdin: argv shows in /proc and in shell history.
65 if f.Has("--secret") && f.Value("--secret") != "-" {
66 return c.fail(protocol.ExitUsage, "the secret is read from stdin, never argv: pipe it and pass --secret - (printf %%s SECRET | ... --secret -)")
67 }
5968 repo, code := resolveRepo(c, path, policy.CanAdmin)
6069 if code >= 0 {
6170 return code
@@ -71,6 +80,17 @@ func runWebhookAdd(c *Ctx, args []string) int {
7180 // Exit 1 carries the reason to every client verbatim (#187).
7281 return c.fail(protocol.ExitFailure, "%v", err)
7382 }
83 secret := ""
84 if f.Has("--secret") {
85 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
86 if err != nil {
87 return c.fail(protocol.ExitFailure, "reading secret: %v", err)
88 }
89 secret = strings.TrimRight(string(raw), "\n")
90 if secret == "" {
91 return c.fail(protocol.ExitUsage, "no secret on stdin (pipe it: printf %%s SECRET | ... --secret -)")
92 }
93 }
7494 id, err := c.Store.AddWebhook(repo.ID, url, secret, events)
7595 if err != nil {
7696 return c.fail(protocol.ExitFailure, "%v", err)
internal/control/webhook_test.go +39
@@ -39,3 +39,42 @@ func TestWebhookAddRefusedURLIsAFailure(t *testing.T) {
3939 t.Errorf("missing url: exit %d, want %d", code, protocol.ExitUsage)
4040 }
4141}
42
43// The signing secret arrives on stdin with --secret -, like a build
44// secret: a value on the command line is refused before anything is
45// stored, since argv shows in /proc and in shell history (#284).
46func TestWebhookAddSecretFromStdin(t *testing.T) {
47 st, repo, uid := newQueueTestRepo(t)
48 run := func(stdin string, argv ...string) (string, int) {
49 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
50 c.Cfg.Limits.WriteRate = -1
51 c.Cfg.Webhooks.AllowLocal = true
52 c.Stdin = strings.NewReader(stdin)
53 code := Dispatch(c, argv)
54 return errOut.String(), code
55 }
56 msg, code := run("", "webhook", "add", repo.Path(), "http://127.0.0.1/hook", "--secret", "s3cret")
57 if code != protocol.ExitUsage || !strings.Contains(msg, "--secret -") {
58 t.Fatalf("literal secret: exit %d, %q", code, msg)
59 }
60 if msg, code := run("", "webhook", "add", repo.Path(), "http://127.0.0.1/hook", "--secret", "-"); code != protocol.ExitUsage || !strings.Contains(msg, "no secret on stdin") {
61 t.Fatalf("empty stdin: exit %d, %q", code, msg)
62 }
63 if hooks, err := st.ListWebhooks(repo.ID); err != nil || len(hooks) != 0 {
64 t.Fatalf("a refused add stored %+v (%v)", hooks, err)
65 }
66 if msg, code := run("s3cret\n", "webhook", "add", repo.Path(), "http://127.0.0.1/hook", "--secret", "-"); code != protocol.ExitOK {
67 t.Fatalf("piped secret: exit %d, %q", code, msg)
68 }
69 // Without --secret nothing reads stdin and the hook is unsigned.
70 if msg, code := run("not a secret\n", "webhook", "add", repo.Path(), "http://127.0.0.1/other"); code != protocol.ExitOK {
71 t.Fatalf("no secret: exit %d, %q", code, msg)
72 }
73 hooks, err := st.ListWebhooks(repo.ID)
74 if err != nil || len(hooks) != 2 {
75 t.Fatalf("hooks: %+v %v", hooks, err)
76 }
77 if hooks[0].Secret != "s3cret" || hooks[1].Secret != "" {
78 t.Fatalf("secrets: %q, %q", hooks[0].Secret, hooks[1].Secret)
79 }
80}