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';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsmWithActual } = createMockUtils(jest);
|
||||
const { mockEsmWithActual, mockEsm } = createMockUtils(jest);
|
||||
|
||||
const nanoIdMock = 'mockId';
|
||||
await mockEsmWithActual('@logto/shared', () => ({
|
||||
|
@ -15,6 +15,11 @@ await mockEsmWithActual('@logto/shared', () => ({
|
|||
generateStandardId: () => nanoIdMock,
|
||||
}));
|
||||
|
||||
const mockSignature = 'mockSignature';
|
||||
mockEsm('#src/utils/sign.js', () => ({
|
||||
sign: () => mockSignature,
|
||||
}));
|
||||
|
||||
const { MockQueries } = await import('#src/test-utils/tenant.js');
|
||||
|
||||
const url = 'https://logto.gg';
|
||||
|
@ -78,7 +83,11 @@ describe('triggerInteractionHooksIfNeeded()', () => {
|
|||
|
||||
expect(findAllHooks).toHaveBeenCalled();
|
||||
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: {
|
||||
hookId: 'foo',
|
||||
event: 'PostSignIn',
|
||||
|
|
|
@ -7,13 +7,14 @@ import {
|
|||
} from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { conditional, pick, trySafe } from '@silverhand/essentials';
|
||||
import type { Response } from 'got';
|
||||
import { got, HTTPError } from 'got';
|
||||
import { type Response } from 'got';
|
||||
import type Provider from 'oidc-provider';
|
||||
|
||||
import { LogEntry } from '#src/middleware/koa-audit-log.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import { consoleLog } from '#src/utils/console.js';
|
||||
import { sign } from '#src/utils/sign.js';
|
||||
|
||||
const parseResponse = ({ statusCode, body }: Response) => ({
|
||||
statusCode,
|
||||
|
@ -81,7 +82,7 @@ export const createHookLibrary = (queries: Queries) => {
|
|||
} satisfies Omit<HookEventPayload, 'hookId'>;
|
||||
|
||||
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`);
|
||||
const json: HookEventPayload = { hookId: id, ...payload };
|
||||
const logEntry = new LogEntry(`TriggerHook.${hookEvent}`);
|
||||
|
@ -91,7 +92,11 @@ export const createHookLibrary = (queries: Queries) => {
|
|||
// Trigger web hook and log response
|
||||
await got
|
||||
.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,
|
||||
retry: { limit: retries ?? 3 },
|
||||
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 { createServer } from 'node:http';
|
||||
import { createServer, type RequestListener } from 'node:http';
|
||||
import path from 'node:path';
|
||||
|
||||
import { mockSmsVerificationCodeFileName } from '@logto/connector-kit';
|
||||
|
@ -87,12 +87,14 @@ export const expectRequestError = (error: unknown, code: string, messageIncludes
|
|||
}
|
||||
};
|
||||
|
||||
export const createMockServer = (port: number) => {
|
||||
const server = createServer((request, response) => {
|
||||
const defaultRequestListener: RequestListener = (request, response) => {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
response.statusCode = 204;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
|
||||
export const createMockServer = (port: number, requestListener?: RequestListener) => {
|
||||
const server = createServer(requestListener ?? defaultRequestListener);
|
||||
|
||||
return {
|
||||
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 { 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', () => {
|
||||
const { listen, close } = createMockServer(9999);
|
||||
const { listen, close } = createMockServer(9999, hookServerRequestListener);
|
||||
|
||||
beforeAll(async () => {
|
||||
await enableAllPasswordSignInMethods({
|
||||
|
@ -259,4 +289,51 @@ describe('hooks', () => {
|
|||
|
||||
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