This commit was merged in pull request #716.
This commit is contained in:
89
apps/gateway/src/plugin/discord-ingress.security.spec.ts
Normal file
89
apps/gateway/src/plugin/discord-ingress.security.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createDiscordIngressEnvelope,
|
||||
verifyDiscordIngressEnvelope,
|
||||
type DiscordIngressPayload,
|
||||
} from '@mosaicstack/discord-plugin';
|
||||
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
||||
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
||||
|
||||
const SERVICE_TOKEN = 'test-service-token';
|
||||
|
||||
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
|
||||
return {
|
||||
correlationId: 'correlation-001',
|
||||
messageId: 'discord-message-001',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
userId: 'user-001',
|
||||
conversationId: 'discord-channel-001',
|
||||
content: 'hello Tess',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Discord ingress security', () => {
|
||||
it('accepts only the configured Discord service identity', () => {
|
||||
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
|
||||
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
|
||||
expect(validateDiscordServiceToken(undefined, SERVICE_TOKEN)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unauthenticated or tampered service envelopes', () => {
|
||||
const envelope = createDiscordIngressEnvelope(createPayload(), SERVICE_TOKEN);
|
||||
|
||||
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)).toEqual(createPayload());
|
||||
expect(verifyDiscordIngressEnvelope(envelope, 'wrong-service-token')).toBeNull();
|
||||
expect(
|
||||
verifyDiscordIngressEnvelope(
|
||||
{ ...envelope, payload: { ...envelope.payload, content: 'forged command' } },
|
||||
SERVICE_TOKEN,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['guild', { guildId: 'unlisted-guild' }],
|
||||
['channel', { channelId: 'unlisted-channel' }],
|
||||
['user', { userId: 'unlisted-user' }],
|
||||
])(
|
||||
'rejects an unallowlisted Discord %s',
|
||||
(_kind: string, overrides: Partial<DiscordIngressPayload>) => {
|
||||
const envelope = createDiscordIngressEnvelope(createPayload(overrides), SERVICE_TOKEN);
|
||||
|
||||
expect(
|
||||
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
|
||||
guildIds: ['guild-001'],
|
||||
channelIds: ['channel-001'],
|
||||
userIds: ['user-001'],
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('retains Discord message and correlation IDs after authenticated allowlisted validation', () => {
|
||||
const payload = createPayload({
|
||||
correlationId: 'correlation-trace-123',
|
||||
messageId: 'discord-snowflake-987',
|
||||
});
|
||||
const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);
|
||||
|
||||
expect(
|
||||
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
|
||||
guildIds: ['guild-001'],
|
||||
channelIds: ['channel-001'],
|
||||
userIds: ['user-001'],
|
||||
}),
|
||||
).toEqual(payload);
|
||||
});
|
||||
|
||||
it('rejects a replayed Discord message ID while retaining bounded replay state', () => {
|
||||
const replayProtector = new DiscordReplayProtector(60_000, 2);
|
||||
|
||||
expect(replayProtector.claim('discord-message-001')).toBe(true);
|
||||
expect(replayProtector.claim('discord-message-001')).toBe(false);
|
||||
expect(replayProtector.claim('discord-message-002')).toBe(true);
|
||||
expect(replayProtector.claim('discord-message-003')).toBe(true);
|
||||
expect(replayProtector.size).toBe(2);
|
||||
});
|
||||
});
|
||||
40
apps/gateway/src/plugin/discord-replay-protector.ts
Normal file
40
apps/gateway/src/plugin/discord-replay-protector.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Bounded replay cache for Discord's globally unique native message IDs.
|
||||
* Durable ingress idempotency is added with Tess's canonical inbox/outbox work.
|
||||
*/
|
||||
export class DiscordReplayProtector {
|
||||
private readonly claimedAt = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly ttlMs = 15 * 60 * 1000,
|
||||
private readonly maxEntries = 10_000,
|
||||
) {}
|
||||
|
||||
get size(): number {
|
||||
return this.claimedAt.size;
|
||||
}
|
||||
|
||||
/** Claims an ID exactly once within its bounded retention window. */
|
||||
claim(messageId: string, now = Date.now()): boolean {
|
||||
this.prune(now);
|
||||
if (this.claimedAt.has(messageId)) return false;
|
||||
|
||||
this.claimedAt.set(messageId, now);
|
||||
this.evictOverflow();
|
||||
return true;
|
||||
}
|
||||
|
||||
private prune(now: number): void {
|
||||
for (const [messageId, claimedAt] of this.claimedAt) {
|
||||
if (now - claimedAt >= this.ttlMs) this.claimedAt.delete(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
private evictOverflow(): void {
|
||||
while (this.claimedAt.size > this.maxEntries) {
|
||||
const oldestMessageId = this.claimedAt.keys().next().value;
|
||||
if (oldestMessageId === undefined) return;
|
||||
this.claimedAt.delete(oldestMessageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,19 +50,41 @@ class TelegramChannelPluginAdapter implements IChannelPlugin {
|
||||
|
||||
const DEFAULT_GATEWAY_URL = 'http://localhost:14242';
|
||||
|
||||
function requiredDiscordAllowlist(name: string): string[] {
|
||||
const value = process.env[name]
|
||||
?.split(',')
|
||||
.map((id: string): string => id.trim())
|
||||
.filter((id: string): boolean => id.length > 0);
|
||||
if (!value || value.length === 0) {
|
||||
throw new Error(`${name} is required when DISCORD_BOT_TOKEN is configured`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function createPluginRegistry(): IChannelPlugin[] {
|
||||
const plugins: IChannelPlugin[] = [];
|
||||
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
||||
const discordGuildId = process.env['DISCORD_GUILD_ID'];
|
||||
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
|
||||
const discordServiceToken = process.env['DISCORD_SERVICE_TOKEN'];
|
||||
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
|
||||
|
||||
if (discordToken) {
|
||||
if (!discordServiceToken || !discordServiceUserId) {
|
||||
throw new Error(
|
||||
'DISCORD_SERVICE_TOKEN and DISCORD_SERVICE_USER_ID are required when DISCORD_BOT_TOKEN is configured',
|
||||
);
|
||||
}
|
||||
plugins.push(
|
||||
new DiscordChannelPluginAdapter(
|
||||
new DiscordPlugin({
|
||||
token: discordToken,
|
||||
guildId: discordGuildId,
|
||||
gatewayUrl: discordGatewayUrl,
|
||||
serviceToken: discordServiceToken,
|
||||
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
||||
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
||||
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user