From 3382cd8db93c4cab12a2767d76e766242b72aa35 Mon Sep 17 00:00:00 2001 From: Polliog Date: Fri, 27 Mar 2026 20:01:00 +0100 Subject: [PATCH 01/12] fix multiple bugs across frontend and backend - fix SSE auth using wrong localStorage key (session_token -> getAuthToken) - fix incidentsTotal never updated in siem store - fix authStore.subscribe memory leaks (20+ components missing onDestroy cleanup) - fix offset=0 dropped in API calls (falsy check on 0) - fix search debounce timer not cleared on destroy - fix escapeHtml using DOM nodes instead of pure string replace - fix SSE stream re-emitting duplicate logs on same timestamp - fix verifyProjectAccess called twice for array projectId - fix getInvitationByToken returning expired invitations - fix time_bucket missing ::interval cast in siem dashboard - fix login creating session for disabled users - fix severity ordering using alphabetical MAX instead of severity rank - fix updateIncident silently skipping falsy title/severity/status - rebuild shared package to fix stale dist types --- .../src/modules/invitations/service.ts | 1 + packages/backend/src/modules/query/routes.ts | 55 ++++++++++--------- .../src/modules/siem/dashboard-service.ts | 2 +- packages/backend/src/modules/siem/service.ts | 6 +- packages/backend/src/modules/users/service.ts | 7 ++- .../src/queue/jobs/incident-autogrouping.ts | 2 +- packages/frontend/src/lib/api/exceptions.ts | 2 +- packages/frontend/src/lib/api/logs.ts | 2 +- packages/frontend/src/lib/api/siem.ts | 2 +- packages/frontend/src/lib/api/traces.ts | 2 +- .../components/OrganizationSwitcher.svelte | 10 +++- .../lib/components/UserSettingsDialog.svelte | 7 ++- .../onboarding/steps/ApiKeyStep.svelte | 11 +++- .../onboarding/steps/FirstLogStep.svelte | 6 +- .../onboarding/steps/OrganizationStep.svelte | 10 +++- .../onboarding/steps/ProjectStep.svelte | 10 +++- packages/frontend/src/lib/stores/siem.ts | 4 +- packages/frontend/src/lib/utils/siem.ts | 9 ++- .../src/routes/dashboard/metrics/+page.svelte | 8 ++- .../src/routes/dashboard/search/+page.svelte | 4 +- .../security/incidents/[id]/+page.svelte | 10 +++- .../dashboard/settings/audit-log/+page.svelte | 11 +++- .../dashboard/settings/channels/+page.svelte | 11 +++- .../dashboard/settings/general/+page.svelte | 11 +++- .../dashboard/settings/members/+page.svelte | 11 +++- .../dashboard/settings/patterns/+page.svelte | 11 +++- .../settings/pii-masking/+page.svelte | 8 ++- .../src/routes/dashboard/traces/+page.svelte | 8 ++- .../src/routes/invite/[token]/+page.svelte | 8 ++- .../frontend/src/routes/login/+page.svelte | 8 ++- .../src/routes/onboarding/+page.svelte | 11 +++- .../frontend/src/routes/register/+page.svelte | 8 ++- 32 files changed, 190 insertions(+), 86 deletions(-) diff --git a/packages/backend/src/modules/invitations/service.ts b/packages/backend/src/modules/invitations/service.ts index c84b2bc6..e6069165 100644 --- a/packages/backend/src/modules/invitations/service.ts +++ b/packages/backend/src/modules/invitations/service.ts @@ -283,6 +283,7 @@ export class InvitationsService { ]) .where('organization_invitations.token', '=', token) .where('organization_invitations.accepted_at', 'is', null) + .where('organization_invitations.expires_at', '>', new Date()) .executeTakeFirst(); if (!result) { diff --git a/packages/backend/src/modules/query/routes.ts b/packages/backend/src/modules/query/routes.ts index e68c299a..396e0a6f 100644 --- a/packages/backend/src/modules/query/routes.ts +++ b/packages/backend/src/modules/query/routes.ts @@ -95,26 +95,13 @@ const queryRoutes: FastifyPluginAsync = async (fastify) => { } if (request.user?.id) { - const hasAccess = await verifyProjectAccess( - Array.isArray(projectId) ? projectId[0] : projectId, - request.user.id - ); - - if (!hasAccess) { - return reply.code(403).send({ - error: 'Access denied - you do not have access to this project', - }); - } - - // If multiple projects requested, verify access to all - if (Array.isArray(projectId)) { - for (const pid of projectId) { - const access = await verifyProjectAccess(pid, request.user.id); - if (!access) { - return reply.code(403).send({ - error: `Access denied - you do not have access to project ${pid}`, - }); - } + const projectIds = Array.isArray(projectId) ? projectId : [projectId]; + for (const pid of projectIds) { + const hasAccess = await verifyProjectAccess(pid, request.user.id); + if (!hasAccess) { + return reply.code(403).send({ + error: `Access denied - you do not have access to project ${pid}`, + }); } } } @@ -761,8 +748,9 @@ const queryRoutes: FastifyPluginAsync = async (fastify) => { reply.raw.setHeader('Cache-Control', 'no-cache'); reply.raw.setHeader('Connection', 'keep-alive'); - // Track last timestamp to avoid duplicates + // Track last timestamp and sent IDs to avoid duplicates let lastTimestamp = new Date(); + let sentIds = new Set(); // Send initial connection message reply.raw.write(`data: ${JSON.stringify({ type: 'connected', timestamp: new Date() })}\n\n`); @@ -781,13 +769,26 @@ const queryRoutes: FastifyPluginAsync = async (fastify) => { }); if (newLogs.logs.length > 0) { - // Update last timestamp - const latestLog = newLogs.logs[newLogs.logs.length - 1]; - lastTimestamp = new Date(latestLog.time); + // Filter out already-sent logs + const unseenLogs = newLogs.logs.filter((log: any) => !sentIds.has(log.id)); + + if (unseenLogs.length > 0) { + // Update last timestamp + const latestLog = unseenLogs[unseenLogs.length - 1]; + lastTimestamp = new Date(latestLog.time); + + // Rebuild sentIds with only logs at the latest timestamp to bound memory + sentIds = new Set(); + for (const log of newLogs.logs) { + if (new Date(log.time).getTime() === lastTimestamp.getTime()) { + sentIds.add(log.id); + } + } - // Send each log as separate event - for (const log of newLogs.logs) { - reply.raw.write(`data: ${JSON.stringify({ type: 'log', data: log })}\n\n`); + // Send each new log as separate event + for (const log of unseenLogs) { + reply.raw.write(`data: ${JSON.stringify({ type: 'log', data: log })}\n\n`); + } } } diff --git a/packages/backend/src/modules/siem/dashboard-service.ts b/packages/backend/src/modules/siem/dashboard-service.ts index 64441c3a..697a8289 100644 --- a/packages/backend/src/modules/siem/dashboard-service.ts +++ b/packages/backend/src/modules/siem/dashboard-service.ts @@ -108,7 +108,7 @@ export class SiemDashboardService { let query = this.db .selectFrom('detection_events') .select([ - sql`time_bucket(${bucketInterval}, time)`.as('timestamp'), + sql`time_bucket(${bucketInterval}::interval, time)`.as('timestamp'), sql`count(*)::int`.as('count'), ]) .where('organization_id', '=', filters.organizationId) diff --git a/packages/backend/src/modules/siem/service.ts b/packages/backend/src/modules/siem/service.ts index adb7f7af..2c7a5b0e 100644 --- a/packages/backend/src/modules/siem/service.ts +++ b/packages/backend/src/modules/siem/service.ts @@ -267,12 +267,12 @@ export class SiemService { const result = await this.db .updateTable('incidents') .set({ - ...(updates.title && { title: updates.title }), + ...(updates.title !== undefined && { title: updates.title }), ...(updates.description !== undefined && { description: updates.description, }), - ...(updates.severity && { severity: updates.severity }), - ...(updates.status && { status: updates.status }), + ...(updates.severity !== undefined && { severity: updates.severity }), + ...(updates.status !== undefined && { status: updates.status }), ...(updates.assigneeId !== undefined && { assignee_id: updates.assigneeId, }), diff --git a/packages/backend/src/modules/users/service.ts b/packages/backend/src/modules/users/service.ts index 9d1f44ce..026e68b0 100644 --- a/packages/backend/src/modules/users/service.ts +++ b/packages/backend/src/modules/users/service.ts @@ -110,7 +110,7 @@ export class UsersService { // Find user by email const user = await db .selectFrom('users') - .select(['id', 'email', 'password_hash']) + .select(['id', 'email', 'password_hash', 'disabled']) .where('email', '=', input.email) .executeTakeFirst(); @@ -129,6 +129,11 @@ export class UsersService { throw new Error('Invalid email or password'); } + // Check if account is disabled + if (user.disabled) { + throw new Error('This account has been disabled'); + } + // Update last login await db .updateTable('users') diff --git a/packages/backend/src/queue/jobs/incident-autogrouping.ts b/packages/backend/src/queue/jobs/incident-autogrouping.ts index ef971afe..c5eb6b43 100644 --- a/packages/backend/src/queue/jobs/incident-autogrouping.ts +++ b/packages/backend/src/queue/jobs/incident-autogrouping.ts @@ -53,7 +53,7 @@ async function groupByTraceId(organizationId: string): Promise { 'trace_id', 'project_id', db.fn.count('id').as('count'), - db.fn.max('severity').as('maxSeverity'), // Highest severity wins + sql`(ARRAY['critical','high','medium','low','informational'])[MIN(CASE severity WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 WHEN 'informational' THEN 5 ELSE 5 END)]`.as('maxSeverity'), sql`array_agg(id)`.as('eventIds'), sql`array_agg(service)`.as('services'), db.fn.min('time').as('firstSeen'), diff --git a/packages/frontend/src/lib/api/exceptions.ts b/packages/frontend/src/lib/api/exceptions.ts index ace40f0e..9f605d15 100644 --- a/packages/frontend/src/lib/api/exceptions.ts +++ b/packages/frontend/src/lib/api/exceptions.ts @@ -105,7 +105,7 @@ export async function getErrorGroups( if (filters.limit) { searchParams.append('limit', filters.limit.toString()); } - if (filters.offset) { + if (filters.offset != null) { searchParams.append('offset', filters.offset.toString()); } diff --git a/packages/frontend/src/lib/api/logs.ts b/packages/frontend/src/lib/api/logs.ts index 0bf6bbe1..d9ae86bf 100644 --- a/packages/frontend/src/lib/api/logs.ts +++ b/packages/frontend/src/lib/api/logs.ts @@ -122,7 +122,7 @@ export class LogsAPI { if (filters.q) params.append('q', filters.q); if (filters.searchMode) params.append('searchMode', filters.searchMode); if (filters.limit) params.append('limit', filters.limit.toString()); - if (filters.offset) params.append('offset', filters.offset.toString()); + if (filters.offset != null) params.append('offset', filters.offset.toString()); if (filters.cursor) params.append('cursor', filters.cursor); const url = `${getApiBaseUrl()}/logs?${params.toString()}`; diff --git a/packages/frontend/src/lib/api/siem.ts b/packages/frontend/src/lib/api/siem.ts index 8dc86ce5..65c6c0ef 100644 --- a/packages/frontend/src/lib/api/siem.ts +++ b/packages/frontend/src/lib/api/siem.ts @@ -146,7 +146,7 @@ export async function listIncidents(filters: IncidentFilters): Promise<{ inciden searchParams.append('limit', filters.limit.toString()); } - if (filters.offset) { + if (filters.offset != null) { searchParams.append('offset', filters.offset.toString()); } diff --git a/packages/frontend/src/lib/api/traces.ts b/packages/frontend/src/lib/api/traces.ts index f4c9d72d..cf340f5a 100644 --- a/packages/frontend/src/lib/api/traces.ts +++ b/packages/frontend/src/lib/api/traces.ts @@ -122,7 +122,7 @@ export class TracesAPI { if (filters.from) params.append('from', filters.from); if (filters.to) params.append('to', filters.to); if (filters.limit) params.append('limit', filters.limit.toString()); - if (filters.offset) params.append('offset', filters.offset.toString()); + if (filters.offset != null) params.append('offset', filters.offset.toString()); const url = `${getApiBaseUrl()}/traces?${params.toString()}`; diff --git a/packages/frontend/src/lib/components/OrganizationSwitcher.svelte b/packages/frontend/src/lib/components/OrganizationSwitcher.svelte index 635199a4..d83ce311 100644 --- a/packages/frontend/src/lib/components/OrganizationSwitcher.svelte +++ b/packages/frontend/src/lib/components/OrganizationSwitcher.svelte @@ -1,4 +1,5 @@ diff --git a/packages/reservoir/package.json b/packages/reservoir/package.json index b2d786e9..dc36e5a9 100644 --- a/packages/reservoir/package.json +++ b/packages/reservoir/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/reservoir", - "version": "0.8.4", + "version": "0.8.5", "description": "Pluggable storage abstraction for Logtide log management", "type": "module", "main": "./dist/index.js", diff --git a/packages/shared/package.json b/packages/shared/package.json index 7a278ec0..1a575e94 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/shared", - "version": "0.8.4", + "version": "0.8.5", "private": true, "description": "Shared types, schemas and utilities for LogTide", "type": "module", From 94cd28e85ad8a8b1e7bb68e9df7137a2c924a838 Mon Sep 17 00:00:00 2001 From: Polliog Date: Fri, 27 Mar 2026 21:38:29 +0100 Subject: [PATCH 09/12] Date change in CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5335e04e..518c419b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.8.5] - 2026-03-27 +## [0.8.5] - 2026-03-28 ### Security - **Cross-org isolation fix in SIEM**: `linkDetectionEventsToIncident` now scopes detection events to the requesting organization, preventing cross-tenant data corruption via crafted API calls From 6f2bfeb33759e50042170df501f04423bf84ff20 Mon Sep 17 00:00:00 2001 From: Polliog Date: Fri, 27 Mar 2026 21:54:54 +0100 Subject: [PATCH 10/12] fix test failures from pattern routes and invitation changes - pattern PUT/DELETE: keep 400 for missing organizationId, add org membership check after - revert expires_at filter on getInvitationByToken (intentional: frontend shows expiry message to user) --- .../src/modules/correlation/pattern-routes.ts | 22 +++++++++++++++++-- .../src/modules/invitations/service.ts | 1 - 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/modules/correlation/pattern-routes.ts b/packages/backend/src/modules/correlation/pattern-routes.ts index 0b173866..5ea8426f 100644 --- a/packages/backend/src/modules/correlation/pattern-routes.ts +++ b/packages/backend/src/modules/correlation/pattern-routes.ts @@ -389,9 +389,18 @@ export default async function patternRoutes(fastify: FastifyInstance) { }, async (request: any, reply) => { const { id } = request.params; + const requestedOrgId = (request as any).organizationId || request.query.organizationId; + + if (!requestedOrgId) { + return reply.status(400).send({ + success: false, + error: 'Organization ID is required', + }); + } + const organizationId = await getUserOrganizationId( request.user.id, - request.query.organizationId + requestedOrgId ); if (!organizationId) { @@ -503,9 +512,18 @@ export default async function patternRoutes(fastify: FastifyInstance) { }, async (request: any, reply) => { const { id } = request.params; + const requestedOrgId = (request as any).organizationId || request.query.organizationId; + + if (!requestedOrgId) { + return reply.status(400).send({ + success: false, + error: 'Organization ID is required', + }); + } + const organizationId = await getUserOrganizationId( request.user.id, - request.query.organizationId + requestedOrgId ); if (!organizationId) { diff --git a/packages/backend/src/modules/invitations/service.ts b/packages/backend/src/modules/invitations/service.ts index e6069165..c84b2bc6 100644 --- a/packages/backend/src/modules/invitations/service.ts +++ b/packages/backend/src/modules/invitations/service.ts @@ -283,7 +283,6 @@ export class InvitationsService { ]) .where('organization_invitations.token', '=', token) .where('organization_invitations.accepted_at', 'is', null) - .where('organization_invitations.expires_at', '>', new Date()) .executeTakeFirst(); if (!result) { From 30f97cf9e47e6d23419366f0560c820723d38556 Mon Sep 17 00:00:00 2001 From: Polliog Date: Fri, 27 Mar 2026 22:02:43 +0100 Subject: [PATCH 11/12] fix bootstrap test to match new default admin behavior ensureInitialAdmin now creates a system@logtide.internal admin when no env vars are set, test was still expecting 0 users --- .../src/tests/modules/bootstrap/bootstrap-service.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/tests/modules/bootstrap/bootstrap-service.test.ts b/packages/backend/src/tests/modules/bootstrap/bootstrap-service.test.ts index e5392b2d..4b98f86b 100644 --- a/packages/backend/src/tests/modules/bootstrap/bootstrap-service.test.ts +++ b/packages/backend/src/tests/modules/bootstrap/bootstrap-service.test.ts @@ -37,7 +37,7 @@ describe('BootstrapService', () => { }); describe('ensureInitialAdmin', () => { - it('should skip when env vars not set', async () => { + it('should create default system admin when env vars not set', async () => { // Mock config without initial admin const originalEmail = config.INITIAL_ADMIN_EMAIL; const originalPassword = config.INITIAL_ADMIN_PASSWORD; @@ -47,9 +47,11 @@ describe('BootstrapService', () => { await bootstrapService.ensureInitialAdmin(); - // No users should be created + // A default system admin should be created with fallback email const users = await db.selectFrom('users').selectAll().execute(); - expect(users).toHaveLength(0); + expect(users).toHaveLength(1); + expect(users[0].email).toBe('system@logtide.internal'); + expect(users[0].is_admin).toBe(true); // Restore (config as any).INITIAL_ADMIN_EMAIL = originalEmail; From 8ced294f0a0f9d2cd78f462b6584fd47316caa70 Mon Sep 17 00:00:00 2001 From: Polliog Date: Sat, 28 Mar 2026 00:22:20 +0100 Subject: [PATCH 12/12] add tests for SSRF protection and null historyId handling in alert notifications --- .../correlation/pattern-routes.test.ts | 123 +++++++++++++ .../tests/modules/retention/service.test.ts | 10 ++ .../tests/modules/users/users-service.test.ts | 21 +++ .../queue/jobs/alert-notification.test.ts | 161 ++++++++++++++++++ 4 files changed, 315 insertions(+) diff --git a/packages/backend/src/tests/modules/correlation/pattern-routes.test.ts b/packages/backend/src/tests/modules/correlation/pattern-routes.test.ts index cdfc083c..ef202962 100644 --- a/packages/backend/src/tests/modules/correlation/pattern-routes.test.ts +++ b/packages/backend/src/tests/modules/correlation/pattern-routes.test.ts @@ -397,6 +397,69 @@ describe('Pattern Routes', () => { expect(response.statusCode).toBe(400); }); + + it('should return 403 when user has no access to the specified organization', async () => { + // Create a second user to own the other org + const otherUser = await db + .insertInto('users') + .values({ + email: 'other-put@example.com', + password_hash: 'hash', + name: 'Other User', + }) + .returningAll() + .executeTakeFirstOrThrow(); + + // Create an organization that testUser is NOT a member of + const otherOrg = await db + .insertInto('organizations') + .values({ + name: 'Other Org', + slug: `other-org-put-${Date.now()}`, + owner_id: otherUser.id, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + // Add otherUser as member (but NOT testUser) + await db + .insertInto('organization_members') + .values({ + organization_id: otherOrg.id, + user_id: otherUser.id, + role: 'owner', + }) + .execute(); + + // Create a pattern in the other org + const pattern = await db + .insertInto('identifier_patterns') + .values({ + organization_id: otherOrg.id, + name: 'other_org_pattern', + display_name: 'Other Org Pattern', + pattern: '\\bOTHER\\b', + field_names: [], + enabled: true, + priority: 50, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + // Try to update it with testUser's auth token + const response = await app.inject({ + method: 'PUT', + url: `/v1/patterns/${pattern.id}?organizationId=${otherOrg.id}`, + headers: { + Authorization: `Bearer ${authToken}`, + }, + payload: { + displayName: 'Hacked Pattern', + }, + }); + + expect(response.statusCode).toBe(403); + }); }); describe('DELETE /v1/patterns/:id', () => { @@ -460,6 +523,66 @@ describe('Pattern Routes', () => { expect(response.statusCode).toBe(400); }); + + it('should return 403 when user has no access to the specified organization', async () => { + // Create a second user to own the other org + const otherUser = await db + .insertInto('users') + .values({ + email: 'other-delete@example.com', + password_hash: 'hash', + name: 'Other User', + }) + .returningAll() + .executeTakeFirstOrThrow(); + + // Create an organization that testUser is NOT a member of + const otherOrg = await db + .insertInto('organizations') + .values({ + name: 'Other Org', + slug: `other-org-del-${Date.now()}`, + owner_id: otherUser.id, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + // Add otherUser as member (but NOT testUser) + await db + .insertInto('organization_members') + .values({ + organization_id: otherOrg.id, + user_id: otherUser.id, + role: 'owner', + }) + .execute(); + + // Create a pattern in the other org + const pattern = await db + .insertInto('identifier_patterns') + .values({ + organization_id: otherOrg.id, + name: 'other_org_pattern_del', + display_name: 'Other Org Pattern', + pattern: '\\bOTHER\\b', + field_names: [], + enabled: true, + priority: 50, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + // Try to delete it with testUser's auth token + const response = await app.inject({ + method: 'DELETE', + url: `/v1/patterns/${pattern.id}?organizationId=${otherOrg.id}`, + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + + expect(response.statusCode).toBe(403); + }); }); describe('Regex Validation Edge Cases', () => { diff --git a/packages/backend/src/tests/modules/retention/service.test.ts b/packages/backend/src/tests/modules/retention/service.test.ts index b79cff17..e2fc3f0f 100644 --- a/packages/backend/src/tests/modules/retention/service.test.ts +++ b/packages/backend/src/tests/modules/retention/service.test.ts @@ -296,6 +296,16 @@ describe('RetentionService', () => { expect(summary.totalExecutionTimeMs).toBeGreaterThanOrEqual(0); }); + + it('should return early when no organizations exist', async () => { + const summary = await service.executeRetentionForAllOrganizations(); + + expect(summary.totalOrganizations).toBe(0); + expect(summary.successfulOrganizations).toBe(0); + expect(summary.failedOrganizations).toBe(0); + expect(summary.totalLogsDeleted).toBe(0); + expect(summary.results).toEqual([]); + }); }); describe('getOrganizationRetentionStatus - edge cases', () => { diff --git a/packages/backend/src/tests/modules/users/users-service.test.ts b/packages/backend/src/tests/modules/users/users-service.test.ts index 10eece80..3da578c6 100644 --- a/packages/backend/src/tests/modules/users/users-service.test.ts +++ b/packages/backend/src/tests/modules/users/users-service.test.ts @@ -191,6 +191,27 @@ describe('UsersService', () => { expect(updatedUser?.lastLogin).not.toBeNull(); }); + it('should reject login for disabled user', async () => { + await usersService.createUser({ + email: 'disabled@example.com', + password: 'password123', + name: 'Disabled User', + }); + + await db + .updateTable('users') + .set({ disabled: true }) + .where('email', '=', 'disabled@example.com') + .execute(); + + await expect( + usersService.login({ + email: 'disabled@example.com', + password: 'password123', + }) + ).rejects.toThrow('This account has been disabled'); + }); + it('should allow multiple concurrent sessions', async () => { await usersService.createUser({ email: 'multi@example.com', diff --git a/packages/backend/src/tests/queue/jobs/alert-notification.test.ts b/packages/backend/src/tests/queue/jobs/alert-notification.test.ts index 7fd5a306..da6f1b59 100644 --- a/packages/backend/src/tests/queue/jobs/alert-notification.test.ts +++ b/packages/backend/src/tests/queue/jobs/alert-notification.test.ts @@ -478,4 +478,165 @@ describe('Alert Notification Job', () => { ); }); }); + + describe('SSRF protection and null historyId', () => { + it('should block webhook to private IP addresses', async () => { + const { organization, project } = await createTestContext(); + const { alertsService } = await import('../../../modules/alerts/index.js'); + + // Mock notification channel with webhook pointing to loopback + mockGetAlertRuleChannels.mockResolvedValueOnce([{ + id: '00000000-0000-0000-0000-000000000010', + type: 'webhook', + enabled: true, + config: { url: 'http://127.0.0.1/webhook' }, + }]); + + const jobData: AlertNotificationData = { + historyId: '00000000-0000-0000-0000-000000000001', + rule_id: '00000000-0000-0000-0000-000000000002', + rule_name: 'SSRF Loopback Test', + organization_id: organization.id, + project_id: project.id, + log_count: 100, + threshold: 50, + time_window: 5, + email_recipients: [], + webhook_url: undefined, + }; + + await processAlertNotification({ data: jobData }); + + // fetch should NOT have been called since the URL is blocked + expect(mockFetch).not.toHaveBeenCalled(); + // markAsNotified should be called with an error about private addresses + expect(alertsService.markAsNotified).toHaveBeenCalledWith( + jobData.historyId, + expect.stringContaining('private/internal') + ); + }); + + it('should block webhook to link-local addresses', async () => { + const { organization, project } = await createTestContext(); + const { alertsService } = await import('../../../modules/alerts/index.js'); + + // Mock notification channel with webhook pointing to cloud metadata endpoint + mockGetAlertRuleChannels.mockResolvedValueOnce([{ + id: '00000000-0000-0000-0000-000000000010', + type: 'webhook', + enabled: true, + config: { url: 'http://169.254.169.254/latest/meta-data/' }, + }]); + + const jobData: AlertNotificationData = { + historyId: '00000000-0000-0000-0000-000000000001', + rule_id: '00000000-0000-0000-0000-000000000002', + rule_name: 'SSRF Link-Local Test', + organization_id: organization.id, + project_id: project.id, + log_count: 100, + threshold: 50, + time_window: 5, + email_recipients: [], + webhook_url: undefined, + }; + + await processAlertNotification({ data: jobData }); + + // fetch should NOT have been called since the URL is blocked + expect(mockFetch).not.toHaveBeenCalled(); + // markAsNotified should be called with an error about private addresses + expect(alertsService.markAsNotified).toHaveBeenCalledWith( + jobData.historyId, + expect.stringContaining('private/internal') + ); + }); + + it('should skip markAsNotified when historyId is null', async () => { + const { organization, project } = await createTestContext(); + const { alertsService } = await import('../../../modules/alerts/index.js'); + + const jobData: AlertNotificationData = { + historyId: null as any, + rule_id: '00000000-0000-0000-0000-000000000002', + rule_name: 'Null HistoryId Alert', + organization_id: organization.id, + project_id: project.id, + log_count: 100, + threshold: 50, + time_window: 5, + email_recipients: [], + webhook_url: undefined, + }; + + await processAlertNotification({ data: jobData }); + + expect(alertsService.markAsNotified).not.toHaveBeenCalled(); + }); + + it('should still call markAsNotified when historyId is present', async () => { + const { organization, project } = await createTestContext(); + const { alertsService } = await import('../../../modules/alerts/index.js'); + + const jobData: AlertNotificationData = { + historyId: '00000000-0000-0000-0000-000000000001', + rule_id: '00000000-0000-0000-0000-000000000002', + rule_name: 'Valid HistoryId Alert', + organization_id: organization.id, + project_id: project.id, + log_count: 100, + threshold: 50, + time_window: 5, + email_recipients: [], + webhook_url: undefined, + }; + + await processAlertNotification({ data: jobData }); + + expect(alertsService.markAsNotified).toHaveBeenCalledWith( + jobData.historyId + ); + }); + + it('should include HTTP status code in webhook error', async () => { + const { organization, project } = await createTestContext(); + const { alertsService } = await import('../../../modules/alerts/index.js'); + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: '', + text: () => Promise.resolve('Service Unavailable'), + }); + + // Mock notification channel with webhook + mockGetAlertRuleChannels.mockResolvedValueOnce([{ + id: '00000000-0000-0000-0000-000000000010', + type: 'webhook', + enabled: true, + config: { url: 'https://hooks.example.com/failing-503' }, + }]); + + const jobData: AlertNotificationData = { + historyId: '00000000-0000-0000-0000-000000000001', + rule_id: '00000000-0000-0000-0000-000000000002', + rule_name: 'HTTP Status Code Test', + organization_id: organization.id, + project_id: project.id, + log_count: 100, + threshold: 50, + time_window: 5, + email_recipients: [], + webhook_url: undefined, + }; + + await processAlertNotification({ data: jobData }); + + // markAsNotified should be called with an error containing the 503 status + expect(alertsService.markAsNotified).toHaveBeenCalledWith( + jobData.historyId, + expect.stringContaining('503') + ); + }); + }); });