From aa600cebbb7bdd9d91a2640221c219b60b0fe286 Mon Sep 17 00:00:00 2001 From: 0xSacul <63975424+0xSacul@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:47:36 +0100 Subject: [PATCH] Accept webhook URLs pasted twice or wrapped in quotes The setup input field reads the clipboard on Ctrl+V by itself. In a terminal that also pastes the text and forwards the key, the URL ends up in the masked field twice ("...TOKENhttps://discord.com/..."), which failed validation with "that does not look like a Discord webhook URL" even though the copied URL was valid. Reported by a customer; reproduced by feeding the field a paste plus Ctrl+V (168 characters for an 84-character URL). NormalizeWebhookURL now extracts the webhook from pasted text: the same URL repeated, surrounding whitespace, quotes, and angle brackets are accepted; two different URLs, a thread_id query, or other extra text are rejected with a specific message. Setup confirms the URL it understood in masked form and notes when it was pasted more than once. The scripted --webhook-url-stdin path uses the same normalization. --- internal/cli/notify_cmd.go | 4 +- internal/cli/notify_cmd_test.go | 13 ++++++ internal/notify/discord.go | 38 ++++++++++++++++ internal/notify/normalize_test.go | 65 +++++++++++++++++++++++++++ internal/wizard/notifications.go | 8 +++- internal/wizard/notifications_test.go | 24 ++++++++++ 6 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 internal/notify/normalize_test.go diff --git a/internal/cli/notify_cmd.go b/internal/cli/notify_cmd.go index ae86251..93fb2ae 100644 --- a/internal/cli/notify_cmd.go +++ b/internal/cli/notify_cmd.go @@ -128,9 +128,11 @@ func runNotify(ctx context.Context, args []string, stdin io.Reader, stdout, stde if webhook == "" { return withCode(exitUsage, errors.New("provide the webhook URL with --webhook-url-stdin, or run this command in a terminal")) } - if err := notify.ValidateWebhookURL(webhook); err != nil { + normalized, _, err := notify.NormalizeWebhookURL(webhook) + if err != nil { return withCode(exitUsage, err) } + webhook = normalized discord.Enabled = true if *events != "" { parsed, parseErr := parseNotifyEvents(*events) diff --git a/internal/cli/notify_cmd_test.go b/internal/cli/notify_cmd_test.go index 612c06a..971eca4 100644 --- a/internal/cli/notify_cmd_test.go +++ b/internal/cli/notify_cmd_test.go @@ -174,3 +174,16 @@ type failingSender struct{} func (failingSender) Send(context.Context, notify.Event) error { return errors.New("Discord is unavailable") } + +func TestNotifyDiscordStdinAcceptsDoublePastedWebhook(t *testing.T) { + paths := notifyTestPaths(t) + stubNotifySend(t, nil) + code, _, stderr := runNotifyCommand(t, paths, cliTestWebhook+cliTestWebhook+"\n", "discord", "--webhook-url-stdin") + if code != exitSuccess { + t.Fatalf("exit %d: %s", code, stderr) + } + credentials, _ := config.LoadCredentials(paths.Credentials) + if credentials.Notifications.DiscordWebhookURL != cliTestWebhook { + t.Fatalf("stored webhook = %q", credentials.Notifications.DiscordWebhookURL) + } +} diff --git a/internal/notify/discord.go b/internal/notify/discord.go index 8abd1e0..f4e1db4 100644 --- a/internal/notify/discord.go +++ b/internal/notify/discord.go @@ -56,6 +56,44 @@ func ValidateWebhookURL(raw string) error { return nil } +var webhookInText = regexp.MustCompile(`(?i:https://(?:(?:ptb|canary)\.)?discord(?:app)?\.com)/api/(?:v[0-9]+/)?webhooks/[0-9]{15,22}/[A-Za-z0-9_.-]{20,}`) + +// NormalizeWebhookURL extracts the webhook URL from pasted text. Terminals +// can deliver a paste twice (the terminal pastes and the input field also +// handles Ctrl+V), and people copy URLs with quotes or angle brackets. +func NormalizeWebhookURL(raw string) (webhook string, duplicated bool, err error) { + // Separate URLs that were glued together ("...TOKENhttps://..."). + text := strings.ReplaceAll(raw, "https://", " https://") + matches := webhookInText.FindAllString(text, -1) + if len(matches) == 0 { + trimmed := strings.Trim(strings.TrimSpace(raw), "<>\"'`") + if strings.Count(strings.ToLower(trimmed), "https://") > 1 { + return "", false, errors.New("the pasted text contains more than one URL; paste the webhook URL once") + } + return "", false, ValidateWebhookURL(trimmed) + } + for _, match := range matches[1:] { + if match != matches[0] { + return "", false, errors.New("two different webhook URLs were pasted; paste only the one you want to use") + } + } + leftover := text + for _, match := range matches { + leftover = strings.Replace(leftover, match, "", 1) + } + leftover = strings.Trim(leftover, " \t\r\n<>\"'`") + switch { + case strings.HasPrefix(leftover, "?"): + return "", false, errors.New("webhook URLs with options after \"?\" (such as thread_id) are not supported; use the plain webhook URL") + case leftover != "": + return "", false, errors.New("the webhook URL has extra text around it; paste only the URL copied from Discord") + } + if err := ValidateWebhookURL(matches[0]); err != nil { + return "", false, err + } + return matches[0], len(matches) > 1, nil +} + // MaskWebhookURL shows enough of a webhook to recognise it without exposing // its token. func MaskWebhookURL(raw string) string { diff --git a/internal/notify/normalize_test.go b/internal/notify/normalize_test.go new file mode 100644 index 0000000..fff4c73 --- /dev/null +++ b/internal/notify/normalize_test.go @@ -0,0 +1,65 @@ +package notify + +import ( + "strings" + "testing" +) + +func TestNormalizeWebhookURLAcceptsCommonPastes(t *testing.T) { + cases := map[string]struct { + input string + duplicated bool + }{ + "plain": {testWebhook, false}, + "surrounding whitespace": {" " + testWebhook + " \r\n", false}, + "angle brackets": {"<" + testWebhook + ">", false}, + "quotes": {`"` + testWebhook + `"`, false}, + "pasted twice glued": {testWebhook + testWebhook, true}, + "pasted twice with a space": {testWebhook + " " + testWebhook, true}, + "pasted three times": {strings.Repeat(testWebhook, 3), true}, + "uppercase host": {strings.Replace(testWebhook, "discord.com", "Discord.com", 1), false}, + "canary host": {strings.Replace(testWebhook, "discord.com", "canary.discord.com", 1), false}, + "versioned api": {strings.Replace(testWebhook, "/api/", "/api/v10/", 1), false}, + } + for name, testCase := range cases { + got, duplicated, err := NormalizeWebhookURL(testCase.input) + if err != nil { + t.Errorf("%s: unexpected error %v", name, err) + continue + } + if ValidateWebhookURL(got) != nil || strings.Contains(got, " ") || strings.Count(got, "https://") != 1 { + t.Errorf("%s: normalized URL is not a single valid webhook: %q", name, got) + } + if duplicated != testCase.duplicated { + t.Errorf("%s: duplicated = %v, want %v", name, duplicated, testCase.duplicated) + } + } +} + +func TestNormalizeWebhookURLRejectsAmbiguousInput(t *testing.T) { + other := strings.Replace(testWebhook, "123456789012345678", "876543210987654321", 1) + cases := map[string]struct { + input string + message string + }{ + "two different webhooks": {testWebhook + other, "two different"}, + "thread query": {testWebhook + "?thread_id=123", "thread_id"}, + "trailing text": {testWebhook + " please", "extra text"}, + "not discord": {"https://example.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz", "discord.com"}, + "truncated token": {"https://discord.com/api/webhooks/123456789012345678/short", "does not look like"}, + "two non-webhook URLs": {"https://discord.com/channels/1/2https://discord.com/channels/3/4", "more than one URL"}, + } + for name, testCase := range cases { + _, _, err := NormalizeWebhookURL(testCase.input) + if err == nil { + t.Errorf("%s: expected an error", name) + continue + } + if !strings.Contains(err.Error(), testCase.message) { + t.Errorf("%s: error %q does not mention %q", name, err, testCase.message) + } + if strings.Contains(err.Error(), "secret") { + t.Errorf("%s: error leaks the token: %v", name, err) + } + } +} diff --git a/internal/wizard/notifications.go b/internal/wizard/notifications.go index de39f8d..7f3ef36 100644 --- a/internal/wizard/notifications.go +++ b/internal/wizard/notifications.go @@ -67,10 +67,16 @@ func ConfigureDiscord(ctx context.Context, prompt *Prompter, current config.Disc } continue } - if err := notify.ValidateWebhookURL(webhook); err != nil { + normalized, duplicated, err := notify.NormalizeWebhookURL(webhook) + if err != nil { prompt.Fail(err.Error()) continue } + webhook = normalized + if duplicated { + prompt.Note("The URL was pasted more than once; using it once.") + } + prompt.Success("Webhook " + notify.MaskWebhookURL(webhook)) presetIndex := 0 for index, preset := range eventPresets { diff --git a/internal/wizard/notifications_test.go b/internal/wizard/notifications_test.go index d3ec247..d45abaf 100644 --- a/internal/wizard/notifications_test.go +++ b/internal/wizard/notifications_test.go @@ -91,3 +91,27 @@ func TestConfigureDiscordFailedTestCanBeSkipped(t *testing.T) { t.Fatalf("output:\n%s", output.String()) } } + +func TestConfigureDiscordAcceptsDoublePastedWebhook(t *testing.T) { + var output bytes.Buffer + var sentTo string + prompt := NewPrompter(strings.NewReader(wizardTestWebhook+wizardTestWebhook+"\n\n\n"), &output) + setup, err := ConfigureDiscord(context.Background(), prompt, config.DiscordNotifications{}, "", + func(_ context.Context, webhook string, _ config.DiscordNotifications, _ notify.Event) error { + sentTo = webhook + return nil + }) + if err != nil { + t.Fatalf("double-pasted webhook was rejected: %v\n%s", err, output.String()) + } + if setup.WebhookURL != wizardTestWebhook || sentTo != wizardTestWebhook { + t.Fatalf("webhook = %q, test sent to %q", setup.WebhookURL, sentTo) + } + text := output.String() + if !strings.Contains(text, "pasted more than once") || !strings.Contains(text, "/api/webhooks/123456789012345678/") { + t.Fatalf("missing duplicate note or masked confirmation:\n%s", text) + } + if strings.Contains(text, "secret-token") { + t.Fatal("webhook token was shown") + } +}