Skip to content

Outgoing webhook sender#186

Merged
rbnswartz merged 18 commits intodevelopfrom
rs/webhook-sender
Mar 23, 2026
Merged

Outgoing webhook sender#186
rbnswartz merged 18 commits intodevelopfrom
rs/webhook-sender

Conversation

@rbnswartz
Copy link
Copy Markdown
Member

@rbnswartz rbnswartz commented Feb 3, 2026

This pull request introduces a robust outgoing webhook infrastructure for the pipeline, enabling registration, deletion, listing, and resilient dispatching of webhook notifications. The main changes include implementing Azure Table Storage-backed webhook management, providing HTTP endpoints for webhook registration and deletion, and adding a resilient dispatcher for outgoing webhook messages.

Webhook Infrastructure Implementation

  • Added IWebhookService interface and AzureStorageWebhookStorage implementation for managing webhook registration, deletion, existence checks, and listing, backed by Azure Table Storage (PipelineCommon/IWebhookService.cs, PipelineCommon/Helpers/AzureStorageWebhookStorage.cs). [1] [2]
  • Introduced WebhookDefinition and response models for registration and deletion operations (PipelineCommon/Models/WebhookDefinition.cs, PipelineCommon/Models/Webhook/WebhookResponses.cs). [1] [2]

HTTP Endpoint Integration

  • Implemented Azure Functions HTTP endpoints for webhook registration (RegisterWebhook) and deletion (UnregisterWebhook), including validation and error handling (ScriptureRenderingPipeline/OutgoingWebhook.cs).

Webhook Dispatching and Resilience

  • Added WebhookDispatcher class for sending messages to registered webhooks, using Polly-based retry and resilience strategies to handle transient failures (ScriptureRenderingPipelineWorker/WebhookDispatcher.cs).
  • Registered webhook services and dispatcher in worker and pipeline startup, and configured Azure Table client for webhook storage (ScriptureRenderingPipeline/Program.cs, ScriptureRenderingPipelineWorker/Program.cs). [1] [2]

Dependency and Project Configuration

  • Added necessary NuGet packages for Azure Table Storage and resilience, and updated project files to support new functionality (PipelineCommon/PipelineCommon.csproj, ScriptureRenderingPipeline/ScriptureRenderingPipeline.csproj, ScriptureRenderingPipelineWorker/ScriptureRenderingPipelineWorker.csproj). [1] [2] [3]- WIP

@rbnswartz rbnswartz changed the title rs/webhook sender Outgoing webhook sender Feb 3, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds an outgoing webhook system so pipeline events (e.g., WACS events and repo analysis results) can be delivered to registered external endpoints.

Changes:

  • Added HTTP endpoints to register/unregister outgoing webhooks backed by Azure Table Storage.
  • Added worker Service Bus triggers + dispatcher to POST matching messages to registered webhook URLs.
  • Introduced shared webhook models/storage service in PipelineCommon, plus resilience/retry support for dispatching.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
