add brute force interactions tests

This commit is contained in:
41666 2022-01-29 03:49:51 -05:00
parent 0d5bc60c92
commit f2508fbea4
6 changed files with 286 additions and 9 deletions

View file

@ -0,0 +1,22 @@
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
import { Context } from '@roleypoly/api/src/utils/context';
import {
InteractionCallbackType,
InteractionRequest,
InteractionResponse,
} from '@roleypoly/types';
export const helloWorld: InteractionHandler = (
interaction: InteractionRequest,
context: Context
): InteractionResponse => {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `Hey there, ${interaction.member?.nick || interaction.user?.username}`,
},
};
};
helloWorld.ephemeral = true;
helloWorld.deferred = true;

View file

@ -1,5 +1,12 @@
import { Config } from '@roleypoly/api/src/utils/config';
import { InteractionRequest } from '@roleypoly/types';
import { Context } from '@roleypoly/api/src/utils/context';
import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
import {
InteractionCallbackType,
InteractionFlags,
InteractionRequest,
InteractionResponse,
} from '@roleypoly/types';
export const verifyRequest = async (
config: Config,
@ -28,3 +35,60 @@ export const verifyRequest = async (
Buffer.from(timestamp + JSON.stringify(interaction))
);
};
export type InteractionHandler = ((
interaction: InteractionRequest,
context: Context
) => Promise<InteractionResponse> | InteractionResponse) & {
ephemeral?: boolean;
deferred?: boolean;
};
export const runAsync = async (
handler: InteractionHandler,
interaction: InteractionRequest,
context: Context
): Promise<void> => {
const url = `/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`;
try {
const response = await handler(interaction, context);
await discordFetch(url, '', AuthType.None, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
data: {
flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0,
...response.data,
},
}),
});
} catch (e) {
console.error('/interations runAsync failed', {
e,
interaction: {
data: interaction.data,
user: interaction.user,
guild: interaction.guild_id,
},
});
try {
await discordFetch(url, '', AuthType.None, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "I'm sorry, I'm having trouble processing this request.",
flags: InteractionFlags.EPHEMERAL,
},
} as InteractionResponse),
});
} catch (e) {}
}
};

View file

