mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core): sign hook payload data (#3854)
This commit is contained in:
parent
c6821aab77
commit
0c79437ba4
6 changed files with 137 additions and 13 deletions
|
@ -6,7 +6,7 @@ import { got } from 'got';
|
||||||
import type { Interaction } from './hook.js';
|
import type { Interaction } from './hook.js';
|
||||||
|
|
||||||
const { jest } = import.meta;
|
const { jest } = import.meta;
|
||||||
const { mockEsmWithActual } = createMockUtils(jest);
|
const { mockEsmWithActual, mockEsm } = createMockUtils(jest);
|
||||||
|
|
||||||
const nanoIdMock = 'mockId';
|
const nanoIdMock = 'mockId';
|
||||||
await mockEsmWithActual('@logto/shared', () => ({
|
await mockEsmWithActual('@logto/shared', () => ({
|
||||||
|
@ -15,6 +15,11 @@ await mockEsmWithActual('@logto/shared', () => ({
|
||||||
generateStandardId: () => nanoIdMock,
|
generateStandardId: () => nanoIdMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockSignature = 'mockSignature';
|
||||||
|
mockEsm('#src/utils/sign.js', () => ({
|
||||||
|
sign: () => mockSignature,
|
||||||
|
}));
|
||||||
|
|
||||||
const { MockQueries } = await import('#src/test-utils/tenant.js');
|
const { MockQueries } = await import('#src/test-utils/tenant.js');
|
||||||
|
|
||||||
const url = 'https://logto.gg';
|
const url = 'https://logto.gg';
|
||||||
|
@ -78,7 +83,11 @@ describe('triggerInteractionHooksIfNeeded()', () => {
|
||||||
|
|
||||||
expect(findAllHooks).toHaveBeenCalled();
|
expect(findAllHooks).toHaveBeenCalled();
|
||||||
expect(post).toHaveBeenCalledWith(url, {
|
expect(post).toHaveBeenCalledWith(url, {
|
||||||
headers: { 'user-agent': 'Logto (https://logto.io)', bar: 'baz' },
|
headers: {
|
||||||
|
'user-agent': 'Logto (https://logto.io/)',
|
||||||
|
bar: 'baz',
|
||||||
|
'logto-signature-sha-256': mockSignature,
|
||||||
|
},
|
||||||
json: {
|
json: {
|
||||||
hookId: 'foo',
|
hookId: 'foo',
|
||||||
event: 'PostSignIn',
|
event: 'PostSignIn',
|
||||||
|
|
|
@ -7,13 +7,14 @@ import {
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { generateStandardId } from '@logto/shared';
|
import { generateStandardId } from '@logto/shared';
|
||||||
import { conditional, pick, trySafe } from '@silverhand/essentials';
|
import { conditional, pick, trySafe } from '@silverhand/essentials';
|
||||||
import type { Response } from 'got';
|
|
||||||
import { got, HTTPError } from 'got';
|
import { got, HTTPError } from 'got';
|
||||||
|
import { type Response } from 'got';
|
||||||
import type Provider from 'oidc-provider';
|
import type Provider from 'oidc-provider';
|
||||||
|
|
||||||
import { LogEntry } from '#src/middleware/koa-audit-log.js';
|
import { LogEntry } from '#src/middleware/koa-audit-log.js';
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
import { consoleLog } from '#src/utils/console.js';
|
||||||
|
import { sign } from '#src/utils/sign.js';
|
||||||
|
|
||||||
const parseResponse = ({ statusCode, body }: Response) => ({
|
const parseResponse = ({ statusCode, body }: Response) => ({
|
||||||
statusCode,
|
statusCode,
|
||||||
|
@ -81,7 +82,7 @@ export const createHookLibrary = (queries: Queries) => {
|
||||||
} satisfies Omit<HookEventPayload, 'hookId'>;
|
} satisfies Omit<HookEventPayload, 'hookId'>;
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
rows.map(async ({ config: { url, headers, retries }, id }) => {
|
rows.map(async ({ config: { url, headers, retries }, id, signingKey }) => {
|
||||||
consoleLog.info(`\tTriggering hook ${id} due to ${hookEvent} event`);
|
consoleLog.info(`\tTriggering hook ${id} due to ${hookEvent} event`);
|
||||||
const json: HookEventPayload = { hookId: id, ...payload };
|
const json: HookEventPayload = { hookId: id, ...payload };
|
||||||
const logEntry = new LogEntry(`TriggerHook.${hookEvent}`);
|
const logEntry = new LogEntry(`TriggerHook.${hookEvent}`);
|
||||||
|
@ -91,7 +92,11 @@ export const createHookLibrary = (queries: Queries) => {
|
||||||
// Trigger web hook and log response
|
// Trigger web hook and log response
|
||||||
await got
|
await got
|
||||||
.post(url, {
|
.post(url, {
|
||||||
headers: { 'user-agent': 'Logto (https://logto.io)', ...headers },
|
headers: {
|
||||||
|
'user-agent': 'Logto (https://logto.io/)',
|
||||||
|
...headers,
|
||||||
|
...conditional(signingKey && { 'logto-signature-sha-256': sign(signingKey, json) }),
|
||||||
|
},
|
||||||
json,
|
json,
|
||||||
retry: { limit: retries ?? 3 },
|
retry: { limit: retries ?? 3 },
|
||||||
timeout: { request: 10_000 },
|
timeout: { request: 10_000 },
|
||||||
|
|
23
packages/core/src/utils/sign.test.ts
Normal file
23
packages/core/src/utils/sign.test.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { sign } from './sign.js';
|
||||||
|
|
||||||
|
describe('sign', () => {
|
||||||
|
it('should generate correct signature', async () => {
|
||||||
|
const signingKey = 'foo';
|
||||||
|
const payload = {
|
||||||
|
bar: 'bar',
|
||||||
|
foo: 'foo',
|
||||||
|
};
|
||||||
|
|
||||||
|
const signature = sign(signingKey, payload);
|
||||||
|
const expectedResult = '436958f1dbfefab37712fb3927760490fbf7757da8c0b2306ee7b485f0360eee';
|
||||||
|
expect(signature).toBe(expectedResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate correct signature if payload is empty', async () => {
|
||||||
|
const signingKey = 'foo';
|
||||||
|
const payload = {};
|
||||||
|
const signature = sign(signingKey, payload);
|
||||||
|
const expectedResult = 'c76356efa19d219d1d7e08ccb20b1d26db53b143156f406c99dcb8e0876d6c55';
|
||||||
|
expect(signature).toBe(expectedResult);
|
||||||
|
});
|
||||||
|
});
|
8
packages/core/src/utils/sign.ts
Normal file
8
packages/core/src/utils/sign.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
|
||||||
|
export const sign = (signingKey: string, payload: Record<string, unknown>) => {
|
||||||
|
const hmac = createHmac('sha256', signingKey);
|
||||||
|
const payloadString = JSON.stringify(payload);
|
||||||
|
hmac.update(payloadString);
|
||||||
|
return hmac.digest('hex');
|
||||||
|
};
|
|
@ -1,5 +1,5 @@
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { createServer } from 'node:http';
|
import { createServer, type RequestListener } from 'node:http';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { mockSmsVerificationCodeFileName } from '@logto/connector-kit';
|
import { mockSmsVerificationCodeFileName } from '@logto/connector-kit';
|
||||||
|
@ -87,12 +87,14 @@ export const expectRequestError = (error: unknown, code: string, messageIncludes
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createMockServer = (port: number) => {
|
const defaultRequestListener: RequestListener = (request, response) => {
|
||||||
const server = createServer((request, response) => {
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
response.statusCode = 204;
|
||||||
response.statusCode = 204;
|
response.end();
|
||||||
response.end();
|
};
|
||||||
});
|
|
||||||
|
export const createMockServer = (port: number, requestListener?: RequestListener) => {
|
||||||
|
const server = createServer(requestListener ?? defaultRequestListener);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
listen: async () =>
|
listen: async () =>
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { type RequestListener } from 'node:http';
|
||||||
|
|
||||||
import type { Hook, HookConfig, Log, LogKey } from '@logto/schemas';
|
import type { Hook, HookConfig, Log, LogKey } from '@logto/schemas';
|
||||||
import { HookEvent, SignInIdentifier, LogResult, InteractionEvent } from '@logto/schemas';
|
import { HookEvent, SignInIdentifier, LogResult, InteractionEvent } from '@logto/schemas';
|
||||||
|
|
||||||
|
@ -22,8 +25,35 @@ const createPayload = (event: HookEvent, url = 'not_work_url'): CreateHookPayloa
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type HookSecureData = {
|
||||||
|
signature: string;
|
||||||
|
payload: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note: return hook payload and signature for webhook security testing
|
||||||
|
const hookServerRequestListener: RequestListener = (request, response) => {
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
|
response.statusCode = 204;
|
||||||
|
|
||||||
|
const data: Buffer[] = [];
|
||||||
|
request.on('data', (chunk: Buffer) => {
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||||
|
data.push(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on('end', () => {
|
||||||
|
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
const payload = Buffer.concat(data).toString();
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({
|
||||||
|
signature: request.headers['logto-signature-sha-256'] as string,
|
||||||
|
payload,
|
||||||
|
} satisfies HookSecureData)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
describe('hooks', () => {
|
describe('hooks', () => {
|
||||||
const { listen, close } = createMockServer(9999);
|
const { listen, close } = createMockServer(9999, hookServerRequestListener);
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await enableAllPasswordSignInMethods({
|
await enableAllPasswordSignInMethods({
|
||||||
|
@ -259,4 +289,51 @@ describe('hooks', () => {
|
||||||
|
|
||||||
await deleteUser(id);
|
await deleteUser(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should secure webhook payload data successfully', async () => {
|
||||||
|
const createdHook = await authedAdminApi
|
||||||
|
.post('hooks', { json: createPayload(HookEvent.PostRegister, 'http://localhost:9999') })
|
||||||
|
.json<Hook>();
|
||||||
|
|
||||||
|
// Init session and submit
|
||||||
|
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||||
|
const client = await initClient();
|
||||||
|
await client.send(putInteraction, {
|
||||||
|
event: InteractionEvent.Register,
|
||||||
|
profile: {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { redirectTo } = await client.submitInteraction();
|
||||||
|
const id = await processSession(client, redirectTo);
|
||||||
|
await waitFor(500); // Wait for hooks execution
|
||||||
|
|
||||||
|
const logs = await authedAdminApi
|
||||||
|
.get(`hooks/${createdHook.id}/recent-logs?page_size=100`)
|
||||||
|
.json<Log[]>();
|
||||||
|
|
||||||
|
const log = logs.find(({ payload: { hookId } }) => hookId === createdHook.id);
|
||||||
|
expect(log).toBeTruthy();
|
||||||
|
|
||||||
|
const response = log?.payload.response;
|
||||||
|
expect(response).toBeTruthy();
|
||||||
|
|
||||||
|
const {
|
||||||
|
body: { signature, payload },
|
||||||
|
} = response as { body: HookSecureData };
|
||||||
|
|
||||||
|
expect(signature).toBeTruthy();
|
||||||
|
expect(payload).toBeTruthy();
|
||||||
|
|
||||||
|
const calculateSignature = createHmac('sha256', createdHook.signingKey)
|
||||||
|
.update(payload)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
expect(calculateSignature).toEqual(signature);
|
||||||
|
|
||||||
|
await authedAdminApi.delete(`hooks/${createdHook.id}`);
|
||||||
|
|
||||||
|
await deleteUser(id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue