Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
66adf33
webhooks: Add validate_webhook_delivery validation helper.
Srinandha-Murugesan Jul 23, 2026
c0374d1
tests: Support WEBHOOK_TEST_SECRET in WebhookTestCase class.
Srinandha-Murugesan Jul 23, 2026
4c02b64
integrations_dev_panel: Add webhook secret UI options and sync.
Srinandha-Murugesan Jul 23, 2026
188ee49
integrations_dev_panel: Add recalculate_signature endpoint.
Srinandha-Murugesan Jul 23, 2026
af96c9f
github: Enforce webhook signature check and register dev route.
Srinandha-Murugesan Jul 23, 2026
c38e26f
integrations: Avoid leaking exception details in signature response.
Srinandha-Murugesan Jul 23, 2026
699c79f
Fixed overall parsing and UI to not use url param
Srinandha-Murugesan Jul 23, 2026
dc832df
webhooks: Store incoming webhook secrets in BotConfigData.
Jul 24, 2026
33c39e4
Fixed mypy errors and merge conflicts
Srinandha-Murugesan Jul 24, 2026
960a8d8
tests: Added test cases for creating incoming webhook bot with and wi…
Jul 24, 2026
cc7c4a0
Cleared up frontend and test case issues
Srinandha-Murugesan Jul 27, 2026
ec3287c
Cleared up frontend and test case issues
Srinandha-Murugesan Jul 27, 2026
a647b54
webhooks: Add back the feature to edit the secret.
JDoe-code Jul 28, 2026
a163475
bots: Add test cases for updating, adding, or clearing a webhook secr…
Jul 28, 2026
cf93405
webhooks: Add the ability to delete a secret.
JDoe-code Jul 28, 2026
e0c5b7a
webhooks: Scoping down webhook verification.
JDoe-code Aug 5, 2026
b8c181f
webhooks: Add integration_name preffix to webhook secret key
Aug 5, 2026
facef55
tests: fix test_validate_webhook_delivery test from failing
Aug 5, 2026
f9eb9a2
Modified validation to go into webhook view
Srinandha-Murugesan Aug 6, 2026
fef0cc8
Connected test cases + api logic to new data class
Srinandha-Murugesan Aug 6, 2026
6141cc2
Reworked integration dev panel to use new dataclass
Srinandha-Murugesan Aug 7, 2026
be61b19
tests: Add test for missing coverage within validate_webhook_delivery…
Aug 7, 2026
2671933
fixed lint error
Aug 8, 2026
7789cc2
webhooks: Combined webhook validation functions.
JDoe-code Aug 12, 2026
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: 4 additions & 0 deletions templates/zerver/development/integrations_dev_panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
<label class="optional"><b>Topic</b></label>
<input id="topic_name" type="text" />
</div>
<div>
<label class="optional"><b>Webhook Secret</b></label>
<input id="webhook_secret" type="text" />
</div>
</div>

<br />
Expand Down
3 changes: 2 additions & 1 deletion web/src/bot_type_values.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Bot type integer values from the API.
export const GENERIC_BOT_TYPE = 1;
export const GENERIC_BOT_TYPE_INT = 1;
export const INCOMING_WEBHOOK_BOT_TYPE_INT = 2;
export const OUTGOING_WEBHOOK_BOT_TYPE_INT = 3;

