Skip to content
Closed
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.4.6",
"version": "1.4.7",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down Expand Up @@ -42,7 +42,7 @@
"@graphql-tools/schema": "^8.5.1",
"@graphql-tools/utils": "^8.9.0",
"@hawk.so/nodejs": "^3.3.1",
"@hawk.so/types": "^0.5.8",
"@hawk.so/types": "^0.5.9",
"@n1ru4l/json-patch-plus": "^0.2.0",
"@node-saml/node-saml": "^5.0.1",
"@octokit/oauth-methods": "^4.0.0",
Expand Down
9 changes: 9 additions & 0 deletions src/rabbitmq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export enum Queues {
Telegram = 'notify/telegram',
Slack = 'notify/slack',
Loop = 'notify/loop',
Webhook = 'sender/webhook',
Limiter = 'cron-tasks/limiter',
}

Expand Down Expand Up @@ -90,6 +91,14 @@ export const WorkerPaths: Record<string, WorkerPath> = {
queue: Queues.Loop,
},

/**
* Path to webhook worker
*/
Webhook: {
exchange: Exchanges.Empty,
queue: Queues.Webhook,
},

/**
* Path to limiter worker
*/
Expand Down
15 changes: 12 additions & 3 deletions src/resolvers/projectNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ProjectNotificationsRuleDBScheme } from '@hawk.so/types';
import { ResolverContextWithUser } from '../types/graphql';
import { ApolloError, UserInputError } from 'apollo-server-express';
import { NotificationsChannelsDBScheme } from '../types/notification-channels';
import { validateWebhookEndpoint } from '../utils/webhookEndpointValidator';

/**
* Mutation payload for creating notifications rule from GraphQL Schema
Expand Down Expand Up @@ -101,7 +102,7 @@ function validateNotificationsRuleTresholdAndPeriod(
/**
* Return true if all passed channels are filled with correct endpoints
*/
function validateNotificationsRuleChannels(channels: NotificationsChannelsDBScheme): string | null {
async function validateNotificationsRuleChannels(channels: NotificationsChannelsDBScheme): Promise<string | null> {
if (channels.email!.isEnabled) {
if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(channels.email!.endpoint)) {
return 'Invalid email endpoint passed';
Expand All @@ -126,6 +127,14 @@ function validateNotificationsRuleChannels(channels: NotificationsChannelsDBSche
}
}

if (channels.webhook?.isEnabled && channels.webhook.endpoint) {
const webhookError = await validateWebhookEndpoint(channels.webhook.endpoint);

if (webhookError !== null) {
return webhookError;
}
}

return null;
}

Expand All @@ -152,7 +161,7 @@ export default {
throw new ApolloError('No project with such id');
}

const channelsValidationResult = validateNotificationsRuleChannels(input.channels);
const channelsValidationResult = await validateNotificationsRuleChannels(input.channels);

if (channelsValidationResult !== null) {
throw new UserInputError(channelsValidationResult);
Expand Down Expand Up @@ -190,7 +199,7 @@ export default {
throw new ApolloError('No project with such id');
}

const channelsValidationResult = validateNotificationsRuleChannels(input.channels);
const channelsValidationResult = await validateNotificationsRuleChannels(input.channels);

if (channelsValidationResult !== null) {
throw new UserInputError(channelsValidationResult);
Expand Down
10 changes: 10 additions & 0 deletions src/resolvers/userNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { ResolverContextWithUser } from '../types/graphql';
import { UserNotificationsDBScheme, UserNotificationType } from '../models/user';
import { NotificationsChannelsDBScheme } from '../types/notification-channels';
import { UserDBScheme } from '@hawk.so/types';
import { UserInputError } from 'apollo-server-express';
import { validateWebhookEndpoint } from '../utils/webhookEndpointValidator';

/**
* We will get this structure from the client to update Channel settings
Expand Down Expand Up @@ -45,6 +47,14 @@ export default {
{ input }: ChangeUserNotificationsChannelPayload,
{ user, factories }: ResolverContextWithUser
): Promise<ChangeNotificationsResponse> {
if (input.webhook?.isEnabled && input.webhook.endpoint) {
const webhookError = await validateWebhookEndpoint(input.webhook.endpoint);

if (webhookError !== null) {
throw new UserInputError(webhookError);
}
}

const currentUser = await factories.usersFactory.findById(user.id);
const currentNotifySet = currentUser?.notifications || {} as UserNotificationsDBScheme;
const oldChannels = currentNotifySet.channels || {};
Expand Down
5 changes: 5 additions & 0 deletions src/typeDefs/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export default gql`
"""
loop: NotificationsChannelSettings

"""
Webhook channel
"""
webhook: NotificationsChannelSettings

"""
Webpush
"""
Expand Down
5 changes: 5 additions & 0 deletions src/typeDefs/notificationsInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ export default gql`
"""
loop: NotificationsChannelSettingsInput

"""
Webhook channel
"""
webhook: NotificationsChannelSettingsInput

"""
Web push
"""
Expand Down
5 changes: 5 additions & 0 deletions src/types/notification-channels.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export interface NotificationsChannelsDBScheme {
* Pushes through the Hawk Desktop app
*/
desktopPush?: NotificationsChannelSettingsDBScheme;

/**
* Alerts through a custom Webhook URL
*/
webhook?: NotificationsChannelSettingsDBScheme;
}

/**
Expand Down
64 changes: 64 additions & 0 deletions src/utils/ipValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Regex patterns matching private/reserved IP ranges:
*
* IPv4: 0.x (current-network), 10.x, 172.16-31.x, 192.168.x (RFC1918),
* 127.x (loopback), 169.254.x (link-local/metadata), 100.64-127.x (CGN/RFC6598),
* 255.255.255.255 (broadcast), 224-239.x (multicast),
* 192.0.2.x, 198.51.100.x, 203.0.113.x (documentation), 198.18-19.x (benchmarking).
*
* IPv6: ::1, ::, fe80 (link-local), fc/fd (ULA), ff (multicast).
*
* Also handles IPv4-mapped IPv6 (::ffff:A.B.C.D) and zone IDs (fe80::1%lo0).
*/
const PRIVATE_IP_PATTERNS: RegExp[] = [
/^0\./,
/^10\./,
/^127\./,
/^169\.254\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^192\.168\./,
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
/^255\.255\.255\.255$/,
/^2(2[4-9]|3\d)\./,
/^192\.0\.2\./,
/^198\.51\.100\./,
/^203\.0\.113\./,
/^198\.1[89]\./,
/^::1$/,
/^::$/,
/^fe80/i,
/^f[cd]/i,
/^ff[0-9a-f]{2}:/i,
/^::ffff:(0\.|10\.|127\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/i,
];

/**
* Checks whether an IP address belongs to a private/reserved range.
* Strips zone ID before matching (e.g. fe80::1%lo0).
*
* @param ip - IP address string (v4 or v6)
*/
export function isPrivateIP(ip: string): boolean {
const bare = ip.split('%')[0];

return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(bare));
}

/**
* Hostnames blocked regardless of DNS resolution
*/
export const BLOCKED_HOSTNAMES: RegExp[] = [
/^localhost$/i,
/\.local$/i,
/\.internal$/i,
/\.lan$/i,
/\.localdomain$/i,
];

/**
* Only these ports are allowed for webhook delivery
*/
export const ALLOWED_PORTS: Record<string, number> = {
'http:': 80,
'https:': 443,
};
10 changes: 10 additions & 0 deletions src/utils/personalNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,14 @@ export default async function sendNotification(user: UserDBScheme, task: SenderW
},
});
}

