Skip to content

Commit 61d3422

Browse files
fix(discord): harden API URL construction (#3659)
* fix(discord): harden API URL construction * fix(discord): tighten snowflake pattern to require 17-20 digits * style: fix oxfmt formatting in discord-id.test.ts --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
1 parent 414358c commit 61d3422

13 files changed

Lines changed: 762 additions & 46 deletions

apps/web/src/app/discord/webhook/route.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import {
1717
truncateForDiscord,
1818
} from '@/lib/discord-bot/discord-utils';
1919
import { getDevUserSuffix } from '@/lib/slack-bot/dev-user-info';
20+
import {
21+
parseForwardedGatewayMessageEvent,
22+
type ForwardedGatewayEvent,
23+
} from '@/lib/discord-bot/forwarded-gateway-event';
2024

2125
export const maxDuration = 800;
2226

@@ -26,24 +30,6 @@ export const maxDuration = 800;
2630
const PROCESSING_EMOJI = '\u23f3'; // hourglass
2731
const COMPLETE_EMOJI = '\u2705'; // white check mark
2832

29-
/**
30-
* Forwarded Gateway event shape (from the Gateway listener)
31-
*/
32-
type ForwardedGatewayEvent = {
33-
type: string;
34-
timestamp: number;
35-
botUserId: string | null;
36-
data: {
37-
id: string;
38-
content: string;
39-
channel_id: string;
40-
guild_id: string;
41-
author: { id: string; username: string; bot?: boolean };
42-
mentions?: Array<{ id: string }>;
43-
message_reference?: { message_id: string };
44-
};
45-
};
46-
4733
/**
4834
* Discord webhook handler.
4935
* Handles:
@@ -60,10 +46,19 @@ export async function POST(request: NextRequest) {
6046
return new NextResponse('Unauthorized', { status: 401 });
6147
}
6248

63-
const event = JSON.parse(rawBody) as ForwardedGatewayEvent;
64-
if (event.type === 'GATEWAY_MESSAGE_CREATE') {
65-
after(processGatewayMessage(event));
49+
let parsedBody: unknown;
50+
try {
51+
parsedBody = JSON.parse(rawBody);
52+
} catch {
53+
return new NextResponse('Invalid gateway event', { status: 400 });
6654
}
55+
56+
const event = parseForwardedGatewayMessageEvent(parsedBody);
57+
if (!event) {
58+
return new NextResponse('Invalid gateway event', { status: 400 });
59+
}
60+
61+
after(processGatewayMessage(event));
6762
return new NextResponse(null, { status: 200 });
6863
}
6964

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
jest.mock('@/lib/config.server', () => ({
2+
DISCORD_BOT_TOKEN: 'bot-token',
3+
}));
4+
5+
jest.mock('@sentry/nextjs', () => ({
6+
captureException: jest.fn(),
7+
}));
8+
9+
import { getDiscordConversationContext } from './discord-channel-context';
10+
11+
describe('getDiscordConversationContext', () => {
12+
beforeEach(() => {
13+
jest.restoreAllMocks();
14+
});
15+
16+
it('does not fetch Discord API data when the channel ID is malformed', async () => {
17+
const fetchSpy = jest.spyOn(globalThis, 'fetch');
18+
19+
const result = await getDiscordConversationContext({
20+
channelId: '../../users/@me',
21+
guildId: '111111111111111111',
22+
userId: '222222222222222222',
23+
messageId: '333333333333333333',
24+
});
25+
26+
expect(fetchSpy).not.toHaveBeenCalled();
27+
expect(result.channel).toBeNull();
28+
expect(result.recentMessages).toEqual([]);
29+
expect(result.errors).toEqual(['Invalid Discord channel ID']);
30+
});
31+
32+
it('uses fixed-origin Discord API URLs for valid context fetches', async () => {
33+
const fetchSpy = jest
34+
.spyOn(globalThis, 'fetch')
35+
.mockResolvedValueOnce(
36+
new Response(JSON.stringify({ id: '111111111111111111', type: 0, name: 'general' }), {
37+
status: 200,
38+
})
39+
)
40+
.mockResolvedValueOnce(
41+
new Response(
42+
JSON.stringify([
43+
{
44+
id: '222222222222222222',
45+
content: 'hello',
46+
timestamp: '2026-06-02T00:00:00.000Z',
47+
author: { id: '333333333333333333', username: 'alice' },
48+
},
49+
]),
50+
{ status: 200 }
51+
)
52+
);
53+
54+
const result = await getDiscordConversationContext(
55+
{
56+
channelId: '111111111111111111',
57+
guildId: '444444444444444444',
58+
userId: '333333333333333333',
59+
messageId: '222222222222222222',
60+
},
61+
{ channelMessages: 1 }
62+
);
63+
64+
expect(result.errors).toEqual([]);
65+
expect(fetchSpy).toHaveBeenNthCalledWith(
66+
1,
67+
'https://discord.com/api/v10/channels/111111111111111111',
68+
{ headers: { Authorization: 'Bot bot-token' } }
69+
);
70+
expect(fetchSpy).toHaveBeenNthCalledWith(
71+
2,
72+
'https://discord.com/api/v10/channels/111111111111111111/messages?limit=1',
73+
{ headers: { Authorization: 'Bot bot-token' } }
74+
);
75+
});
76+
});

apps/web/src/lib/discord-bot/discord-channel-context.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'server-only';
22
import { DISCORD_BOT_TOKEN } from '@/lib/config.server';
33
import { captureException } from '@sentry/nextjs';
4+
import { buildDiscordApiUrl, parseDiscordSnowflake } from './discord-id';
45

56
export type DiscordEventContext = {
67
channelId: string;
@@ -44,14 +45,15 @@ type DiscordApiMessage = {
4445
};
4546

4647
async function fetchDiscordApi<T>(
47-
path: string
48+
pathSegments: string[],
49+
query?: Record<string, string | number>
4850
): Promise<{ ok: true; data: T } | { ok: false; error: string }> {
4951
if (!DISCORD_BOT_TOKEN) {
5052
return { ok: false, error: 'DISCORD_BOT_TOKEN is not configured' };
5153
}
5254

5355
try {
54-
const response = await fetch(`https://discord.com/api/v10${path}`, {
56+
const response = await fetch(buildDiscordApiUrl(pathSegments, query), {
5557
headers: { Authorization: `Bot ${DISCORD_BOT_TOKEN}` },
5658
});
5759

@@ -71,7 +73,14 @@ async function fetchDiscordApi<T>(
7173
async function getChannelInfo(
7274
channelId: string
7375
): Promise<{ ok: true; channel: DiscordChannelInfo } | { ok: false; error: string }> {
74-
const result = await fetchDiscordApi<DiscordApiChannel>(`/channels/${channelId}`);
76+
let validatedChannelId: string;
77+
try {
78+
validatedChannelId = parseDiscordSnowflake(channelId, 'channel ID');
79+
} catch (error) {
80+
return { ok: false, error: error instanceof Error ? error.message : 'Invalid channel ID' };
81+
}
82+
83+
const result = await fetchDiscordApi<DiscordApiChannel>(['channels', validatedChannelId]);
7584
if (!result.ok) return result;
7685

7786
return {
@@ -89,8 +98,22 @@ async function getChannelMessages(
8998
channelId: string,
9099
limit: number
91100
): Promise<{ ok: true; messages: DiscordMessageForPrompt[] } | { ok: false; error: string }> {
101+
let validatedChannelId: string;
102+
try {
103+
validatedChannelId = parseDiscordSnowflake(channelId, 'channel ID');
104+
} catch (error) {
105+
return { ok: false, error: error instanceof Error ? error.message : 'Invalid channel ID' };
106+
}
107+
108+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
109+
return { ok: false, error: 'Invalid Discord channel message limit' };
110+
}
111+
92112
const result = await fetchDiscordApi<DiscordApiMessage[]>(
93-
`/channels/${channelId}/messages?limit=${limit}`
113+
['channels', validatedChannelId, 'messages'],
114+
{
115+
limit,
116+
}
94117
);
95118
if (!result.ok) return result;
96119

@@ -111,6 +134,31 @@ export async function getDiscordConversationContext(
111134
const channelMessagesLimit = limits?.channelMessages ?? 12;
112135
const errors: string[] = [];
113136

137+
const contextIds = [
138+
{ fieldName: 'guild ID', value: context.guildId },
139+
{ fieldName: 'channel ID', value: context.channelId },
140+
{ fieldName: 'user ID', value: context.userId },
141+
{ fieldName: 'message ID', value: context.messageId },
142+
];
143+
144+
for (const { fieldName, value } of contextIds) {
145+
try {
146+
parseDiscordSnowflake(value, fieldName);
147+
} catch (error) {
148+
errors.push(error instanceof Error ? error.message : `Invalid Discord ${fieldName}`);
149+
}
150+
}
151+
152+
if (errors.length > 0) {
153+
captureException(new Error('Invalid Discord conversation context'), {
154+
level: 'warning',
155+
tags: { source: 'discord_conversation_context' },
156+
extra: { errors },
157+
});
158+
159+
return { channel: null, recentMessages: [], errors };
160+
}
161+
114162
const [channelInfoResult, messagesResult] = await Promise.all([
115163
getChannelInfo(context.channelId),
116164
getChannelMessages(context.channelId, channelMessagesLimit),
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { buildDiscordApiUrl, isDiscordSnowflake, parseDiscordSnowflake } from './discord-id';
2+
3+
describe('discord-id', () => {
4+
it('accepts numeric snowflake values', () => {
5+
expect(isDiscordSnowflake('123456789012345678')).toBe(true);
6+
expect(parseDiscordSnowflake('123456789012345678', 'user ID')).toBe('123456789012345678');
7+
});
8+
9+
it.each([
10+
'',
11+
' ',
12+
'abc',
13+
'123/456',
14+
'123?limit=1',
15+
'123#frag',
16+
'%2f',
17+
'..',
18+
'1',
19+
'1234',
20+
'1234567890123456',
21+
'1'.repeat(21),
22+
])('rejects malformed snowflake value %p', value => {
23+
expect(isDiscordSnowflake(value)).toBe(false);
24+
expect(() => parseDiscordSnowflake(value, 'user ID')).toThrow('Invalid Discord user ID');
25+
});
26+
27+
it('builds fixed-origin Discord API URLs with encoded path segments', () => {
28+
expect(buildDiscordApiUrl(['channels', '123', 'messages'], { limit: 12 })).toBe(
29+
'https://discord.com/api/v10/channels/123/messages?limit=12'
30+
);
31+
expect(buildDiscordApiUrl(['reactions', '✅', '@me'])).toBe(
32+
'https://discord.com/api/v10/reactions/%E2%9C%85/%40me'
33+
);
34+
});
35+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const DISCORD_API_BASE_URL = 'https://discord.com/api/v10/';
2+
const DISCORD_SNOWFLAKE_PATTERN = /^\d{17,20}$/;
3+
4+
export function isDiscordSnowflake(value: string): boolean {
5+
return DISCORD_SNOWFLAKE_PATTERN.test(value);
6+
}
7+
8+
export function parseDiscordSnowflake(value: string, fieldName: string): string {
9+
if (isDiscordSnowflake(value)) {
10+
return value;
11+
}
12+
13+
throw new Error(`Invalid Discord ${fieldName}`);
14+
}
15+
16+
export function buildDiscordApiUrl(
17+
pathSegments: string[],
18+
query?: Record<string, string | number>
19+
): string {
20+
const url = new URL(
21+
pathSegments.map(segment => encodeURIComponent(segment)).join('/'),
22+
DISCORD_API_BASE_URL
23+
);
24+
25+
if (query) {
26+
for (const [key, value] of Object.entries(query)) {
27+
url.searchParams.set(key, String(value));
28+
}
29+
}
30+
31+
return url.toString();
32+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
jest.mock('@/lib/config.server', () => ({
2+
DISCORD_BOT_TOKEN: 'bot-token',
3+
}));
4+
5+
import {
6+
buildDiscordMessageLink,
7+
replaceDiscordUserMentionsWithNames,
8+
stripDiscordBotMention,
9+
} from './discord-utils';
10+
11+
describe('discord-utils', () => {
12+
beforeEach(() => {
13+
jest.restoreAllMocks();
14+
});
15+
16+
it('does not strip mentions when the bot ID is malformed', () => {
17+
expect(stripDiscordBotMention('<@bot/1> hello', 'bot/1')).toBe('<@bot/1> hello');
18+
});
19+
20+
it('rejects malformed message link IDs', () => {
21+
expect(() => buildDiscordMessageLink('111111111111111111', '2/../3', '4')).toThrow(
22+
'Invalid Discord channel ID'
23+
);
24+
});
25+
26+
it('does not fetch members when the guild ID is malformed', async () => {
27+
const fetchSpy = jest.spyOn(globalThis, 'fetch');
28+
29+
await expect(
30+
replaceDiscordUserMentionsWithNames('<@123456789012345678>', 'guild/1')
31+
).resolves.toBe('<@123456789012345678>');
32+
expect(fetchSpy).not.toHaveBeenCalled();
33+
});
34+
35+
it('fetches valid mention IDs through the fixed Discord API origin', async () => {
36+
const fetchSpy = jest
37+
.spyOn(globalThis, 'fetch')
38+
.mockResolvedValue(new Response(JSON.stringify({ nick: 'Alice' }), { status: 200 }));
39+
40+
await expect(
41+
replaceDiscordUserMentionsWithNames('<@123456789012345678>', '234567890123456789')
42+
).resolves.toBe('@Alice');
43+
44+
expect(fetchSpy).toHaveBeenCalledWith(
45+
'https://discord.com/api/v10/guilds/234567890123456789/members/123456789012345678',
46+
{ headers: { Authorization: 'Bot bot-token' } }
47+
);
48+
});
49+
});

0 commit comments

Comments
 (0)