ScriptureRenderingPipelineWorker/WebhookDispatcherTrigger.cs New Service Bus triggers to invoke webhook dispatch for WACS + repo analysis result messages.
ScriptureRenderingPipelineWorker/WebhookDispatcher.cs New dispatcher that filters registered webhooks and POSTs payloads with retry/resilience.
ScriptureRenderingPipelineWorker/ScriptureRenderingPipelineWorker.csproj Adds dependency needed for the resilience pipeline.
ScriptureRenderingPipelineWorker/Program.cs Registers Table client, HttpClient, and webhook services for the worker.
ScriptureRenderingPipeline/ScriptureRenderingPipeline.csproj Adds Azure Tables package for webhook storage support.
ScriptureRenderingPipeline/Program.cs Registers Table client and webhook storage service for the API app.
ScriptureRenderingPipeline/OutgoingWebhook.cs New HTTP functions to register and unregister webhook definitions.
PipelineCommon/PipelineCommon.csproj Adds Azure Tables + Azure client factory dependencies for shared webhook storage implementation.
PipelineCommon/Models/WebhookDefinition.cs New model representing a webhook subscription (URL + message/event type).
PipelineCommon/Models/Webhook/WebhookResponses.cs New response DTOs for webhook registration/deletion endpoints.
PipelineCommon/IWebhookService.cs New interface for webhook registration, deletion, existence checks, and listing.
PipelineCommon/Helpers/AzureStorageWebhookStorage.cs New Azure Table Storage implementation of IWebhookService.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ScriptureRenderingPipeline/Program.cs
Comment thread PipelineCommon/Helpers/AzureStorageWebhookStorage.cs Outdated
Comment thread ScriptureRenderingPipelineWorker/WebhookDispatcher.cs Outdated
Comment thread ScriptureRenderingPipeline/OutgoingWebhook.cs Outdated
Comment on lines +29 to +40
[Function("RegisterWebhook")]
public async Task<HttpResponseData> Register([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req)
{
try
{
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
var webhookDefinition = JsonSerializer.Deserialize<WebhookDefinition>(requestBody);

if (webhookDefinition == null || string.IsNullOrWhiteSpace(webhookDefinition.Url))
{
var badResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badResponse.WriteStringAsync("Invalid webhook definition: URL is required");
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

RegisterWebhook is AuthorizationLevel.Anonymous and persists arbitrary URLs provided by the caller. This enables unauthenticated modification of webhook configuration and can create an SSRF vector when the worker later POSTs to those URLs. Consider requiring at least Function auth (or other authn/z), and/or validating/allow-listing destinations (e.g., allowed hostnames, require HTTPS).

Suggested change
[Function("RegisterWebhook")]
public async Task<HttpResponseData> Register([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req)
{
try
{
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
var webhookDefinition = JsonSerializer.Deserialize<WebhookDefinition>(requestBody);
if (webhookDefinition == null || string.IsNullOrWhiteSpace(webhookDefinition.Url))
{
var badResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badResponse.WriteStringAsync("Invalid webhook definition: URL is required");
private static bool IsValidWebhookUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
{
return false;
}
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
return false;
}
// Only allow HTTPS webhooks to reduce risk of SSRF and ensure transport security.
return string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
}
[Function("RegisterWebhook")]
public async Task<HttpResponseData> Register([HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
{
try
{
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
var webhookDefinition = JsonSerializer.Deserialize<WebhookDefinition>(requestBody);
if (webhookDefinition == null || !IsValidWebhookUrl(webhookDefinition.Url))
{
var badResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badResponse.WriteStringAsync("Invalid webhook definition: a valid HTTPS URL is required");

Copilot uses AI. Check for mistakes.
Comment thread PipelineCommon/IWebhookService.cs Outdated
Comment thread PipelineCommon/Helpers/AzureStorageWebhookStorage.cs Outdated
Comment thread PipelineCommon/PipelineCommon.csproj Outdated
Comment thread ScriptureRenderingPipeline/OutgoingWebhook.cs Outdated
Comment thread ScriptureRenderingPipeline/OutgoingWebhook.cs Outdated
Comment thread ScriptureRenderingPipelineWorker/WebhookDispatcher.cs Outdated
Comment thread ScriptureRenderingPipelineWorker/WebhookDispatcher.cs Outdated
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 11 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ScriptureRenderingPipelineWorker/WebhookDispatcher.cs Outdated
Comment thread ScriptureRenderingPipelineWorker/WebhookDispatcher.cs Outdated
Comment thread ScriptureRenderingPipelineWorker/WebhookDispatcher.cs Outdated
Comment on lines +29 to +43
[Function("RegisterWebhook")]
public async Task<HttpResponseData> Register([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req)
{
try
{
using var reader = new StreamReader(req.Body);
var requestBody = await reader.ReadToEndAsync();
var webhookDefinition = JsonSerializer.Deserialize<WebhookDefinition>(requestBody);

if (webhookDefinition == null || string.IsNullOrWhiteSpace(webhookDefinition.Url))
{
var badResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badResponse.WriteStringAsync("Invalid webhook definition: URL is required");
return badResponse;
}
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

This endpoint is AuthorizationLevel.Anonymous and accepts an arbitrary Url that will later be POSTed to by the worker. That’s a direct SSRF/abuse vector (e.g., registering internal IPs/metadata endpoints) and also allows anyone to register/delete webhooks. Require authentication/authorization and add strict URL validation (https-only, host allowlist, block private/link-local IP ranges, etc.).

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +37
using var reader = new StreamReader(req.Body);
var requestBody = await reader.ReadToEndAsync();
var webhookDefinition = JsonSerializer.Deserialize<WebhookDefinition>(requestBody);

Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

JsonSerializer.Deserialize<WebhookDefinition>(requestBody) uses default options (case-sensitive by default). Clients sending conventional camelCase JSON (url, messageType, eventType) will fail to bind, and MessageType may silently fall back to the model default. Use a configured JsonSerializerOptions (e.g., case-insensitive) or add WebhookDefinition to a source-generated JsonSerializerContext and deserialize with that for consistent API behavior.

Copilot uses AI. Check for mistakes.
Comment thread ScriptureRenderingPipeline/OutgoingWebhook.cs
Comment thread ScriptureRenderingPipelineWorker/WebhookDispatcher.cs
Comment thread ScriptureRenderingPipelineWorker/WebhookDispatcherTrigger.cs Outdated
Comment thread ScriptureRenderingPipeline/OutgoingWebhook.cs Outdated
Comment thread ScriptureRenderingPipelineWorker/Program.cs
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 9 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ScriptureRenderingPipeline/Program.cs
Comment thread PipelineCommon/Models/WebhookDefinition.cs
Comment on lines +51 to +62
public async Task DispatchGenericMessageAsync<T>(string messageType, string eventType, T message)
{
try
{
var webhooks = await _webhookService.GetWebhooksAsync();

// Filter webhooks matching the event type
var matchingWebhooks = webhooks
.Where(w => string.Equals(w.EventType, eventType, StringComparison.OrdinalIgnoreCase) &&
string.Equals(w.MessageType, messageType, StringComparison.OrdinalIgnoreCase))
.ToList();

Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

New webhook dispatch logic is core behavior and would benefit from automated tests (e.g., filtering by MessageType+EventType, retry behavior, and that failed webhook calls don’t block others). There are existing NUnit tests under SRPTests; consider adding unit tests for this dispatcher and webhook storage integration points.

Copilot uses AI. Check for mistakes.
Comment thread ScriptureRenderingPipelineWorker/WebhookDispatcherTrigger.cs
Comment thread PipelineCommon/Helpers/AzureStorageWebhookStorage.cs
Comment thread ScriptureRenderingPipelineWorker/WebhookDispatcherTrigger.cs
Comment on lines +29 to +50
[Function("RegisterWebhook")]
public async Task<HttpResponseData> Register([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req)
{
try
{
using var reader = new StreamReader(req.Body);
var requestBody = await reader.ReadToEndAsync();
var webhookDefinition = JsonSerializer.Deserialize<WebhookDefinition>(requestBody);

if (webhookDefinition == null || string.IsNullOrWhiteSpace(webhookDefinition.Url))
{
var badResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badResponse.WriteStringAsync("Invalid webhook definition: URL is required");
return badResponse;
}

if (!AllowedMessageTypes.Any(i => i == webhookDefinition.MessageType))
{
var badResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badResponse.WriteStringAsync($"Invalid webhook definition: MessageType '{webhookDefinition.MessageType}' is not allowed");
return badResponse;
}
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

This endpoint is AuthorizationLevel.Anonymous and allows registering arbitrary webhook URLs. That enables SSRF and data exfiltration (the worker will POST internal data to attacker-controlled URLs). Require authentication (function key/AAD) and add URL validation/allowlisting (e.g., only https, block private IP ranges, optional domain allowlist) before persisting the webhook.

Copilot uses AI. Check for mistakes.
Comment thread PipelineCommon/Helpers/AzureStorageWebhookStorage.cs Outdated
Comment thread ScriptureRenderingPipeline/OutgoingWebhook.cs Outdated
@rbnswartz
Copy link
Copy Markdown
Member Author

@PurpleGuitar What do you think about the needing or not needing a secret function key to create and or delete webhooks?

@rbnswartz rbnswartz merged commit 13aef04 into develop Mar 23, 2026
6 checks passed
@rbnswartz
Copy link
Copy Markdown
Member Author

Ended up making the register webhook require a function key. We can remove that later if we want.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants