Files
twenty/packages/twenty-server/test/integration/metadata/suites/utils/webhook-test.util.ts
T
Félix Malfait cd7c2864d2 fix(twenty-server): add SSRF protection to webhook requests (#17403)
## Summary

- Adds SSRF (Server-Side Request Forgery) protection to webhook requests
by using the same secure axios adapter already used by HTTP workflow
actions
- Prevents webhooks from making requests to private/internal IP
addresses (10.x, 192.168.x, 172.16-31.x, 169.254.x, localhost)
- Adds specific error logging when a webhook fails due to SSRF
protection

## Context

The HTTP workflow tool (`HTTP_REQUEST` action) already had SSRF
protection via `HTTP_TOOL_SAFE_MODE_ENABLED`, but webhooks were using
`HttpService` directly without this protection. This inconsistency meant
users could potentially configure webhooks to probe internal
infrastructure.

### What's protected now:

| Feature | Before | After |
|---------|--------|-------|
| HTTP Workflow Action | Protected (secure adapter) | Protected (secure
adapter) |
| Webhooks | **Unprotected** | Protected (secure adapter) |

### The secure adapter validates:
1. Protocol must be `http:` or `https:`
2. DNS resolution of hostname
3. Resolved IP must not be in private ranges

## Test plan

- [ ] Configure a webhook with an external URL (e.g.,
`https://webhook.site`) - should work
- [ ] Configure a webhook with `http://localhost:3000` - should fail
with SSRF error in audit log
- [ ] Configure a webhook with `http://10.0.0.1/test` - should fail with
SSRF error in audit log
- [ ] Configure a webhook with a domain that resolves to a private IP -
should fail
2026-01-24 10:46:46 +00:00

145 lines
2.9 KiB
TypeScript

import http from 'http';
import { gql } from 'graphql-tag';
import { makeMetadataAPIRequest } from './make-metadata-api-request.util';
const CREATE_WEBHOOK_MUTATION = gql`
mutation CreateWebhook($input: CreateWebhookInput!) {
createWebhook(input: $input) {
id
targetUrl
operations
description
secret
}
}
`;
const DELETE_WEBHOOK_MUTATION = gql`
mutation DeleteWebhook($input: DeleteWebhookInput!) {
deleteWebhook(input: $input)
}
`;
const GET_WEBHOOK_QUERY = gql`
query GetWebhook($input: GetWebhookInput!) {
webhook(input: $input) {
id
targetUrl
operations
description
secret
}
}
`;
const GET_WEBHOOKS_QUERY = gql`
query GetWebhooks {
webhooks {
id
targetUrl
operations
description
secret
}
}
`;
const UPDATE_WEBHOOK_MUTATION = gql`
mutation UpdateWebhook($input: UpdateWebhookInput!) {
updateWebhook(input: $input) {
id
targetUrl
operations
description
secret
}
}
`;
export type WebhookInput = {
targetUrl: string;
operations: string[];
description?: string;
secret?: string;
};
export type WebhookReceiver = {
server: http.Server;
receivedPayloads: object[];
close: () => Promise<void>;
};
export const createWebhook = (input: WebhookInput) => {
return makeMetadataAPIRequest({
query: CREATE_WEBHOOK_MUTATION,
variables: { input },
});
};
export const deleteWebhook = (id: string) => {
return makeMetadataAPIRequest({
query: DELETE_WEBHOOK_MUTATION,
variables: { input: { id } },
});
};
export const getWebhook = (id: string) => {
return makeMetadataAPIRequest({
query: GET_WEBHOOK_QUERY,
variables: { input: { id } },
});
};
export const getWebhooks = () => {
return makeMetadataAPIRequest({
query: GET_WEBHOOKS_QUERY,
});
};
export const updateWebhook = (
input: Partial<WebhookInput> & { id: string },
) => {
return makeMetadataAPIRequest({
query: UPDATE_WEBHOOK_MUTATION,
variables: { input },
});
};
export const createWebhookReceiver = (
port: number,
): Promise<WebhookReceiver> => {
return new Promise((resolve) => {
const receivedPayloads: object[] = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
try {
receivedPayloads.push(JSON.parse(body));
} catch {
receivedPayloads.push({ raw: body });
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
});
});
server.listen(port, '127.0.0.1', () => {
resolve({
server,
receivedPayloads,
close: () =>
new Promise<void>((resolveClose) =>
server.close(() => resolveClose()),
),
});
});
});
};