if (user.notifications.channels.webhook?.isEnabled) {
await enqueue(WorkerPaths.Webhook, {
type: task.type,
payload: {
...task.payload,
endpoint: user.notifications.channels.webhook.endpoint,
},
});
}
}
59 changes: 59 additions & 0 deletions src/utils/webhookEndpointValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import dns from 'dns';
import { isPrivateIP, BLOCKED_HOSTNAMES, ALLOWED_PORTS } from './ipValidator';

/**
* Validates a webhook endpoint URL for SSRF safety.
* Returns null if valid, or an error message string if invalid.
*
* Checks:
* - Protocol whitelist (http/https)
* - Port whitelist (80/443)
* - Hostname blocklist (localhost, *.local, etc.)
* - Private IP in URL
* - DNS resolution — all A/AAAA records must be public
*
* @param endpoint - webhook URL to validate
*/
export async function validateWebhookEndpoint(endpoint: string): Promise<string | null> {
let url: URL;

try {
url = new URL(endpoint);
} catch {
return 'Invalid webhook URL';
}

if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return 'Webhook URL must use http or https protocol';
}

const requestedPort = url.port ? Number(url.port) : ALLOWED_PORTS[url.protocol];

if (requestedPort !== ALLOWED_PORTS[url.protocol]) {
return `Webhook URL port ${requestedPort} is not allowed — only 80 (http) and 443 (https)`;
}

const hostname = url.hostname;

if (BLOCKED_HOSTNAMES.some((pattern) => pattern.test(hostname))) {
return `Webhook hostname "${hostname}" is not allowed`;
}

if (isPrivateIP(hostname)) {
return 'Webhook URL points to a private/reserved IP address';
}

try {
const results = await dns.promises.lookup(hostname, { all: true });

for (const { address } of results) {
if (isPrivateIP(address)) {
return `Webhook hostname resolves to a private IP address (${address})`;
}
}
} catch {
return `Cannot resolve webhook hostname "${hostname}"`;
}

return null;
}
Loading
Loading