Skip to content
Merged
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
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) {
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) {
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