diff --git a/src/channels/official-channels-store.ts b/src/channels/official-channels-store.ts index 00624d28..0c8cbc98 100644 --- a/src/channels/official-channels-store.ts +++ b/src/channels/official-channels-store.ts @@ -22,6 +22,8 @@ export interface OfficialChannelTokenStatus { processEnvAvailable: boolean; } +const MAX_OFFICIAL_CHANNEL_TOKEN_LENGTH = 4096; + function getResolvedStateDirOverride( channelId: OfficialChannelId, envOverrides?: NodeJS.ProcessEnv | null @@ -293,6 +295,11 @@ export function setConfiguredOfficialChannelToken( if (!normalized) { throw new Error(`${envKey} cannot be empty or multiline.`); } + if (normalized.length > MAX_OFFICIAL_CHANNEL_TOKEN_LENGTH) { + throw new Error( + `${envKey} cannot exceed ${MAX_OFFICIAL_CHANNEL_TOKEN_LENGTH} characters.` + ); + } const envPath = getOfficialChannelEnvPath(channelId); const currentContent = readFileIfExists(envPath) ?? ''; diff --git a/src/web-server/routes/channels-routes.ts b/src/web-server/routes/channels-routes.ts index 044dd5ca..89a67cef 100644 --- a/src/web-server/routes/channels-routes.ts +++ b/src/web-server/routes/channels-routes.ts @@ -178,7 +178,8 @@ router.put('/:channelId/token', (req: Request, res: Response): void => { res.json({ success: true, tokenConfigured: true, tokenPath }); } catch (error) { const message = (error as Error).message; - const statusCode = message.includes('cannot be empty') ? 400 : 500; + const statusCode = + message.includes('cannot be empty') || message.includes('cannot exceed') ? 400 : 500; res.status(statusCode).json({ error: message }); } }); diff --git a/tests/unit/channels/official-channels-store.test.ts b/tests/unit/channels/official-channels-store.test.ts index e114c000..7731ad92 100644 --- a/tests/unit/channels/official-channels-store.test.ts +++ b/tests/unit/channels/official-channels-store.test.ts @@ -56,6 +56,16 @@ describe('official channels token store', () => { expect(readConfiguredOfficialChannelToken('telegram')).toBe('telegram-secret'); }); + it('rejects oversized official channel tokens before writing channel state', () => { + const envPath = getOfficialChannelEnvPath('discord'); + + expect(() => setConfiguredOfficialChannelToken('discord', 'x'.repeat(4097))).toThrow( + 'DISCORD_BOT_TOKEN cannot exceed 4096 characters.' + ); + expect(fs.existsSync(envPath)).toBe(false); + expect(hasConfiguredOfficialChannelToken('discord')).toBe(false); + }); + it('uses the official state-dir override when one is configured', () => { const originalDiscordStateDir = process.env.DISCORD_STATE_DIR; process.env.DISCORD_STATE_DIR = path.join(tempHome, 'discord-state'); diff --git a/tests/unit/web-server/channels-routes.test.ts b/tests/unit/web-server/channels-routes.test.ts index f414f742..5afc77ad 100644 --- a/tests/unit/web-server/channels-routes.test.ts +++ b/tests/unit/web-server/channels-routes.test.ts @@ -194,4 +194,16 @@ describe('web-server channels-routes', () => { else delete process.env.DISCORD_BOT_TOKEN; } }); + + it('rejects oversized token writes with a 400 response', async () => { + const response = await putJson(baseUrl, '/api/channels/discord/token', { + token: 'x'.repeat(4097), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'DISCORD_BOT_TOKEN cannot exceed 4096 characters.', + }); + expect(fs.existsSync(path.join(tempHome, '.claude', 'channels', 'discord', '.env'))).toBe(false); + }); });