Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions database/migrations/V1770789662__createSecurityAuditLogsTable.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Security audit logs for tracking security-related events
CREATE TABLE IF NOT EXISTS security_audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
event_type TEXT NOT NULL,
endpoint TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT,
attempted_value TEXT,
details JSONB,
CONSTRAINT check_event_type CHECK (event_type IN ('invalid_redirect', 'auth_failure', 'rate_limit_exceeded'))
);

-- Index for efficient querying by event type and time
CREATE INDEX idx_security_audit_logs_event_type ON security_audit_logs(event_type);
CREATE INDEX idx_security_audit_logs_created_at ON security_audit_logs(created_at);
31 changes: 28 additions & 3 deletions frontend/server/api/auth/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,32 @@
import { discovery, authorizationCodeGrant } from 'openid-client';
import jwt from 'jsonwebtoken';
import { jwtDecode } from 'jwt-decode';
import { H3Error } from 'h3';
import { Pool } from 'pg';
import { hasLfxInsightsPermission } from '../../utils/jwt';
import { isValidRedirectUrl, getSafeRedirectUrl } from '../../utils/redirect';
import { SecurityAuditRepository } from '../../repo/securityAudit.repo';
import { type DecodedIdToken } from '~~/types/auth/auth-jwt.types';
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const query = getQuery(event);

const isProduction = process.env.NUXT_APP_ENV === 'production';
const redirectTo = getCookie(event, 'auth_redirect_to') || '/';
// Validate redirect URL from cookie (defense in depth - also validated at login)
const rawRedirectTo = getCookie(event, 'auth_redirect_to');
const insightsDbPool = event.context.insightsDbPool as Pool;
// Log if cookie contains invalid redirect (could indicate cookie tampering)
if (rawRedirectTo && !isValidRedirectUrl(rawRedirectTo) && insightsDbPool) {
const securityAuditRepo = new SecurityAuditRepository(insightsDbPool);
// Fire-and-forget: don't await to avoid blocking the request
securityAuditRepo.logInvalidRedirect(
'/api/auth/callback',
rawRedirectTo,
getHeader(event, 'x-forwarded-for') || getHeader(event, 'x-real-ip'),
getHeader(event, 'user-agent'),
);
}
const redirectTo = getSafeRedirectUrl(rawRedirectTo);

try {
// Handle Auth0 errors (including silent authentication failures)
Expand Down Expand Up @@ -149,9 +167,16 @@ export default defineEventHandler(async (event) => {
deleteCookie(event, 'auth_oidc_token');
deleteCookie(event, 'auth_refresh_token');

let errorMessage = 'Authentication callback error';
let errorCode = 500;
if (error instanceof H3Error) {
errorMessage = error.statusMessage || error.message || 'Authentication callback error';
errorCode = error.statusCode ?? 500;
}

throw createError({
statusCode: 500,
statusMessage: 'Authentication callback error',
statusCode: errorCode,
statusMessage: errorMessage,
});
}
});
22 changes: 20 additions & 2 deletions frontend/server/api/auth/login.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
calculatePKCECodeChallenge,
buildAuthorizationUrl,
} from 'openid-client';
import { Pool } from 'pg';
import { isValidRedirectUrl, getSafeRedirectUrl } from '../../utils/redirect';
import { SecurityAuditRepository } from '../../repo/securityAudit.repo';

