Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"@types/convert-source-map": "^2.0.3",
"@types/css-tree": "^2.3.11",
"@types/eslint-config-prettier": "^6.11.3",
"@types/estree": "^1.0.9",
"@types/gtag.js": "^0.0.20",
"@types/is-url": "^1.2.32",
"@types/jquery": "^4.0.0",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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
78 changes: 77 additions & 1 deletion web/src/portico/integrations_dev_panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const integrations_api_response_schema = z.object({

type ServerResponse = z.infer<typeof integrations_api_response_schema>;

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

const loaded_fixtures = new Map<string, Fixtures>();
const url_base = "/api/v1/external/";

Expand Down Expand Up @@ -231,11 +233,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 && Object.hasOwn(headers_object, last_computed_header_key)) {
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: string;

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 +514,6 @@ $(() => {
$("#stream_name").on("change", update_url);

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

$("#webhook_secret").on("change", update_url);
});
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
8 changes: 8 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 @@ -390,6 +393,11 @@ def _wrapped_func_arguments(
allow_webhook_access=True,
client_name=full_webhook_client_name(webhook_client_name),
)
validate_webhook_signature(
request,
user_profile,
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 @@ -12,7 +12,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 @@ -1196,6 +1201,15 @@ def is_enabled_in_catalog(self) -> bool:
| hubot_integration_names
)

WEBHOOK_SIGNATURE_CONFIGS: dict[str, WebhookSignatureConfig] = {
"github": WebhookSignatureConfig(
integration_name="github",
header="X_HUB_SIGNATURE_256",
algorithm="sha256",
prefix="sha256=",
),
}

# Add integrations that are not meant to have example screenshots here
INTEGRATIONS_WITHOUT_SCREENSHOTS = (
# Integration frameworks
Expand Down
49 changes: 48 additions & 1 deletion zerver/lib/test_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from django.test.testcases import SerializeMixin
from django.urls import resolve
from django.utils import translation
from django.utils.encoding import force_bytes
from django.utils.module_loading import import_string
from django.utils.timezone import now as timezone_now
from fakeldap import MockLDAP
Expand All @@ -54,9 +55,11 @@
from zerver.actions.user_settings import do_change_full_name, do_change_user_setting
from zerver.actions.users import do_change_user_role
from zerver.decorator import do_two_factor_login
from zerver.lib.bot_config import set_bot_config
from zerver.lib.cache import bounce_key_prefix_for_testing
from zerver.lib.email_notifications import MissedMessageData, handle_missedmessage_emails
from zerver.lib.initial_password import initial_password
from zerver.lib.integrations import WEBHOOK_SIGNATURE_CONFIGS
from zerver.lib.mdiff import diff_strings
from zerver.lib.message import access_message
from zerver.lib.notification_data import UserMessageNotificationsData
Expand Down Expand Up @@ -92,8 +95,10 @@
from zerver.lib.upload import upload_message_attachment_from_request
from zerver.lib.user_groups import get_system_user_group_for_user
from zerver.lib.webhooks.common import (
WEBHOOK_SECRET_TOKEN_KEY,
call_fixture_to_headers,
check_send_webhook_message,
compute_webhook_signature,
standardize_headers,
)
from zerver.models import (
Expand Down Expand Up @@ -2544,6 +2549,7 @@ class WebhookTestCase(ZulipTestCase):
DEFAULT_URL_TEMPLATE: str = (
"/api/v1/external/{webhook_dir_name}?stream={stream}&api_key={api_key}"
)
WEBHOOK_TEST_SECRET: str | None = None

def get_webhook_dir_name(self) -> str:
module_parts = self.__module__.split(".")
Expand Down Expand Up @@ -2650,16 +2656,52 @@ def check_webhook(
"""
self.subscribe(self.test_user, self.channel_name)

url = getattr(self, "url", None)
if url is None:
url = self.build_webhook_url() # nocoverage

webhook_secret = getattr(self, "WEBHOOK_TEST_SECRET", None)
config = WEBHOOK_SIGNATURE_CONFIGS.get(self.webhook_dir_name.lower())

if webhook_secret is not None and config is None:
raise AssertionError(
f"WEBHOOK_TEST_SECRET was set for '{self.webhook_dir_name}', "
f"but no WebhookSignatureConfig is registered in WEBHOOK_SIGNATURE_CONFIGS."
)

payload = self.get_payload(fixture_name)
if content_type is not None:
extra["content_type"] = content_type

if webhook_secret is not None and config is not None:
set_bot_config(
self.test_user,
WEBHOOK_SECRET_TOKEN_KEY.format(integration_name=self.webhook_dir_name.lower()),
webhook_secret,
)

try:
raw_payload = self.get_body(fixture_name)
except FileNotFoundError: # nocoverage
raw_payload = ""

header_val = compute_webhook_signature(
force_bytes(webhook_secret),
force_bytes(raw_payload),
config,
)

django_header = "HTTP_" + config.header.upper().replace("-", "_")
if django_header not in extra:
extra[django_header] = header_val

headers = call_fixture_to_headers(self.webhook_dir_name, fixture_name)
headers = standardize_headers(headers)
extra.update(headers)
try:
msg = self.send_webhook_payload(
self.test_user,
self.url,
url,
payload,
**extra,
)
Expand Down Expand Up @@ -2715,6 +2757,11 @@ def send_and_test_private_message(
Most webhooks send to streams, and you will want to look at
check_webhook.
"""

webhook_secret = getattr(self, "WEBHOOK_TEST_SECRET", None)
if webhook_secret is not None:
set_bot_config(self.test_user, "webhook_secret", webhook_secret) # nocoverage

payload = self.get_payload(fixture_name)
extra["content_type"] = content_type

Expand Down
Loading