cd7c2864d2
## 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
145 lines
2.9 KiB
TypeScript
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()),
|
|
),
|
|
});
|
|
});
|
|
});
|
|
};
|