export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
Expand Down Expand Up @@ -40,10 +43,25 @@ export default defineEventHandler(async (event) => {
setCookie(event, 'auth_state', state, cookieOptions);
setCookie(event, 'auth_code_verifier', codeVerifier, cookieOptions);

// Store redirect URL if provided
// Store redirect URL if provided (validated to prevent open redirect)
const redirectTo = query.redirectTo as string;
if (redirectTo) {
setCookie(event, 'auth_redirect_to', redirectTo, cookieOptions);
// Log invalid redirect attempts for security monitoring
if (!isValidRedirectUrl(redirectTo)) {
const insightsDbPool = event.context.insightsDbPool as Pool;
if (insightsDbPool) {
const securityAuditRepo = new SecurityAuditRepository(insightsDbPool);
// Fire-and-forget: don't await to avoid blocking the request
securityAuditRepo.logInvalidRedirect(
'/api/auth/login',
redirectTo,
getHeader(event, 'x-forwarded-for') || getHeader(event, 'x-real-ip'),
getHeader(event, 'user-agent'),
);
}
}
const safeRedirectTo = getSafeRedirectUrl(redirectTo);
setCookie(event, 'auth_redirect_to', safeRedirectTo, cookieOptions);
}

// Build authorization URL
Expand Down
44 changes: 40 additions & 4 deletions frontend/server/api/auth/logout.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import { getCookie, deleteCookie } from 'h3';
import jwt from 'jsonwebtoken';
import type { H3Event } from 'h3';
import { Pool } from 'pg';
import { isValidRedirectUrl } from '../../utils/redirect';
import { SecurityAuditRepository } from '../../repo/securityAudit.repo';
import type { DecodedOidcToken } from '~~/types/auth/auth-jwt.types';

const isProduction = process.env.NUXT_APP_ENV === 'production';
Expand All @@ -29,6 +32,39 @@ const setOIDCCookie = (event: H3Event) => {
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();

// Read optional returnTo from request body and validate it
let returnToUrl = `${config.public.appUrl}?auth=logout`;
try {
const body = await readBody(event);
const insightsDbPool = event.context.insightsDbPool as Pool;
if (body?.returnTo) {
if (isValidRedirectUrl(body.returnTo)) {
// Build absolute URL if relative path provided
const validatedReturnTo = body.returnTo.startsWith('/')
? `${config.public.appUrl}${body.returnTo}`
: body.returnTo;
returnToUrl = validatedReturnTo.includes('?')
? `${validatedReturnTo}&auth=logout`
: `${validatedReturnTo}?auth=logout`;
} else {
if (insightsDbPool) {
// Log invalid redirect attempt for security monitoring
const securityAuditRepo = new SecurityAuditRepository(insightsDbPool);
// Fire-and-forget: don't await to avoid blocking the request
securityAuditRepo.logInvalidRedirect(
'/api/auth/logout',
body.returnTo,
getHeader(event, 'x-forwarded-for') || getHeader(event, 'x-real-ip'),
getHeader(event, 'user-agent'),
);
}
}
}
} catch {
// Body parsing failed, use default returnTo
returnToUrl = `${config.public.appUrl}?auth=logout`;
}

try {
// Get the OIDC token for logout (don't delete yet - we need it for proper logout)
const oidcToken = getCookie(event, 'auth_oidc_token');
Expand Down Expand Up @@ -65,7 +101,7 @@ export default defineEventHandler(async (event) => {
if (isProduction && parsedAuth0Domain.hostname === 'sso.linuxfoundation.org') {
// For Linux Foundation SSO, use their logout endpoint with ID token hint
const logoutParams = new URLSearchParams({
returnTo: `${config.public.appUrl}?auth=logout`,
returnTo: returnToUrl,
client_id: config.public.auth0ClientId,
});

Expand All @@ -79,7 +115,7 @@ export default defineEventHandler(async (event) => {
// For standard Auth0 domains, use the standard logout endpoint
const auth0Domain = config.public.auth0Domain.replace('https://', '');
const logoutParams = new URLSearchParams({
returnTo: `${config.public.appUrl}?auth=logout`,
returnTo: returnToUrl,
client_id: config.public.auth0ClientId,
});

Expand Down Expand Up @@ -126,7 +162,7 @@ export default defineEventHandler(async (event) => {

return {
success: true,
logoutUrl: `${config.public.appUrl}?auth=logout`,
logoutUrl: returnToUrl,
};
} catch (error) {
console.error('Auth logout error:', error);
Expand All @@ -137,7 +173,7 @@ export default defineEventHandler(async (event) => {

return {
success: true,
logoutUrl: `${config.public.appUrl}?auth=logout`,
logoutUrl: returnToUrl,
};
}
});
4 changes: 2 additions & 2 deletions frontend/server/middleware/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { getInsightsDbPool, getCMDbPool } from '../utils/db';
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();

// Only apply to chat endpoints
if (event.node.req.url?.startsWith('/api/chat/')) {
const allowedRoutes = ['/api/chat/', '/api/auth/login', '/api/auth/callback', '/api/auth/logout'];
if (allowedRoutes.some((route) => event.node.req.url?.startsWith(route))) {
// Add the database pool to the event context
event.context.insightsDbPool = getInsightsDbPool();

Expand Down
70 changes: 70 additions & 0 deletions frontend/server/repo/securityAudit.repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) 2025 The Linux Foundation and each contributor.
// SPDX-License-Identifier: MIT
import type { Pool } from 'pg';

export type SecurityAuditEventType = 'invalid_redirect' | 'auth_failure' | 'rate_limit_exceeded';

export interface SecurityAuditLogEntry {
eventType: SecurityAuditEventType;
endpoint: string;
ipAddress?: string;
userAgent?: string;
attemptedValue?: string;
details?: Record<string, unknown>;
}

export class SecurityAuditRepository {
constructor(private pool: Pool) {}

/**
* Logs a security audit event to the database.
* This method is designed to be fire-and-forget - it catches and logs errors
* internally to avoid affecting the main request flow.
*/
async logSecurityEvent(entry: SecurityAuditLogEntry): Promise<void> {
try {
const query = `
INSERT INTO security_audit_logs (
event_type,
endpoint,
ip_address,
user_agent,
attempted_value,
details
)
VALUES ($1, $2, $3, $4, $5, $6)
`;

await this.pool.query(query, [
entry.eventType,
entry.endpoint,
entry.ipAddress || null,
entry.userAgent || null,
entry.attemptedValue || null,
entry.details ? JSON.stringify(entry.details) : null,
]);
} catch (error) {
// Log to console but don't throw - security logging should not break the main flow
console.error('Failed to log security audit event:', error);
}
}

/**
* Logs an invalid redirect attempt.
* Convenience method for the common case of logging redirect validation failures.
*/
async logInvalidRedirect(
endpoint: string,
attemptedUrl: string,
ipAddress?: string,
userAgent?: string,
): Promise<void> {
await this.logSecurityEvent({
eventType: 'invalid_redirect',
endpoint,
ipAddress,
userAgent,
attemptedValue: attemptedUrl,
});
}
}
91 changes: 91 additions & 0 deletions frontend/server/utils/redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) 2025 The Linux Foundation and each contributor.
// SPDX-License-Identifier: MIT
import { isLocal } from './common';

const ALLOWED_REDIRECT_DOMAINS = isLocal
? ['linuxfoundation.org', 'auth0.com', 'localhost']
: ['linuxfoundation.org', 'auth0.com'];
export const DEFAULT_REDIRECT = '/';

/**
* Validates a redirect URL to prevent open redirect vulnerabilities.
* @param url - The URL to validate
* @returns true if the URL is safe for redirect, false otherwise
*/
export function isValidRedirectUrl(url: string | undefined | null): boolean {
// Reject empty/null/undefined
if (!url || typeof url !== 'string') {
return false;
}

const trimmedUrl = url.trim();

if (!trimmedUrl) {
return false;
}

// Reject protocol-relative URLs (//example.com) - these bypass same-origin checks
if (trimmedUrl.startsWith('//')) {
return false;
}

// Reject javascript:, data:, vbscript:, and other dangerous protocols
const dangerousProtocols = ['javascript:', 'data:', 'vbscript:', 'file:'];
const lowerUrl = trimmedUrl.toLowerCase();
if (dangerousProtocols.some((protocol) => lowerUrl.startsWith(protocol))) {
return false;
}

// Allow relative URLs (starting with / but not //)
if (trimmedUrl.startsWith('/') && !trimmedUrl.startsWith('//')) {
// Additional check: reject URLs with encoded characters that could bypass validation
// e.g., /%2F%2Fexample.com could decode to //example.com
try {
const decoded = decodeURIComponent(trimmedUrl);
if (decoded.startsWith('//')) {
return false;
}
} catch {
// If decoding fails, reject to be safe
return false;
}
return true;
}

// For absolute URLs, validate against allowed domains
try {
const parsedUrl = new URL(trimmedUrl);

if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
return false;
}

// Check if hostname matches or is a subdomain of allowed domains
const hostname = parsedUrl.hostname.toLowerCase();
return ALLOWED_REDIRECT_DOMAINS.some((domain) => {
return hostname === domain || hostname.endsWith(`.${domain}`);
});
} catch {
// If URL parsing fails, it's not a valid absolute URL
// Could be a malformed URL or a relative path without leading slash
// Reject to be safe
return false;
}
}

/**
* Validates and sanitizes a redirect URL, returning a safe default if invalid.
*
* @param url - The URL to validate
* @param fallback - Optional custom fallback URL (defaults to "/")
* @returns The original URL if valid, otherwise the fallback
*/
export function getSafeRedirectUrl(
url: string | undefined | null,
fallback: string = DEFAULT_REDIRECT,
): string {
if (isValidRedirectUrl(url)) {
return url!.trim();
}
return fallback;
}