feat: outgoing webhooks
Owners and admins can point a project at other systems: a signed JSON POST (HMAC-SHA256 of the body) when tickets, comments, releases or sprints change, filtered by event or family. Deliveries are retried a couple of times, the last one is shown, and a ping can be sent from the settings. Private addresses are refused unless WEBHOOKS_ALLOW_PRIVATE is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import {
|
||||
OutgoingWebhook,
|
||||
OutgoingWebhooksRepository,
|
||||
WebhookDelivery,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { Model } from 'mongoose';
|
||||
import { OutgoingWebhookDoc, ReadModels, fromDoc } from './read-model.schemas';
|
||||
|
||||
@Injectable()
|
||||
export class MongoOutgoingWebhooksRepository extends OutgoingWebhooksRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.OutgoingWebhook)
|
||||
private readonly webhooks: Model<OutgoingWebhookDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async findByProject(projectId: string): Promise<OutgoingWebhook[]> {
|
||||
const docs = await this.webhooks
|
||||
.find({ projectId })
|
||||
.sort({ createdAt: 1 })
|
||||
.lean<OutgoingWebhookDoc[]>();
|
||||
return docs.map((doc) => fromDoc(doc) as OutgoingWebhook);
|
||||
}
|
||||
|
||||
async findActive(projectId: string): Promise<OutgoingWebhook[]> {
|
||||
const docs = await this.webhooks
|
||||
.find({ projectId, active: true })
|
||||
.lean<OutgoingWebhookDoc[]>();
|
||||
return docs.map((doc) => fromDoc(doc) as OutgoingWebhook);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<OutgoingWebhook | null> {
|
||||
const doc = await this.webhooks.findById(id).lean<OutgoingWebhookDoc>();
|
||||
return doc ? (fromDoc(doc) as OutgoingWebhook) : null;
|
||||
}
|
||||
|
||||
async insert({ id, ...rest }: OutgoingWebhook): Promise<void> {
|
||||
await this.webhooks.create({ _id: id, ...rest });
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
changes: Partial<Pick<OutgoingWebhook, 'url' | 'events' | 'active'>>,
|
||||
): Promise<void> {
|
||||
if (Object.keys(changes).length)
|
||||
await this.webhooks.updateOne({ _id: id }, { $set: changes });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.webhooks.deleteOne({ _id: id });
|
||||
}
|
||||
|
||||
async recordDelivery(id: string, delivery: WebhookDelivery): Promise<void> {
|
||||
await this.webhooks.updateOne(
|
||||
{ _id: id },
|
||||
{ $set: { lastDelivery: delivery } },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OutgoingWebhook } from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
ActivityReadModel,
|
||||
CommentReadModel,
|
||||
@@ -30,6 +31,7 @@ export type ActivityDoc = Doc<ActivityReadModel>;
|
||||
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
||||
export type NotificationDoc = Doc<NotificationReadModel>;
|
||||
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
|
||||
export type OutgoingWebhookDoc = Omit<OutgoingWebhook, 'id'> & { _id: string };
|
||||
export interface GitIntegrationDoc {
|
||||
_id: string;
|
||||
secret: string;
|
||||
@@ -52,6 +54,7 @@ export const ReadModels = {
|
||||
SavedFilter: 'SavedFilter',
|
||||
Sprint: 'SprintReadModel',
|
||||
GitIntegration: 'GitIntegration',
|
||||
OutgoingWebhook: 'OutgoingWebhook',
|
||||
} as const;
|
||||
|
||||
const userRef = { _id: false, id: String, name: String };
|
||||
@@ -203,6 +206,18 @@ export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
|
||||
{ ...loose, collection: 'git_integrations' },
|
||||
);
|
||||
|
||||
export const OutgoingWebhookSchema = new Schema<OutgoingWebhookDoc>(
|
||||
{
|
||||
_id: String,
|
||||
projectId: { type: String, index: true },
|
||||
active: Boolean,
|
||||
events: [String],
|
||||
createdBy: userRef,
|
||||
createdAt: String,
|
||||
},
|
||||
{ ...loose, collection: 'outgoing_webhooks' },
|
||||
);
|
||||
|
||||
export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.Project, schema: ProjectSchema },
|
||||
{ name: ReadModels.Issue, schema: IssueSchema },
|
||||
@@ -210,6 +225,7 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.Release, schema: ReleaseSchema },
|
||||
{ name: ReadModels.Sprint, schema: SprintSchema },
|
||||
{ name: ReadModels.GitIntegration, schema: GitIntegrationSchema },
|
||||
{ name: ReadModels.OutgoingWebhook, schema: OutgoingWebhookSchema },
|
||||
{ name: ReadModels.Activity, schema: ActivitySchema },
|
||||
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
||||
{ name: ReadModels.Notification, schema: NotificationSchema },
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
ReleaseDoc,
|
||||
SavedFilterDoc,
|
||||
GitIntegrationDoc,
|
||||
OutgoingWebhookDoc,
|
||||
TimelineDoc,
|
||||
} from './read-model.schemas';
|
||||
import { upsertIfNewer } from './upsert-if-newer';
|
||||
@@ -52,6 +53,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
||||
private readonly savedFilters: Model<SavedFilterDoc>,
|
||||
@InjectModel(ReadModels.GitIntegration)
|
||||
private readonly gitIntegrations: Model<GitIntegrationDoc>,
|
||||
@InjectModel(ReadModels.OutgoingWebhook)
|
||||
private readonly outgoingWebhooks: Model<OutgoingWebhookDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -64,6 +67,7 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
||||
// Views of the board of a project that is gone.
|
||||
this.savedFilters.deleteMany({ projectId }),
|
||||
this.gitIntegrations.deleteOne({ _id: projectId }),
|
||||
this.outgoingWebhooks.deleteMany({ projectId }),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
NotificationsRepository,
|
||||
SavedFiltersRepository,
|
||||
GitIntegrationsRepository,
|
||||
OutgoingWebhooksRepository,
|
||||
WebhookSender,
|
||||
FindActivityRepository,
|
||||
FindCommentsRepository,
|
||||
FindIssuesRepository,
|
||||
@@ -62,6 +64,8 @@ import { RedisChangeFeed } from './realtime/redis-change-feed';
|
||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
||||
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
||||
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
|
||||
import {
|
||||
MongoFindSprintsRepository,
|
||||
MongoUpsertMaterializedSprintRepository,
|
||||
@@ -127,6 +131,11 @@ const PORTS = [
|
||||
provide: GitIntegrationsRepository,
|
||||
useClass: MongoGitIntegrationsRepository,
|
||||
},
|
||||
{
|
||||
provide: OutgoingWebhooksRepository,
|
||||
useClass: MongoOutgoingWebhooksRepository,
|
||||
},
|
||||
{ provide: WebhookSender, useClass: HttpWebhookSender },
|
||||
{ provide: NotificationMailer, useClass: SmtpNotificationMailer },
|
||||
// Realtime → Redis pub/sub
|
||||
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { isPrivateAddress } from './http-webhook.sender';
|
||||
|
||||
describe('webhook sender', () => {
|
||||
it('knows private and loopback addresses', () => {
|
||||
for (const address of [
|
||||
'127.0.0.1',
|
||||
'10.1.2.3',
|
||||
'172.20.0.5',
|
||||
'192.168.1.9',
|
||||
'169.254.169.254',
|
||||
'::1',
|
||||
'fd00::1',
|
||||
'::ffff:127.0.0.1',
|
||||
])
|
||||
expect(isPrivateAddress(address)).toBe(true);
|
||||
for (const address of ['8.8.8.8', '172.32.0.1', '2a00:1450::1'])
|
||||
expect(isPrivateAddress(address)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { WebhookSender } from '@task-stream/api/tracker/application';
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
/** Loopback, private, link-local and unique-local addresses. */
|
||||
export function isPrivateAddress(address: string): boolean {
|
||||
if (isIP(address) === 4) {
|
||||
const [a, b] = address.split('.').map(Number);
|
||||
return (
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
a === 0 ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 100 && b >= 64 && b <= 127)
|
||||
);
|
||||
}
|
||||
const v6 = address.toLowerCase();
|
||||
return (
|
||||
v6 === '::1' ||
|
||||
v6 === '::' ||
|
||||
v6.startsWith('fe80:') ||
|
||||
v6.startsWith('fc') ||
|
||||
v6.startsWith('fd') ||
|
||||
(v6.startsWith('::ffff:') && isPrivateAddress(v6.slice(7)))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts deliveries with fetch, 5 s at most. Unless WEBHOOKS_ALLOW_PRIVATE is
|
||||
* true, refuses hosts that resolve to this machine or its private network:
|
||||
* a webhook must not become a way to reach internal services.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HttpWebhookSender extends WebhookSender {
|
||||
private readonly allowPrivate: boolean;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
super();
|
||||
this.allowPrivate =
|
||||
String(config.get('WEBHOOKS_ALLOW_PRIVATE') ?? '').toLowerCase() ===
|
||||
'true';
|
||||
}
|
||||
|
||||
async send(request: {
|
||||
url: string;
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
}): Promise<{ status: number }> {
|
||||
const url = new URL(request.url);
|
||||
if (!this.allowPrivate) {
|
||||
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||
const addresses = isIP(host)
|
||||
? [host]
|
||||
: (await lookup(host, { all: true })).map((a) => a.address);
|
||||
if (addresses.some(isPrivateAddress))
|
||||
throw new Error(`${url.hostname} is a private address`);
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
return { status: response.status };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user