// String forms used as HTML form values.
export const GENERIC_BOT_TYPE = "1";
export const OUTGOING_WEBHOOK_BOT_TYPE = "3";
export const EMBEDDED_BOT_TYPE = "4";
86 changes: 83 additions & 3 deletions web/src/portico/integrations_dev_panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type HTMLSelectOneElement = HTMLSelectElement & {type: "select-one"};
type ClearHandlers = {
stream_name: string;
topic_name: string;
webhook_secret: string;
URL: string;
results_notice: string;
bot_name: () => void;
Expand All @@ -47,6 +48,8 @@ const integrations_api_response_schema = z.object({
result: z.string(),
});

let last_computed_header_key: string | null = null; // Tracks the current signature header for auto-clearing when switching integrations

type ServerResponse = z.infer<typeof integrations_api_response_schema>;

const loaded_fixtures = new Map<string, Fixtures>();
Expand All @@ -56,6 +59,7 @@ const url_base = "/api/v1/external/";
const clear_handlers: ClearHandlers = {
stream_name: "#stream_name",
topic_name: "#topic_name",
webhook_secret: "#webhook_secret",
URL: "#URL",
results_notice: "#results_notice",
bot_name() {
Expand Down Expand Up @@ -180,6 +184,8 @@ function load_fixture_body(fixture_name: string): void {
null,
4,
);
const webhook_secret = $<HTMLInputElement>("input#webhook_secret").val()!;
sync_signature_headers(integration_name, webhook_secret);

return;
}
Expand Down Expand Up @@ -210,8 +216,8 @@ function load_fixture_options(integration_name: string): void {

function update_url(): void {
/* Construct the URL that the webhook should be targeting, using
the bot's API key and the integration name. The stream and topic
are both optional, and for the sake of completeness, it should be
the bot's API key, the integration name, and webhook secret. The stream, topic,
and webhook secret are all optional, and for the sake of completeness, it should be
noted that the topic is irrelevant without specifying the
stream. */
const url_field = $<HTMLInputElement>("input#URL")[0];
Expand All @@ -231,11 +237,83 @@ function update_url(): void {
params.set("topic", topic_name);
}
}
const webhook_secret = $<HTMLInputElement>("input#webhook_secret").val()!;
const url = `${url_base}${integration_name}?${params.toString()}`;
url_field!.value = url;

sync_signature_headers(integration_name, webhook_secret);
}
}

return;
function sync_signature_headers(integration_name: string, webhook_secret: string): void {
const $custom_headers_field = $<HTMLTextAreaElement>("textarea#custom_http_headers");
const current_headers_raw = $custom_headers_field.val()?.toString().trim() ?? "";

let headers_object: Record<string, string> = {};
if (current_headers_raw !== "") {
try {
headers_object = z
.record(z.string(), z.string())
.parse(JSON.parse(current_headers_raw));
} catch {
headers_object = {};
}
}

if (last_computed_header_key && last_computed_header_key in headers_object) {
Reflect.deleteProperty(headers_object, last_computed_header_key);
}

if (webhook_secret.trim() === "") {
last_computed_header_key = null;
if (Object.keys(headers_object).length === 0) {
$custom_headers_field.val("{}");
} else {
$custom_headers_field.val(JSON.stringify(headers_object, null, 4));
}
return;
}

const raw_payload = $<HTMLTextAreaElement>("textarea#fixture_body").val() ?? "";
let cleaned_payload = raw_payload;

try {
cleaned_payload = JSON.stringify(JSON.parse(raw_payload));
} catch {
cleaned_payload = raw_payload.trim();
}

channel.post({
url: "/devtools/integrations/recalculate_signature",
data: JSON.stringify({
secret: webhook_secret,
payload: cleaned_payload,
integration_name,
}),
success(raw_data: unknown) {
const data = z
.object({
supported: z.optional(z.boolean()),
clear_signature: z.optional(z.boolean()),
header_key: z.string(),
signature: z.string(),
})
.parse(raw_data);

if (!data.supported || data.clear_signature) {
last_computed_header_key = null;
if (Object.keys(headers_object).length === 0) {
$custom_headers_field.val("{}");
} else {
$custom_headers_field.val(JSON.stringify(headers_object, null, 4));
}
} else {
headers_object[data.header_key] = data.signature;
last_computed_header_key = data.header_key;
$custom_headers_field.val(JSON.stringify(headers_object, null, 4));
}
},
});
}

// API callers: These methods handle communicating with the Python backend API.
Expand Down Expand Up @@ -440,4 +518,6 @@ $(() => {
$("#stream_name").on("change", update_url);

$("#topic_name").on("change", update_url);

$("#webhook_secret").on("change", update_url);
});
20 changes: 10 additions & 10 deletions web/src/settings_bots.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import $ from "jquery";
import {$} from "jquery";
import assert from "minimalistic-assert";
import type * as tippy from "tippy.js";

Expand All @@ -12,7 +12,7 @@ import type {Bot} from "./bot_data.ts";
import * as bot_helper from "./bot_helper.ts";
import {
EMBEDDED_BOT_TYPE,
GENERIC_BOT_TYPE,
GENERIC_BOT_TYPE_INT,
INCOMING_WEBHOOK_BOT_TYPE_INT,
OUTGOING_WEBHOOK_BOT_TYPE,
OUTGOING_WEBHOOK_BOT_TYPE_INT,
Expand Down Expand Up @@ -455,7 +455,7 @@ function bot_info(bot_user_id: number): BotInfo {
: {
bot_owner_id: null,
}),
show_download_zuliprc_button: is_bot_owner && bot_user.bot_type === GENERIC_BOT_TYPE,
show_download_zuliprc_button: is_bot_owner && bot_user.bot_type === GENERIC_BOT_TYPE_INT,
show_generate_integration_url_button:
can_modify_bot && bot_user.bot_type === INCOMING_WEBHOOK_BOT_TYPE_INT,
};
Expand Down Expand Up @@ -742,11 +742,11 @@ function set_up_bot_handlers($container: JQuery): void {
add_a_new_bot();
});

