0
Fork 0
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:
Xiao Yijun 2023-05-23 18:02:17 +08:00 committed by GitHub
parent c6821aab77
commit 0c79437ba4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 137 additions and 13 deletions

View file

@ -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',

View file

@ -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 },

View 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);
});
});

View 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');
};

View file

@ -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 () =>

View file

@ -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);
});
});