@ -1,3 +1,49 @@
describe('interactions validations from Discord', () => {
it('', () => {});
jest.mock('../../utils/discord');
import { InteractionCallbackType, InteractionFlags } from '@roleypoly/types';
import { AuthType, discordFetch } from '../../utils/discord';
import { configContext } from '../../utils/testHelpers';
import { extractInteractionResponse, makeInteractionsRequest } from './testHelpers';
const mockDiscordFetch = discordFetch as jest.Mock;
it('responds with a simple hello-world!', async () => {
const [config, context] = configContext();
const response = await makeInteractionsRequest(context, {
name: 'hello-world',
});
expect(response.status).toBe(200);
const interaction = await extractInteractionResponse(response);
expect(interaction.type).toEqual(
InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
);
expect(interaction.data).toEqual({
flags: InteractionFlags.EPHEMERAL,
});
expect(mockDiscordFetch).toBeCalledWith(expect.any(String), '', AuthType.None, {
body: JSON.stringify({
type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
data: {
flags: InteractionFlags.EPHEMERAL,
content: 'Hey there, test-user',
},
}),
headers: expect.any(Object),
method: 'PATCH',
});
});
it('does not allow requests that are invalid', async () => {
const [config, context] = configContext();
const response = await makeInteractionsRequest(
context,
{
name: 'hello-world',
},
true
);
expect(response.status).toBe(401);
});

View file

@ -1,7 +1,24 @@
import { verifyRequest } from '@roleypoly/api/src/routes/interactions/helpers';
import { helloWorld } from '@roleypoly/api/src/routes/interactions/commands/hello-world';
import {
InteractionHandler,
runAsync,
verifyRequest,
} from '@roleypoly/api/src/routes/interactions/helpers';
import { notImplemented } from '@roleypoly/api/src/routes/interactions/responses';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { invalid, json } from '@roleypoly/api/src/utils/response';
import { InteractionRequest, InteractionType } from '@roleypoly/types';
import {
InteractionCallbackType,
InteractionData,
InteractionFlags,
InteractionRequest,
InteractionResponse,
InteractionType,
} from '@roleypoly/types';
const commands: Record<InteractionData['name'], InteractionHandler> = {
'hello-world': helloWorld,
};
export const handleInteraction: RoleypolyHandler = async (
request: Request,
@ -17,6 +34,10 @@ export const handleInteraction: RoleypolyHandler = async (
}
if (interaction.type !== InteractionType.APPLICATION_COMMAND) {
if (interaction.type === InteractionType.PING) {
return json({ type: InteractionCallbackType.PONG });
}
return json({ err: 'not implemented' }, { status: 400 });
}
@ -24,5 +45,37 @@ export const handleInteraction: RoleypolyHandler = async (
return json({ err: 'data missing' }, { status: 400 });
}
return json({});
const handler = commands[interaction.data.name] || notImplemented;
try {
if (handler.deferred) {
context.fetchContext.waitUntil(runAsync(handler, interaction, context));
return json({
type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
data: {
flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0,
},
} as InteractionResponse);
}
const response = await handler(interaction, context);
return json({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0,
...response.data,
},
});
} catch (e) {
console.error('/interactions error:', {
interaction: {
data: interaction.data,
user: interaction.user,
guild: interaction.guild_id,
},
e,
});
return invalid();
}
};

View file

@ -1,10 +1,11 @@
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
import {
InteractionCallbackType,
InteractionFlags,
InteractionResponse,
} from '@roleypoly/types';
export const mustBeInGuild = (): InteractionResponse => ({
export const mustBeInGuild: InteractionHandler = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: ':x: This command has to be used in a server.',
@ -12,7 +13,7 @@ export const mustBeInGuild = (): InteractionResponse => ({
},
});
export const invalid = (): InteractionResponse => ({
export const invalid: InteractionHandler = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: ':x: You filled that command out wrong...',
@ -20,10 +21,18 @@ export const invalid = (): InteractionResponse => ({
},
});
export const somethingWentWrong = (): InteractionResponse => ({
export const somethingWentWrong: InteractionHandler = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: '<a:promareFlame:624850108667789333> Something went terribly wrong.',
flags: InteractionFlags.EPHEMERAL,
},
});
export const notImplemented: InteractionHandler = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: ':x: This command is not implemented yet.',
flags: InteractionFlags.EPHEMERAL,
},
});

View file

@ -0,0 +1,83 @@
import { handleInteraction } from '@roleypoly/api/src/routes/interactions/interactions';
import { Context } from '@roleypoly/api/src/utils/context';
import { getID } from '@roleypoly/api/src/utils/id';
import {
InteractionData,
InteractionRequest,
InteractionResponse,
InteractionType,
} from '@roleypoly/types';
import nacl from 'tweetnacl';
const { publicKey, secretKey } = nacl.sign.keyPair();
const hexPublicKey = Buffer.from(publicKey).toString('hex');
export const getSignatureHeaders = (
context: Context,
interaction: InteractionRequest
): {
'x-signature-ed25519': string;
'x-signature-timestamp': string;
} => {
const timestamp = Date.now().toString();
const body = JSON.stringify(interaction);
const signature = nacl.sign.detached(Buffer.from(timestamp + body), secretKey);
return {
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
'x-signature-timestamp': timestamp,
};
};
export const makeInteractionsRequest = async (
context: Context,
interactionData: Partial<InteractionData>,
forceInvalid?: boolean
): Promise<Response> => {
context.config.publicKey = hexPublicKey;
const interaction: InteractionRequest = {
data: {
id: getID(),
name: 'hello-world',
...interactionData,
} as InteractionData,
id: '123',
type: InteractionType.APPLICATION_COMMAND,
application_id: context.config.botClientID,
token: getID(),
version: 1,
user: {
id: '123',
username: 'test-user',
discriminator: '1234',
bot: false,
avatar: '',
},
member: {
nick: 'test-user',
roles: [],
},
};
const request = new Request('http://localhost:3000/interactions', {
method: 'POST',
headers: {
'content-type': 'application/json',
...getSignatureHeaders(context, {
...interaction,
...(forceInvalid ? { id: 'invalid-id' } : {}),
}),
},
body: JSON.stringify(interaction),
});
return handleInteraction(request, context);
};
export const extractInteractionResponse = async (
response: Response
): Promise<InteractionResponse> => {
const body = await response.json();
return body as InteractionResponse;
};