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
7 changes: 7 additions & 0 deletions .changeset/great-experts-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@inkeep/agents-core": minor
"@inkeep/agents-manage-ui": minor
"@inkeep/agents-api": minor
---

Add custom HTTP headers for outbound webhooks; tighten header validation (RFC 7230 name charset, length limits) across all user-configurable header fields
102 changes: 98 additions & 4 deletions agents-api/__snapshots__/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1857,8 +1857,12 @@
},
"headers": {
"additionalProperties": {
"maxLength": 1000,
"minLength": 1,
"pattern": "^[^\\r\\n\\0]+$",
"type": "string"
},
"description": "Custom HTTP headers as key-value pairs. Keys must be valid RFC 7230 token characters (alphanumeric plus !#$%&'*+-.^_`|~), max 128 chars. Values: 1-1000 chars. Reserved names: Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-Authorization, Proxy-Connection, Content-Length.",
"nullable": true,
"type": "object"
},
Expand Down Expand Up @@ -1900,8 +1904,12 @@
},
"headers": {
"additionalProperties": {
"maxLength": 1000,
"minLength": 1,
"pattern": "^[^\\r\\n\\0]+$",
"type": "string"
},
"description": "Custom HTTP headers as key-value pairs. Keys must be valid RFC 7230 token characters (alphanumeric plus !#$%&'*+-.^_`|~), max 128 chars. Values: 1-1000 chars. Reserved names: Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-Authorization, Proxy-Connection, Content-Length.",
"nullable": true,
"type": "object"
},
Expand Down Expand Up @@ -1936,8 +1944,12 @@
},
"headers": {
"additionalProperties": {
"maxLength": 1000,
"minLength": 1,
"pattern": "^[^\\r\\n\\0]+$",
"type": "string"
},
"description": "Custom HTTP headers as key-value pairs. Keys must be valid RFC 7230 token characters (alphanumeric plus !#$%&'*+-.^_`|~), max 128 chars. Values: 1-1000 chars. Reserved names: Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-Authorization, Proxy-Connection, Content-Length.",
"nullable": true,
"type": "object"
},
Expand Down Expand Up @@ -2400,9 +2412,12 @@
},
"metadata": {
"additionalProperties": {
"maxLength": 1000,
"minLength": 1,
"pattern": "^[^\\r\\n\\0]+$",
"type": "string"
},
"description": "The metadata for the credential",
"description": "Credential metadata. Keys are injected as HTTP headers on outbound MCP server requests, so they must be valid HTTP header names.",
"nullable": true,
"type": "object"
},
Expand Down Expand Up @@ -7149,7 +7164,15 @@
"type": "string"
},
"headers": {
"$ref": "#/components/schemas/StringRecord"
"additionalProperties": {
"maxLength": 1000,
"minLength": 1,
"pattern": "^[^\\r\\n\\0]+$",
"type": "string"
},
"description": "Custom HTTP headers as key-value pairs. Keys must be valid RFC 7230 token characters (alphanumeric plus !#$%&'*+-.^_`|~), max 128 chars. Values: 1-1000 chars. Reserved names: Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-Authorization, Proxy-Connection, Content-Length.",
"nullable": true,
"type": "object"
},
"id": {
"$ref": "#/components/schemas/ResourceId"
Expand Down Expand Up @@ -9514,8 +9537,12 @@
},
"headers": {
"additionalProperties": {
"maxLength": 1000,
"minLength": 1,
"pattern": "^[^\\r\\n\\0]+$",
"type": "string"
},
"description": "Custom HTTP headers as key-value pairs. Keys must be valid RFC 7230 token characters (alphanumeric plus !#$%&'*+-.^_`|~), max 128 chars. Values: 1-1000 chars. Reserved names: Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-Authorization, Proxy-Connection, Content-Length.",
"nullable": true,
"type": "object"
},
Expand Down Expand Up @@ -9567,8 +9594,12 @@
},
"headers": {
"additionalProperties": {
"maxLength": 1000,
"minLength": 1,
"pattern": "^[^\\r\\n\\0]+$",
"type": "string"
},
"description": "Custom HTTP headers as key-value pairs. Keys must be valid RFC 7230 token characters (alphanumeric plus !#$%&'*+-.^_`|~), max 128 chars. Values: 1-1000 chars. Reserved names: Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-Authorization, Proxy-Connection, Content-Length.",
"nullable": true,
"type": "object"
},
Expand Down Expand Up @@ -10078,8 +10109,12 @@
},
"headers": {
"additionalProperties": {
"maxLength": 1000,
"minLength": 1,
"pattern": "^[^\\r\\n\\0]+$",
"type": "string"
},
"description": "Custom HTTP headers as key-value pairs. Keys must be valid RFC 7230 token characters (alphanumeric plus !#$%&'*+-.^_`|~), max 128 chars. Values: 1-1000 chars. Reserved names: Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-Authorization, Proxy-Connection, Content-Length.",
"nullable": true,
"type": "object"
},
Expand Down Expand Up @@ -10131,8 +10166,12 @@
},
"headers": {
"additionalProperties": {
"maxLength": 1000,
"minLength": 1,
"pattern": "^[^\\r\\n\\0]+$",
"type": "string"
},
"description": "Custom HTTP headers as key-value pairs. Keys must be valid RFC 7230 token characters (alphanumeric plus !#$%&'*+-.^_`|~), max 128 chars. Values: 1-1000 chars. Reserved names: Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-Authorization, Proxy-Connection, Content-Length.",
"nullable": true,
"type": "object"
},
Expand Down Expand Up @@ -10284,8 +10323,12 @@
},
"headers": {
"additionalProperties": {
"maxLength": 1000,
"minLength": 1,
"pattern": "^[^\\r\\n\\0]+$",
"type": "string"
},
"description": "Custom HTTP headers as key-value pairs. Keys must be valid RFC 7230 token characters (alphanumeric plus !#$%&'*+-.^_`|~), max 128 chars. Values: 1-1000 chars. Reserved names: Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-Authorization, Proxy-Connection, Content-Length.",
"nullable": true,
"type": "object"
},
Expand Down Expand Up @@ -10364,8 +10407,12 @@
},
"headers": {
"additionalProperties": {
"maxLength": 1000,
"minLength": 1,
"pattern": "^[^\\r\\n\\0]+$",
"type": "string"
},
"description": "Custom HTTP headers as key-value pairs. Keys must be valid RFC 7230 token characters (alphanumeric plus !#$%&'*+-.^_`|~), max 128 chars. Values: 1-1000 chars. Reserved names: Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-Authorization, Proxy-Connection, Content-Length.",
"nullable": true,
"type": "object"
},
Expand Down Expand Up @@ -11007,7 +11054,15 @@
"$ref": "#/components/schemas/DescriptionSchema"
},
"headers": {
"$ref": "#/components/schemas/StringRecord"
"additionalProperties": {
"maxLength": 1000,
"minLength": 1,
"pattern": "^[^\\r\\n\\0]+$",
"type": "string"
},
"description": "Custom HTTP headers as key-value pairs. Keys must be valid RFC 7230 token characters (alphanumeric plus !#$%&'*+-.^_`|~), max 128 chars. Values: 1-1000 chars. Reserved names: Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-Authorization, Proxy-Connection, Content-Length.",
"nullable": true,
"type": "object"
},
"id": {
"$ref": "#/components/schemas/ResourceId"
Expand Down Expand Up @@ -11187,7 +11242,15 @@
"$ref": "#/components/schemas/DescriptionSchema"
},
"headers": {
"$ref": "#/components/schemas/StringRecord"
"additionalProperties": {
"maxLength": 1000,
"minLength": 1,
"pattern": "^[^\\r\\n\\0]+$",
"type": "string"
},
"description": "Custom HTTP headers as key-value pairs. Keys must be valid RFC 7230 token characters (alphanumeric plus !#$%&'*+-.^_`|~), max 128 chars. Values: 1-1000 chars. Reserved names: Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-Authorization, Proxy-Connection, Content-Length.",
"nullable": true,
"type": "object"
},
"id": {
"$ref": "#/components/schemas/ResourceId"
Expand Down Expand Up @@ -12283,6 +12346,14 @@
},
"type": "array"
},
"headers": {
"additionalProperties": {
"type": "string"
},
"description": "Custom HTTP headers included in webhook delivery requests",
"nullable": true,
"type": "object"
},
"id": {
"maxLength": 256,
"type": "string"
Expand All @@ -12305,6 +12376,7 @@
"enabled",
"url",
"eventTypes",
"headers",
"createdAt",
"updatedAt"
],
Expand Down Expand Up @@ -12344,6 +12416,17 @@
"minItems": 1,
"type": "array"
},
"headers": {
"additionalProperties": {
"maxLength": 1000,
"minLength": 1,
"pattern": "^[^\\r\\n\\0]+$",
"type": "string"
},
"description": "Custom HTTP headers as key-value pairs. Keys must be valid RFC 7230 token characters (alphanumeric plus !#$%&'*+-.^_`|~), max 128 chars. Values: 1-1000 chars. Reserved names: Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-Authorization, Proxy-Connection, Content-Length.",
"nullable": true,
"type": "object"
},
"id": {
"$ref": "#/components/schemas/ResourceId"
},
Expand Down Expand Up @@ -12447,6 +12530,17 @@
"minItems": 1,
"type": "array"
},
"headers": {
"additionalProperties": {
"maxLength": 1000,
"minLength": 1,
"pattern": "^[^\\r\\n\\0]+$",
"type": "string"
},
"description": "Custom HTTP headers as key-value pairs. Keys must be valid RFC 7230 token characters (alphanumeric plus !#$%&'*+-.^_`|~), max 128 chars. Values: 1-1000 chars. Reserved names: Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-Authorization, Proxy-Connection, Content-Length.",
"nullable": true,
"type": "object"
},
"id": {
"$ref": "#/components/schemas/ResourceId"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,25 @@ describe('Webhook Destination CRUD Routes - Integration Tests', () => {
url = 'https://example.com/webhook',
eventTypes = ['conversation.created', 'conversation.updated'],
enabled = true,
headers,
}: {
tenantId: string;
projectId: string;
name?: string;
url?: string;
eventTypes?: string[];
enabled?: boolean;
headers?: Record<string, string>;
}) => {
const createData = {
const createData: Record<string, unknown> = {
name,
url,
eventTypes,
enabled,
};
if (headers !== undefined) {
createData.headers = headers;
}

const createRes = await makeRequest(basePath(tenantId, projectId), {
method: 'POST',
Expand Down Expand Up @@ -549,6 +554,105 @@ describe('Webhook Destination CRUD Routes - Integration Tests', () => {
});
});

describe('Custom headers', () => {
it('should create a webhook destination with custom headers', async () => {
const tenantId = await createTestTenantWithOrg('wh-headers-create');
const { projectId } = await createTestProjectForWebhooks(tenantId);

const headers = { 'X-Api-Key': 'secret-123', 'X-Custom': 'value' };
const { webhookDestination } = await createTestWebhookDestination({
tenantId,
projectId,
headers,
});

expect(webhookDestination.headers).toEqual(headers);
});

it('should round-trip headers through create and get', async () => {
const tenantId = await createTestTenantWithOrg('wh-headers-roundtrip');
const { projectId } = await createTestProjectForWebhooks(tenantId);

const headers = { Authorization: 'Bearer tok-123', 'X-Trace-Id': 'abc' };
const { webhookDestination } = await createTestWebhookDestination({
tenantId,
projectId,
headers,
});

const getRes = await makeRequest(`${basePath(tenantId, projectId)}/${webhookDestination.id}`);
expect(getRes.status).toBe(200);
const { data: fetched } = await getRes.json();
expect(fetched.headers).toEqual(headers);
});

it('should update headers via PATCH', async () => {
const tenantId = await createTestTenantWithOrg('wh-headers-update');
const { projectId } = await createTestProjectForWebhooks(tenantId);

const { webhookDestination } = await createTestWebhookDestination({
tenantId,
projectId,
headers: { 'X-Old': 'old-value' },
});

const patchRes = await makeRequest(
`${basePath(tenantId, projectId)}/${webhookDestination.id}`,
{
method: 'PATCH',
body: JSON.stringify({ headers: { 'X-New': 'new-value' } }),
}
);

expect(patchRes.status).toBe(200);
const { data: updated } = await patchRes.json();
expect(updated.headers).toEqual({ 'X-New': 'new-value' });
});

it('should create a webhook destination without headers (null by default)', async () => {
const tenantId = await createTestTenantWithOrg('wh-headers-null');
const { projectId } = await createTestProjectForWebhooks(tenantId);

const { webhookDestination } = await createTestWebhookDestination({
tenantId,
projectId,
});

expect(webhookDestination.headers).toBeNull();
});

it('should include custom headers in test delivery', async () => {
const tenantId = await createTestTenantWithOrg('wh-headers-test-delivery');
const { projectId } = await createTestProjectForWebhooks(tenantId);

const headers = { 'X-Webhook-Secret': 'my-secret' };
const { webhookDestination } = await createTestWebhookDestination({
tenantId,
projectId,
headers,
});

mockSsrfFetch.mockResolvedValueOnce({ ok: true, status: 200 });

const res = await makeRequest(
`${basePath(tenantId, projectId)}/${webhookDestination.id}/test`,
{ method: 'POST' }
);

expect(res.status).toBe(200);
expect(mockSsrfFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'X-Webhook-Secret': 'my-secret',
'Content-Type': 'application/json',
'User-Agent': 'Inkeep-Webhooks/1.0',
}),
})
);
});
});

describe('CRUD round-trip', () => {
it('should create, read, update, and delete a webhook destination', async () => {
const tenantId = await createTestTenantWithOrg('wh-roundtrip');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ app.openapi(
const response = await fetchWithSsrfProtection(dest.url, {
method: 'POST',
headers: {
...dest.headers,
'Content-Type': 'application/json',
'User-Agent': 'Inkeep-Webhooks/1.0',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export async function emitWebhookEvent(params: EmitWebhookEventParams): Promise<
agentId,
webhookDestinationId: dest.id,
payload: envelope as unknown as Record<string, unknown>,
headers: dest.headers,
}));

const results = await Promise.allSettled(
Expand Down
Loading
Loading