$container.find(".download-botserverrc-file").on("click", (e) => {
$container.find(".download-botserverrc-file").on("click", function () {
void (async () => {
let content = "";
buttons.show_button_loading_indicator($(e.currentTarget));
$(e.currentTarget).prop("disabled", true);
buttons.show_button_loading_indicator($(this));
$(this).prop("disabled", true);
for (const bot of bot_data.get_all_bots_for_current_user()) {
if (bot.is_active && bot.bot_type === OUTGOING_WEBHOOK_BOT_TYPE_INT) {
const bot_token = bot_helper.get_outgoing_webhook_token(bot.user_id);
Expand All @@ -755,15 +755,15 @@ function set_up_bot_handlers($container: JQuery): void {
$("#admin-your-bots-list .bot-list-error"),
);
if (!api_key) {
buttons.hide_button_loading_indicator($(e.currentTarget));
$(e.currentTarget).prop("disabled", false);
buttons.hide_button_loading_indicator($(this));
$(this).prop("disabled", false);
return;
}
content += generate_botserverrc_content(bot.email, api_key, bot_token);
}
}
buttons.hide_button_loading_indicator($(e.currentTarget));
$(e.currentTarget).prop("disabled", false);
buttons.hide_button_loading_indicator($(this));
$(this).prop("disabled", false);

$container
.find(".hidden-botserverrc-download")
Expand Down
9 changes: 7 additions & 2 deletions web/src/user_profile.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import ClipboardJS from "clipboard";
import {parseISO} from "date-fns";
import $ from "jquery";
import {parseOneAddress} from "email-addresses";
import {$} from "jquery";
import _ from "lodash";
import assert from "minimalistic-assert";
import type * as tippy from "tippy.js";
Expand Down Expand Up @@ -850,7 +851,11 @@ export function show_edit_bot_info_modal(user_id: number, $container: JQuery): v

assert(bot.is_bot);
// Extract short_name from email (format: {short_name}-bot@domain)
const short_name = bot.email.split("@")[0]!.slice(0, -4);
const parsed_address = parseOneAddress(bot.email);
assert(parsed_address?.type === "mailbox");
const short_name = parsed_address.local.endsWith("-bot")
? parsed_address.local.slice(0, -"-bot".length)
: parsed_address.local;
const modal_content_html = render_edit_bot_form({
user_id,
is_active,
Expand Down
3 changes: 2 additions & 1 deletion web/styles/portico/integrations_dev_panel.css
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@
}

#stream_name,
#topic_name {
#topic_name,
#webhook_secret {
width: 206px;
}

Expand Down
4 changes: 2 additions & 2 deletions zerver/actions/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -827,15 +827,15 @@ def do_update_outgoing_webhook_service(
def do_update_bot_config_data(bot_profile: UserProfile, config_data: dict[str, str]) -> None:
for key, value in config_data.items():
set_bot_config(bot_profile, key, value)
updated_config_data = get_bot_config(bot_profile)
service_dicts = get_service_dicts_for_bot(bot_profile.id)
send_event_on_commit(
bot_profile.realm,
dict(
type="realm_bot",
op="update",
bot=dict(
user_id=bot_profile.id,
services=[dict(config_data=updated_config_data)],
services=service_dicts,
),
),
bot_owner_user_ids(bot_profile),
Expand Down
10 changes: 10 additions & 0 deletions zerver/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@
from zerver.lib.utils import has_api_key_format
from zerver.lib.webhooks.common import (
MissingHTTPEventHeaderError,
WebhookSignatureConfig,
notify_bot_owner_about_invalid_json,
validate_webhook_signature,
)
from zerver.models import UserProfile
from zerver.models.clients import get_client
Expand Down Expand Up @@ -372,6 +374,7 @@ def webhook_view(
webhook_client_name: str,
notify_bot_owner_on_invalid_json: bool = True,
all_event_types: Sequence[str] | None = None,
signature_config: WebhookSignatureConfig | None = None,
) -> Callable[[Callable[..., HttpResponse]], Callable[..., HttpResponse]]:
# Unfortunately, callback protocols are insufficient for this:
# https://mypy.readthedocs.io/en/stable/protocols.html#callback-protocols
Expand All @@ -391,6 +394,13 @@ def _wrapped_func_arguments(
client_name=full_webhook_client_name(webhook_client_name),
)

if signature_config and settings.VERIFY_WEBHOOK_SIGNATURES:
validate_webhook_signature(
user_profile,
request,
signature_config,
)

request_notes = RequestNotes.get_notes(request)
request_notes.is_webhook_view = True

Expand Down
16 changes: 15 additions & 1 deletion zerver/lib/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
from typing_extensions import override

from zerver.lib.storage import static_path
from zerver.lib.webhooks.common import PresetUrlOption, WebhookConfigOption, WebhookUrlOption
from zerver.lib.webhooks.common import (
PresetUrlOption,
WebhookConfigOption,
WebhookSignatureConfig,
WebhookUrlOption,
)
from zerver.webhooks import fixtureless_integrations

"""This module declares all of the (documented) integrations available
Expand Down Expand Up @@ -1187,6 +1192,15 @@ def is_enabled_in_catalog(self) -> bool:
}
)

WEBHOOK_SIGNATURE_CONFIGS: dict[str, WebhookSignatureConfig] = {
"github": WebhookSignatureConfig(
integration_name="github",
header="X-Hub-Signature-256",
algorithm="sha256",
prefix="sha256=",
),
}

NO_SCREENSHOT_CONFIG = INTEGRATIONS_MISSING_SCREENSHOT_CONFIG | INTEGRATIONS_WITHOUT_SCREENSHOTS


Expand Down
Loading