diff --git a/apps/backend/drizzle.config.ts b/apps/backend/drizzle.config.ts
index d45a1f5..a32f3f6 100644
--- a/apps/backend/drizzle.config.ts
+++ b/apps/backend/drizzle.config.ts
@@ -1,10 +1,39 @@
import { defineConfig } from 'drizzle-kit';
+function getDatabaseUrl(): string {
+ const databaseUrl = process.env.DATABASE_URL;
+
+ if (!databaseUrl) {
+ throw new Error('DATABASE_URL is required for database migrations');
+ }
+
+ let parsedUrl: URL;
+ try {
+ parsedUrl = new URL(databaseUrl);
+ } catch {
+ throw new Error('DATABASE_URL must be a valid PostgreSQL connection URL');
+ }
+
+ if (
+ !['postgres:', 'postgresql:'].includes(parsedUrl.protocol) ||
+ !parsedUrl.username ||
+ !parsedUrl.password ||
+ !parsedUrl.hostname ||
+ parsedUrl.pathname.length <= 1
+ ) {
+ throw new Error(
+ 'DATABASE_URL must include postgres username, password, host, and database name'
+ );
+ }
+
+ return databaseUrl;
+}
+
export default defineConfig({
schema: './src/database/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
- url: process.env.DATABASE_URL!,
+ url: getDatabaseUrl(),
},
});
diff --git a/apps/backend/src/config/cors.test.ts b/apps/backend/src/config/cors.test.ts
new file mode 100644
index 0000000..4be780b
--- /dev/null
+++ b/apps/backend/src/config/cors.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+
+import { isOriginAllowed } from './cors';
+
+describe('isOriginAllowed', () => {
+ it('allows exact origin matches', () => {
+ expect(isOriginAllowed('http://localhost:3001', 'http://localhost:3001')).toBe(true);
+ });
+
+ it('allows wildcard origins', () => {
+ expect(isOriginAllowed('https://logarr.example.com', '*')).toBe(true);
+ });
+
+ it('allows LAN hosts when configured origin is localhost on the same port', () => {
+ expect(isOriginAllowed('http://192.168.1.120:3001', 'http://localhost:3001')).toBe(true);
+ });
+
+ it('rejects different ports for loopback alias matching', () => {
+ expect(isOriginAllowed('http://192.168.1.120:1337', 'http://localhost:3001')).toBe(false);
+ });
+});
diff --git a/apps/backend/src/config/cors.ts b/apps/backend/src/config/cors.ts
new file mode 100644
index 0000000..de7413e
--- /dev/null
+++ b/apps/backend/src/config/cors.ts
@@ -0,0 +1,63 @@
+const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
+
+function parseConfiguredOrigins(configuredOrigins: string): string[] {
+ return configuredOrigins
+ .split(',')
+ .map((origin) => origin.trim())
+ .filter((origin) => origin.length > 0);
+}
+
+function defaultPort(protocol: string): string {
+ return protocol === 'https:' ? '443' : '80';
+}
+
+function parseOrigin(origin: string): URL | null {
+ try {
+ return new URL(origin);
+ } catch {
+ return null;
+ }
+}
+
+function isLoopbackAlias(configuredOrigin: string, requestOrigin: string): boolean {
+ const configuredUrl = parseOrigin(configuredOrigin);
+ const requestUrl = parseOrigin(requestOrigin);
+
+ if (!configuredUrl || !requestUrl) {
+ return false;
+ }
+
+ if (!LOOPBACK_HOSTS.has(configuredUrl.hostname)) {
+ return false;
+ }
+
+ return (
+ configuredUrl.protocol === requestUrl.protocol &&
+ (configuredUrl.port.length > 0 ? configuredUrl.port : defaultPort(configuredUrl.protocol)) ===
+ (requestUrl.port.length > 0 ? requestUrl.port : defaultPort(requestUrl.protocol))
+ );
+}
+
+export function isOriginAllowed(origin: string | undefined, configuredOrigins: string): boolean {
+ if (origin === undefined || origin.length === 0) {
+ return true;
+ }
+
+ const allowedOrigins = parseConfiguredOrigins(configuredOrigins);
+ if (allowedOrigins.includes('*')) {
+ return true;
+ }
+
+ return allowedOrigins.some(
+ (configuredOrigin) => configuredOrigin === origin || isLoopbackAlias(configuredOrigin, origin)
+ );
+}
+
+export function createCorsOriginValidator(configuredOrigins: string) {
+ return (
+ origin: string | undefined,
+ callback: (error: Error | null, allow?: boolean) => void
+ ): void => {
+ callback(null, isOriginAllowed(origin, configuredOrigins));
+ };
+}
diff --git a/apps/backend/src/config/env.ts b/apps/backend/src/config/env.ts
index 8f9f91f..d154e69 100644
--- a/apps/backend/src/config/env.ts
+++ b/apps/backend/src/config/env.ts
@@ -1,5 +1,20 @@
import { z } from 'zod';
+function isValidDatabaseUrl(value: string): boolean {
+ try {
+ const url = new URL(value);
+ return (
+ (url.protocol === 'postgres:' || url.protocol === 'postgresql:') &&
+ url.username.length > 0 &&
+ url.password.length > 0 &&
+ url.hostname.length > 0 &&
+ url.pathname.length > 1
+ );
+ } catch {
+ return false;
+ }
+}
+
/**
* Environment configuration schema.
* All required values must be set - no defaults that hide misconfiguration.
@@ -7,7 +22,13 @@ import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
BACKEND_PORT: z.coerce.number().min(1).max(65535),
- DATABASE_URL: z.string().url(),
+ DATABASE_URL: z
+ .string()
+ .url()
+ .refine(
+ isValidDatabaseUrl,
+ 'DATABASE_URL must include postgres username, password, host, and database name'
+ ),
REDIS_URL: z.string(),
CORS_ORIGIN: z.string().min(1),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).optional(),
diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts
index c5fedc3..58d4fe6 100644
--- a/apps/backend/src/main.ts
+++ b/apps/backend/src/main.ts
@@ -3,6 +3,7 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';
+import { createCorsOriginValidator } from './config/cors';
import { validateEnv, type Env } from './config/env';
import { AuthService } from './modules/auth/auth.service';
import { SettingsService } from './modules/settings/settings.service';
@@ -23,13 +24,8 @@ async function bootstrap() {
// Use Pino logger for all NestJS logging
app.useLogger(app.get(Logger));
- // Enable CORS - support multiple origins via comma-separated string
- const origins = env.CORS_ORIGIN.includes(',')
- ? env.CORS_ORIGIN.split(',').map((o) => o.trim())
- : env.CORS_ORIGIN;
-
app.enableCors({
- origin: origins,
+ origin: createCorsOriginValidator(env.CORS_ORIGIN),
credentials: true,
});
@@ -79,9 +75,9 @@ async function bootstrap() {
const setupStatus = await authService.getSetupStatus();
if (setupStatus.setupRequired && setupStatus.setupToken) {
// Log the setup token prominently for first-time setup
- const frontendUrl = env.CORS_ORIGIN.includes(',')
- ? (env.CORS_ORIGIN.split(',')[0] ?? env.CORS_ORIGIN).trim()
- : env.CORS_ORIGIN;
+ const frontendUrl = env.CORS_ORIGIN.includes('*')
+ ? 'your Logarr frontend'
+ : (env.CORS_ORIGIN.split(',')[0] ?? env.CORS_ORIGIN).trim() || 'your Logarr frontend';
const token = setupStatus.setupToken;
console.log('');
diff --git a/apps/backend/src/modules/audit/audit.gateway.ts b/apps/backend/src/modules/audit/audit.gateway.ts
index 6ebf2e9..bf68774 100644
--- a/apps/backend/src/modules/audit/audit.gateway.ts
+++ b/apps/backend/src/modules/audit/audit.gateway.ts
@@ -8,14 +8,15 @@ import {
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
+import { createCorsOriginValidator } from '../../config/cors';
+
/**
* WebSocket Gateway for real-time audit log updates
* Clients can connect to receive live audit logs as they're created
*/
@WebSocketGateway({
cors: {
- origin:
- (process.env as { CORS_ORIGIN?: string }).CORS_ORIGIN?.split(',') || 'http://localhost:3000',
+ origin: createCorsOriginValidator(process.env['CORS_ORIGIN'] || 'http://localhost:3000'),
credentials: true,
},
namespace: '/audit',
diff --git a/apps/backend/src/modules/issues/issues.gateway.ts b/apps/backend/src/modules/issues/issues.gateway.ts
index 25060b6..2e1ec61 100644
--- a/apps/backend/src/modules/issues/issues.gateway.ts
+++ b/apps/backend/src/modules/issues/issues.gateway.ts
@@ -8,12 +8,20 @@ import {
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
+import { createCorsOriginValidator } from '../../config/cors';
+
import { IssuesService } from './issues.service';
interface IssueUpdatePayload {
type: 'new' | 'updated' | 'resolved' | 'merged';
issueId: string;
- data?: any;
+ data?: unknown;
+}
+
+interface IssueBroadcast {
+ id: string;
+ serverId?: string;
+ [key: string]: unknown;
}
interface BackfillProgressPayload {
@@ -32,7 +40,7 @@ interface BackfillProgressPayload {
@WebSocketGateway({
namespace: 'issues',
cors: {
- origin: process.env['CORS_ORIGIN']!,
+ origin: createCorsOriginValidator(process.env['CORS_ORIGIN'] ?? 'http://localhost:3000'),
credentials: true,
},
})
@@ -51,9 +59,9 @@ export class IssuesGateway {
async handleSubscribe(
@MessageBody() data: { serverId?: string },
@ConnectedSocket() client: Socket
- ) {
+ ): Promise<{ subscribed: true; room: string }> {
// Join a room for the specific server or 'all' for all issues
- const room = data.serverId || 'all';
+ const room = data.serverId ?? 'all';
await client.join(`issues:${room}`);
return { subscribed: true, room };
}
@@ -65,8 +73,8 @@ export class IssuesGateway {
async handleUnsubscribe(
@MessageBody() data: { serverId?: string },
@ConnectedSocket() client: Socket
- ) {
- const room = data.serverId || 'all';
+ ): Promise<{ unsubscribed: true; room: string }> {
+ const room = data.serverId ?? 'all';
await client.leave(`issues:${room}`);
return { unsubscribed: true, room };
}
@@ -74,7 +82,7 @@ export class IssuesGateway {
/**
* Broadcast a new issue to subscribers
*/
- broadcastNewIssue(issue: any) {
+ broadcastNewIssue(issue: IssueBroadcast): void {
const payload: IssueUpdatePayload = {
type: 'new',
issueId: issue.id,
@@ -83,7 +91,7 @@ export class IssuesGateway {
// Broadcast to both server-specific room and 'all' room
this.server.to('issues:all').emit('issue:new', payload);
- if (issue.serverId) {
+ if (issue.serverId !== undefined && issue.serverId.length > 0) {
this.server.to(`issues:${issue.serverId}`).emit('issue:new', payload);
}
}
@@ -91,7 +99,7 @@ export class IssuesGateway {
/**
* Broadcast an issue update to subscribers
*/
- broadcastIssueUpdate(issue: any) {
+ broadcastIssueUpdate(issue: IssueBroadcast): void {
const payload: IssueUpdatePayload = {
type: 'updated',
issueId: issue.id,
@@ -99,7 +107,7 @@ export class IssuesGateway {
};
this.server.to('issues:all').emit('issue:updated', payload);
- if (issue.serverId) {
+ if (issue.serverId !== undefined && issue.serverId.length > 0) {
this.server.to(`issues:${issue.serverId}`).emit('issue:updated', payload);
}
}
@@ -107,7 +115,7 @@ export class IssuesGateway {
/**
* Broadcast when an issue is resolved
*/
- broadcastIssueResolved(issue: any) {
+ broadcastIssueResolved(issue: IssueBroadcast): void {
const payload: IssueUpdatePayload = {
type: 'resolved',
issueId: issue.id,
@@ -115,7 +123,7 @@ export class IssuesGateway {
};
this.server.to('issues:all').emit('issue:resolved', payload);
- if (issue.serverId) {
+ if (issue.serverId !== undefined && issue.serverId.length > 0) {
this.server.to(`issues:${issue.serverId}`).emit('issue:resolved', payload);
}
}
@@ -123,9 +131,9 @@ export class IssuesGateway {
/**
* Broadcast stats update
*/
- broadcastStatsUpdate(stats: any, serverId?: string) {
+ broadcastStatsUpdate(stats: unknown, serverId?: string): void {
this.server.to('issues:all').emit('stats:updated', stats);
- if (serverId) {
+ if (serverId !== undefined && serverId.length > 0) {
this.server.to(`issues:${serverId}`).emit('stats:updated', stats);
}
}
@@ -133,12 +141,12 @@ export class IssuesGateway {
/**
* Broadcast backfill progress
*/
- broadcastBackfillProgress(progress: BackfillProgressPayload, serverId?: string) {
+ broadcastBackfillProgress(progress: BackfillProgressPayload, serverId?: string): void {
this.logger.log(
`Broadcasting backfill progress: ${progress.status} - ${progress.processedLogs}/${progress.totalLogs}`
);
this.server.to('issues:all').emit('backfill:progress', progress);
- if (serverId) {
+ if (serverId !== undefined && serverId.length > 0) {
this.server.to(`issues:${serverId}`).emit('backfill:progress', progress);
}
}
diff --git a/apps/backend/src/modules/logs/logs.gateway.ts b/apps/backend/src/modules/logs/logs.gateway.ts
index 6167000..ae8f11a 100644
--- a/apps/backend/src/modules/logs/logs.gateway.ts
+++ b/apps/backend/src/modules/logs/logs.gateway.ts
@@ -7,6 +7,8 @@ import {
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
+import { createCorsOriginValidator } from '../../config/cors';
+
interface LogSubscription {
serverId?: string;
levels?: string[];
@@ -43,7 +45,7 @@ interface FileIngestionProgress {
@WebSocketGateway({
namespace: 'logs',
cors: {
- origin: process.env['CORS_ORIGIN']!,
+ origin: createCorsOriginValidator(process.env['CORS_ORIGIN'] || 'http://localhost:3000'),
credentials: true,
},
})
diff --git a/apps/backend/src/modules/sessions/sessions.gateway.ts b/apps/backend/src/modules/sessions/sessions.gateway.ts
index 242d0f4..3ba5a71 100644
--- a/apps/backend/src/modules/sessions/sessions.gateway.ts
+++ b/apps/backend/src/modules/sessions/sessions.gateway.ts
@@ -7,6 +7,8 @@ import {
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
+import { createCorsOriginValidator } from '../../config/cors';
+
interface SessionSubscription {
serverId?: string;
}
@@ -22,7 +24,7 @@ export interface SessionUpdatePayload {
@WebSocketGateway({
namespace: 'sessions',
cors: {
- origin: process.env['CORS_ORIGIN']!,
+ origin: createCorsOriginValidator(process.env['CORS_ORIGIN'] || 'http://localhost:3000'),
credentials: true,
},
})
diff --git a/apps/frontend/docker-entrypoint.sh b/apps/frontend/docker-entrypoint.sh
index 78534ec..39a1504 100644
--- a/apps/frontend/docker-entrypoint.sh
+++ b/apps/frontend/docker-entrypoint.sh
@@ -12,12 +12,39 @@ if [ -z "$NEXT_PUBLIC_WS_URL" ]; then
exit 1
fi
-# Generate runtime config that will be injected into the page
+# Generate runtime config that will be injected into the page.
+# When Docker defaults point at localhost, remote browsers need those URLs rewritten
+# to the host they actually used to open Logarr.
cat > /app/apps/frontend/public/__config.js << EOF
-window.__LOGARR_CONFIG__ = {
- apiUrl: "${NEXT_PUBLIC_API_URL}",
- wsUrl: "${NEXT_PUBLIC_WS_URL}"
-};
+(function () {
+ var loopbackHosts = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
+
+ function normalizeUrl(rawUrl, type) {
+ try {
+ var url = new URL(rawUrl, window.location.origin);
+ if (!loopbackHosts.has(url.hostname)) {
+ return url.toString();
+ }
+
+ url.hostname = window.location.hostname;
+ url.protocol =
+ type === 'ws'
+ ? window.location.protocol === 'https:'
+ ? 'wss:'
+ : 'ws:'
+ : window.location.protocol;
+
+ return url.toString();
+ } catch {
+ return rawUrl;
+ }
+ }
+
+ window.__LOGARR_CONFIG__ = {
+ apiUrl: normalizeUrl("${NEXT_PUBLIC_API_URL}", 'http'),
+ wsUrl: normalizeUrl("${NEXT_PUBLIC_WS_URL}", 'ws')
+ };
+})();
EOF
echo "Runtime config generated:"
diff --git a/apps/frontend/src/components/app-sidebar.test.tsx b/apps/frontend/src/components/app-sidebar.test.tsx
index d1a231e..98238d9 100644
--- a/apps/frontend/src/components/app-sidebar.test.tsx
+++ b/apps/frontend/src/components/app-sidebar.test.tsx
@@ -1,5 +1,5 @@
-import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
import { render, screen } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
// Set the version environment variable before any imports
const TEST_VERSION = '0.4.3';
@@ -131,7 +131,7 @@ describe('AppSidebar', () => {
});
});
- it('should display version without v prefix in collapsed state', async () => {
+ it('should display version without v prefix in collapsed state', () => {
// This test validates the conditional rendering logic
// In collapsed state, version should show as "0.4.3" without the "v" prefix
// The actual behavior depends on useSidebar().state
@@ -196,5 +196,13 @@ describe('AppSidebar', () => {
render(